C++类和对象(下):从初始化列表到匿名对象,别再把这些知识学成一堆补丁
如果说“类和对象上”是在帮你建立起类是什么、对象是什么、this指针为什么存在这些最基础的认知那么这一篇就是继续往下走去解决另一些更“像工程”的问题构造函数到底该怎么初始化成员为什么有的成员必须放在初始化列表里explicit到底在防什么static成员为什么不属于某个对象友元为什么会破坏封装内部类为什么有时候很好用匿名对象到底什么时候出现编译器为什么会帮你做对象拷贝优化这些内容看起来各自独立但其实都在回答同一个问题当一个类开始真正承担“组织对象”的职责时C 还要给它补哪些规则。这一篇我还是按前面的风格来写不堆概念而是顺着 C 的思路把这些点串起来。一、再看构造函数真正重要的不是“写法”而是“初始化时机”前面我们已经知道构造函数的作用不是“开空间创建对象”而是让对象在出生时就进入一个可用状态。但到了这一节你会发现一个更重要的问题光靠函数体里赋值有时候已经不够了。比如下面这个类classTime{public:Time(inthour):_hour(hour){coutTime()endl;}private:int_hour;};classDate{public:Date(intx,intyear1,intmonth1,intday1):_year(year),_month(month),_day(day),_t(12),_ref(x),_n(1){}voidPrint()const{cout_year-_month-_dayendl;}private:int_year;int_month;int_day;Time _t;int_ref;constint_n;};这里最关键的点不是“代码长”而是有些成员变量必须在初始化列表里完成初始化不能只在构造函数体内赋值。这类成员主要有三种引用成员const成员没有默认构造函数的类类型成员比如int _ref必须初始化const int _n必须初始化Time _t如果没有默认构造也必须在初始化列表里初始化这也是为什么很多时候你会发现初始化列表不是“可写可不写”的装饰品它是构造函数真正的初始化入口。二、初始化列表不是补充写法而是构造过程本身很多人第一次接触初始化列表时会把它理解成“构造函数外面再额外写一层初始化语法。”其实不对。更准确的说法应该是初始化列表不是构造函数的附属部分它就是构造时初始化成员变量的真正地方。比如classDate{public:Date():_month(2){coutDate()endl;}private:int_year1;int_month1;int_day;Time _t1;constint_n1;int*_ptr(int*)malloc(12);};这里有几个很容易误解的点。1. 成员声明处写的不是初始化列表像下面这种int_year1;它不是“在对象创建后再赋值”而是给初始化列表准备的缺省值。如果构造函数初始化列表里没有显式写这个成员那它就会拿声明处的缺省值来初始化。2. 每个构造函数都一定有初始化列表哪怕你没写编译器也会有。3. 每个成员变量最终都要走初始化流程你可以显式写也可以用声明处的缺省值兜底但它一定会被初始化。4. 初始化顺序不是按初始化列表写的顺序而是按成员声明顺序这一点非常重要。很多人会误以为Date(inta):_a2(_a1),_a1(a){}这样先写_a2它就先初始化。不是。实际初始化顺序只看成员在类里声明的顺序不看你在初始化列表里写的顺序。所以如果你要写初始化列表最稳妥的方式就是声明顺序和初始化列表顺序保持一致。三、初始化列表为什么这么重要因为有些成员根本不能“后补”如果你看到这个例子classDate{public:Date(intx,intyear1,intmonth1,intday1):_year(year),_month(month),_day(day),_t(12),_ref(x),_n(1){}private:int_year;int_month;int_day;Time _t;int_ref;constint_n;};你就会发现_year/_month/_day可以先声明后赋值但_ref、_n、_t不行原因很简单。因为这些成员里有些东西不能先默认生成再补不能先空着再赋值不能等构造函数体里再改所以初始化列表的意义不是“更高级”而是它能保证那些必须“一开始就正确”的成员在对象出生时就已经正确。这件事对资源类尤其重要。四、类型转换C 为什么允许“一个构造函数顺手变成转换器”C 有一个很自然但也很容易被忽略的特性内置类型可以隐式转换成类类型对象。例如classA{public:A(inta1):_a1(a1){}A(inta1,inta2):_a1(a1),_a2(a2){}voidPrint(){cout_a1 _a2endl;}intGet()const{return_a1_a2;}private:int_a11;int_a22;};这时你可以这样写A aa11;constAaa21;A aa3{2,2};这背后的逻辑其实很直白1先构造出一个A临时对象再用这个临时对象去初始化目标对象C 允许这种“从一个值顺手变成一个对象”的行为。这在语法上很方便但也有一个潜在风险有时候你并不希望别人随便把一个整数、一个字符、一个表达式隐式变成你的类对象。这时就可以在构造函数前加explicitexplicitA(inta1){}加了explicit之后隐式类型转换就不支持了。这就是explicit的意义它不是让你少写东西而是让类的转换行为更明确避免不必要的隐式转换。五、类与类之间也能转换但前提是你真的定义了“这种转换关系”比如classB{public:B(constAa):_b(a.Get()){}private:int_b0;};这说明什么说明一个类对象也可以通过另一个类对象来构造。只要你写了对应的构造函数C 就能帮你完成这种类型转换。所以B baa3;本质上也是一种类型转换。你可以把它理解成构造函数不只是“初始化自己的对象”它有时候还在定义“这个类愿意接受什么样的输入”。六、static成员属于类但不属于某一个对象static成员这一块初学阶段特别容易被表面现象带偏。最核心的一句话是static修饰的成员属于类不属于某个具体对象。比如classA{public:A(){_scount;}A(constA){_scount;}~A(){--_scount;}staticintGetACount(){return_scount;}private:staticint_scount;};intA::_scount0;这里的_scount是所有对象共享的一份数据。所以对象多一个计数就加一对象少一个计数就减一这也就是“类对象计数器”这类题最常见的写法。静态成员变量的几个特点所有对象共享同一份不属于某个具体对象存在静态区必须在类外初始化静态成员函数的几个特点staticintGetACount(){return_scount;}没有this指针只能访问静态成员不能直接访问非静态成员访问静态成员的方式可以通过A::GetACount();也可以通过对象访问a1.GetACount();但更推荐前者因为它更清楚地表达“这是类成员不是对象独有成员”。七、静态成员变量为什么不能在类内“直接当作普通成员写值”这一点也很容易被问。静态成员变量不是某个对象的一部分所以它不走构造函数初始化列表。它的初始化必须放在类外intA::_scount0;原因很简单它不属于某一个对象所以不能像普通成员那样靠对象构造时一份一份去初始化。这也是静态成员和普通成员最本质的区别之一。八、友元给你便利但也会顺手把封装打开一条口子友元是一个非常“实用但别乱用”的机制。它的作用很直接允许类外的函数或另一个类访问本来不允许直接访问的私有成员。比如classB;classA{friendvoidfunc(constAaa,constBbb);private:int_a11;int_a22;};classB{friendvoidfunc(constAaa,constBbb);private:int_b13;int_b24;};voidfunc(constAaa,constBbb){coutaa._a1endl;coutbb._b1endl;}这里func不是成员函数但它能访问A和B的私有成员因为它被声明成友元了。友元的特点友元函数不是成员函数友元关系是单向的友元关系不能传递友元会增加耦合破坏封装所以友元虽然方便但不宜多用。你可以把它理解为友元像“临时开门”真方便但门一开封装也就少了一层保护。九、友元类整个类都能访问另一个类的私有成员除了友元函数还可以有友元类。例如classA{friendclassB;private:int_a11;int_a22;};classB{public:voidfunc1(constAaa){coutaa._a1endl;cout_b1endl;}voidfunc2(constAaa){coutaa._a2endl;cout_b2endl;}private:int_b13;int_b24;};这意味着B的成员函数都可以访问A的私有成员。但注意这是单向的不是交换的也不是传递的所以友元类更像“给另一个类整套权限”比友元函数更强但也更要慎用。十、内部类类里面再定义一个类本质上也是一种封装如果一个类定义在另一个类内部这个类就叫内部类。例如classA{private:staticint_k;int_h1;public:classB{public:voidfoo(constAa){cout_kendl;couta._hendl;}int_b1;};};intA::_k1;内部类有几个很重要的点1. 它本质上还是一个独立的类它只是被放到了外部类的类域里。2. 外部类的对象中不包含内部类对象A的对象里不会自动多出一个B。3. 内部类默认是外部类的友元所以内部类可以访问外部类的私有成员。这就让内部类特别适合那种某个类只是专门给另一个类配套使用不希望被外界随便拿去用的场景。这也是一种很实用的封装思路。十一、匿名对象只用一下不想取名字的时候就很方便匿名对象这个东西初看有点别扭但其实很实用。比如classA{public:A(inta0):_a(a){coutA(int a)endl;}~A(){cout~A()endl;}private:int_a;};在main里你可以这样写A();A(1);Aaa2(2);这里的A()和A(1)都是匿名对象。匿名对象的特点不需要取名字生命周期只在当前这一行下一行就会自动析构这类对象特别适合临时构造一下马上就用临时调用一个成员函数某些一次性操作场景例如Solution().Sum_Solution(10);这种写法就很典型。十二、匿名对象和普通对象别混了这一点很容易踩坑。像下面这种Aaa1();不是你以为的“创建了一个对象”。它更像是一个函数声明的歧义写法。所以如果你只是想临时构造一个对象不要这么写。正确的匿名对象写法应该是A();A(1);这才是明确的匿名对象。十三、对象拷贝时的编译器优化现代编译器比你想得更“聪明”这一节特别适合你在理解“返回值”“拷贝构造”“临时对象”时顺手一起看。现代编译器为了提高效率会尽可能合并那些可以省略的拷贝。比如classA{public:A(inta0):_a1(a){coutA(int a)endl;}A(constAaa):_a1(aa._a1){coutA(const A aa)endl;}Aoperator(constAaa){coutA operator(const A aa)endl;if(this!aa){_a1aa._a1;}return*this;}~A(){cout~A()endl;}private:int_a11;};然后voidf1(A aa){}Af2(){A aa;returnaa;}编译器在很多情况下会做优化连续构造 拷贝构造合并成一次构造构造局部对象 返回时拷贝可能直接优化掉某些表达式中的临时对象也可能直接被合并这就是为什么你在不同编译器、不同版本、不同优化级别下看到的输出可能不一样。你要记住的不是“每次都会优化成什么样”而是现代编译器会尽量减少不必要的拷贝但具体怎么优化不由标准强制死写死。如果你想在 Linux 下观察更多“未优化”的构造/拷贝过程可以用类似g test.cpp -fno-elide-constructors这样的方式关闭部分构造优化。十四、这一章真正该串起来的是类的“完整生命周期”把“类和对象下”这一篇学完后你应该开始真正建立这样一条完整认识构造函数负责初始化初始化列表负责解决那些必须一开始就初始化的成员explicit负责控制隐式转换static成员负责共享数据友元负责在必要时突破封装内部类负责做更强的局部封装匿名对象负责临时使用编译器优化负责减少不必要的拷贝也就是说这一篇不是在继续堆语法而是在把“一个类从出生到使用再到复制”的整个过程慢慢补完整。十五、收个尾类和对象学到这里才算真正开始像工程了前一篇你看到的是类是什么对象是什么this为什么存在这一篇你看到的是对象如何正确初始化对象如何正确复制对象如何正确共享数据对象如何在必要时开放访问编译器如何帮你优化对象拷贝所以到这里类和对象就不再只是“一个语法章节”了。它开始变成一个真正的工程组织方式一个对象既要能创建又要能销毁还能复制还能表达自己的行为还能在合适的边界内开放能力。这就是 C 类和对象真正的味道。复习时只看这几句就够了初始化列表不是补充语法而是构造时真正初始化成员的地方引用成员、const成员、没有默认构造的类成员必须放到初始化列表里初始化顺序按成员声明顺序不按初始化列表书写顺序explicit用来禁止不必要的隐式类型转换static成员属于类不属于某个对象静态成员变量必须在类外初始化静态成员函数没有this不能访问非静态成员友元函数和友元类可以突破访问控制但会增加耦合内部类本质上还是独立类只是受外部类类域限制匿名对象没有名字生命周期只在当前行编译器会尽量优化对象拷贝但具体优化行为取决于编译器和编译选项