目录
类是什么?
内联函数
类的访问限定符
类的作用域
类的实例化
对象大小的计算
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 类默认权限为 private
- protected(保护):类内部、派生类可以访问,类外部不能直接访问
- 作用范围:从当前限定符开始,到下一个访问限定符为止
- 作用:隐藏内部细节,对外只开放必要接口,保护内部数据
- class 默认 private;struct 默认 public。
类的作用域
1.类拥有独立的作用域,称为类作用域
2.在类内部定义的成员变量、成员函数,都属于该类的作用域
3.在类内可以直接访问本类成员;类外访问成员,需要指定对象或使用类名::
4.成员函数声明在类内,即使定义写在类外,仍然属于该类的作用域
5.不同类的作用域相互独立,不同类中可以存在同名成员,互不冲突
6.访问类外成员时,需要借助.、->或者::作用域限定符
有以下几点需要注意:
局部域,全局域,命名空间域,类域,不同的域中可以定义同名变量。
类中的变量查找优先级:局部域>类域>全局域/命名空间域(非指定展开则不查找)。
局部域和全局域会影响生命周期,类域和命名空间域不会影响生命周期。
类的实例化
类的实例化,就是以类作为模板,在内存中创建对象的过程。类只是对一类事物的抽象描述,本身不分配存储数据的内存空间,只有经过实例化之后,才会为对象开辟内存空间,用来存放类中的成员变量。类实例化出对象就像现实中使⽤建筑设计图建造出房子,类就像是设计图,设计图规划了有多少个房间,房间大小功能等,但是并没有实体的建筑存在,也不能住人,用设计图修建出房子,房子才能住人。同样类就像设计图⼀样,不能存储数据,实例化出的对象分配物理内存存储数据。
一个类可以实例化出多个不同的对象,每个对象都拥有自己独立的成员变量,不同对象之间的成员变量互不干扰。实例化对象时,编译器会自动调用对应的构造函数完成对象初始化;对象生命周期结束时,自动调用析构函数释放资源。我们可以通过多种方式实例化对象,例如在栈上创建局部对象、在堆上使用 new 创建对象,也可以直接用已有对象拷贝实例出新对象。
对象大小的计算
在C语言中,我们学习过如何使用 sizeof 操作符计算结构体的大小,以及如何通过结构体对齐规则去计算结构体的大小。在C++中,对类的大小计算我们同样可以使用 sizeof 操作符。
内存对齐规则:内存对齐是编译器为了提高 CPU 读取内存的效率,对类的成员变量进行内存排布的规则。对齐数 = 成员自身大小与系统默认对齐数两者之中较小的值,在 VS 中默认对齐数是 8,Linux 下 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 指针存放在函数栈帧当中,不属于对象内存。这里有一个经典的面试场景,如果使用空指针去调用成员函数,只要函数内部不去读写任何对象成员,程序可以正常运行;一旦函数内部访问了成员变量,就会发生空指针解引用,程序直接崩溃。
#include<iostream> 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取地址重载,C++11之后还新增了移动构造和移动赋值。
编译器自动生成的无参默认构造函数,不会对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 的默认析构函数,这个默认析构内部会自动调用成员_t(Time 对象)的析构函数,保证 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成员变量,没有默认构造的类类型变量,必须放在初始化列表位置进⾏初始 化,否则会编译报错。
•C++11支持在成员变量声明的位置给缺省值,这个缺省值主要是给没有显示在初始化列表初始化的成员使用的。
•尽量使用初始化列表初始化,因为那些你不在初始化列表初始化的成员也会走初始化列表,如果这个成员在声明位置给了缺省值,初始化列表会用这个缺省值初始化。如果你没有给缺省值,对于没有显示在初始化列表初始化的内置类型成员是否初始化取决于编译器,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 限制单参数构造函数,关闭隐式转换。
类类型的对象之间也可以隐式转换,需要相应的构造函数支持。
#include<iostream> 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; // C++11之后才⽀持多参数转化 A aa3 = { 2,2 }; // aa3隐式类型转换为b对象 // 原理跟上⾯类似 B b = aa3; const B& rb = aa3; return 0; }