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