
文章目录前言一、类的定义1.类的定义格式2.访问限定符3.类域4.注意二、类的实例化1. 实例化的概念2. 对象的大小注意内存对齐规则三、this指针实践一下四、类的默认成员函数1.构造函数构造函数的特点自定义类型类的构造注意2.析构函数3.拷贝构造函数拷贝构造的特点拷贝构造的调用手写拷贝构造实例前言一、类的定义1.类的定义格式class 是定义类的关键字Stack 为类名大括号内部是类的主体注意类定义结束后的分号不能省略。类体内的内容统称为类的成员类中的变量叫做类的属性或成员变量类中的函数叫做类的方法或者成员函数。为了区分成员变量编程习惯上会给成员变量添加特殊标识比如在成员变量开头或结尾加下划线或者以 m 开头。C 语法并不强制要求遵守该规范。usingnamespacestd;classname{public:voidPrint(){couthelloendl;}voidset(inta){agea;}intgetAge(){returnage;}private:intage;// 为了区分成员变量一般习惯上成员变量// 会加一个特殊标识如以下划线或 m 开头//int _year; // year_ m_year//int _month;//int _day;};intmain(){name n;n.Print();n.set(1);coutn.getAge()endl;return0;}用法与 C 的结构体类似创建对象后使用“.”来调用属性和方法。C 中的 struct 同样可以用来定义类它既兼容 C 语言里 struct 的使用方式又升级具备了类的特性最明显的变化就是 struct 内部可以定义成员函数。日常开发中依旧推荐使用 class 来定义类。usingnamespacestd;// C 中 struct 升级成了类// 1、类里面可以定义函数// 2、struct 名称就可以代表类型// C 兼容 C 中 struct 的用法typedefstructListcodeCPP{intval;structListcodeCPP*next;}LC;// 不再需要typedefListNodeCPP就可以代表类型structListcodecpp{voidPrint(){couthelloendl;}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(intn4);private:// 成员变量int*array;size_t capacity;size_t top;};// 声明与定义分离需要指定类域voidStack::Init(intn){array(int*)malloc(sizeof(int)*n);if(nullptrarray){perror(malloc申请空间失败);return;}capacityn;top0;}intmain(){Stack st;st.Init();return0;}即对于类中的成员函数一共有2种定义方式第一种就是直接在类中定义类内直接写函数体既是声明也是定义适合短小简单函数编译器会默认尝试内联。第二种仅在类内声明类外实现即我们展示的这种。类内声明必须带分号 。类外实现必须加 类名::否则是普通全局函数。返回值、函数名、参数列表必须和声明完全一致。4.注意类域解决了类和类之间的命名冲突。这样即使两个成员函数同名也都可以正常使用。二、类的实例化1. 实例化的概念用类类型在物理内存中创建对象的过程称为类的实例化。类是对对象的抽象描述相当于模板它规定了类包含哪些成员变量。这些成员变量在类中仅为声明不会分配内存只有通过类实例化出对象时才会为这些成员变量分配存储空间。usingnamespacestd;classA{public:voidPrint(){coutaendl;}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_chendl;}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(){coutA::Print()endl;//结果一cout_aendl;//结果二coutA::Print()endl;//结果三}private:int_a;};intmain(){A*pnullptr;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个最后两个取地址重载不重要简单了解即可。此外C11 之后新增了两个默认成员函数移动构造函数和移动赋值运算符这部分内容我们后面再讲解。默认成员函数十分重要且知识点比较复杂我们可以从以下两个方面去学习第一我们不写时编译器默认生成的函数行为是什么是否满足我们的需求。第二如果编译器默认生成的函数不满足我们的需求我们需要自己实现那么应如何实现1.构造函数构造函数是特殊的成员函数需要注意的是构造函数虽然名称叫构造但是其主要任务并不是开辟空间创建对象我们常使用的局部对象是在栈帧创建时空间就已经开辟好了而是在对象实例化时初始化对象。构造函数的本质是替代我们之前在 Stack 和 Date 类中编写的 Init 函数的功能其自动调用的特性完美地替代了 Init。构造函数的特点函数名与类名相同。无返回值不需要指定返回类型包括 void。对象实例化时系统会自动调用对应的构造函数。构造函数可以重载。usingnamespacestd;classStudent{public:Student(){age0;name0;}Student(inta){agea;name0;}Student(inta,charn){agea;namen;}intage;charname;};intmain(){Student();Student(1);Student(1,q);}构造函数可以重载系统会根据传入的参数自动调用对应的构造函数。如果类中没有显式定义构造函数则 C 编译器会自动生成一个无参的默认构造函数一旦用户显式定义了任何构造函数编译器将不再自动生成默认构造函数。无参构造函数、全缺省构造函数以及编译器自动生成的默认构造函数都称为默认构造函数。但一个类中只能存在一个默认构造函数不能同时存在多个。无参构造函数和全缺省构造函数虽然构成函数重载但调用时会产生歧义。需要注意的是默认构造函数并非特指编译器自动生成的那个。实际上无参构造函数和全缺省构造函数也属于默认构造函数。总结来说调用时无需传入任何实参的构造函数就是默认构造函数。usingnamespacestd;classPerson{public:// 无参构造默认构造Person(){cout无参构造endl;}// 全缺省构造也是默认构造Person(inta0,string n无名){cout全缺省构造endl;}};intmain(){Person p;// 编译器歧义不知道调用上面哪一个return0;}很多人误以为 “默认构造” 单指编译器自动生成的那个这是错误的。判断标准只有一条调用时无需传入任何实参 → 就是默认构造函数。区分部分缺省 ≠ 全缺省只有所有参数都有默认值才算默认构造如果只有部分参数带默认值不传参无法调用不属于默认构造自定义类型类的构造如果我们不写构造函数编译器默认生成的构造函数对于内置类型成员变量的初始化没有要求即是否初始化是不确定的取决于编译器。对于自定义类型成员变量则会调用该成员变量的默认构造函数进行初始化。如果该成员变量没有默认构造函数则会导致编译错误。此时我们需要使用初始化列表来初始化该成员变量。说明C将类型分为内置类型基本类型和自定义类型。内置类型是语言提供的原生数据类型如 int、char、double、指针等自定义类型则是我们使用 class、struct 等关键字自己定义的类型。usingnamespacestd;typedefintSTDataType;classStack{public:Stack(intn4){_a(STDataType*)malloc(sizeof(STDataType)*n);if(nullptr_a){perror(malloc申请空间失败);return;}_capacityn;_top0;}// ...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(){coutA::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(intn4){_a(DataTypedef*)malloc(sizeof(DataTypedef)*n);if(_anullptr){perror(申请失败);return;}top0;capn;}~Stack(){free(_a);_anullptr;top0;cap0;}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(constAa)// 拷贝构造函数{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 立刻销毁//外部接收变量再用拷贝构造把临时对象复制过来}AbadFunc(){A local;// 局部对象存储在栈上。returnlocal;// 危险返回局部对象引用。}拷贝构造的调用usingnamespacestd;classDate{public:Date(intyear1,intmonth1,intday1){_yearyear;_monthmonth;_dayday;}Date(constDated){_yeard._year;_monthd._month;_dayd._day;}Date(Date*d){_yeard-_year;_monthd-_month;_dayd-_day;}private:int_year;int_month;int_day;};intmain(){Dated1(2026,7,9);//调用普通构造Date d2d1;// 调用拷贝构造Dated3(d1);// 调用拷贝构造Dated4(d1);// 取地址传入指针构造return0;}Date(Date* d) —— 指针构造不属于拷贝构造。拷贝构造函数仅用一个同类对象就能调用且第一个参数是本类引用。手写拷贝构造实例我们以栈Stack为例系统默认生成的拷贝构造函数如下Stack(constStackother){this-_aother._a;// 直接复制指针地址不是新开内存this-_capacityother._capacity;this-_topother._top;}如果 Stack 类不显式实现拷贝构造函数使用编译器自动生成的拷贝构造函数进行浅拷贝会导致 st1 和 st2 中的 _a 指针指向同一块堆内存。在析构阶段两个对象会先后释放同一块内存造成同一块空间被重复释放程序将直接崩溃。注意局部变量的销毁顺序遵循栈的规则后创建的先销毁。因此会先销毁 st2再销毁 st1。由于篇幅问题剩下的知识由我后续的博客展开。