C++类和对象进阶:初始化列表铁律、explicit与编译器拷贝优化 文章目录一、初始化列表构造函数真正的战场1.1 函数体内赋值的局限1.2 初始化列表的语法和执行规则1.3 两句话总结初始化列表二、隐式类型转换与explicit2.1 单参数构造函数的隐式转换2.2 explicit禁止隐式转换2.3 多参数构造函数的隐式转换C112.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 已经被默认初始化了// 现在做的只是覆盖赋值_yearyear;_monthmonth;_dayday;}};对于内置类型先默认初始化再赋值多了一轮操作但能忍。但对于以下三种成员函数体内赋值根本行不通const成员变量只能初始化不能赋值。引用成员变量必须在定义时绑定不能后续改绑。没有默认构造函数的类类型成员编译器在初始化列表中找不到它的构造调用直接报错。classTime{public:Time(inthour):_hour(hour){}// 只有一个有参构造没有默认构造private:int_hour;};classDate{public:Date(intx,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 _a2endl;// _a1 是随机值_a2 还没初始化就用了}private:int_a1;// ← 声明顺序_a1 先于 _a2int_a2;};输出_a1是什么它用_a2初始化但_a2此时还没被初始化虽然_a2(a)在初始化列表中写在了前面但声明顺序决定了_a1先初始化。结果_a1是随机值_a2是a的值。解法很简单把初始化列表的顺序和声明顺序保持一致。别玩花活。铁律二无论你写不写初始化列表每个构造函数都有初始化列表每个成员变量都要走一次初始化列表。你没写的那些成员怎么办看你有没有在声明处给缺省值classDate{public:Date():_month(2)// 只显式初始化了 _month{coutDate()endl;}private:int_year1;// 声明处给的缺省值——_year 用 1 初始化int_month1;// 初始化列表显式指定了 2缺省值被覆盖int_day;// 没缺省值内置类型——是否初始化取决于编译器结果不确定Time _t1;// 缺省值调用 Time(1) 初始化constint_n1;// 缺省值int*_ptr(int*)malloc(12);// 缺省值};缺省值的作用初始化列表中没显式初始化的成员就用声明处给的缺省值。注意这不是赋值——缺省值是给初始化列表用的本质还是初始化。1.3 两句话总结初始化列表必须出现在初始化列表中的const成员、引用成员、没有默认构造的类类型成员。建议出现在初始化列表中的所有成员。因为不在初始化列表中的成员也会走初始化列表用缺省值或随机值然后再在函数体内被赋值——多一轮开销。二、隐式类型转换与explicit2.1 单参数构造函数的隐式转换classA{public:A(inta1):_a1(a1){}voidPrint(){cout_a1endl;}private:int_a1;};intmain(){Aaa1(10);// 直接构造A aa210;// 隐式转换10 → A(10) → 拷贝给 aa2// 编译器实际做法构造一个临时 A(10)再用这个临时对象拷贝构造 aa2// 现代编译器会优化直接把 aa2 当成 A(10) 来构造省掉临时对象constAref10;// 隐式转换临时对象 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;}加了explicitA aa2 10就编译不过。什么时候加explicit当你认为int不应该被自动转成你的类时——判断标准是这种隐式转换是否会让代码的意图变得含糊。2.3 多参数构造函数的隐式转换C11classA{public:A(inta1,inta2):_a1(a1),_a2(a2){}private:int_a1;int_a2;};intmain(){A aa1{2,2};// C11用大括号做多参数隐式转换A aa2{2,2};// 等价写法return0;}C11之前只支持单参数隐式转换C11之后大括号初始化列表让多参数也能转。同样可以用explicit禁掉。2.4 类类型之间的隐式转换classA{public:A(inta1):_a1(a1){}intGet()const{return_a1;}private:int_a1;};classB{public:B(constAa):_b(a.Get()){}// 接收 A 对象的构造private:int_b;};intmain(){Aaa(10);B baa;// 隐式转换aa → 临时 B(aa) → 拷贝给 bconstBrefaa;// 隐式转换ref 绑定到临时 B 对象return0;}只要B有一个接受const A的构造函数且非explicitA对象就能隐式转成B对象。原理和内置类型转类类型完全一样。三、static成员不属于任何对象的共享数据3.1 静态成员变量classA{public:A(){_scount;}A(constAt){_scount;}~A(){--_scount;}staticintGetACount(){return_scount;}private:staticint_scount;// 声明在类内部};intA::_scount0;// 定义初始化必须在类外部全局位置intmain(){coutA::GetACount()endl;// 0A a1,a2;Aa3(a1);coutA::GetACount()endl;// 3couta1.GetACount()endl;// 3也可以通过对象访问但不推荐return0;}关键特性静态成员变量属于类不属于任何具体对象。存在静态区不计入sizeof(类)。必须在类外定义并初始化。声明处给的 0不给过——那是C#和Java的语法C不认。受访问限定符限制private的静态成员不能在类外直接访问。不能在声明处给缺省值。因为缺省值是给构造函数的初始化列表用的而静态成员不属于具体对象不走构造函数的初始化列表。3.2 静态成员函数classA{public:staticintGetACount()// 静态成员函数{// _a 10; // 错误没有 this 指针访问不了普通成员return_scount;// OK静态函数可以访问静态成员}private:int_a0;// 普通成员staticint_scount;// 静态成员};静态成员函数没有this指针。这意味着不能访问任何非静态成员没有this不知道是哪个对象的成员。可以访问所有静态成员。可以通过类名::函数名()访问也可以通过对象访问但不推荐容易混淆。3.3 实用场景对象计数上面的A类就是一个完整的对象计数器。每次构造1每次拷贝构造1每次析构-1。这种模式在内存管理和调试中有实际应用。四、友元封装的后门4.1 友元函数classA;// 前置声明classB{friendvoidFunc(constAaa,constBbb);// 声明Func为B的友元private:int_b13;int_b24;};classA{friendvoidFunc(constAaa,constBbb);// 声明Func为A的友元private:int_a11;int_a22;};voidFunc(constAaa,constBbb){coutaa._a1endl;// Func是A和B的友元可以访问私有成员coutbb._b1endl;}核心规则友元函数不是类的成员函数——它就是一个普通函数只是被授权访问类的私有成员。声明位置不限——放public、private、protected区域都一样因为它不是成员不受访问限定符约束。一个函数可以是多个类的友元如上例中的Func同时是A和B的友元。4.2 友元类classA{friendclassB;// B 是 A 的友元private:int_a11;int_a22;};classB{public:voidfunc1(constAaa){coutaa._a1endl;// OKB的成员函数可以访问A的私有成员cout_b1endl;}private:int_b13;int_b24;};友元类的特性B是A的友元→B的所有成员函数都能访问A的私有成员。单向性B是A的友元不代表A是B的友元。上例中A不能访问B的私有成员。不可传递A是B的友元B是C的友元不代表A是C的友元。4.3 友元的代价友元打破了封装。封装的意义是限制访问权限降低模块间的耦合——友元直接越过这道墙。能不用就不用只在两个类确实紧密关联比如迭代器和容器时才考虑。五、内部类定义在类里面的独立类classA{private:staticint_k;int_h1;public:classB// B 是 A 的内部类{public:voidfoo(constAa){cout_kendl;// OKB是A的友元可以访问A的静态成员couta._hendl;// OKB是A的友元可以访问A的私有成员}int_b1;};};intA::_k1;intmain(){coutsizeof(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(inta0):_a(a){coutA(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(inta0):_a1(a){coutA(int a)endl;}A(constAaa):_a1(aa._a1){coutA(const A aa)endl;}~A(){cout~A()endl;}private:int_a11;};voidf1(A aa){}// 传值传参——调拷贝构造Af2(){A aa;returnaa;// 传值返回——可能调拷贝构造}7.2 编译器在偷偷做什么现代编译器会进行**拷贝消除Copy Elision**优化在不改变程序行为的前提下把不必要的拷贝省掉。以下用代码和注释一一说明intmain(){// 场景1传值传参 A aa1;// 构造f1(aa1);// 拷贝构造这次消不掉aa1本身就是左值必须拷贝coutendl;// 场景2连续 构造拷贝构造 → 优化为一个构造f1(1);// 本来临时A(1)构造 → 拷贝给参数aa → 析构临时// 优化后直接在参数aa的位置构造A(1)coutendl;// 场景3匿名对象传参f1(A(2));// 同上连续 构造拷贝 → 优化为一个构造coutendl;// 场景4传值返回 f2();// 返回局部对象// 不优化aa构造 → 拷贝到临时返回值 → 析构aa → 析构临时// 优化后部分编译器直接在返回值位置构造aacoutendl;// 场景5用返回值初始化新对象A aa2f2();// 不优化aa构造 → 拷贝到临时 → 拷贝到aa2// 优化后激进编译器直接在aa2位置构造局部aa// 三次操作变一次coutendl;// 场景6用返回值赋值给已存在的对象——优化不了aa1f2();// 必须调赋值运算符因为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。友元打破封装的后门单向且不传递能不用就不用。内部类独立的类默认是外部类的友元适合实现紧密绑定的辅助类如迭代器。匿名对象生命周期只有一行适合临时调用。