C++类和对象进阶:初始化列表铁律、explicit与编译器拷贝优化
2026/7/18 5:44:36 网站建设 项目流程

文章目录

  • 一、初始化列表:构造函数真正的战场
    • 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是随机值,_a2a的值

解法很简单:把初始化列表的顺序和声明顺序保持一致。别玩花活。

铁律二:无论你写不写初始化列表,每个构造函数都有初始化列表;每个成员变量都要走一次初始化列表。

你没写的那些成员怎么办?看你有没有在声明处给缺省值:

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;}

加了explicitA 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;}

关键特性

  1. 静态成员变量属于类,不属于任何具体对象。存在静态区,不计入sizeof(类)
  2. 必须在类外定义并初始化。声明处给的= 0不给过——那是C#和Java的语法,C++不认。
  3. 受访问限定符限制:private的静态成员不能在类外直接访问。
  4. 不能在声明处给缺省值。因为缺省值是给构造函数的初始化列表用的,而静态成员不属于具体对象,不走构造函数的初始化列表。

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;}

核心规则

  1. 友元函数不是类的成员函数——它就是一个普通函数,只是被授权访问类的私有成员。
  2. 声明位置不限——放publicprivateprotected区域都一样,因为它不是成员,不受访问限定符约束。
  3. 一个函数可以是多个类的友元(如上例中的Func同时是AB的友元)。

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;};

友元类的特性

  1. B是A的友元→B的所有成员函数都能访问A的私有成员。
  2. 单向性:B是A的友元,不代表A是B的友元。上例中A不能访问B的私有成员。
  3. 不可传递: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;}

五点理解

  1. 内部类是独立的类,不是外部类的成员。sizeof(A)不包括B的大小。
  2. 内部类只是受外部类的类域和访问限定符限制。如果B放在A的private区域,外部就不能使用A::B
  3. 内部类默认是外部类的友元。这是最核心的特性——B天生就能访问A的私有成员。
  4. 外部类不能访问内部类的私有成员(友元关系是单向的,和友元规则一致)。
  5. 使用场景:当B只为A服务时(比如A的迭代器、节点类),把B作为A的内部类放在privateprotected区域,可以防止外部滥用。

六、匿名对象:只用一次,不留名字

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;}

关键特性

  1. 匿名对象没有名字,生命周期只有当前这一行——这行结束立刻析构。
  2. 适用场景:临时创建一个对象来调用某个函数或作为参数传入,不用起名字,干净利落。
  3. 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编译选项。

实际建议:别跟编译器较劲。不要为了"避免拷贝"写出奇怪的代码,编译器比你更清楚什么时候该优化。传值返回局部对象完全没问题——编译器会帮你处理。


总结

七个知识点,按重要性排序:

  1. 初始化列表:const/引用/无默认构造的类成员必须放初始化列表。其他成员建议放——能少一轮赋值。顺序要和声明顺序一致。
  2. explicit:单参数构造函数默认会做隐式转换,不希望这个行为就加explicit
  3. static成员:属于类,属于所有对象共享。静态成员变量必须在类外定义,静态成员函数没有this指针。
  4. 编译器优化:连续的构造+拷贝构造会被优化掉。传值返回局部对象不会有效率问题,编译器会做RVO/NRVO。
  5. 友元:打破封装的后门,单向且不传递,能不用就不用。
  6. 内部类:独立的类,默认是外部类的友元,适合实现紧密绑定的辅助类(如迭代器)。
  7. 匿名对象:生命周期只有一行,适合临时调用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询