文章目录
- 一、初始化列表:构造函数真正的战场
- 1.1 函数体内赋值的局限
- 1.2 初始化列表的语法和执行规则
- 1.3 两句话总结初始化列表
- 二、隐式类型转换与explicit
- 2.1 单参数构造函数的隐式转换
- 2.2 explicit:禁止隐式转换
- 2.3 多参数构造函数的隐式转换(C++11)
- 2.4 类类型之间的隐式转换
- 三、static成员:不属于任何对象的共享数据
- 3.1 静态成员变量
- 3.2 静态成员函数
- 3.3 实用场景:对象计数
- 四、友元:封装的"后门"
- 4.1 友元函数
- 4.2 友元类
- 4.3 友元的代价
- 五、内部类:定义在类里面的独立类
- 六、匿名对象:只用一次,不留名字
- 七、编译器拷贝优化:你以为的拷贝,它压根没做
- 7.1 什么时候会触发拷贝
- 7.2 编译器在偷偷做什么
- 7.3 优化规则总结
- 总结
上一篇讲了类和对象的四个核心默认成员函数——构造、析构、拷贝构造、赋值重载。这篇继续往下挖:构造函数该怎么优雅地初始化成员(初始化列表),static成员和友元怎么打破封装边界,编译器在背后做了哪些拷贝优化,以及内部类和匿名对象这些容易被忽略但面试常考的点。
一、初始化列表:构造函数真正的战场
1.1 函数体内赋值的局限
构造函数里写_year = year这种方式不叫"初始化",叫"赋值"。真正的初始化发生在构造函数体执行之前:
classDate{public:Date(intyear,intmonth,intday){// 这行开始执行之前,_year/_month/_day 已经被默认初始化了// 现在做的只是"覆盖赋值"_year=year;_month=month;_day=day;}};对于内置类型,先默认初始化再赋值,多了一轮操作,但能忍。但对于以下三种成员,函数体内赋值根本行不通:
const成员变量:只能初始化,不能赋值。引用成员变量:必须在定义时绑定,不能后续改绑。- 没有默认构造函数的类类型成员:编译器在初始化列表中找不到它的构造调用,直接报错。
classTime{public:Time(inthour):_hour(hour){}// 只有一个有参构造,没有默认构造private:int_hour;};classDate{public:Date(int&x,intyear,intmonth,intday)// 下面这些必须在初始化列表里做,函数体内做不了:_year(year),_month(month),_day(day),_t(12)// Time没有默认构造,必须在这里显式初始化,_ref(x)// 引用必须在定义时绑定,_n(1)// const必须初始化{// 函数体可以为空,或者做额外的逻辑判断、校验}private:int_year;int_month;int_day;Time _t;// 没有默认构造的类类型成员int&_ref;// 引用成员constint_n;// const成员};这三种成员,必须出现在初始化列表中。放在函数体内用赋值替代,编译器不干。
1.2 初始化列表的语法和执行规则
Date(intyear,intmonth,intday):_year(year)// 用括号里的值初始化成员,_month(month),_day(day),_t(12),_ref(x),_n(1){// 函数体}三条铁律:
铁律一:初始化顺序和初始化列表中的书写顺序无关,只跟成员变量在类中声明的顺序有关。
classA{public:A(inta):_a2(a)// 虽然写在前面……,_a1(_a2)// ……但 _a1 先初始化(因为声明在前){}voidPrint(){cout<<_a1<<" "<<_a2<<endl;// _a1 是随机值!_a2 还没初始化就用了}private:int_a1;// ← 声明顺序:_a1 先于 _a2int_a2;};输出_a1是什么?它用_a2初始化,但_a2此时还没被初始化(虽然_a2(a)在初始化列表中写在了前面,但声明顺序决定了_a1先初始化)。结果:_a1是随机值,_a2是a的值。
解法很简单:把初始化列表的顺序和声明顺序保持一致。别玩花活。
铁律二:无论你写不写初始化列表,每个构造函数都有初始化列表;每个成员变量都要走一次初始化列表。
你没写的那些成员怎么办?看你有没有在声明处给缺省值:
classDate{public:Date():_month(2)// 只显式初始化了 _month{cout<<"Date()"<<endl;}private:int_year=1;// 声明处给的缺省值——_year 用 1 初始化int_month=1;// 初始化列表显式指定了 2,缺省值被覆盖int_day;// 没缺省值,内置类型——是否初始化取决于编译器,结果不确定Time _t=1;// 缺省值,调用 Time(1) 初始化constint_n=1;// 缺省值int*_ptr=(int*)malloc(12);// 缺省值};缺省值的作用:初始化列表中没显式初始化的成员,就用声明处给的缺省值。注意这不是赋值——缺省值是给初始化列表用的,本质还是初始化。
1.3 两句话总结初始化列表
- 必须出现在初始化列表中的:const成员、引用成员、没有默认构造的类类型成员。
- 建议出现在初始化列表中的:所有成员。因为不在初始化列表中的成员也会走初始化列表(用缺省值或随机值),然后再在函数体内被赋值——多一轮开销。
二、隐式类型转换与explicit
2.1 单参数构造函数的隐式转换
classA{public:A(inta1):_a1(a1){}voidPrint(){cout<<_a1<<endl;}private:int_a1;};intmain(){Aaa1(10);// 直接构造A aa2=10;// 隐式转换:10 → A(10) → 拷贝给 aa2// 编译器实际做法:构造一个临时 A(10),再用这个临时对象拷贝构造 aa2// 现代编译器会优化:直接把 aa2 当成 A(10) 来构造,省掉临时对象constA&ref=10;// 隐式转换:临时对象 A(10),ref 绑定到这个临时对象上return0;}隐式转换发生的条件:存在一个单参数的构造函数(或者多参数但后面都有默认值),且该构造函数没有被explicit修饰。
这种隐式转换有时候很便利,但更多时候是隐患——你可能不希望一个int值莫名其妙变成你的类对象。
2.2 explicit:禁止隐式转换
classA{public:explicitA(inta1):_a1(a1){}private:int_a1;};intmain(){Aaa1(10);// OK:直接构造// A aa2 = 10; // 错误:explicit 禁止了隐式转换return0;}加了explicit,A aa2 = 10就编译不过。什么时候加explicit?当你认为int不应该被自动转成你的类时——判断标准是:这种隐式转换是否会让代码的意图变得含糊。
2.3 多参数构造函数的隐式转换(C++11)
classA{public:A(inta1,inta2):_a1(a1),_a2(a2){}private:int_a1;int_a2;};intmain(){A aa1={2,2};// C++11:用大括号做多参数隐式转换A aa2{2,2};// 等价写法return0;}C++11之前只支持单参数隐式转换,C++11之后大括号初始化列表让多参数也能转。同样可以用explicit禁掉。
2.4 类类型之间的隐式转换
classA{public:A(inta1):_a1(a1){}intGet()const{return_a1;}private:int_a1;};classB{public:B(constA&a):_b(a.Get()){}// 接收 A 对象的构造private:int_b;};intmain(){Aaa(10);B b=aa;// 隐式转换:aa → 临时 B(aa) → 拷贝给 bconstB&ref=aa;// 隐式转换:ref 绑定到临时 B 对象return0;}只要B有一个接受const A&的构造函数(且非explicit),A对象就能隐式转成B对象。原理和内置类型转类类型完全一样。
三、static成员:不属于任何对象的共享数据
3.1 静态成员变量
classA{public:A(){++_scount;}A(constA&t){++_scount;}~A(){--_scount;}staticintGetACount(){return_scount;}private:staticint_scount;// 声明:在类内部};intA::_scount=0;// 定义+初始化:必须在类外部,全局位置intmain(){cout<<A::GetACount()<<endl;// 0A a1,a2;Aa3(a1);cout<<A::GetACount()<<endl;// 3cout<<a1.GetACount()<<endl;// 3(也可以通过对象访问,但不推荐)return0;}关键特性:
- 静态成员变量属于类,不属于任何具体对象。存在静态区,不计入
sizeof(类)。 - 必须在类外定义并初始化。声明处给的
= 0不给过——那是C#和Java的语法,C++不认。 - 受访问限定符限制:
private的静态成员不能在类外直接访问。 - 不能在声明处给缺省值。因为缺省值是给构造函数的初始化列表用的,而静态成员不属于具体对象,不走构造函数的初始化列表。
3.2 静态成员函数
classA{public:staticintGetACount()// 静态成员函数{// _a = 10; // 错误!没有 this 指针,访问不了普通成员return_scount;// OK:静态函数可以访问静态成员}private:int_a=0;// 普通成员staticint_scount;// 静态成员};静态成员函数没有this指针。这意味着:
- 不能访问任何非静态成员(没有
this,不知道是哪个对象的成员)。 - 可以访问所有静态成员。
- 可以通过
类名::函数名()访问,也可以通过对象访问(但不推荐,容易混淆)。
3.3 实用场景:对象计数
上面的A类就是一个完整的对象计数器。每次构造+1,每次拷贝构造+1,每次析构-1。这种模式在内存管理和调试中有实际应用。
四、友元:封装的"后门"
4.1 友元函数
classA;// 前置声明classB{friendvoidFunc(constA&aa,constB&bb);// 声明Func为B的友元private:int_b1=3;int_b2=4;};classA{friendvoidFunc(constA&aa,constB&bb);// 声明Func为A的友元private:int_a1=1;int_a2=2;};voidFunc(constA&aa,constB&bb){cout<<aa._a1<<endl;// Func是A和B的友元,可以访问私有成员cout<<bb._b1<<endl;}核心规则:
- 友元函数不是类的成员函数——它就是一个普通函数,只是被授权访问类的私有成员。
- 声明位置不限——放
public、private、protected区域都一样,因为它不是成员,不受访问限定符约束。 - 一个函数可以是多个类的友元(如上例中的
Func同时是A和B的友元)。
4.2 友元类
classA{friendclassB;// B 是 A 的友元private:int_a1=1;int_a2=2;};classB{public:voidfunc1(constA&aa){cout<<aa._a1<<endl;// OK:B的成员函数可以访问A的私有成员cout<<_b1<<endl;}private:int_b1=3;int_b2=4;};友元类的特性:
- B是A的友元→B的所有成员函数都能访问A的私有成员。
- 单向性:B是A的友元,不代表A是B的友元。上例中A不能访问B的私有成员。
- 不可传递:A是B的友元,B是C的友元,不代表A是C的友元。
4.3 友元的代价
友元打破了封装。封装的意义是限制访问权限,降低模块间的耦合——友元直接越过这道墙。能不用就不用,只在两个类确实紧密关联(比如迭代器和容器)时才考虑。
五、内部类:定义在类里面的独立类
classA{private:staticint_k;int_h=1;public:classB// B 是 A 的内部类{public:voidfoo(constA&a){cout<<_k<<endl;// OK:B是A的友元,可以访问A的静态成员cout<<a._h<<endl;// OK:B是A的友元,可以访问A的私有成员}int_b1;};};intA::_k=1;intmain(){cout<<sizeof(A)<<endl;// 4 —— B不占用A的空间A::B b;// 用 A::B 访问内部类A aa;b.foo(aa);return0;}五点理解:
- 内部类是独立的类,不是外部类的成员。
sizeof(A)不包括B的大小。 - 内部类只是受外部类的类域和访问限定符限制。如果B放在A的
private区域,外部就不能使用A::B。 - 内部类默认是外部类的友元。这是最核心的特性——B天生就能访问A的私有成员。
- 外部类不能访问内部类的私有成员(友元关系是单向的,和友元规则一致)。
- 使用场景:当B只为A服务时(比如A的迭代器、节点类),把B作为A的内部类放在
private或protected区域,可以防止外部滥用。
六、匿名对象:只用一次,不留名字
classA{public:A(inta=0):_a(a){cout<<"A(int a)"<<endl;}~A(){cout<<"~A()"<<endl;}private:int_a;};classSolution{public:intSum_Solution(intn){// ... 计算逻辑returnn;}};intmain(){A aa1;// 有名对象A();// 匿名对象——生命周期只在这一行A(1);// 匿名对象,带参数// 匿名对象的典型用法:只调一次成员函数,用完即扔Solution().Sum_Solution(10);// 创建匿名Solution,调函数,这行结束就析构return0;}关键特性:
- 匿名对象没有名字,生命周期只有当前这一行——这行结束立刻析构。
- 适用场景:临时创建一个对象来调用某个函数或作为参数传入,不用起名字,干净利落。
A aa1();不是匿名对象——这是函数声明(返回A,参数void),和A();是两回事。
七、编译器拷贝优化:你以为的拷贝,它压根没做
这是面试高频考点,也是理解C++性能的关键。
7.1 什么时候会触发拷贝
classA{public:A(inta=0):_a1(a){cout<<"A(int a)"<<endl;}A(constA&aa):_a1(aa._a1){cout<<"A(const A& aa)"<<endl;}~A(){cout<<"~A()"<<endl;}private:int_a1=1;};voidf1(A aa){}// 传值传参——调拷贝构造Af2(){A aa;returnaa;// 传值返回——可能调拷贝构造}7.2 编译器在偷偷做什么
现代编译器会进行**拷贝消除(Copy Elision)**优化,在不改变程序行为的前提下,把不必要的拷贝省掉。以下用代码和注释一一说明:
intmain(){// === 场景1:传值传参 ===A aa1;// 构造f1(aa1);// 拷贝构造(这次消不掉,aa1本身就是左值,必须拷贝)cout<<endl;// 场景2:连续 构造+拷贝构造 → 优化为一个构造f1(1);// 本来:临时A(1)构造 → 拷贝给参数aa → 析构临时// 优化后:直接在参数aa的位置构造A(1)cout<<endl;// 场景3:匿名对象传参f1(A(2));// 同上,连续 构造+拷贝 → 优化为一个构造cout<<endl;// === 场景4:传值返回 ===f2();// 返回局部对象// 不优化:aa构造 → 拷贝到临时返回值 → 析构aa → 析构临时// 优化后(部分编译器):直接在返回值位置构造aacout<<endl;// 场景5:用返回值初始化新对象A aa2=f2();// 不优化:aa构造 → 拷贝到临时 → 拷贝到aa2// 优化后(激进编译器):直接在aa2位置构造局部aa// 三次操作变一次!cout<<endl;// 场景6:用返回值赋值给已存在的对象——优化不了aa1=f2();// 必须调赋值运算符,因为aa1已经存在// 但返回值的临时对象可能被优化掉(直接构造到临时位置)return0;}7.3 优化规则总结
| 场景 | 优化前 | 优化后 | 备注 |
|---|---|---|---|
f1(1) | 临时构造 + 拷贝 | 一次构造 | 几乎所有编译器都做 |
A aa2 = f2() | 局部构造 + 拷贝 + 拷贝 | 一次构造 | VS2022 Debug也做 |
f2() | 局部构造 + 拷贝到临时 | 一次构造(直接构造到临时位置) | 取决于编译器 |
aa1 = f2() | 构造 + 拷贝临时 + 赋值 | 构造(在临时位置)+ 赋值 | 中间拷贝可消除,赋值消不了 |
f1(aa1) | 拷贝构造 | 拷贝构造 | 消不了,aa1是左值 |
核心规律:连续的「构造 + 拷贝构造」在一个表达式内发生时,编译器会合并为一次构造。如果需要关闭这个优化来观察原始行为,GCC/Clang用-fno-elide-constructors编译选项。
实际建议:别跟编译器较劲。不要为了"避免拷贝"写出奇怪的代码,编译器比你更清楚什么时候该优化。传值返回局部对象完全没问题——编译器会帮你处理。
总结
七个知识点,按重要性排序:
- 初始化列表:const/引用/无默认构造的类成员必须放初始化列表。其他成员建议放——能少一轮赋值。顺序要和声明顺序一致。
- explicit:单参数构造函数默认会做隐式转换,不希望这个行为就加
explicit。 - static成员:属于类,属于所有对象共享。静态成员变量必须在类外定义,静态成员函数没有this指针。
- 编译器优化:连续的构造+拷贝构造会被优化掉。传值返回局部对象不会有效率问题,编译器会做RVO/NRVO。
- 友元:打破封装的后门,单向且不传递,能不用就不用。
- 内部类:独立的类,默认是外部类的友元,适合实现紧密绑定的辅助类(如迭代器)。
- 匿名对象:生命周期只有一行,适合临时调用。