文章目录
- 前言
- 一、类的定义
- 1.类的定义格式
- 2.访问限定符
- 3.类域
- 4.注意
- 二、类的实例化
- 1. 实例化的概念
- 2. 对象的大小
- 注意:内存对齐规则
- 三、this指针
- 实践一下
- 四、类的默认成员函数
- 1.构造函数
- 构造函数的特点:
- 自定义类型类的构造
- 注意:
- 2.析构函数
- 3.拷贝构造函数
- 拷贝构造的特点:
- 拷贝构造的调用:
- 手写拷贝构造实例
前言
一、类的定义
1.类的定义格式
class 是定义类的关键字,Stack 为类名,大括号内部是类的主体,注意类定义结束后的分号不能省略。类体内的内容统称为类的成员:类中的变量叫做类的属性或成员变量;类中的函数叫做类的方法或者成员函数。
为了区分成员变量,编程习惯上会给成员变量添加特殊标识,比如在成员变量开头或结尾加下划线,或者以 m 开头。C++ 语法并不强制要求遵守该规范。
usingnamespacestd;classname{public:voidPrint(){cout<<"hello"<<endl;}voidset(inta){age=a;}intgetAge(){returnage;}private:intage;// 为了区分成员变量,一般习惯上成员变量// 会加一个特殊标识,如以下划线或 m 开头//int _year; // year_ m_year//int _month;//int _day;};intmain(){name n;n.Print();n.set(1);cout<<n.getAge()<<endl;return0;}用法与 C 的结构体类似,创建对象后,使用“.”来调用属性和方法。
C++ 中的 struct 同样可以用来定义类,它既兼容 C 语言里 struct 的使用方式,又升级具备了类的特性,最明显的变化就是 struct 内部可以定义成员函数。日常开发中依旧推荐使用 class 来定义类。
usingnamespacestd;// C++ 中 struct 升级成了类// 1、类里面可以定义函数// 2、struct 名称就可以代表类型// C++ 兼容 C 中 struct 的用法typedefstructListcodeCPP{intval;structListcodeCPP*next;}LC;// 不再需要typedef,ListNodeCPP就可以代表类型structListcodecpp{voidPrint(){cout<<"hello"<<endl;}intval;Listcodecpp*next;};intmain(){return0;}同时要特别注意struct 定义的类默认为public,外部可以直接访问;而class 默认 private,外部不能直接访问。
直接定义在类内部的成员函数,默认是内联函数。
2.访问限定符
C++ 实现封装的一种方式,就是通过类将对象的属性与方法整合在一起,让对象的设计更加规整,并且可以借助访问权限控制,选择性地对外提供接口供外部调用。
public 修饰的成员可以在类外部直接访问;protected 和 private 修饰的成员不能在类外部直接访问,二者在当前阶段用法一致,只有在C++继承中才能体现其差异。
访问权限的作用范围:从当前访问限定符出现的位置开始,到下一个访问限定符为止;如果后续没有其他访问限定符,则作用范围持续到类定义结束的大括号处。
classname{public:voida1(){}voida2(){}voida3(){}inta;};像这里的三个成员函数和成员变量都是公有的(public)。
开发规范中,成员变量一般设置为 private 或 protected,要对外开放使用的成员函数通常设置为 public。
3.类域
类定义了一个新的作用域,类的所有成员都位于该类的作用域内。在类体外定义成员函数时,需要使用::作用域解析运算符指明该成员属于哪个类域。
类域会影响编译阶段的名字查找规则。如下代码中,若定义Init函数时不指定类域Stack,编译器会将Init视作全局函数,编译时无法找到array等成员的声明与定义,从而直接报错。加上Stack::指明类域后,编译器便能识别Init是Stack的成员函数,在当前局部作用域找不到array这类成员时,会自动进入Stack类域中查找。
usingnamespacestd;classStack{public:// 成员函数声明voidInit(intn=4);private:// 成员变量int*array;size_t capacity;size_t top;};// 声明与定义分离,需要指定类域voidStack::Init(intn){array=(int*)malloc(sizeof(int)*n);if(nullptr==array){perror("malloc申请空间失败");return;}capacity=n;top=0;}intmain(){Stack st;st.Init();return0;}即对于类中的成员函数,一共有2种定义方式,第一种就是直接在类中定义,类内直接写函数体,既是声明也是定义,适合短小简单函数,编译器会默认尝试内联。
第二种仅在类内声明,类外实现,即我们展示的这种。类内声明必须带分号 。类外实现必须加 类名::,否则是普通全局函数。返回值、函数名、参数列表必须和声明完全一致。
4.注意
类域解决了类和类之间的命名冲突。
这样即使两个成员函数同名,也都可以正常使用。
二、类的实例化
1. 实例化的概念
用类类型在物理内存中创建对象的过程,称为类的实例化。
类是对对象的抽象描述,相当于模板,它规定了类包含哪些成员变量。这些成员变量在类中仅为声明,不会分配内存;只有通过类实例化出对象时,才会为这些成员变量分配存储空间。
usingnamespacestd;classA{public:voidPrint(){cout<<"a"<<endl;}inta;intb;};intmain(){A.a++;// 只是声明,要想使用需要实例化//A ss; ss.a;}一个类可以实例化出多个对象。实例化后的对象占用真实的物理内存,用于存储类的成员变量。例如,类的实例化如同按照建筑图纸修建房屋:类相当于图纸,图纸仅规划房间的数量、尺寸和功能,并不存在实体建筑,无法入住;依据图纸建成房屋后,房屋才具备实际使用价值。同理,类本身无法存储数据,只有实例化生成的对象分配物理内存后,才能存放数据。
2. 对象的大小
分析一下类对象中包含哪些成员:类实例化出的每个对象,都拥有独立的数据空间,所以对象里一定包含成员变量,那成员函数是否会存在于对象内部?首先,函数经过编译后是一段指令,无法存放在对象里,所有函数指令统一存放在单独的代码段区域。如果非要在对象里记录函数相关信息,只能保存成员函数的指针。
再进一步分析,对象根本没有存储函数指针的必要:用 Date 类实例化 d1、d2 两个对象,d1 和 d2 各自拥有独立的成员变量_year/_month/_day 用来存放自身数据,但 d1 和 d2 对应的 Init/Print 成员函数地址完全相同,把指针存进每个对象会造成内存浪费。假如用 Date 创建 100 个对象,函数指针就会重复保存 100 份,内存开销极大。
usingnamespacestd;classA{public:voidPrint(){cout<<_ch<<endl;}char_ch;int_i;};intmain(){A n1;A n2;n1.Print();//相同的函数地址,一份代码即可n2.Print();n1._i++;//不同的数据成员,需要各自的内存n2._i++;return0;}这里额外补充一点:普通成员函数连指针都不需要存储。函数调用在编译链接阶段就会确定函数地址,最终编译为 call 函数地址的汇编指令,地址查找发生在编译期而非运行期。只有动态多态场景才需要在对象中保存函数地址。
注意:内存对齐规则
对象的内存大小计算与结构体十分类似。
第一个成员存放在相对于结构体起始偏移量为 0 的地址处。其余成员变量需要对齐到对齐数整数倍的地址位置。
对齐数 = min(编译器默认对齐数, 当前成员自身大小)。
VS 编译器默认对齐数为 8。
结构体的总大小必须是其最大对齐数的整数倍。最大对齐数是所有成员的对齐数中的最大值。
存在结构体嵌套时,嵌套的内部结构体要对齐到其自身最大对齐数的整数倍地址。整个外层结构体的总大小必须是所有最大对齐数(包含嵌套结构体的最大对齐数)的整数倍。
大家可以练练手:
上面的程序运行后可以看到,没有任何成员变量的 B 类和 C 类,其实例化对象的大小为 1 字节。空类没有成员变量却占用 1 字节的原因是:如果完全不分配内存,程序就无法标识这个对象真实存在;分配 1 字节仅用于占位,以标记该对象是一个独立有效的实例。
三、this指针
Date 类中有 Init 与 Print 两个成员函数,函数体内没有区分不同对象的相关代码。那么当 d1 调用 Init 或 Print 时,函数如何判断要操作 d1 还是 d2 的数据?C++ 通过隐含的 this 指针解决该问题。
编译器编译时,会给所有成员函数在形参首位自动增加一个当前类类型的指针(类名* const this),即 this 指针。例如 Date 类 Init 函数的真实原型为:
void Init(Date* const this, int year, int month, int day)
在成员函数内访问成员变量,本质上都是通过 this 指针访问。例如函数里执行 _year = year;,完整等价于 this->_year = year;。
C++ 语法规定:不能在调用传参和函数形参列表中显式书写 this 指针(由编译器自动处理),但允许在函数体内直接显式使用 this 指针。
实践一下
usingnamespacestd;classA{public:voidPrint(){cout<<"A::Print()"<<endl;//结果一cout<<_a<<endl;//结果二cout<<"A::Print()"<<endl;//结果三}private:int_a;};intmain(){A*p=nullptr;p->Print();(*p).Print()//结果三return0;}结果一、二、三分别是什么?答案是一为正常运行,二为运行崩溃。
通过 this 指针的学习,我们知道在编译器底层,会自动创建类名* const this指针,用来接收调用对象的地址。这段程序相当于创建了一个地址为空的指针(空对象),并将空地址传递给Print函数。结果一只是接收了 this 指针而没有使用它,所以没有崩溃;结果二相当于打印this->_a,涉及空指针解引用,因此崩溃。
结果三:p->Print()等价于(*p).Print(),二者底层逻辑完全一样。这里p是nullptr,*p从语法上是对空指针解引用,但仅用于获取 this 指针并传递给成员函数,不会真正访问对象内存。
另外,Print()是非虚成员函数,调用时编译器只需要知道类A存在该函数,不需要访问对象内存;编译器不会检查指针是否为nullptr,只做语法和符号检查,因此编译不会报错。
答案为A,但实际上底层实现:编译器优先用 CPU 寄存器传递 this,用来提速。
四、类的默认成员函数
默认成员函数就是用户没有显式实现,编译器会自动生成的成员函数。一个类,在我们不手动书写的情况下编译器会默认生成以下6个默认成员函数,需要注意的是,这6个当中最重要的是前4个,最后两个取地址重载不重要,简单了解即可。此外,C++11 之后新增了两个默认成员函数:移动构造函数和移动赋值运算符,这部分内容我们后面再讲解。默认成员函数十分重要且知识点比较复杂,我们可以从以下两个方面去学习:
第一,我们不写时,编译器默认生成的函数行为是什么,是否满足我们的需求。
第二,如果编译器默认生成的函数不满足我们的需求,我们需要自己实现,那么应如何实现?
1.构造函数
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是其主要任务并不是开辟空间创建对象(我们常使用的局部对象是在栈帧创建时,空间就已经开辟好了),而是在对象实例化时初始化对象。构造函数的本质是替代我们之前在 Stack 和 Date 类中编写的 Init 函数的功能,其自动调用的特性完美地替代了 Init。
构造函数的特点:
函数名与类名相同。
无返回值(不需要指定返回类型,包括 void)。
对象实例化时系统会自动调用对应的构造函数。
构造函数可以重载。
usingnamespacestd;classStudent{public:Student(){age=0;name='0';}Student(inta){age=a;name='0';}Student(inta,charn){age=a;name=n;}intage;charname;};intmain(){Student();Student(1);Student(1,'q');}构造函数可以重载,系统会根据传入的参数自动调用对应的构造函数。
- 如果类中没有显式定义构造函数,则 C++ 编译器会自动生成一个无参的默认构造函数;一旦用户显式定义了任何构造函数,编译器将不再自动生成默认构造函数。
- 无参构造函数、全缺省构造函数以及编译器自动生成的默认构造函数,都称为默认构造函数。但一个类中只能存在一个默认构造函数,不能同时存在多个。无参构造函数和全缺省构造函数虽然构成函数重载,但调用时会产生歧义。
需要注意的是,默认构造函数并非特指编译器自动生成的那个。实际上,无参构造函数和全缺省构造函数也属于默认构造函数。总结来说,调用时无需传入任何实参的构造函数就是默认构造函数。
usingnamespacestd;classPerson{public:// 无参构造(默认构造)Person(){cout<<"无参构造"<<endl;}// 全缺省构造(也是默认构造)Person(inta=0,string n="无名"){cout<<"全缺省构造"<<endl;}};intmain(){Person p;// 编译器歧义!不知道调用上面哪一个return0;}很多人误以为 “默认构造” 单指编译器自动生成的那个,这是错误的。
判断标准只有一条:调用时无需传入任何实参 → 就是默认构造函数。
区分:部分缺省 ≠ 全缺省
只有所有参数都有默认值才算默认构造;如果只有部分参数带默认值,不传参无法调用,不属于默认构造:
自定义类型类的构造
如果我们不写构造函数,编译器默认生成的构造函数对于内置类型成员变量的初始化没有要求,即是否初始化是不确定的,取决于编译器。对于自定义类型成员变量,则会调用该成员变量的默认构造函数进行初始化。如果该成员变量没有默认构造函数,则会导致编译错误。此时,我们需要使用初始化列表来初始化该成员变量。
说明:C++将类型分为内置类型(基本类型)和自定义类型。内置类型是语言提供的原生数据类型,如 int、char、double、指针等;自定义类型则是我们使用 class、struct 等关键字自己定义的类型。
usingnamespacestd;typedefintSTDataType;classStack{public:Stack(intn=4){_a=(STDataType*)malloc(sizeof(STDataType)*n);if(nullptr==_a){perror("malloc申请空间失败");return;}_capacity=n;_top=0;}// ...private:STDataType*_a;size_t _capacity;size_t _top;};// 两个Stack实现队列classMyQueue{public://编译器默认⽣成MyQueue的构造函数调⽤了Stack的构造,完成了两个成员的初始化private:Stack pushst;Stack popst;};intmain(){MyQueue mq;return0;}编译器默认⽣成MyQueue的构造函数调⽤了Stack的构造,完成了两个成员的初始化
注意:
usingnamespacestd;classA{public:voidPrint(){cout<<"A::Print()"<<endl;}private:int_a;};intmain(){Aa();//a.Print();报错return0;}不能以A a();来初始化,这实际上是一个函数声明。
2.析构函数
析构函数与构造函数功能相反,析构函数并不是销毁对象本身,例如局部对象存储在栈帧中,函数执行结束栈帧销毁,对象内存会自动释放,无需手动处理。C++ 规定对象销毁时会自动调用析构函数,用于清理、释放对象内部申请的资源。析构函数的作用等同于之前 Stack 类的 Destroy 销毁函数;像 Date 类没有 Destroy 函数,是因为它不存在需要手动释放的资源,严格来说,Date 类无需手动编写析构函数。
1.析构函数名由波浪号~加类名组成。
2.无参数、无返回值(和构造函数规则类似,不能写 void)。
3.一个类只能拥有一个析构函数;用户未显式定义时,编译器自动生成默认析构函数。
4.对象生命周期结束时,系统自动调用析构函数。
usingnamespacestd;typedefintDataTypedef;classStack{public:Stack(intn=4){_a=(DataTypedef*)malloc(sizeof(DataTypedef)*n);if(_a==nullptr){perror("申请失败");return;}top=0;cap=n;}~Stack(){free(_a);_a=nullptr;top=0;cap=0;}private:inttop;intcap;DataTypedef*_a;};5.和默认构造函数规则相似,编译器自动生成的析构函数不会处理内置类型成员,对于自定义类型成员,会自动调用其自身的析构函数。
6.即使手动显式定义析构函数,自定义类型成员依旧会调用自身析构函数,即自定义类型成员无论何种场景都会自动执行析构逻辑。
7.若类内部没有向堆区申请资源,可直接使用编译器生成的默认析构函数,无需手动编写,例如 Date、MyQueue;如果类动态申请了堆资源,则必须手动实现析构函数,否则会出现内存资源泄漏,例如 Stack。
classMyQueue{public://编译器默认生成的MyQueue的析构函数调用了Stack的析构,//释放了Stack内部的资源//显式写析构,也会自动调用Stack的析构/*~MyQueue() {}*/private:Stack pushst;Stack popst;};intmain(){Stack st;MyQueue mq;return0;}*[注释]:
相当于MyQueue类自己默认生成的析构函数,调用了Stack的析构函数。
8.同一局部作用域内创建的多个对象,遵循后创建先析构的销毁规则。
在同一个局部作用域(函数体、{}代码块)中,栈上自动存储的局部对象:
构造顺序 = 代码从上到下书写顺序(先定义先构造)
析构顺序 = 构造顺序逆序(后构造先销毁)
底层原因:局部对象存放在栈内存,栈遵循「后进先出 LIFO」规则。
总而言之
析构函数解决了C中函数的销毁问题,杜绝了用户忘记释放的可能。
3.拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外的参数都有默认值,则此构造函数也叫做拷贝构造函数,也就是说拷贝构造函数是一个特殊的构造函数。
拷贝构造的特点:
拷贝构造函数是构造函数的一个重载。
拷贝构造函数的第一个参数必须是类类型对象的引用,使用传值方式编译器直接报错,因为语法逻辑上会引发无穷递归调用。拷贝构造函数也可以有多个参数,但是第一个参数必须是类类型对象的引用,后面的参数必须有缺省值。
我们知道形参是实参的一份临时拷贝,当你使用传值方式时,d2 在调用它的拷贝构造函数,形参用来接收实参,然后形参也调用了自己的拷贝构造函数,至此形成无限递归。C++ 规定自定义类型对象进行拷贝行为必须调用拷贝构造,因此自定义类型传值传参和传值返回都会调用拷贝构造完成。
usingnamespacestd;classA{public:A(){cout<<"构造"<<endl;}A(constA&a)// 拷贝构造函数{cout<<"拷贝构造"<<endl;}voidtest(A a)//传值传参{}Afuc(){A a;returna;}};intmain(){A a;a.fuc();a.test(a);}4.若未显式定义拷贝构造,编译器会自动生成拷贝构造函数。自动生成的拷贝构造对内置类型成员变量会完成值拷贝 / 浅拷贝 (一个字节一个字节的拷贝)。
5.像有的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的拷贝构造就可以完成需要的拷贝,所以不需要我们显式实现拷贝构造。
像 Stack 这样的类,虽然也都是内置类型,但是_a 指向了资源,编译器自动生成的拷贝构造完成的值拷贝 / 浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝 (对指向的资源也进行拷贝)。
自定义类型成员变量会调用它的拷贝构造。像 MyQueue 这样的类型内部主要是自定义类型 Stack 成员,编译器自动生成的拷贝构造会调用 Stack 的拷贝构造,也不需要我们显式实现 MyQueue 的拷贝构造。
这里还有一个小技巧
如果一个类显式实现了析构并释放资源,那么他就需要显式写拷贝构造,否则就不需要。
6.传值返回会产生一个临时对象调用拷贝构造,优点:安全,不受对象生命周期限制;缺点:产生临时对象,调用拷贝构造,有性能开销。
传引用返回,返回的是返回对象的别名 (引用),没有产生拷贝。优点:无临时对象、不触发拷贝构造,效率高;
但是如果返回对象是一个当前函数局部域的局部对象,函数结束就销毁了,那么使用引用返回是有问题的,这时的引用相当于一个野引用,类似一个野指针一样。传引用返回可以减少拷贝,但是一定要确保返回对象,在当前函数结束后还在,才能用引用返回。
像下面的代码中,如果引用就会报错。
Afuc(){A a;returna;//函数内部创建局部对象 a;//return a 时,标准规定要拷贝构造一个临时对象,//把 a 的数据复制给临时对象;//函数执行完毕,局部对象 a 立刻销毁;//外部接收变量再用拷贝构造把临时对象复制过来;}A&badFunc(){A local;// 局部对象,存储在栈上。returnlocal;// 危险!返回局部对象引用。}拷贝构造的调用:
usingnamespacestd;classDate{public:Date(intyear=1,intmonth=1,intday=1){_year=year;_month=month;_day=day;}Date(constDate&d){_year=d._year;_month=d._month;_day=d._day;}Date(Date*d){_year=d->_year;_month=d->_month;_day=d->_day;}private:int_year;int_month;int_day;};intmain(){Dated1(2026,7,9);//调用普通构造Date d2=d1;// 调用拷贝构造Dated3(d1);// 调用拷贝构造Dated4(&d1);// 取地址传入指针构造return0;}Date(Date* d) —— 指针构造,不属于拷贝构造。拷贝构造函数仅用一个同类对象就能调用,且第一个参数是本类引用。
手写拷贝构造实例
我们以栈(Stack)为例
系统默认生成的拷贝构造函数如下:
Stack(constStack&other){this->_a=other._a;// 直接复制指针地址,不是新开内存this->_capacity=other._capacity;this->_top=other._top;}如果 Stack 类不显式实现拷贝构造函数,使用编译器自动生成的拷贝构造函数进行浅拷贝,会导致 st1 和 st2 中的 _a 指针指向同一块堆内存。在析构阶段,两个对象会先后释放同一块内存,造成同一块空间被重复释放,程序将直接崩溃。
注意
局部变量的销毁顺序遵循栈的规则:后创建的先销毁。因此,会先销毁 st2,再销毁 st1。
由于篇幅问题剩下的知识由我后续的博客展开。