文章目录
- 什么是面向对象?
- 一:类是什么?
- 1.类的访问限定符
- 2.封装
- 3.类的实例化
- 4.this指针
- 二:类的6个默认成员函数
- 1.构造函数
- 2.析构函数
- 3.拷贝构造函数
什么是面向对象?
c语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。
比如洗衣服:
c++是基于面向对象的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。
在C语言中很多的过程在c++中被分为了人 衣服 洗衣机 洗衣粉,想要完成洗衣粉这件事只需要人将衣服放进洗衣机,倒入洗衣粉,启动洗衣机就完成了。
一、类是什么?
C语言结构体中只能定义变量,在c++中结构体内不仅可以定义变量,也可以定义函数。比如:之前我们用C语言方式实现的栈,结构体中只能定义变量,现在以c++的方式实现,会发现struct中也可以定义函数。
struct Stack
{void Init(int n = 4){a = (int*)malloc(sizeof(int) * n);if (nullptr == a){perror("malloc申请空间失败");return;}capcity = n;top = 0;}void Push(int x){}void Pop(){}int Top(){}bool Empty(){}int* a;int capcity;int top;
};
int main()
{Stack st;st.Init();st.Push(1);st.Push(2);st.Push(3);return 0;
}
就像上面的代码段一样,以前C语言是不支持将函数写入结构体的,而c++现在能做到了。
而上面的struct在c++中更喜欢用class来代替。
那么怎么创建一个类呢?看下面代码段:
class classname // class后面跟你自己想要取的类名
{//类体,由成员函数和成员变量组成
}; //后面一定要加;和结构体一样
class为定义类的关键字,classname为类的名字,{}中为类的主体,注意类定义结束时后面分号不能省略。类体中的内容称为类的成员,类中的变量称为类的属性或成员变量,类中的函数称为类的方法或者成员函数。类中定义的变量都可以直接在类中使用,类外则需要域限定符。
类的两种定义方式:
1.声明和定义全部放在类体中,需要注意的是,成员函数如果在类中定义,编译器可能会当成内联函数来处理。比如下面这样的:
class classname
{
public:void add(){year++;}
private :int year;
};
2.类声明放在头文件中,成员函数的定义放在.cpp文件中。
class classname
{
public:void add();
private :int year;
};
void classname::add()
{year++;
}
类的访问限定符:
c++中有三种访问限定符,分为public(公有),private(私有)私有的在类外不可以访问,protected(受保护的)同样在类外不可以访问。
c++实现封装的方式:用类将对象的属性和方法结合在一块,让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用。
1.pubic修饰的成员在类外可以直接被访问
2.protected和private修饰的成员在类外不能直接被访问
3.访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止。
4.如果后面没有访问限定符,作用域到 } 及类结束。
5.class的默认访问权限为private,struct的默认访问权限为public(因为要兼容C语言)
注意:访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别。
class Date
{
public:void Init(int year, int month, int day){year = year;_month = month;_day = day;}void Print(){cout << year << "年" << _month << "月" << _day << "日" << endl;}
private:int year;int _month;int _day;
};int main()
{Date d1;d1.Init(2023,2,5);d1.Print();return 0;
}
大家觉得上面这个代码段可以成功打印出年月日吗?答案是不可以,因为我们在private中定义的年与Init函数传来的参数year一样,这就导致编译器识别不出来,所以我们在定义成员变量的时候最好都像month那样在前面加个符号用来区分。
封装 :
面向对象的三大特性:封装,继承,多态。
封装的意思就是将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。封装的本质就是一种管理,让用户更方便的使用类。举个例子:就像电脑的主机一样,主机只提供开机键等接口,而实际上电脑真正工作的东西是cpu等硬件,而这些硬件是不会暴露在外边让用户看到的。
类的实例化:
用类的类型创建对象的过程,称为类的实例化。类是对对象进行描述的,是一个模型一样的东西,限定了类有哪些成员,定义出一个类并没有分配实际的内存空间来存储它,比如以下代码:
我们已经说过类是对对象进行描述的,只有创建了一个类对象,才会给这个类对象分配空间,这样就可以使用类里面的函数等变量。下图为正确的使用方式:
下面我们来看类中成员如何存储的:
class Date
{
public:void Init(int year, int month, int day){_year = year;_month = month;_day = day;}void Print(){cout << _year << "年" << _month << "月" << _day << "日" << endl;}
private:int _year; ///这些只是声明并不是定义int _month;int _day;
};int main()
{Date d1;cout << sizeof(d1) << endl;return 0;
}
大家可以猜一猜d1这个对象的大小是多少?答案是12,12不就是private中三个成员变量的大小吗,为什么成员函数不占用空间呢?
大家看上图,d1的year变量和d2的year变量是在同一块空间吗?答案是不在同一块空间,因为对象实例化后会给每个对象都开辟一个空间,那么d1的year肯定是在d1这个对象开辟的空间内,d2的year是在d2这个对象开辟的空间内。
那么 d1的init函数和d2的init函数是在同一块空间吗?答案是是的,c++中为了防止每个对象都开辟空间存储不同的函数所以将函数放在了公共的代码段,想要调用这个函数直接去公共的代码段去找即可,这也就解释了为什么我们再计算对象的大小的时候不包含函数的大小了。至于为什么成员变量不设为公共的问题就很好回答了,应该每个对象都能对自己的成员变量进行修改,如果设为一个公共的那么d2对象修改year的值也会将d1对象的year进行修改。
// 类中既有成员变量,又有成员函数
class A1 {
public:void f1() {}
private:int _a;
};// 类中仅有成员函数
class A2 {
public:void f2() {}
};// 类中什么都没有---空类
class A3
{};
上面这三个类的sizeof大小是多少呢?
有了上面的解释回答这道题就很容易了,首先A1中只有变量_a占实际空间,所以大小为4字节。
A2中只有成员函数,而成员函数在代码段中那么这个A2就相当于A3是一个空类,空类在c++中占一个字节。
this指针
class Date
{
public:void Init(int year, int month, int day){_year = year;_month = month;_day = day;}void Print(){cout << _year << "年" << _month << "月" << _day << "日" << endl;}
private:int _year; ///这些只是声明并不是定义int _month;int _day;
};int main()
{Date d1;Date d2;d1.Init(1, 2, 3);d2.Init(4, 5, 6);d1.Print();d2.Print();return 0;
}
对于上面的代码段,有这样一个问题:Date类中有Init和Print两个成员函数,函数体中没有关于不同对象的区分,那当d1调用Init函数时,该函数是如何知道设置d1对象,而不是设置d2对象呢?
对于这个问题,c++中引用了this指针来解决这个问题。即:c++编译器给每个"非静态的成员函数"增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有"成员变量"的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需用来传递,编译器自动完成。比如下面代码:
class Date
{
public:void Init(int year, int month, int day)//用户看到的//实际上 void Init(Date* this,int year,int month,int day){_year = year;_month = month;_day = day;}void Print(){cout << _year << "年" << _month << "月" << _day << "日" << endl;}
private:int _year; ///这些只是声明并不是定义int _month;int _day;
};int main()
{Date d1;Date d2;d1.Init(1, 2, 3);//用户看到的//实际上//d1.Init(&d1, 1, 2, 3);d2.Init(4, 5, 6);d1.Print();d2.Print();return 0;
}
在这里要注意,我们不能显式的自己去调用的时候传入对象的地址,这样编译器会报错。
那么this指针有什么作用呢?
class Date
{
public:void Init(int year, int month, int day){this->year = year;this->month = month;this->day = day;cout << this << endl;}void Print(){cout << year << "年" << month << "月" << day << "日" << endl;}
private:int year; ///这些只是声明并不是定义int month;int day;
};int main()
{Date d1;Date d2;d1.Init(1, 2, 3);d2.Init(4, 5, 6);d1.Print();d2.Print();return 0;
}
之前Init函数中不能分辨的year等变量用上this指针就可以正确分辨,还可以打印此对象的地址
那么this指针存放在哪里呢?this指针存放在栈中,因为this是隐含形参/vs下面是存在ecx寄存器中
this指针可以为空吗?看以下代码:
class Date
{
public:void Init(int year, int month, int day){this->year = year;this->month = month;this->day = day;cout << this << endl;}void Print(){cout << year << "年" << month << "月" << day << "日" << endl;}void Func(){cout << "Func()" << endl;}
private:int year; ///这些只是声明并不是定义int month;int day;
};int main()
{Date* ptr = nullptr;ptr->Func();return 0;
}
上面这段代码可以正常编译吗?很多人看到ptr是个空指针然后去调用Func函数会觉得这里对空指针进行解引用了,这样理解其实是不对的,首先这个代码可以正常编译看下图:
这里解释一下为什么可以编译,我们之前说过调用类中的函数时编译器会隐式修改为传对象的地址然后函数多了一个this指针的参数,所以当我们调用func这个函数的时候,编译器通过this指针找到了类中的这个函数即使把ptr这个空指针传给了this,也是可以正常使用的。那么下面这个程序的运行结果又是怎么样的?
上图这段代码运行起来程序崩溃了,首先这个Init和刚刚的func函数一样都不在对象里面,他们都在公共区域,调用这个函数直接跳到存放代码的地址,这些都没问题,有问题的是ptr是空指针,ptr给this传了个空指针然后再Init函数中这个空指针指向Year这个变量,这就是对空指针进行解引用了。
那么上图中这个代码是否可以正常运行呢?很多人看到括号内对ptr空指针进行解引用了以为程序会崩溃,但其实并不是,编译器还是先去对象里找有没有Func()这个函数,然后编译器发现找不到通过this指针找到了Func函数的公共代码段,而这里的(ptr)是起到了给传给this指针的作用。
那么上图中的这个代码运行起来会不会崩溃呢? 这个一定是崩溃了,编译器先去找year是不是在对象里,找到后发现这个对象有自己的空间所以对空指针进行解引用了。通过上面几个问题大家应该知道this指针是可以为空的了。
二、类的默认6个成员函数
如果一个类中什么成员都没有,简称为空类。空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数,默认成员函数:用户没有显式实现,编译器会生成的函数称为默认成员函数。
构造函数主要完成初始化工作
析构函数主要完成清理工作
拷贝构造是使用同类对象初始化创建对象
赋值重载主要是把一个对象赋值给另一个对象
完成取地址重载的两个函数很少会自己实现所以就不在讲解
构造函数
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次。
构造函数的特性:1.函数名与类名相同
2.无返回值
3.对象实例化时编译器自动调用对应的构造函数
4.构造函数可以重载
class Stack
{
public:Stack(int capcity = 0,int top = 0){_capcity = capcity;_top = top;}void Print(){cout << "_capcity:" << _capcity << endl;cout << "_top:" << _top << endl;}
private:int* a;int _capcity;int _top;
};
int main()
{Stack st;Stack st1(10, 4);st.Print();st1.Print();return 0;
}
无参构造函数和带参构造函数的调用方式是不一样的,上面的代码我们在构造函数中用了缺省值的方式,必须要说明的是无参构造函数直接Stack st;即可,也就是说对象实例化创建后就会调用,而带参的构造函数必须在后面加括号写入参数入上图中st1一样。
需要注意的是:
class Date
{
public:Date(){_year = 1;_month = 1;_day = 1;}Date(int year = 1, int month = 1, int day = 1){_year = year;_month = month;_day = day;}void Print(){cout << _year << "年" << _month << "月" << _day << "日" << endl;}
public:int _year; int _month;int _day;
};int main()
{Date d1;d1.Print();return 0;
}
像上图中这样的情况,语法本身并没有问题,是因为编译器无法分清无参的构造函数和带缺省参数的构造函数,解决的方法为调用是以带参构造函数的方式去调用。
我们之前已经说过,在实例化对象的时候必须调用构造函数,如果我们不写编译器会给一个默认的构造函数,那么我们就看看默认的构造函数可以完成对象的初始化吗
class Date
{
public:void Print(){cout << _year << "年" << _month << "月" << _day << "日" << endl;}
public:int _year; int _month;int _day;
};int main()
{Date d1;d1.Print();return 0;
}
可以看到我们是没有写构造函数的。
当我们调用的时候发现好像编译器默认的构造函数好像并不能完成初始化,这是怎么回事呢?这是因为c++把类型分为内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如int/char...等,自定义类型就是我们使用class/struct/union等自己定义的类型,而编译器生成的默认构造函数会对自定义类型成员调用它的默认成员函数。
也就是说默认生成的构造函数,对内置类型成员不做处理。自定义类型成员会去调用它的默认构造(不用传参数的构造)
class MyQueue
{
public://默认生成的构造函数对自定义类型,会调用它的默认构造函数void Push(int x){}Stack _pushST;Stack _popST;
};int main()
{MyQueue mq;return 0;
}
从上图中我们发现默认构造函数对于自定义类型会进行初始化 。析构函数作为专门初始化的函数大多人都认为应该将内置类型也初始化了而不是只针对自定义类型,针对这个问题在c++11中打入了一个补丁,就是可以直接给内置类型变量一个缺省值,什么意思呢?看下面代码:
class Date
{
public:void Print(){cout << _year << "年" << _month << "月" << _day << "日" << endl;}
public:int _year = 1; int _month = 1;int _day = 1;
};int main()
{Date d1;d1.Print();return 0;
}
通过这个补丁就能解决内置类型初始化的问题了。
析构函数
与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的,而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
析构函数的特性:1.析构函数是在类名前加上~
2.无参数无返回值类型
3.一个类只能有一个析构函数,若未显式定义,系统会自动生成默认的析构函 数。注意:析构函数不能重载。
4.对象生命周期结束时,c++编译系统系统自动调用析构函数
class Stack
{
public:Stack(int capcity = 0,int top = 0){_capcity = capcity;_top = top;}void Print(){cout << "_capcity:" << _capcity << endl;cout << "_top:" << _top << endl;}~Stack(){cout << "这是析构函数" << endl;}
private:int* a;int _capcity;int _top;
};
int main()
{Stack st;Stack st1(10, 4);st.Print();st1.Print();return 0;
}
我们在return处打了一个断点然后F5跳到断点处F11进入函数发现进入到了析构函数中并且打印了
默认生成的析构函数与默认生成的构造函数一样,对内置类型成员不做处理。自定义类型成员会去调用它的默认析构函数。析构函数的调用遵循后进先出原则,也就是说后定义的对象先进行析构函数的调用。
class Time
{
public:Time(){cout << "Time()" << endl;}~Time(){cout << "~Time()" << endl;}
private:int _hour;int _minute;int _second;
};
class Date
{
private:// 基本类型(内置类型)int _year = 1970;int _month = 1;int _day = 1;// 自定义类型Time _t;
};
int main()
{Date d;return 0;
}
在main中根本没有创建Time类的对象,但还是打印了Time和~Time,可见析构函数确实会对自定义类型调用其自己的析构函数。(构造函数同理)
拷贝构造函数
只有单个形参,该形参是本类类对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
1.拷贝构造函数是构造函数的一个重载形式 以下是拷贝构造函数实例:
class Date
{
public:Date(int year = 1, int month = 1, int day = 1){_year = year;_month = month;_day = day;}Date(const Date& d){_year = d._year;_month = d._month;_day = d._day;}void Print(){cout << _year << "年" << _month << "月" << _day << "日" << endl;}
public:int _year ; int _month ;int _day ;
};int main()
{Date d1;Date d2(d1); //调用拷贝构造函数d1.Print();d2.Print();return 0;
}
我们可以看到d2确实和d1的参数是一样的说明拷贝成功了。
const修饰能起到什么作用呢?我们都知道加const修饰就是为了不被修改,看以下代码:
这里加const其实是为了防止将谁是谁的拷贝的位置写错了,一旦写错不加const那么用来拷贝的那个类里面的东西都被修改了。
当然用const修饰还有第二个好处,那就是防止权限的放大。
就如图中我们定义了一个const的对象,再调用拷贝构造时发现报错了,原因是d1本来是const this*,到了拷贝构造函数中变成了this*权限放大了,想要解决在拷贝构造的函数参数中加上const即可。
2.拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
为什么会引发无穷递归调用呢,那是因为我们在传值传参的时候只要是自定义类型那么编译器会自动调用拷贝构造函数,举个例子:Date(Date d)如果这是拷贝构造函数,在调用这个函数的时候发现参数为自定义的Date类型那么就会调用拷贝构造函数又出现了Date(Date d)然后一直无穷递归下去,而使用引用为什么会避免呢?因为引用就是他本身再调用也是他本身不会再去调用新的拷贝构造函数。那么为什么编译器不能去拷贝自定义类型呢?
想要解决这样的问题就直接在拷贝构造函数中重新给要拷贝的对象开和被拷贝对象同样的空间,这样他们两个的地址不同在释放内存的时候就不会崩溃了。
3.日期类不需要写拷贝构造,因为内置类型编译器也会处理。
class Date
{
public:Date(int year = 10, int month = 10, int day = 10){_year = year;_month = month;_day = day;}void print(){cout << _year << "年" << _month << "月" << _day << "日" << endl;}
private:int _year;int _month;int _day;
};
int main()
{Date d1;Date d2(d1);d2.print();return 0;
}
通过上面的代码我们可以发现即使我们不写拷贝构造函数编译器也能完成日期类的拷贝,那么自定义类型可以吗?
typedef int DataType;
class Stack
{
public:Stack(size_t capacity = 10){cout << "Stack(size_t capacity = 10)" << endl;_array = (DataType*)malloc(capacity * sizeof(DataType));if (nullptr == _array){perror("malloc申请空间失败");exit(-1);}_size = 0;_capacity = capacity;}void Push(const DataType& data){// CheckCapacity();_array[_size] = data;_size++;}~Stack(){cout << "~Stack()" << endl;if (_array){free(_array);_array = nullptr;_capacity = 0;_size = 0;}}
private:DataType *_array;size_t _size;size_t _capacity;
};
int main()
{Stack s1;Stack s2(s1);return 0;
}
我们以栈为例,运行后发现程序崩溃了,这是因为编译器默认的拷贝方式是浅拷贝这里我们在上面已经讲过这个拿图来看:
我们调试发现确实两个栈的地址是一样的,所以在析构函数释放空间的时候两个对象释放两次导致崩溃了。解决方式如下:
typedef int DataType;
class Stack
{
public:Stack(size_t capacity = 10){cout << "Stack(size_t capacity = 10)" << endl;_array = (DataType*)malloc(capacity * sizeof(DataType));if (nullptr == _array){perror("malloc申请空间失败");exit(-1);}_size = 0;_capacity = capacity;}void Push(const DataType& data){// CheckCapacity();_array[_size] = data;_size++;}Stack(const Stack& s){_array = (DataType*)malloc(sizeof(DataType) * s._capacity);if (_array == nullptr){perror("malloc:");exit(-1);}memcpy(_array, s._array, sizeof(DataType) * s._size);_capacity = s._capacity;_size = s._size;}~Stack(){cout << "~Stack()" << endl;if (_array){free(_array);_array = nullptr;_capacity = 0;_size = 0;}}
private:DataType *_array;size_t _size;size_t _capacity;
};
int main()
{Stack s1;Stack s2(s1);return 0;
}
通过上面这个例子我们能得出结论,对于内置类型编译器的默认拷贝构造函数可以通过浅拷贝完成拷贝,但是像栈这样的自定义类型默认的拷贝构造函数不仅无法正确拷贝还会引发错误。
那么什么情况下需要自己写拷贝构造呢?
结论:自己实现了析构函数释放空间,就需要实现拷贝构造。
那么下面这样的代码需要自己写拷贝构造吗?
typedef int DataType;
class Stack
{
public:Stack(size_t capacity = 10){cout << "Stack(size_t capacity = 10)" << endl;_array = (DataType*)malloc(capacity * sizeof(DataType));if (nullptr == _array){perror("malloc申请空间失败");exit(-1);}_size = 0;_capacity = capacity;}void Push(const DataType& data){// CheckCapacity();_array[_size] = data;_size++;}Stack(const Stack& s){_array = (DataType*)malloc(sizeof(DataType) * s._capacity);if (_array == nullptr){perror("malloc:");exit(-1);}memcpy(_array, s._array, sizeof(DataType) * s._size);_capacity = s._capacity;_size = s._size;}~Stack(){cout << "~Stack()" << endl;if (_array){free(_array);_array = nullptr;_capacity = 0;_size = 0;}}
private:DataType *_array;size_t _size;size_t _capacity;
};
class MyQueue
{
public:// 默认生成构造// 默认生成析构// 默认生成拷贝构造
private:Stack _pushST;Stack _popST;int _size = 0;
};int main()
{MyQueue q1;MyQueue q2(q1);return 0;
}
通过上图我们就知道了答案是不需要,因为自定义类型会去调用它自己的拷贝构造,而栈的拷贝构造我们已经实现了所以不需要。
什么情况下会调用拷贝构造呢?1.显式调用(就是自己要去时候拷贝构造) 2.传值传参(比如参数是自定义类型) 3.自定义类型做返回值 。
所以为了减少空间的消耗那么能用引用尽量去用引用。
总结
c++初学者对于学习构造函数,拷贝构造,析构函数是一大难点,所以想要真正的了解必须多练习多调试多思考,只有基础好了才能建高楼!
下一期是c++前期的重点运算符重载等的学习。