C++ 初始化列表

在 C++ 中,“成员初始化列表(Member Initializer List)” 是一种专门用来给类成员赋初值的语法,平时我们也常简称为 “初始化列表”。

前面编写构造函数时,我们都是在函数体的花括号 {} 内部使用赋值语句设置初始值。而使用初始化列表,我们可以在程序进入构造函数体之前,直接在成员变量创建出来的那一刻完成初始化。

C++ 初始化列表的基本用法

在 C++ 中,初始化列表写在构造函数参数列表的小括号 () 之后、函数体的左花括号 { 之前。

语法:

类名(参数列表) : 成员1(初始值1), 成员2(初始值2) {
    // 构造函数体
}

说明:

初始化列表以一个英文冒号(:)开头,后面紧跟需要初始化的成员变量名,并在小括号 () 或花括号 {} 中填入初始值。如果有多个成员变量需要初始化,不同成员之间使用英文逗号(,)隔开。

刚接触 : name(n) 这种写法时,不少初学者容易分不清小括号内外哪个是成员变量、哪个是函数参数。其实规律非常简单:小括号外面的名字固定是类的成员变量,小括号里面的内容则是要赋给它的初始值。

正因为小括号内外的角色是固定的,哪怕参数名和成员变量名完全相同,写成 : name(name) 编译器也能准确区分,不会产生冲突。等程序跨进花括号 {} 开始执行构造函数体时,初始化列表里的工作就已经全部完成了。

示例 1:使用初始化列表

#include <iostream>
#include <string>

class Student {
private:
    std::string name;
    int age;

public:
    // 使用初始化列表初始化成员
    Student(std::string n, int a) : name(n), age(a) {}

    void showInfo() {
        std::cout << name << ",年龄:" << age << '\n';
    }
};

int main() {
    Student stu("Jack", 20);
    stu.showInfo();
    return 0;
}

运行结果如下。

Jack,年龄:20

分析:

在这个例子中,当我们执行 Student stu("Jack", 20); 创建对象时,形参 n 接收到 "Jack",形参 a 接收到 20。随后初始化列表直接拿 "Jack" 初始化了 name,拿 20 初始化了 age。

这里构造函数的花括号 {} 之所以为空,是因为所有成员都已经在初始化列表中完成了初始化。如果还需要打印提示信息或检查数据合法性,我们依然可以把代码写在 {} 里面。

提示 如果某个成员变量既写了类内默认初始值,又出现在了构造函数的初始化列表中,创建对象时会以初始化列表指定的值为准。只有未写进初始化列表的其他成员,才会继续使用各自的类内默认初始值。

初始化列表与函数体内赋值的区别

在初始化列表里写 : name(n),和在花括号里写 name = n;,最后都能把值放进去,那这两种写法到底有什么区别呢?我们可以先把这 2 种方式放在一起对比一下。

// 方式 1(推荐)
Student(std::string n, int a) : name(n), age(a) {}

// 方式 2
Student(std::string n, int a) {
    name = n;
    age = a;
}

在 C++ 创建对象时,类的所有成员变量在程序进入构造函数的左花括号 { 之前,就必须全部创建完毕。因此,上面这两种方式在底层的执行过程存在明显区别:

  • 方式 1(初始化列表):属于 “一步直接初始化”。在创建 name 成员变量的那一刻,直接拿参数 n 的内容把它构造出来,一步到位。
  • 方式 2(函数体内赋值):属于 “先默认初始化,再重新赋值”。以 std::string 类型的 name 为例,进入左花括号 { 之前,name 已经先被默认初始化成了空字符串 ""。等进入函数体执行 name = n; 时,才把新内容赋值进去覆盖掉原来的空字符串。

简单来说,进入左花括号 { 之前是 “造出成员变量并赋初值” 的阶段,进入 { 之后则是 “对已有成员重新赋值” 的阶段。对于 std::string 这类对象成员,方式 1 省去了一次多余的默认初始化,因此在实际开发中我们更推荐使用方式 1。

必须使用初始化列表的情况

对于普通的 int 或 std::string 成员,就算不使用初始化列表也能编译通过。但在 C++ 中,有以下 3 种特殊的成员,语法上要求必须在创建的那一刻完成初始化,不能等进入函数体 {} 以后再赋值:

  • const 成员变量:常量一旦创建完成就不能再修改,必须在初始化列表中直接给出初始值。
  • 引用类型成员:引用在定义时必须绑定到一个已有的变量上,不能先悬空再绑定。
  • 没有默认构造函数的成员对象:如果类中包含另一个类的对象作为成员,且那个类没有无参默认构造函数,就必须在初始化列表中显式传参。

1. const 成员与引用成员

无论是 const 常量还是引用,都必须在定义的同时完成初始化。由于程序进入构造函数的左花括号 { 时,类的所有成员变量都已经创建出来了,因此这两类成员只能通过初始化列表来完成初始化。下面我们来看一个例子。

示例 2:初始化 const 与引用成员

#include <iostream>

class Student {
private:
    const int id;     // const 成员变量
    int& scoreRef;    // 引用类型成员

public:
    // 使用初始化列表初始化
    Student(int stuId, int& sc) : id(stuId), scoreRef(sc) {}

    void showInfo() {
        std::cout << id << " 号,成绩:" << scoreRef << '\n';
    }
};

int main() {
    int score = 95;
    Student stu(1001, score);
    stu.showInfo();

    score = 98;       // 修改外部变量 score
    stu.showInfo();
    return 0;
}

运行结果如下。

1001 号,成绩:95
1001 号,成绩:98

分析:

初始化列表 : id(stuId), scoreRef(sc) 在创建 stu 对象的同时完成了两件事:使用 1001 初始化常量成员 id,并让引用成员 scoreRef 绑定到外部变量 score 上。

正因为 scoreRef 是外部变量 score 的别名,后面我们在 main() 函数中把 score 改成 98 之后,再次调用 stu.showInfo() 输出的成绩也跟着变成了 98。

如果不使用初始化列表,而是像下面这样把 const 成员和引用成员放到构造函数的花括号内部去赋值,编译时就会直接报错。

Student(int stuId, int& sc) {
    id = stuId;
    scoreRef = sc;
}

在上面这种错误写法中,进入左花括号 { 之前,id 和 scoreRef 因为没有指定初始值就已经报错了。进入函数体后执行 id = stuId;,还会因为尝试修改常量而再次报错。

2. 没有默认构造函数的成员对象

在实际开发中,一个类的成员变量除了可以是 int、std::string,还可以是另一个类的对象。比如汽车类 Car 内部包含一个发动机类 Engine 的对象,这种把一个类的对象作为另一个类成员变量的方式称为 “对象组合”,里面的对象称为 “成员对象”。

上一节我们讲过,一旦类中手写了带参构造函数,编译器就不再自动生成无参默认构造函数。如果成员对象 Engine 只定义了带参构造函数,外层类 Car 创建时就必须在初始化列表中写出 成员对象名(实参),把初始数据传给它的构造函数。请看下面例子。

示例 3:初始化成员对象

#include <iostream>
#include <string>

class Engine {
private:
    std::string model;

public:
    // 只有带参构造函数
    Engine(std::string m) : model(m) {}

    void start() {
        std::cout << model << " 发动机已启动\n";
    }
};

class Car {
private:
    std::string brand;
    Engine engine;    // 成员对象

public:
    // 初始化成员对象 engine
    Car(std::string b, std::string m) : brand(b), engine(m) {}

    void drive() {
        std::cout << brand << " 准备出发,";
        engine.start();
    }
};

int main() {
    Car myCar("Audi", "V8");
    myCar.drive();
    return 0;
}

运行结果如下。

Audi 准备出发,V8 发动机已启动

分析:

由于 Engine 类只定义了带参构造函数,没有无参默认构造函数。如果 Car 的构造函数不写 : engine(m),编译器在创建 Car 对象时不知道应该传什么参数去构造内部的 engine,就会直接编译报错。

写上 : brand(b), engine(m) 之后就清楚了。当我们执行 Car myCar("Audi", "V8"); 时,"Audi" 用来初始化汽车品牌 brand,"V8" 则会传给 Engine 的构造函数来初始化成员对象 engine。

成员变量的初始化顺序

在使用初始化列表时,还有一条小伙伴们容易忽略的规则:成员变量真正的初始化顺序,只由它们在类中 “声明的先后顺序” 决定,而与初始化列表中谁写在前面、谁写在后面无关。

为什么 C++ 要这样规定呢?因为一个类可以重载多个构造函数,不同构造函数的初始化列表顺序可能写得不一样。为了保证对象销毁时析构函数始终能按照统一的相反顺序清理成员,编译器会固定按照成员变量在类中的声明顺序来初始化。下面我们通过一个例子来验证一下。

示例 4:成员变量的初始化顺序

#include <iostream>
#include <string>

class Part {
public:
    Part(std::string name) {
        std::cout << "正在初始化:" << name << '\n';
    }
};

class Demo {
private:
    Part first;     // 第 1 个声明
    Part second;    // 第 2 个声明

public:
    // 故意把 second 写在前面
    Demo() : second("second"), first("first") {}
};

int main() {
    Demo demo;
    return 0;
}

运行结果如下。

正在初始化:first
正在初始化:second

分析:

从运行结果可以看出,虽然 Demo() 在初始化列表里故意把 second 写在了前面,程序却依然先输出了 first、后输出了 second。这是因为在 Demo 类内部,first 声明在 second 前面,编译器始终按照类内声明的顺序来初始化。

这条规则大家一定要留意。如果我们 “用一个成员去初始化另一个成员”,顺序写反就会引发隐蔽的逻辑错误,请看下面的对比。

class Box {
private:
    int a;    // 第 1 个声明
    int b;    // 第 2 个声明

public:
    // 正确方式
    Box(int val) : a(val), b(a) {}

    // 错误方式
    Box(int val) : b(val), a(b) {}
};

在正确方式中,a 先被初始化为 val,随后再用 a 初始化 b。而在错误方式中,因为 a 声明在 b 前面,编译器依然会先执行 a(b)。此时 b 还没有初始化,结果导致 a 读出了随机的垃圾值。

注意 在实际开发中,我们编写初始化列表时,建议始终按照成员变量在类中的声明顺序来排列,并且不要用后面声明的成员去初始化前面声明的成员。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号