6 minute read

很多初学者容易混淆初始化列表(Member Initializer List)和列表初始化(List Initialization),因为它们的名字很像,但它们实际上解决的是完全不同的问题:

  1. 初始化列表:解决的是对象生命周期与内存模型的问题(“什么时候赋初值”)
  2. 列表初始化:解决的是类型系统的统一性与安全性的问题(“用什么语法赋初值”)

初始化列表(Member Initializer List)

它的形式出现在构造函数参数列表之后,函数体大括号之前,以 : 开头。

class A {
    int x;
public:
    A(int val) : x(val) {} // 初始化列表
};

初始化 vs. 赋值

在 C++ 的对象模型中,“初始化”(Initialization)和 “赋值”(Assignment)是两个截然不同的物理过程。

  1. 内存分配Allocation):在栈上或堆上划出一块内存
  2. 初始化Initialization):在这块内存上构建对象,使其成为一个合法的实例
  3. 赋值Assignment):对象已经存在了,擦出旧值,填入新值。

构造函数的执行时间线:

  1. 进入构造函数之前:编译器必须确保所有成员变量都已经 “出生”(初始化完成)
  2. 进入构造函数体({...}):这已经是 “出生后” 的世界了,这里面写的代码都是 “赋值” 操作。

为什么要用初始化列表?

如果你不用初始化列表,而是写在函数体内:

class Person {
    std::string name;
public:
    // 写法 1:在函数体内赋值
    Person(const std::string& n) {
        name = n;
    }
};

底层发生了什么?

  1. 隐式初始化:在进入 { 之前,编译器发现 name 还没有初始化,于是强行用 std::string 的默认构造函数。name 变成了一个空字符串 ""
  2. 赋值操作:进入 { 后,执行 name = n;。调用 std::string 的赋值运算符,把刚才那个空字符串的内容丢弃,拷贝 n 的内容

这就类似于,你先建了一个空房子,然后立即把它拆了重建成你想要的样子,这就是双倍的开销。

正确的写法(初始化列表):

// 写法 2:使用初始化列表
Person(const std::string& n) : name(n) {}

底层发生了什么?

  1. 直接构造:直接调用 std::string 的拷贝构造函数,用 n 来 “生出” name
  2. 函数体为空,无操作。收益:省去了一次默认构造和一次赋值操作。

必须使用的场景(物理限制)

有些东西必须 “出生时” 就确定,生出来后再改就晚了。这些情况必须使用初始化列表:

  • const 成员:常量一旦出生就不能修改(不能赋值)
  • 引用成员(&):引用一旦出生必须绑定到一个对象,不能重新绑定(不能赋值)
  • 没有默认构造函数的类成员:如果成员是一个类,且它没有默认构造函数(必须带参),编译器无法在进入函数体前 “隐式初始化” 它,必须显式指定

初始化的顺序(内存布局决定)

初始化列表中的顺序并不决定初始化的真实顺序!成员变量的初始化顺序严格由它们在类定义(Class Definition)中声明的顺序决定

class A {
    int x;
    int y;
public:
    // 错误示范!看起来像先算 y,其实 x 会先初始化,次数 y 是垃圾值
    A(int val) : y(val), x(y) {}
};

C++ 对象的内存布局在编译器就确定了(x 在前,y 在后)。析构时必须按相反顺序销毁。为了保证构造和析构的对称性(LIFO),构造顺序必须固定,不能随你怎么写列表儿改变。

列表初始化(List Initialization)

它的形式是使用花括号 {}

int a{0};
std::vector<int> v{1, 2, 3};

统一初始化语义

在 C++11 之前,初始化的语法简直是精神分裂:

int a = 5;
int b(5);
int arr[] = {1, 2};
struct Point p = {1, 2};

C++11 引入 {} 旨在统一初始化Uniform Inialization):万物皆可 {} 初始化。

底层机制:std::initializer_list

当你用{1, 2, 3, 4} 初始化一个容器(如 std::vector)时,发生了什么?

  1. 编译器魔法:编译器会在静态存储区悄悄创建一个数组
  2. 包装:编译器构建一个 std::initializer_list<T> 对象。这其实是一个轻量级的 “视图” 或 “胖指针”,它只包含两个指针:一个指向数组开头,一个指向结尾(或长度)
  3. 传递:这个轻量级对象被传给 std::vector 的构造函数
  4. 消费:vector 遍历这个 list,把数据拷贝到自己的内存里

这就是为什么你可以给 std::vector 赛任意数量的初始值。

安全特性:防止窄化转换(Narrowing Conversion)

这是一个类型安全设计。初始化意味着 “建立一个合法的初始状态”。如果数据在初始化过程中丢失了精度,那么这个初始状态本身就是 “不诚实” 的。

int a = 3.14; // C++98: 允许 a 变成 3(截断)
int b{3.14}; // C++11: 编译器报错!禁止数据丢失

{} 语法强制要求:如果源类型的值无法无损地放入目标类型,编译器必须报错。

解决 “最令人头秃的解析”(Most Vexing Parse)

C++ 的语法歧义:

// 你的意图:创建一个对象 a,调用默认构造函数
A a();

编译器的理解:声明了一个函数 a(),它不接受参数,返回类型是 A

解决办法:使用 {}

A a{}; // 明确无误,这就是创建对象

因为函数声明不可能用 {} 结尾。

两者的 “撞车”

当一个类既有普通构造函数,又有接受 std::initializer_list 的构造函数时,{} 会极度优先匹配后者。

class Magic {
public:
    Magic(int a, int b) {
        // 构造函数 1:普通构造
        std::cout << "Normal Constructor" << std::endl;
    }

    Magic(std::initializer_list<int> list) {
        // 构造函数 2:列表构造
        std::cout << "List Constructor" << std::endl;
    }
};

Magic m1(10, 20); // 调用构造函数 1
Magic m2{10, 20}; // 调用构造函数 2(因为 {10, 20} 被视为列表)

如何选择 {}()

{} 的写法

直接列表初始化(Direct List Initialization)

  • 特征:不带等号 =
  • 心智模型:我非常确定我要构造这个对象,请直接用这些参数造出来,不许做任何隐式类型转换。
// 1. 普通变量
int x{0};                       // x 初始化为 0
double d{3.14};                 // d 初始化为 3.14

// 2. 类对象
std::vector<int> v{1, 2, 3};    // 容器包含 1, 2, 3
Person p{"Alice", 20};          // 调用 Person(string, int)

// 3. 堆内存 new
int* ptr = new int{5};          // 申请内存并初始化为 5

// 4. 临时对象(匿名对象)
func(Person{"Bob", 30});

// 5. 基类与成员初始化
Derive(int x, int y) : Base{x}, m_val{y} {}

拷贝列表初始化(Copy List Initialization)

  • 特征:带有等号 =
  • 心智模型:先把右边的花括号转换成目标类型,然后再拷贝(或移动)给左边
// 1. 变量定义
int x = {0};
std::vector<int> v = {1, 2, 3};

// 2. 函数传参(隐式构造)
void foo(std::vector<int> v);
foo({1, 2, 3});                 // 编译器隐式地把 {1, 2, 3} 转换成了 vector

// 3. 函数返回值
std::vector<int> bar() {
    return {1, 2, 3};           // 隐式构造并返回
}

直接列表初始化和拷贝列表初始化的选择

在绝大多数情况下,由于现代编译器的优化,拷贝列表初始化会被直接优化成为直接列表初始化,这被称为复制省略Copy Elision)。

