【吃透C++】万字解析类和对象

发布时间:2026/9/28 20:34:33
【吃透C++】万字解析类和对象 目录类是什么内联函数类的访问限定符类的作用域类的实例化对象大小的计算this指针是什么this指针的特性类的默认成员函数1.构造函数2.析构函数3.拷贝构造函数初始化列表4.构造函数和拷贝构造函数的相似与不同5.赋值运算符重载类的类型转换类是什么类是 C 面向对象编程当中用来对一类事物进行抽象描述的自定义类型相当于一张设计图纸它描述这类事物拥有哪些属性与行为。类通过class关键字进行定义类内部包含成员变量和成员函数成员变量用来保存事物的属性数据成员函数用来描述事物具备的行为。类本身不占用存储实体数据的内存空间它只是一个模板只有用类实例化创建对象之后才会在内存中开辟空间存储对象的数据。借助访问限定符 public、private、protected类可以实现封装把对象的内部数据保护起来对外只提供安全的访问接口。为了防止在类中定义函数使成员变量和函数参数混淆一般当声明和定义全部放在类体中时我们会将类中的成员变量名前面加_以示区分。类声明与成员函数定义分别放在不同的工程文件中类声明放在.h文件中成员函数定义放在.cpp文件中注意成员函数名前需要加 类名:内联函数内联函数是使用inline关键字修饰的函数C 编译器在调用内联函数时不会进行常规的函数调用跳转而是直接把函数的代码展开、嵌入到调用点位置省去函数调用建立栈帧、参数压栈、返回等开销以此提升程序运行效率。内联函数具有以下特点使用 inline 关键字修饰是给编译器的优化建议不是强制命令原理调用函数处直接嵌入函数代码省去函数调用的栈帧开销适合短小、频繁调用的简单函数含有循环、递归、复杂分支的函数一般不会被内联内联函数的声明和定义需要放在一起通常写在头文件不能声明定义分离不会生成独立函数地址大量使用会造成代码膨胀增大可执行文件体积类中直接定义的成员函数默认自动成为内联函数在类里面直接写函数体的成员函数不加 inline也默认是内联函数。class Stack { public: void Init(size_t capacity) { _arr (int*)malloc(sizeof(int) * capacity); if (nullptr _arr) { perror(malloc申请空间失败); return; } _capacity capacity; _top 0; } public: //成员变量的位置可以是类内的任意地方 int* _arr; int _top; int _capacity; };类的访问限定符C 类中有三种访问限定符分别是 public、private、protected用来控制类成员在类外部的访问权限以此实现封装。一共有三种public、private、protected用于控制类成员的访问权限实现封装public公有类内、类外都可以直接访问private私有仅类内部可以访问类外部不能直接访问class 类默认权限为 privateprotected保护类内部、派生类可以访问类外部不能直接访问作用范围从当前限定符开始到下一个访问限定符为止作用隐藏内部细节对外只开放必要接口保护内部数据class 默认 privatestruct 默认 public。类的作用域1.类拥有独立的作用域称为类作用域2.在类内部定义的成员变量、成员函数都属于该类的作用域3.在类内可以直接访问本类成员类外访问成员需要指定对象或使用类名::4.成员函数声明在类内即使定义写在类外仍然属于该类的作用域5.不同类的作用域相互独立不同类中可以存在同名成员互不冲突6.访问类外成员时需要借助.、-或者::作用域限定符有以下几点需要注意局部域,全局域,命名空间域,类域,不同的域中可以定义同名变量。类中的变量查找优先级:局部域类域全局域/命名空间域(非指定展开则不查找)。局部域和全局域会影响生命周期,类域和命名空间域不会影响生命周期。类的实例化类的实例化就是以类作为模板在内存中创建对象的过程。类只是对一类事物的抽象描述本身不分配存储数据的内存空间只有经过实例化之后才会为对象开辟内存空间用来存放类中的成员变量。类实例化出对象就像现实中使⽤建筑设计图建造出房子类就像是设计图设计图规划了有多少个房间房间大小功能等但是并没有实体的建筑存在也不能住人用设计图修建出房子房子才能住人。同样类就像设计图⼀样不能存储数据实例化出的对象分配物理内存存储数据。一个类可以实例化出多个不同的对象每个对象都拥有自己独立的成员变量不同对象之间的成员变量互不干扰。实例化对象时编译器会自动调用对应的构造函数完成对象初始化对象生命周期结束时自动调用析构函数释放资源。我们可以通过多种方式实例化对象例如在栈上创建局部对象、在堆上使用 new 创建对象也可以直接用已有对象拷贝实例出新对象。对象大小的计算在C语言中,我们学习过如何使用 sizeof 操作符计算结构体的大小以及如何通过结构体对齐规则去计算结构体的大小。在C中对类的大小计算我们同样可以使用 sizeof 操作符。内存对齐规则内存对齐是编译器为了提高 CPU 读取内存的效率对类的成员变量进行内存排布的规则。对齐数 成员自身大小与系统默认对齐数两者之中较小的值在 VS 中默认对齐数是 8Linux 下 gcc 默认对齐数为 4。第一个成员变量放在偏移量为 0 的地址处后续每个成员变量要存放在该成员对齐数的整数倍的地址位置整个类的总大小必须是类中最大成员对齐数的整数倍不足的地方编译器会填充空白字节。内存对齐会增加少量内存开销但是可以减少 CPU 访问内存的次数提升程序运行效率。可以使用预处理指令#pragma pack(n)修改默认对齐数n 代表新的默认对齐数。在C语言中结构体的大小是由成员变量构成这我们很好理解但在C中类的大小计算起来却看起来复杂的多因为类中不仅包含了成员变量还包含了成员函数,它们是如何存储在内存中的?它们和成员变量的关系又是怎样的?对象大小的计算如下对象大小 类中非静态成员变量占用的内存大小静态成员变量存放在静态区不属于对象不计入对象大小成员函数存放在代码段不属于对象不计入对象大小计算需要遵循内存对齐规则编译器会填充对齐字节空类没有成员变量对象大小为 1 字节仅作为占位标识访问限定符 public/private/protected 只控制权限不占用内存不影响对象大小this指针是什么this 指针是编译器提供的隐藏工具每一个非静态的成员函数编译器都会自动增加一个隐藏形参这个形参就是 this 指针它保存着调用该成员函数的对象地址。当我们调用d1.Init(2024,1,1)的时候编译器底层会改写代码把对象 d1 的地址作为实参传递给 this。我们在成员函数里面写的_year year本质等价于this-_year year。我们不能够在形参和实参的位置手动书写 this但是函数体内可以显式写 this- 访问成员。this 的类型是类名* const代表指针本身的指向不能够被修改this 指针存放在函数栈帧当中不属于对象内存。这里有一个经典的面试场景如果使用空指针去调用成员函数只要函数内部不去读写任何对象成员程序可以正常运行一旦函数内部访问了成员变量就会发生空指针解引用程序直接崩溃。#includeiostream using namespace std; 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 a; }; int main() { Date d1, d2; d1.Init(2024, 3, 10); d2.Init(2024, 7, 8); d1.Print(); d2.Print(); return 0; }Date类中有 Init 与 Print 两个成员函数函数体中没有关于不同对象的区分那当d1调用 Init 函数时该函数是如何知道应该设置d1对象而不是设置d2对象呢C中通过引入this指针解决该问题即C编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数让该指针指向当前对象(函数运行时调用该函数的对象)在函数体中所有“成员变量”的操作都是通过该指针去访问。只不过所有的操作对用户是透明的即用户不需要来传递编译器自动完成。this指针的特性this 指针的类型为类类型* const这代表在成员函数内部我们不能够修改 this 指针本身的指向无法给 this 指针赋值但是可以修改 this 所指向对象的成员。this 指针只能够在非静态成员函数的内部使用。它本质上属于成员函数的一个隐藏形参并不存储在对象之中当对象调用成员函数的时候会将该对象的地址作为实参传递给 this 形参。this 指针是成员函数第一个隐含的指针形参通常编译器会通过 ecx 寄存器自动完成参数传递不需要程序员手动进行传参操作。静态成员函数不存在 this 指针。this不能在形参和实参显示传递,但是可以在函数内部显示使用。类的默认成员函数默认成员函数指的是类中用户没有显式手写实现时C编译器会自动帮我们生成的一类成员函数。如果我们自己显式写出了某一个默认成员函数那么编译器就不会再为这个类生成该函数。一个普通的类在我们什么都不写的情况下编译器一共会生成六个默认成员函数分别是构造函数、析构函数、拷贝构造函数、赋值运算符重载、普通取地址重载以及const取地址重载C11之后还新增了移动构造和移动赋值。编译器自动生成的无参默认构造函数不会对int、char这类内置类型成员做可靠初始化成员值是随机的对于类内的自定义类型成员则会调用该成员的默认构造函数。编译器生成的默认析构函数不会处理内置类型成员会自动调用自定义类型成员的析构函数如果类内部存在malloc或者new申请的堆内存资源默认析构不会释放这片资源这时就需要我们手动编写析构避免发生内存泄漏。默认生成的拷贝构造函数和赋值运算符重载都会执行浅拷贝逐字节复制对象内存内容当类中存在指向堆空间的指针时浅拷贝会让多个对象共享同一块堆内存析构阶段重复释放内存造成程序崩溃这种场景就需要我们手动实现深拷贝。普通取地址重载与const取地址重载一般直接使用编译器生成的版本即可只有特殊需求下才需要手动改写。其中构造函数、析构函数、拷贝构造函数、赋值运算符重载是学习的重点取地址的两个重载了解即可。1.构造函数构造函数是一个特殊的成员函数它的主要任务不是为对象开辟内存空间对象的内存空间在实例化的时候就已经开辟完成构造函数专门用于完成对象的初始化工作。构造函数拥有这些特性函数的名字必须和类名保持完全相同没有返回值连 void 也不能书写当对象实例化创建的时候编译器会自动调用对应的构造函数不需要我们手动调用构造函数支持函数重载一个类可以拥有多个不同参数的构造函数。如果我们没有显式地编写任何构造函数编译器就会自动生成一份无参的默认构造函数一旦用户手写了任意一个构造函数编译器就不会再自动生成默认构造。例如创建一个日期类Date但不写构造函数且在主函数调用无参构造函数这样编译器就会默认为你生成一份无参默认构造函数。class Date { public: void Print() { cout _year - _month - _day endl; } private: int _year; int _month; int _day; }; int main() { Date d1; return 0; }但是如果当我们显式自定义了构造函数系统就不会再生成那个无参的构造函数了这时我们再在主函数调用无参的构造函数就会失败class Date { public: //用户自定义的显式带参构造函数 Date(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; return 0; }注意上面举例当中我们自定义带参构造函数目的就是为了验证一条规则只要用户自己手写了任意一个构造函数编译器就不会再自动生成那份系统提供的无参默认构造函数。假如我们手写的是无参构造函数编译器同样不会再生成默认构造函数只不过此时在主函数中创建无参对象可以正常编译运行因为这时编译器调用的是我们自己写的无参构造这种情况没办法直观体现 “用户自定义构造后系统不再生成默认构造” 这一特性。所以我们才选用自定义带参构造的案例来演示印证 C 构造函数的这条特性一旦手写了带参构造类中就没有可用的无参构造直接定义无参对象就会编译报错以此证明编译器已经不再自动生成无参的默认构造函数。不需要传递实参就能够调用的构造函数都叫做默认构造函数包含编译器自动生成的无参构造、用户手写的无参构造以及全缺省构造函数三者只能同时存在一个。编译器默认生成的构造函数对于 int、char 这类内置类型成员不会保证初始化成员的值是随机的对于类里面的自定义类型成员则会调用该成员自身的默认构造函数。class Date{ public: // 1.无参构造函数 Date(){ } // 2.带参构造函数 Date(int year, int month, int day){ _year year; _month month; _day day; } private: int _year; int _month; int _day; }; int main(){ Date d1; // 调用无参构造函数 Date d2(2026, 9, 27); // 调用带参的构造函数 return 0; }注意如果我们要通过无参构造函数去创建对象对象名字后面不能加括号一旦写上括号编译器就不会把它当作对象定义而是识别成一个函数声明。例如Date d3();这行代码并不是创建 Date 类的对象 d3而是声明了一个叫做d3的函数这个函数没有参数函数返回值是 Date日期类型的对象。如果想要定义无参对象直接写为Date d3;即可。下面这段代码就是一段错误代码class Date{ public: // 1.无参构造函数 Date(){ } // 2.带参构造函数 Date(int year, int month, int day){ _year year; _month month; _day day; } private: int _year; int _month; int _day; }; int main(){ // 注意如果通过无参构造函数创建对象时对象后面不用跟括号否则就成了函数声明 // 以下代码的函数声明了d3函数该函数无参返回一个日期类型的对象 Date d3(); return 0; }2.析构函数析构函数是一种特殊的成员函数它的作用是在对象生命周期结束时自动完成对象资源的清理工作它并不会销毁对象本身占用的内存对象内存的回收由操作系统负责析构函数主要用来释放对象内部通过new或malloc在堆上申请的资源。析构函数具有这些特性1.析构函数的名字由波浪号~加上类名构成没有返回值也没有参数因此析构函数不能重载2.当对象生命周期结束时编译器会自动调用析构函数不需要手动调用。3.如果我们没有显式编写析构函数编译器会自动生成默认析构函数。4.编译器生成的默认析构函数不会对 int、char 这类内置类型成员做任何处理对于类里面包含的自定义类型成员则会自动调用该自定义类型的析构函数。class Time { public: ~Time() { cout Time类析构函数:~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 类的析构函数原因是 main 中创建了 Date 类的对象 d。对象 d 内部包含四个成员变量其中_year,_month,_day 属于内置类型成员销毁时无需额外清理资源操作系统直接回收内存即可而成员_t是 Time 类型的对象属于自定义类型成员。当 Date 对象 d 生命周期结束需要销毁时它内部包含的_t对象也要跟着销毁因此就会调用 Time 类的析构函数。main 函数并不会直接去调用 Time 类的析构函数我们要释放的本质是 Date 对象编译器会调用 Date 类的析构函数这里我们没有手写 Date 的析构编译器就自动生成 Date 的默认析构函数这个默认析构内部会自动调用成员_tTime 对象的析构函数保证 Date 对象内部所有自定义类型成员都能够正确完成销毁。这里我们要记住一条核心规则创建哪一个类的对象就调用这个类的构造函数销毁哪一个类的对象就调用这个类的析构函数。5.在同一个局部作用域中多个对象会遵循后创建先析构的顺序执行析构。只要类中有堆上动态开辟的内存资源就必须手动编写析构函数释放资源否则会造成内存泄漏。3.拷贝构造函数拷贝构造函数是一种特殊的构造函数它使用一个已经存在的同类对象去初始化一个新创建出来的对象。拷贝构造函数拥有这些特性1.拷贝构造函数的函数名与类名相同没有返回值第一个参数必须是本类类型的引用一般会加上 const 修饰防止原对象被修改而且不能使用值传递作为参数值传递会无限递归调用拷贝构造引发编译报错。拷贝构造函数的第一个参数不能使用值传递必须使用引用的原因是如果参数写成值传递Date (Date d)当我们使用一个对象去初始化新对象例如Date d2(d1);就会触发无限递归。因为 C 语法规定自定义类型对象值传递传参的时候会调用拷贝构造函数来实参拷贝给形参。执行Date d2(d1);时d1作为实参要赋值给形参d形参d是值传递需要调用拷贝构造把d1拷贝构造出形参对象d调用拷贝构造又需要继续把实参传给形参又会再次调用拷贝构造如此循环往复形成无限递归编译器直接报错。而使用引用参数 const Date d引用只是原对象的别名不会产生新对象不会触发拷贝构造就避免了递归问题。2.拷贝构造函数也是默认成员函数如果我们没有显式编写拷贝构造编译器会自动生成默认拷贝构造函数一旦用户手写实现编译器就不会再生成。编译器默认生成的拷贝构造会执行浅拷贝按照内存字节把旧对象的成员值原样复制到新对象中。当类中没有动态堆内存资源时浅拷贝可以正常工作但类里存在指针管理堆空间时浅拷贝会让多个对象共用同一块堆内存对象析构时重复释放内存造成程序崩溃这时就需要手动实现深拷贝。3.拷贝构造函数会在三种场景自动调用使用已有对象初始化新对象、函数参数以值传递的方式传入类对象、函数以值的方式返回类对象。初始化列表构造函数初始化还有一种方式就是初始化列表初始化列表的使用方式是以一个冒号开始接着是一个以逗号分隔的数据成员列表每个成员变量后面跟一个放在括号中的初始值或表达式。•每个成员变量在初始化列表中只能出现一次语法理解上初始化列表可以认为是每个成员变量定义初始化的地方。•引用成员变量const成员变量没有默认构造的类类型变量必须放在初始化列表位置进⾏初始 化否则会编译报错。•C11支持在成员变量声明的位置给缺省值这个缺省值主要是给没有显示在初始化列表初始化的成员使用的。•尽量使用初始化列表初始化因为那些你不在初始化列表初始化的成员也会走初始化列表如果这个成员在声明位置给了缺省值初始化列表会用这个缺省值初始化。如果你没有给缺省值对于没有显示在初始化列表初始化的内置类型成员是否初始化取决于编译器C并没有规定。对于没有显示在初始化列表初始化的自定义类型成员会调用这个成员类型的默认构造函数如果没有默认构 造会编译错误。•初始化列表中按照成员变量在类中声明顺序进行初始化跟成员在初始化列表出现的的先后顺序无关。建议声明顺序和初始化列表顺序保持一致。总结无论是否显示写初始化列表每个构造函数都有初始化列表 无论是否在初始化列表显示初始化成员变量每个成员变量都要走初始化列表初始化!4.构造函数和拷贝构造函数的相似与不同构造函数与拷贝构造函数都属于特殊的成员函数函数名和类名相同并且没有返回值但二者存在明显区别。1.构造函数用于在创建全新对象时完成对象的初始化参数可以是任意类型、任意数量支持重载而拷贝构造函数是一种特殊的构造函数专门用来使用一个已经存在的同类对象去初始化另一个新对象它的第一个参数必须是同类的引用一般用 const 修饰不能使用值传递否则会引发无限递归。2.如果类中没有手动编写构造函数编译器会生成无参的默认构造函数如果没有手写拷贝构造编译器会自动生成默认拷贝构造函数默认拷贝构造执行浅拷贝。3.调用时机不一样构造函数在直接创建对象时调用拷贝构造函数在三种场景自动触发用已有对象初始化新对象、对象作为函数参数值传递、对象作为返回值以值的形式返回。5.赋值运算符重载赋值运算符重载是 C 的默认成员函数之一作用是处理两个已经存在的同类对象之间互相赋值的操作需要和拷贝构造区分开拷贝构造是用已有对象去初始化新创建的对象而赋值运算符重载针对的是两个生命周期都已经开始的旧对象。赋值运算符重载的函数名格式为operator一般写成类的成员函数函数参数推荐使用const 类名接收源对象返回值为类名用来支持连续赋值例如d1 d2 d3。函数内部通常要先判断 this !src用来防止对象自己给自己赋值引发的问题。如果用户没有显式编写赋值运算符重载编译器会自动生成默认的赋值重载默认版本执行浅拷贝逐字节复制对象成员。如果类中存在堆上动态开辟的资源浅拷贝会造成多个对象共用同一块堆内存析构时重复释放内存程序崩溃这种场景下就需要我们手动实现深拷贝版本的赋值重载。不能通过连接其他符号来创建新的操作符比如operator重载操作符必须有一个类类型参数用于内置类型的运算符其含义不能改变例如内置的整型不能改变其含义操作符是几个操作数,类外的重载函数就有几个参数作为类成员函数重载时其形参看起来比操作数数目少1因为成员函数的第一个参数为隐藏的this.* / :: / sizeof / ?: / .以上5个运算符不能重载!使用operator关键字实现了重载运算符:class Date { public: Date(int year 1, int month 1, int day 1) { _year year; _month month; _day day; } void Print() { cout _year - _month - _day endl; } bool operator(const Date x) { if (_year x._year){ return true; } else if (_year x._year _month x._month){ return true; } else if (_year x._year _month x._month _day x._day){ return true; } return false; } private: int _year; int _month; int _day; }; int main() { Date d1(2024,1,1); Date d2(2024,3,14); //直接使用运算符比较d1和d2 d1 d2; //调用operator函数比较d1和d2 d1.operator(d2); return 0; }类的类型转换C 类的类型转换主要指单参数构造函数支持的隐式类型转换当构造函数只有一个参数或者除第一个参数外其余参数都带有缺省值时编译器可以自动调用该构造函数把对应的参数类型隐式转换为类类型生成一个临时对象。使用explicit关键字修饰构造函数就可以禁止这种隐式类型转换只允许显式调用构造函数创建对象。隐式转换可以简化代码但有时会带来意料之外的对象创建引发 bug所以一般建议用 explicit 限制单参数构造函数关闭隐式转换。类类型的对象之间也可以隐式转换需要相应的构造函数支持。#includeiostream using namespace std; class A { public: // 构造函数explicit就不再⽀持隐式类型转换 // explicit A(int a1) A(int a1) :_a1(a1) {} //explicit A(int a1, int a2) A(int a1, int a2) :_a1(a1) , _a2(a2) {} void Print() { cout _a1 _a2 endl; } int Get() const { return _a1 _a2; } private: int _a1 1; int _a2 2; }; class B { public: B(const A a) :_b(a.Get()) {} private: int _b 0; }; int main() { // 1构造⼀个A的临时对象再⽤这个临时对象拷⻉构造aa3 // 编译器遇到连续构造拷⻉构造-优化为直接构造 A aa1 1; aa1.Print(); const A aa2 1; // C11之后才⽀持多参数转化 A aa3 { 2,2 }; // aa3隐式类型转换为b对象 // 原理跟上⾯类似 B b aa3; const B rb aa3; return 0; }