C++ 类与对象(下):初始化列表、static、友元与编译器优化
上一篇 C++ 类与对象(中) 讲了六大默认成员函数。这一篇聚焦构造函数的高级用法、类级共享机制、打破封装的友元,以及编译器在你背后做的拷贝优化。
再探构造函数:初始化列表
为什么需要初始化列表
中篇写的构造函数,成员变量是在函数体内赋值的:
Date(int year, int month, int day) {
_year = year; // 这是赋值,不是初始化
_month = month;
_day = day;
}
_year = year 这步发生在函数体里,意味着在进入 { 之前,_year 已经走完了一轮初始化——只不过内置类型的"初始化"可能什么都没做(值不确定)。C++ 提供了初始化列表(Initializer List),让成员变量在进入函数体之前就完成真正的初始化。
初始化列表以一个冒号开始,逗号分隔:
Date(int year, int month, int day)
: _year(year) // 这是真正的初始化
, _month(month)
, _day(day)
{
// 函数体可以为空——所有工作已在初始化列表完成
}
强制使用初始化列表的三种情况
有三种成员必须放在初始化列表中初始化,函数体赋值不行:
#include <iostream>
class Time {
public:
Time(int hour) : _hour(hour) { // 只有带参构造,没有默认构造
std::cout << "Time()" << std::endl;
}
private:
int _hour;
};
class Date {
public:
Date(int& x, int year = 1, int month = 1, int day = 1)
: _year(year)
, _month(month)
, _day(day)
, _t(12) // 【必需一】没有默认构造的类类型成员——必须显式调用其构造函数
, _ref(x) // 【必需二】引用成员变量——必须在定义时绑定
, _n(1) // 【必需三】const 成员变量——初始化后不能修改
{
// 以下三行如果放在函数体内,编译器直接报错:
// _t = Time(12); // error: Time 没有默认构造
// _ref = x; // error: 引用必须初始化
// _n = 1; // error: const 变量必须初始化
}
private:
int _year;
int _month;
int _day;
Time _t; // 没有默认构造函数
int& _ref; // 引用
const int _n; // const
};
int main() {
int i = 0;
Date d1(i);
return 0;
}
一句话记死:引用、const、无默认构造的类类型——这三类成员的生命必须在初始化列表中开始。
C++11 声明处缺省值
C++11 允许在成员变量声明位置直接给缺省值。这个缺省值不是"初始化",而是给初始化列表兜底的——如果初始化列表没有显式初始化某个成员,编译器就用这个缺省值。
class Date {
public:
Date() : _month(2) { // _month 用 2 初始化(覆盖声明处的缺省值 1)
std::cout << "Date()" << std::endl;
}
void Print() const {
std::cout << _year << "-" << _month << "-" << _day << std::endl;
}
private:
int _year = 1; // 缺省值——初始化列表没管 _year 时生效
int _month = 1; // 但被初始化列表中的 :_month(2) 覆盖了
int _day; // 没有缺省值,又没有在初始化列表中显式初始化——值不确定
};
// 输出: 1-2-随机值
初始化顺序陷阱
初始化列表中成员的初始化顺序不是按照列表中的书写顺序,而是按照成员在类中声明的顺序:
class A {
public:
A(int a)
: _a2(_a1) // ⚠️ 意图:用 _a1 初始化 _a2
, _a1(a)
{}
void Print() {
std::cout << _a1 << " " << _a2 << std::endl;
}
private:
int _a2 = 2; // 声明顺序:_a2 在前
int _a1 = 2; // 声明顺序:_a1 在后
};
int main() {
A aa(1);
aa.Print(); // 输出: 1 随机值(不是 1 1!)
}
_a2 声明在 _a1 前面,初始化列表中 _a2(_a1) 先执行——此时 _a1 还没被初始化,值是随机的。初始化列表的顺序保持与声明顺序一致,这是一条不容妥协的规则。
📖 参考:《高质量C++/C编程指南》第9章:成员对象的初始化次序由类声明中的声明次序决定,与初始化表中的书写次序无关。
初始化列表总结
无论你是否显式写了初始化列表,构造函数都有初始化列表;无论你是否在列表中显式初始化成员,每个成员都要走初始化列表。你没管的内置类型——编译器爱初始不初始;你没管的自定义类型——编译器调用它的默认构造。
类型转换与 explicit
内置类型隐式转为类类型
C++ 支持通过构造函数将内置类型隐式转换为类类型:
class A {
public:
A(int a1) : _a1(a1) {}
void Print() { std::cout << _a1 << " " << _a2 << std::endl; }
private:
int _a1 = 1;
int _a2 = 2;
};
int main() {
A aa1 = 1; // 隐式转换:先用 1 构造临时 A 对象,再用临时对象拷贝构造 aa1
// 编译器优化:连续 构造+拷贝构造 → 直接构造
aa1.Print(); // 输出: 1 2
const A& aa2 = 1; // 临时对象绑定到 const 引用,生命周期延长至引用结束
// C++11 起支持多参数隐式转换
A aa3 = {2, 2}; // 多参数列表初始化
}
隐式转换是把双刃剑:方便,但也可能在你没注意到的地方悄悄创建临时对象。如果你不希望构造函数被用于隐式转换,在构造函数前加 explicit 关键字:
explicit A(int a1) : _a1(a1) {}
// A aa1 = 1; // 编译报错:不能隐式转换
A aa1(1); // 只能显式调用
默认规则:除非你有明确的理由需要隐式转换(比如
std::string(const char*)这种显然有益的),否则单参数构造函数一律加explicit。控制隐式路径就是控制代码行为。
类类型间的隐式转换
只要存在对应的构造函数,A 类对象也能隐式转为 B 类:
class B {
public:
B(const A& a) : _b(a.Get()) {} // 接受 A 对象的构造
private:
int _b = 0;
};
B b = aa3; // aa3 隐式转为 B
const B& rb = aa3; // 临时 B 对象绑定到 const 引用
static 成员
静态成员变量
用 static 修饰的成员变量叫静态成员变量(Static Member Variable)。它与普通成员变量的核心区别:
- 不属于任何单个对象,而是属于整个类,为所有对象共享。
- 存储在静态区,而非栈或堆。
- 必须在类外初始化——因为它的生命期不随对象走,不经过构造函数初始化列表。
#include <iostream>
class A {
public:
A() { ++_scount; }
A(const A& t) { ++_scount; }
~A() { --_scount; }
static int GetACount() { return _scount; }
private:
static int _scount; // 类内声明,不加 static 就是普通成员了
};
int A::_scount = 0; // 类外定义+初始化——必须写,否则链接报错
int main() {
std::cout << A::GetACount() << std::endl; // 0
A a1, a2;
A a3(a1);
std::cout << A::GetACount() << std::endl; // 3
// 也可以通过对象访问,但本质是同一份数据
std::cout << a1.GetACount() << std::endl; // 3
}
访问静态成员有两种方式:通过类名(A::_scount)或通过对象(a1._scount),前提是有访问权限。private 静态成员变量只能通过 public 静态成员函数间接访问。
⚠️ 静态成员变量不能在声明处用缺省值初始化——缺省值是给构造函数初始化列表用的,静态成员不走那套流程。
静态成员函数
用 static 修饰的成员函数叫静态成员函数(Static Member Function)。核心特征:没有 this 指针。
这意味着:
- 静态成员函数可以访问静态成员变量和其他静态成员函数。
- 静态成员函数不能访问非静态成员变量——没有
this,根本不知道访问哪个对象的成员。 - 非静态成员函数可以访问任意的静态成员。
static 的经典应用
面试题级别的案例:计算 1+2+3+...+n,不可用循环和条件判断。利用静态成员变量 + 构造函数:
class Sum {
public:
Sum() {
_ret += _i;
++_i;
}
static int GetRet() { return _ret; }
private:
static int _i;
static int _ret;
};
int Sum::_i = 1;
int Sum::_ret = 0;
class Solution {
public:
int Sum_Solution(int n) {
Sum arr[n]; // 调用 n 次 Sum 构造函数,自动累加
return Sum::GetRet();
}
};
每构造一个 Sum 对象,_ret 就累加一次 _i,_i 自增。n 个对象就是 1+2+…+n。
友元
友元函数
友元(Friend)提供了一种突破访问限定符的方式。在函数声明前加 friend,并将声明放在类内部,该函数就可以访问这个类的 private 和 protected 成员。
class B; // 前置声明——A 的友元声明需要知道 B 的存在
class A {
friend void func(const A& aa, const B& bb); // 友元声明——只是声明,不是成员函数
private:
int _a1 = 1;
int _a2 = 2;
};
class B {
friend void func(const A& aa, const B& bb);
private:
int _b1 = 3;
int _b2 = 4;
};
void func(const A& aa, const B& bb) {
std::cout << aa._a1 << std::endl; // 访问 A 的私有成员——合法,因为 func 是 A 的友元
std::cout << bb._b1 << std::endl; // 访问 B 的私有成员——合法,因为 func 是 B 的友元
}
关键约束:
- 友元声明可以放在类的任何位置,不受访问限定符影响。
- 友元函数不是成员函数——它只是一个普通的全局函数,被特许访问私有成员。
- 一个函数可以是多个类的友元。
友元类
一个类可以声明另一个类为友元类:
class A {
friend class B; // B 是 A 的友元——B 的所有成员函数都能访问 A 的私有成员
private:
int _a1 = 1;
int _a2 = 2;
};
class B {
public:
void func1(const A& aa) {
std::cout << aa._a1 << std::endl; // OK——B 是 A 的友元
}
void func2(const A& aa) {
std::cout << aa._a2 << std::endl; // OK
}
};
友元关系的两个重要属性:
| 属性 | 含义 |
|---|---|
| 单向性 | A 声明 B 是朋友,不代表 B 把 A 当朋友。B 能访问 A 的私有成员,A 不能访问 B 的私有成员。 |
| 不可传递 | A 是 B 的朋友,B 是 C 的朋友,但 A 不是 C 的朋友。 |
友元增加了耦合度,破坏了封装。不宜多用——把它看作"封装防线上的一个必要缺口",只在重载 <</>> 这类"非用不可"的场景动用。
内部类
一个类定义在另一个类的内部,就叫内部类(Inner Class)。内部类是独立的类,定义在全局也一样——只是它的名字受外部类类域的限制,访问受外部类访问限定符限制。
内部类默认是外部类的友元类——可以访问外部类的私有和静态成员。外部类定义的对象中不包含内部类对象。
#include <iostream>
class A {
private:
static int _k;
int _h = 1;
public:
class B { // B 默认是 A 的友元
public:
void foo(const A& a) {
std::cout << _k << std::endl; // OK——静态成员
std::cout << a._h << std::endl; // OK——通过对象访问私有成员
}
int _b1;
};
};
int A::_k = 1;
int main() {
std::cout << sizeof(A) << std::endl; // 只含 _h,不含 B 的任何东西——输出 4
A::B b; // 使用外部类域限定
A aa;
b.foo(aa);
}
内部类是一种高级封装手段。当 A 类和 B 类紧密关联,B 的存在基本上就是为了服务 A 时,把 B 设计为 A 的 private/protected 内部类,其他地方完全不可见,封装的粒度更细。
之前那道 1+2+...+n 题目,用内部类改写可以避免 Sum 类污染全局命名空间:
class Solution {
class Sum {
public:
Sum() { _ret += _i; ++_i; }
};
static int _i;
static int _ret;
public:
int Sum_Solution(int n) {
Sum arr[n];
return _ret;
}
};
int Solution::_i = 1;
int Solution::_ret = 0;
匿名对象
用 类型(实参) 直接创建的对象叫匿名对象(Anonymous Object),不需要给名字:
class A {
public:
A(int a = 0) : _a(a) {
std::cout << "A(int a)" << std::endl;
}
~A() {
std::cout << "~A()" << std::endl;
}
private:
int _a;
};
int main() {
A aa1; // 有名对象——生命周期到 main 结束
A(); // 匿名对象——生命周期仅此一行
A(1); // 匿名对象——生命周期仅此一行
A aa2(2); // 有名对象
// 匿名对象的典型用途:临时调用一个方法
Solution().Sum_Solution(10); // 创建匿名 Solution,调用方法,然后销毁
}
匿名对象的生命周期只在当前一行,下一行自动调用析构销毁。它适合"临时使用一个对象,用完即弃"的场景。
编译器拷贝优化
现代编译器会在不影响正确性的前提下尽可能省略拷贝。这不是 C++ 标准强制规定的,但主流编译器(MSVC、GCC、Clang)都在做。
class A {
public:
A(int a = 0) : _a1(a) { std::cout << "A(int a)" << std::endl; }
A(const A& aa) : _a1(aa._a1) { std::cout << "A(const A& aa)" << std::endl; }
A& operator=(const A& aa) { std::cout << "A& operator=" << std::endl; return *this; }
~A() { std::cout << "~A()" << std::endl; }
private:
int _a1 = 1;
};
void f1(A aa) {} // 传值——正常情况下会调用拷贝构造
A f2() { A aa; return aa; } // 传值返回——正常情况下会调用拷贝构造
int main() {
A aa1; // 构造
f1(aa1); // 拷贝构造(无法优化——aa1 是左值)
f1(1); // 隐式类型转换:连续 构造+拷贝构造 → 优化为直接构造
f1(A(2)); // 匿名对象:连续 构造+拷贝构造 → 优化为直接构造
f2(); // 返回:局部对象 → 可能优化为直接构造(vs2022 debug)
A aa2 = f2(); // 接收返回值:可能跨行合并优化(vs2022 debug)
aa1 = f2(); // 赋值:无法完全优化(已经存在的对象 + 赋值)
return 0;
}
优化规律:
| 场景 | 原始调用链 | 优化后 |
|---|---|---|
f1(1) |
构造临时 + 拷贝构造 | 直接构造 |
f1(A(2)) |
构造 + 拷贝构造 | 直接构造 |
A aa2 = f2() |
局部构造 + 拷贝临时 + 拷贝 aa2 | 可能合并为一次直接构造 |
aa1 = f2() |
局部构造 + 拷贝临时 + 赋值 aa1 | 只能优化前半段 |
核心认知:编译器优化的是同一个表达式内连续的"构造+拷贝构造"。越新的编译器越激进(跨行合并),但有没有、怎么优化都没有标准保证。写代码时不要依赖优化——该用引用传参就用引用传参,该用返回值就用返回值,优化只是锦上添花。
💡 背景补充:GCC 用
-fno-elide-constructors关闭拷贝省略优化,用于验证"不优化时到底调了多少次拷贝"。日常写代码不需要关。
常见误区
误区一:初始化列表顺序与书写顺序无关——但执行顺序与声明顺序有关
初始化列表按声明顺序执行。如果 _a2 声明在 _a1 前面,_a2(_a1) 中访问的 _a1 还未初始化。
误区二:友元是相互的
友元单向且不可传递。A 把 B 当朋友,不代表 B 把 A 当朋友。
误区三:静态成员变量在类内初始化
class A {
static int _count = 0; // 编译报错——静态成员变量必须在类外定义
};
int A::_count = 0; // 正确写法
本节要点
- 初始化列表是成员变量真正初始化之地。引用、const、无默认构造的类类型——三者必须在此初始化。
explicit禁用隐式类型转换。单参数构造函数默认加explicit,除非你有意让隐式转换发生。- static 成员属于整个类,不属于任何对象。静态成员变量类外初始化,静态成员函数没有
this。 - 友元是封装的例外,不是常态。单向、不可传递。重载
<</>>时几乎必须用。 - 内部类默认是外部类的友元。用于"紧密关联、不对外暴露"的类关系。
- 编译器会优化连续的构造+拷贝构造,但不要依赖优化——正确的设计(如传 const 引用)才是确定性的保障。
📖 参考:《C++ Primer Plus》第10-11章 —— 构造函数初始化列表、友元、运算符重载;《高质量C++/C编程指南》第9章 —— 初始化表 vs 函数体赋值、隐式类型转换;《Effective C++》条款22 —— 封装与访问控制。
*迪亚波罗学编程
更多推荐


所有评论(0)