但是存在一种例外情况,那就是 explicit 关键字声明的构造函数。

explicit 关键字的影响

如果一个类的构造函数被声明为 explicit(显式),那么第 2 种写法(带等号的会被禁止)。

class Widget {
public:
    explicit Widget(int n) {}
};

Widget w1{10};      // OK:直接初始化,显式调用
Widget w2 = {10};   // Error:拷贝列表初始化要求 “隐式转换”,但被 explicit 阻止了

所以我们可以得出结论:Type x{...}Type x = { ... } 更加通用,因为它不受 explicit 的限制,更推荐使用不写等号的写法。

真的应该所有情况都用 {} 吗?

虽然 C++ Core Guidenlines 推荐默认使用 {},但你必须警惕两个巨大的陷阱。这两个陷阱的根源在于编译器对 std::initializer_list 的极度偏爱。

陷阱 1:std::vector 的构造
std::vector<int> v1(10, 5); // 用 ()
std::vector<int> v2{10, 5}; // 用 {}

解析:

  • v1(圆括号):调用 vectore(size_type n, const T& val),创建了 10 个元素,每个元素都是 5
  • v2(花括号):编译器看到 {},优先查找是否接受 std::initializer_list 的构造函数,找到了,于是创建了 2 个元素,分别是 10 和 5

当构造函数发生重载时,如果存在接受 std::intitalizer_list 的版本,编译器会贪婪地优先匹配它,哪怕需要进行一些类型提升。只有完全匹配不上了,才会退回去找普通的构造函数。

建议:

  • 原则上:默认用 {}
  • 例外:当在调用容器的大小/数量构造函数时,必须用 (),否则会被误解为 “元素内容”
陷阱 2:auto 类型推导的歧义

这一点在 C++11/14 和 C++17 中表现不同,非常令人困惑。

auto x = {1};
// x 的类型是什么?是 int 吗?
// NO! x 的类型是 std::initializer_list<int>

如果你本意是想要一个 int,结果得到了一个轻量级列表对象,后面的代码可能会出问题。

修正写法(C++17 起):

auto x{1};      // C++17 规定推导为 int
auto y = {1};   // 推导为 std::initializer_list<int>

建议:配合 auto 使用 {} 时要格外小心,最好明确类型,或者确保你真的想要 std::initializer_list

{} 的特性:默认值初始化(Value Initialization)

这是 {} 最让我觉得 “舒服” 的地方。

如果你想把一个对象清零,或者初始化为 “空状态”:

// 旧写法
int i;      // 危险!如果是局部变量,i 的值是随机的(垃圾值)
int* p;     // 危险!野指针

// 新写法(空花括号)
int i{};    // 安全!保证初始化为 0
int* p{};   // 安全!保证初始化为 nullptr
double d{}; // 安全!保证初始化为 0.0

空的 {} 触发值初始化Value Initialization)。对于内置类型(intfloat、指针),它会执行零初始化Zero Initialization)。

Tags:

Categories:

Updated:

Comments