系列文章
之前的文章中讲解了,什么是类、类的实例化,以及封装的意义,若仍有不理解的部分可以移步上一篇文章 【C++】类与对象(引入)
目录
1.默认成员函数
🧀如果一个类中一个成员都没有的话,就称这个类为空类。
🧀但空类并不是什么都没有。若用以下的代码查看对象 a 的大小,你会发现输出的结果是 1 而不是 0 。虽然这是个空类,但是系统为了表现他至少存在,便为其分配了 1 字节的空间(占位符)以表示其存在。
class A //声明一个空类 { }; int main() { A a; cout << sizeof(a); //打印对象a的大小 return 0; }
不仅如此, 类中还带有六个天选之子,也就是我们说的默认成员函数。只要用户没有显示实现,编译器就会自动生成,下面就开始一个个学习吧。
2.构造函数
2.1定义
🧀在以前,我们使用C语言写数据结构的时候,都会写一个初始化的函数,之后还要手动调用它。显得非常麻烦,而构造函数就相当于实现在类中的初始化函数,不用我们自己手动操作,实例化对象的时候便会自动调用,可谓是十分方便。
🧀百闻不如一见,一起来看看构造函数是怎么实现的。
class A //定义一个类A { public: A(int a) //实现A的构造函数 { _a = a; //用传入的参数初始化成员a } private: int _a; //定义成员a }; int main() { A a(5); //实例化对象a return 0; }
2.2特性
🧀构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次。总结为以下特性:
- 函数名与类名称相同。
- 没有返回值(不是void,而是直接不写)。
- 对象实例化时自动调用对应的构造函数。
- 可以重载。
2.2.1重载构造函数
🧀正是因为构造函数支持重载,这才使其有多种的初始化方式。除了赋初值的方式进行构造,还能够使用无参的构造函数,适用于各个场景。
class A //定义一个类A { public: A(int a) //实现A的构造函数 { _a = a; //用传入的参数初始化成员a } A() //无参的构造函数 {} private: int _a; //定义成员a }; int main() { A a1(5); //内部a被初始化成5 A a2; //内部a未被初始化,是随机值 return 0; }
编辑
2.2.2与缺省参数混合使用
🧀但像上面那样写成两个构造函数未免过于麻烦了,现在我们想要一个函数中,如果我们传参就使用传过去的参数进行初始化,若没有传参的话也希望有一个初始值对其进行初始化而不是系统的随机值。
🧀这时候我们突然想起来,之前学过的缺省参数正好符合我们的目标要求。便可以在定义带参数的构造函数中加上缺省参数,实现目标效果。
class A //定义一个类A { public: A(int a = 0) //实现A的构造函数缺省值为0 { _a = a; //用传入的参数初始化成员a } private: int _a; //定义成员a }; int main() { A a1(5); //内部a被初始化成5 A a2; //内部a被初始化成0 return 0; }
2.2.3默认构造函数
🧀我们知道当我们未显式定义时,编译器会自动生成一个默认成员函数,用于对类进行初始化。但这个默认构造函数对内置类型不做处理,对于自定义类型则会去调用其默认构造函数进行构造。
🧀其中所说的内置类型就是:int char ...以及指针,这种语言提供给我们的数据类型。
🧀同样,自定义类型就是我们自己定义出来的类、结构体、union这类的类型。
🧀C++11 中针对内置类型成员不初始化的缺陷,打了补丁:内置类型成员变量在类中声明时可以给默认值。
🧀于是我们就可以像下方代码这样,给特定的成员变量默认值。
class A //定义一个类A { public: A(int a) //实现A的构造函数 { _a = a; //用传入的参数初始化成员a } A() //无参的构造函数 {} private: int _a = 0; //定义成员a给定初始值为0 }; int main() { A a1(5); //内部a被初始化成5 A a2; //内部a被初始化成0 return 0; }
[注意]
🧀无参的构造函数和全缺省的构造函数都称为默认构造函数,无参构造函数、全缺省构造函数、编译器默认生成的构造函数,都可以认为是默认构造函数。并且默认构造函数只能有一个,若全缺省和无参数的构造函数同时出现,则在无参实例化对象的时候出现歧义。因此二者不能同时出现。
编辑
3.析构函数
3.1定义
🧀析构函数与构造函数的功能正相反,析构函数不是完成对对象本身的销毁,而是对象在销毁时会自动调用析构函数,就像以前写的 destory 函数,完成对象中资源的清理工作。
3.2特性
- 析构函数名就是 ~+类名。
- 没有参数,也没有返回值。
- 一个类只能有一个析构函数,且不能重载。
- 在对象生命周期结束时,C++编译系统自动调用。
🧀我们用如下代码验证析构函数在对象销毁时会自动调用:
class A { public: A() { cout << "调用构造函数:A()" << endl; //调用构造函数的话就输出 } ~A() { cout << "调用析构函数:~A()" << endl; //调用析构函数的话就输出 } }; int main() { A a; return 0; }
🧀可以直接看到程序结束时, 编译器自动地调用了目标类的析构函数:
编辑
🧀不仅如此,若该类中若有自定义类型的成员变量,则会调用该自定义类型成员变量的析构函数。
编辑
class B { public: ~B() { cout << "调用B的析构函数:~B()" << endl; //调用B的析构函数的话就输出 } }; class A { public: A() { cout << "调用A的构造函数:~A()" << endl; //调用构造函数的话就输出 } ~A() { cout << "调用A的析构函数:~A()" << endl; //调用A的析构函数的话就输出 } private: B b; //有个自定义的成员变量 }; int main() { A a; return 0; }
🧀小结:创建哪个类的对象则调用那个类的构造函数,销毁那个类的对象也调用该类的析构函数。
4.拷贝构造
拷贝构造其实是构造函数的一个重载形式,用于创建一个与当前已存在对象一模一样的对象。在用已存在的类类型对象创建新对象时由编译器自动调用。
🧀拷贝构造函数的参数只有一个且必须是类类型对象的引用,由于是拷贝因此不修改原对象的值所以一般还使用 const 修饰形参。
class A { public: A(int a = 5) //A的构造函数 { _a = a; } A(const A& a) //A的拷贝构造函数 { _a = a._a; } private: int _a; //A的成员变量 }; int main() { A a; //实例化a A a1(a); //用a拷贝构造a1 //A a1 = a; //也可以这样写 return 0; }
🧀若使用传值方式传递拷贝构造函数的参数,编译器会直接报错,因为会引发无限递归调用。
🧀我们都知道,在函数之中形参是实参的一份临时拷贝,当我们通过传值传参给拷贝构造函数时,系统会调用拷贝构造函数生成形参。因为又调用了拷贝构造函数所以又要生成形参,为了生成形参又要调用拷贝构造函数。如此反复就会产生无限递归的情况。因此,使用引用作为拷贝构造函数的参数就能够解决当前问题。因为引用就是原来对象的别名,因此不会再次调用拷贝构造函数。
编辑
🧀若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝。
🧀就像这样,我们并没有直接写 A 的拷贝构造函数,但是系统的默认生成的拷贝构造函数还是能够实现对对象的拷贝。其中对于内置类型则直接拷贝,而自定义类型则会调用对应的拷贝构造函数。
编辑
🧀但是否编译器生成的默认拷贝构造函数就能够满足我们的需求呢?答案是否定的,当类涉及资源申请的时候,默认拷贝构造函数就无法完成任务。
class A { public: A() { _a = (int*)malloc(sizeof(int)); //动态开辟空间 } ~A() { free(_a); //释放_a } private: int* _a; //A的成员变量 }; int main() { A a; //实例化a A a1(a); //用a拷贝构造a1 return 0; }
若运行上文的代码,系统便会崩溃报错, 这是为什么呢?通过调试我们能够观察到:两个类中的指针是一样的。这意味着在拷贝构造的时候,默认的拷贝构造只是将数值给了新构造类中的变量,并没有再次动态开辟内存,因此最后调用析构函数时,由于只开辟了一块空间却释放了两次,因此在free处报错。
编辑
🧀若我们自己实现一个拷贝构造函数为变量开辟空间,程序便不会崩溃报错了。
class A { public: A() { _a = (int*)malloc(sizeof(int)); //动态开辟空间 } A(const A& a) { _a = (int*)malloc(sizeof(int)); //动态开辟 *_a = *a._a; //赋值拷贝 } ~A() { free(_a); //释放_a } private: int* _a; //A的成员变量 }; int main() { A a; //实例化a A a1(a); //用a拷贝构造a1 return 0; }
拷贝构造函数典型调用场景:
- 使用已存在对象创建新对象
- 函数参数类型为类类型对象
- 函数返回值类型为类类型对象
🧀每次使用传值传参都要调用一次拷贝构造,其代价是随着代码复杂度而上升的,为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时则根据实际场景,能用引用尽量使用引用。
5.赋值运算符重载
5.1运算符重载
🧀 C++ 为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似,使得自定义对象可以使用运算符。
🧀函数原型:返回值类型 + operator + 重载的操作符 + (参数列表)
class A { public: A(int a = 5) { _a = a; } int operator+(const A& a) //重载+号 { return _a + a._a; } private: int _a; //A的成员变量 }; int main() { A a(3); //实例化a A a1(a); //用a拷贝构造a1 cout << a + a1; return 0; }
🧀凡事都有例外,需注意以下五点:
- 不能通过连接其他符号来创建新的操作符。
- 重载操作符必须有一个类类型参数。
- 用于内置类型的运算符,不能改变其含义,例如:内置的整型+,不能改变其含义。
- 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this。
- .* :: sizeof ? : . 以上5个运算符不能重载。
5.2赋值运算符重载
🧀赋值运算符重载就是对 ‘=’ 的重载,需要注意以下细节:
- 为了能够多次连续赋值,应将自身作为返回值。
- 检测是否给自己赋值。
- 只能重载成类的函数而不能重载成全局函数。
class A { public: A(int a = 5) { _a = a; } A& operator=(const A& a) //赋值重载 { if(&a == this) return *this //检测是否给自己赋值 _a = a._a; //赋值 return *this; //返回自己 } void print() { cout << _a << endl; } private: int _a; //A的成员变量 }; int main() { A a(3); //实例化a A a1(5); //实例化a1 a.print(); a1.print(); a = a1; //用a1赋值a a.print(); return 0; }
🧀如此便完成了赋值运算符重载的实现。
🧀用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。
🧀与拷贝构造函数相同,当类中涉及动态开辟时,默认的运算符重载的缺点就暴露出来了。如果类中未涉及到资源管理,赋值运算符是否实现都可以,一旦涉及到资源管理则必须要实现。
5.3区分调用时的赋值运算符重载与拷贝构造
🧀我们知道,拷贝构造还有以下这种写法,不禁让人想对比其与运算符重载之间的区别。
A a1 = a;
🧀拷贝的双方:赋值运算符重载的两个对象都是已经实例化的对象,而拷贝构造中是以一个已经实例化的对象为基础来实例化一个新的对象。
区分的小细节:只要看当前行是否存在对象的声明,即找当前行是否出现了类名,若出现则是拷贝构造,否则为赋值运算符重载。
A a1(a); //调用拷贝构造 a = a1; //调用赋值重载 A a1 = a; //调用拷贝构造
6.const成员
🧀将const修饰的成员函数称之为 const 成员函数,const 修饰类成员函数,实际修饰该成员函数隐含的 this 指针,表明在该成员函数中不能对类的任何成员进行修改。
🧀例如我们此时有一个 const A 类型的对象,若我们直接调用我们的一个打印函数,编译器会报错,因为会出现权限放大。
编辑
🧀放作平时,我们可以直接给参数列表里的参数加上一个 const ,这样既能避免权限放大,同时普通的对象也能够调用这个函数。
🧀但类中的这个函数没有参数,若想修饰其中的 this 指针就需要使用 const 来修饰函数,进而避免权限放大。
🧀为了函数的泛用性,内部不改变成员变量的成员函数,最好用 const 来修饰
7.取地址操作符重载
🧀取地址操作符重载包括了 取地址及 const 取地址操作符重载,一般不用重新定义 ,编译器默认会生成。可以这么写但是意义不大。
class A { public: A(int a = 5) { _a = a; } A* operator&() //取地址操作符重载 { return this; } const A* operator&() const //const取地址操作符重载 { return this; } private: int _a; //A的成员变量 }; int main() { const A a(3); //实例化a A a1(5); printf("%p\n%p\n", &a, &a1); return 0; }
🧀若想让别人获得特定的内容,就可以使用这个重载。 无论怎么取该对象的地址返回的都是空指针。
class A { public: A(int a = 5) { _a = a; } A* operator&() //取地址操作符重载 { return nullptr; //无论如何返回空指针 } const A* operator&() const //const取地址操作符重载 { return nullptr; } private: int _a; //A的成员变量 }; int main() { const A a(3); //实例化a A a1(5); printf("%p\n%p\n", &a, &a1); return 0; }
8.总结
今天简单地讲了类中 6 个默认成员函数、运算符重载以及 const 成员函数。需要具体区分的是拷贝构造函数和赋值运算符重载,前面四个函数都要了解清楚其性质以及如何实现,并落实到代码上。而最后两个倒没有那么重要作为了解就可以。学会利用一些代码的技巧去减少程序的负担,就比如上文讲到的,情况允许的话使用引用传参和返回。
好了,今天类与对象上半部分讲解到这里就结束了,如果这篇文章对你有用的话还请留下你的三连加关注。