跳到主要内容初始化列表、static 与编译器优化:C++ 类进阶踩坑记录 | 极客日志C++
初始化列表、static 与编译器优化:C++ 类进阶踩坑记录
初始化列表、static 成员与编译器优化是 C++ 类设计中容易踩坑的进阶话题。初始化列表不是语法糖,而是引用、const 和自定义类型成员初始化的唯一正确方式,需注意初始化顺序由声明顺序决定。static 成员用于类级别共享状态,必须在类外初始化。友元有选择地突破封装,内部类能隐式访问外部类私有成员。编译器会合并拷贝构造来提升性能,关闭优化可观察原始行为。理解这些特性的底层约束和设计意图,比记住语法形式更重要。
刚学 C++ 的时候,我总在构造函数里给成员赋值,觉得这样很自然。后来遇到引用和 const 成员,才发现在函数体内赋值根本行不通——编译器不让你这么干。这才慢慢理解,初始化列表才是成员真正'出生'的地方,函数体里的赋值已经是出生之后的事了。
下面从几个实际踩过的坑出发,聊聊初始化列表、静态成员、友元、匿名对象这些进阶特性,以及编译器在背后做的那些优化。
构造函数初始化列表
初始化列表的写法很简单,一个冒号开头,逗号分隔,每个成员用括号给初值:
class Date {
public:
Date(int year, int month, int day) : _year(year), _month(month), _day(day) {}
private:
int _year;
int _month;
int _day;
};
如果成员没有出现在初始化列表里,它会先走默认初始化(内置类型就是随机值),然后才在构造函数体内被赋值,多了一次不必要的操作。但更关键的是,有三种成员你必须用初始化列表,否则编译都过不了。
什么时候必须用初始化列表
-
引用成员
引用在定义时必须绑定到一个对象,函数体内的 = 会被当成修改引用指向,而 C++ 不允许改引用指向。
class A {
public:
A(int& ref) : _ref(ref) {}
private:
int& _ref;
};
-
const 成员
常量必须在定义时初始化,函数体内赋值违反'常量不可修改'的规则。
class A {
public:
A(int n) : _n(n) {}
private:
const int _n;
};
-
没有默认构造的自定义类型成员
如果成员是某个自定义类,而那个类只提供了带参数的构造函数,编译器无法自动初始化它,你必须用初始化列表把参数传进去。
class Time {
public:
Time( hour) : _hour(hour) {}
:
_hour;
};
{
:
( hour, year) : (hour), _year(year) {}
:
Time ;
_year;
};
int
private
int
class
Date
public
Date
int
int
_t
private
_t
int
初始化顺序别搞反
一个很容易出问题的点:初始化列表里写的顺序,不是成员真正初始化的顺序。真正的顺序是成员在类中声明的顺序。如果初始化列表里的顺序和声明顺序不一致,可能造成未定义行为。
class A {
public:
A(int a) : _a2(a), _a1(_a2) {}
void Print() { cout << _a1 << " " << _a2 << endl; }
private:
int _a1;
int _a2;
};
按照声明顺序,_a1 会先用 _a2 初始化,但此时 _a2 还没被初始化(是个随机值),然后 _a2 才被 a 初始化。所以 Print() 会输出一个随机值和一个传入的值。
避坑建议: 让初始化列表的顺序和成员声明的顺序始终保持一致。
C++11 成员缺省值
如果成员声明时给了缺省值,但初始化列表没管它,那就会用这个缺省值。
class Date {
public:
Date(int day) : _day(day) {}
private:
int _year = 1;
int _month = 1;
int _day;
};
不管怎样,每个构造函数都会有一个初始化列表,哪怕你没写,编译器也会帮你补全(内置类型随机初始化,类类型调用默认构造)。所以,尽量自己写清楚,能放在初始化列表里的就别拖到函数体。
隐式类型转换与 explicit
C++ 会在一些地方悄悄做类型转换,比如内置类型转成类对象。如果你的类有一个单参数的构造函数(或虽然有多个参数但都有默认值),编译器就可能用它来做隐式转换。
class A {
public:
A(int a1) : _a1(a1) {}
void Print() { cout << _a1 << endl; }
private:
int _a1 = 1;
};
int main() {
A aa1 = 1;
aa1.Print();
}
这种转换看似方便,但有时会带来意料之外的逻辑。比如一个接受 std::vector<int> 的构造函数,如果没加 explicit,你传个 int 进去可能被编译器误以为要构造一个含一个元素的 vector,很隐蔽。
class A {
public:
explicit A(int a1) : _a1(a1) {}
private:
int _a1;
};
int main() {
A aa3(3);
}
除了内置类型转类,还有类类型之间的隐式转换。比如 B 的构造函数接受一个 A 对象,那么你可以直接 B bb = aa;。
class A {
public:
A(int a1) : _a1(a1) {}
int GetA1() const { return _a1; }
private:
int _a1;
};
class B {
public:
B(const A& a) : _b(a.GetA1()) {}
void Print() { cout << _b << endl; }
private:
int _b;
};
int main() {
A aa(10);
B bb = aa;
bb.Print();
}
只有当转换意图非常明确、且不会引起误解时,我才保留隐式转换(比如 std::string s = "hello" 就很自然)。其他情况一律加 explicit。
static 成员:属于整个类,不属于单个对象
用 static 修饰的成员变量或函数,不是某个对象的专属,而是整个类共享的,存在静态存储区,不占对象的内存。
静态成员变量
静态成员变量必须在类外初始化(除非是 C++17 的 inline static),而且初始化时不用再加 static 关键字。
class A {
public:
static int _scount;
private:
int _a;
};
int A::_scount = 0;
因为不存储在对象里,sizeof(A) 只包含非静态成员 _a,通常为 4。
静态成员函数
静态成员函数没有 this 指针,所以只能访问静态成员,碰不到那些属于对象的玩意儿。
class A {
public:
static int GetCount() { return _scount; }
private:
static int _scount;
int _a;
};
调用时直接 A::GetCount(),不需要实例化对象,当然用对象调用也行,但没那个必要。
一个小例子:统计对象个数
class A {
public:
A() { ++_scount; }
A(const A& t) { ++_scount; }
~A() { --_scount; }
static int GetObjectCount() { return _scount; }
private:
static int _scount;
};
int A::_scount = 0;
创建对象时自动计数,销毁时减 1,GetObjectCount() 随时告诉你当前活着的对象有几个。这在调试或者资源管理里挺实用。
友元:有选择地打开封装
封装是把东西藏起来,但有时候外面某个函数或类真的需要访问私有成员(比如操作符重载)。友元就是给封装开个'后门',让指定的外部函数或类可以访问私有和保护成员。不过这会增加代码耦合,不是必须尽量别用。
友元函数
想在一个普通函数里同时访问两个类的私有成员,可以在每个类里都声明这个函数为友元:
class B;
class A {
friend void func(const A& aa, const B& bb);
private:
int _a = 1;
};
class B {
friend void func(const A& aa, const B& bb);
private:
int _b = 2;
};
void func(const A& aa, const B& bb) {
cout << aa._a << endl;
cout << bb._b << endl;
}
友元声明放在类里面就行,访问限定符(public/private)对友元没影响。
友元类
如果 B 的所有成员函数都可能要访问 A 的私有成员,直接把 B 设成 A 的友元类:
class A {
friend class B;
private:
int _a1 = 1;
int _a2 = 2;
};
class B {
public:
void PrintA(const A& aa) {
cout << aa._a1 << " " << aa._a2 << endl;
}
private:
int _b = 3;
};
注意,友元是单向的:B 是 A 的友元,不代表 A 能访问 B 的私有成员。友元也不能传递或继承。
内部类:藏在类里的辅助类
如果一个类仅仅为另一个类服务,比如是个辅助工具,就可以把它定义在外部类的里面,叫做内部类。内部类是个独立的类,只是受外部类作用域和访问限定符的限制。
一个有用的特性:内部类自动成为外部类的友元。它能直接访问外部类的私有成员(包括静态和非静态),反过来外部类却不能随便访问内部类的私有部分。
class A {
private:
static int _k;
int _h = 1;
public:
class B {
public:
void PrintA(const A& a) {
cout << _k << endl;
cout << a._h << endl;
}
private:
int _b = 2;
};
};
int A::_k = 10;
int main() {
A::B b;
A a;
b.PrintA(a);
}
内部类对象不占外部对象的内存,sizeof(A) 是 4(只有 _h),sizeof(A::B) 也是 4(只有 _b)。
一个比较极端的实战用法:用内部类的构造函数累加静态变量,来实现不需要循环控制的求和。
class Solution {
class Sum {
public:
Sum() { _ret += _i; ++_i; }
static int GetRet() { return _ret; }
private:
static int _i;
static int _ret;
};
public:
int Sum_Solution(int n) {
Sum arr[n];
return Sum::GetRet();
}
};
int Solution::Sum::_i = 1;
int Solution::Sum::_ret = 0;
这种写法有点技巧性,平时不一定用得上,但能帮你理解构造函数的触发时机和静态成员的共享特性。
匿名对象:一次性临时工具
有些对象只在一个地方用一次,没必要给它起个名字。直接 类名(参数) 就能创建个匿名对象,它的生命周期只在这一行。
class A {
public:
A(int a = 0) : _a(a) { cout << "A(int a)" << endl; }
~A() { cout << "~A()" << endl; }
void Print() { cout << _a << endl; }
private:
int _a;
};
int main() {
A aa1(1);
A(2);
A(3).Print();
}
实际写代码时,如果只是想调用某个对象的成员函数,或者把它当参数传给另一个函数,匿名对象可以省掉一行变量声明,让代码更紧凑。
编译器会悄悄做的拷贝优化
现代编译器很聪明,它会在不改变程序语义的前提下,把一些不必要的拷贝构造和构造合并掉。下面两个场景是最常见的。
class A {
public:
A(int a) : _a(a) { cout << "A(int a)" << endl; }
A(const A& aa) : _a(aa._a) { cout << "A(const A& aa)" << endl; }
private:
int _a;
};
隐式类型转换的优化
A aa = 1; 本来的流程是:用 1 构造临时 A 对象,然后用临时对象拷贝构造 aa。但编译器会直接优化成用 1 直接构造 aa,你只会看到一次 A(int a) 的输出。
传值返回的优化
A f() {
A aa(2);
return aa;
}
int main() {
A aa2 = f();
}
优化的本意是'构造局部对象 → 拷贝到临时空间 → 拷贝到接收对象',优化后直接一步到位。
如果你想亲眼看看没优化的版本(尤其是在 GCC 下),可以用 -fno-elide-constructors 关闭拷贝省略:
g++ test.cpp -otest -fno-elide-constructors
这时候你会看到很多拷贝构造的调用输出。不过正常情况下不用关心这些,把优化放心交给编译器就好。
几点个人体会
这些进阶特性不是为了炫技,它们都对应着实际工程中反复出现的问题:
- 初始化列表让你能正确初始化那些'从一而终'的成员(引用、const、无默认构造的对象)。
- static 让你管理整个类级别的共享数据,比如计数、缓冲区、单例。
- 友元虽然会破坏封装,但在操作符重载这类场景里是必要的妥协;能用友元函数就别用友元类。
- 内部类帮你在高内聚的场景下避免全局命名污染。
- 匿名对象让临时操作更简洁,但别滥用,多了会让代码可读性下降。
- 编译器优化是默认行为,你大可不必手动'帮助'编译器去优化,写出清晰的代码更重要。
下面是一些常见问题自测,看看你是不是真的理解了这些细节。
自测题
- 判断题:内部类默认是外部类的友元,所以外部类也能访问内部类的私有成员?
- 选择题:关于 static 成员,哪个说法正确?
A. 静态成员变量可以在类内初始化
B. 静态成员函数可以访问非静态成员
C. 静态成员变量不占对象内存
D. 静态成员函数必须通过对象调用
- 输出题:下面代码输出什么?
class A {
public:
A(int a) : _a1(a), _a2(_a1) {}
void Print() { cout << _a1 << " " << _a2 << endl; }
private:
int _a2 = 2;
int _a1 = 1;
};
int main() {
A aa(3);
aa.Print();
}
- 错误。友元关系是单向的,内部类能访问外部类私有成员,但外部类不能访问内部类私有成员。
- 答案 C。A 需要在类外初始化(C++11 缺省值不算初始化静态变量);B 没有 this 指针,不能访问非静态成员;D 可以直接用类名调用。
- 输出
3 随机值。因为 _a2 先初始化,它依赖还未初始化的 _a1,所以得到一个随机值;然后 _a1 用 3 初始化,最终输出 3 和那个随机值。
这些题如果都能答对,说明你已经把初始化顺序和 static 的特性吃透了。如果还有卡壳的地方,回头再看一遍对应的代码示例,动手改一改参数跑一跑,会更有感觉。
相关免费在线工具
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
- JSON美化和格式化
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online