Skip to content

类和类的层次结构

类和类的层次结构规则概览:

类是一种用户定义类型,程序员可以为其指定表示方法、操作和接口。类的层次结构被用来组织相关的结构。

C++ Core Guidelines 中大约有100条关于用户定义类型的规则。

Guidelines 先给出了一些概要规则,然后深入讨论了下面的特殊规则:

  • 具体类型
  • 构造函数、赋值和析构函数
  • 类的层次结构
  • 重载和运算符重载
  • 联合体

下面的 8 条概要规则为特殊规则提供了背景。

5.1 概要规则

概要规则相当简短,没有涉及太多细节。它们对类概括提供了有价值的深刻见解。

class(类)和 struct(结构体)之间的语法差异 本节经常提到类和结构体之间的语义区别。首先。语法上的差异是什么?差异很小,但很重要:

  • 在结构体中,所有成员默认为 public(公开);类为(private)私有。
  • 继承情况也是如此。结构体默认继承权限为 public,类为 private。

除此之外,二者在语言语法层面完全一致

C.1 把相关的数据组织到结构(struct 或 class)中

如何改进 draw 的接口?

cpp
void draw(int fromX, int fromY, int toX, int toY);

不明显的是,这些 int 代表了什么。因此,调用函数的时候参数顺序可能会出错。可以对比一下上面的 draw 和下面的新函数:

cpp
void draw(Point from, Point to);

通过将相关元素放在结构体中,函数签名变得可以自我描述,因此,比起之前的函数,新函数更不容易出错。

类对象的构造函数也可以用来检测参数的合法性,不过这里的 Point 类型倒是没啥好检测的了。

C.2 当类具有不变式时使用 class;如果数据成员可以独立变化,则使用 struct

不变式(Invariant)是一个在程序执行过程中永远保持成立的条件。不变式在检测程序是否正确方面非常有用。例如编译器优化就用到了不变式。

类的不变式是用于约束类的实例的不变式。成员函数必须使这个不变式保持成立。 不变式约束了类的实例的可能取值。

这是 C++ 中一个常见的问题:什么时候该使用 class,什么时候该用 struct?

C++ Core Guidelines 给出了以下建议。如果类有不变式,就使用 class

如果类有一个需要在程序执行过程中永远保持成立的条件,就使用 class

一个可能的类的不变式是,(y,m,d)可表示一个有效的日期。

cpp
struct Pair{  //成员可以独立变化
    string name;
    int volume;
};

class Date{
public:
    //校验 {yy,mm,dd}是不是合法的日期并进行初始化
    Date(int yy, Month mm, char dd);
    // ...
private:
    int y;
    Month m;
    char d;    //日
}

类的不变式在构造函数中被初始化和检查。数据类型 Pair 没有不变式,因为名称(name)和体积(volume)的所有值都是有效的。Pair 是简单的数据持有者,不需要显式提供构造函数。

值得一提的是,很多库并没有很好的遵守,我们举例 QPoint源码

QPoint 显然是没有不变式,它的成员(xp,yp)所有的值都是有效的,但它依旧使用的是 class。 以及,它没有将它的数据成员设置为 public,反而提供了愚蠢的 6 个成员函数进行访问:rx,ry,x,y,setX,setY

C.3 在类中体现出接口和实现之间的区别

类的公开成员函数是类的接口,私有部分则是实现。

cpp
class Date{
public:
    Date();
    //校验 {yy,mm,dd}是不是合法的日期并进行初始化
    Date(int yy, Month mm, char dd);
    int day()const;
    Month month()const;
    // ...
private:
    // ... 具体的内部表示
};

从可维护性的角度看,可以修改 Date 类的实现,而毫不影响该类的使用者。

就是说面向对象的封装,数据和操作数据的方法(即类的接口)捆绑在一起,并对外部隐藏对象的内部状态。这样可以确保类的接口有效性和不变性。

C.4 仅当函数需要直接访问类的内部表示时,才把它变成成员

如果一个函数不需要访问类的内部结构,它就不应该是成员。这样的话,你会得到松耦合,而且类的内部结构的改变不会影响辅助函数。

一个函数不需要直接访问私有的结构,它就不该是成员

cpp
class Date{
    // ... 相对小的接口 ...
};

//辅助函数
Date next_weekday(Date);
bool operator == (Date, Date);

运算符 =()[]-> 必须是类的成员。

事实上这条规则没那么好遵守,有非常多的额外情况,英文原书和我们当前描写的都太少了。可以看英文文档,或国人翻译

C.5 将辅助函数与它们支持的类放在同一个命名空间中

辅助函数应该在类的命名空间中,因为它是类的接口的一部分。与成员函数相反,辅助函数不需要直接访问类的内部表示。

cpp
namespace Chrono{ // 在这里放置跟时间有关的服务
    class Date { /* ... */ };

    // 辅助函数:
    bool operator == (Date, Date);
    Date next_weekday(Date);
    // ...
}
...
if (date1 == date2) { ... //(1)

由于有实参依赖查找(argument-dependent lookup,ADL),比较 date1 == date2 将额外查找 Chrono 命名空间中的相等运算符。ADL 对于重载的运算符尤其重要,如输出运算符<<。

C.7 不要在一条语句里定义类或枚举的同时声明该类型的变量

若在一条语句里定义类或枚举并同时声明其他类型的变量,会引起混淆,因此应该避免。

cpp
// 不好
struct Date { /*...*/ } date { /*...*/ };

// 好
struct Date{ /*...*/ };
Date date{ /*...*/ };

C.8 如果有任何非公开成员,就使用 class 而不是 struct

明确某事被隐藏/抽象。这是一个有用的约定。

除此之外可能还有很多乱七八糟的理由,但总而言之,就是这样,约定

C.9 尽量减少成员的暴露

数据隐藏和封装是面向对象类设计的基石之一:你将类中的成员封装起来,只允许通过公共成员函数进行访问。你的类可能有两种接口:一种是用于外部的 public 接口,一种是用于派生类的 protected 接口。其余成员都应该属于 private。

封装。信息隐藏。最大限度地减少意外访问的机会。这简化了维护。 需要注意的是,也不要什么成员都给封装了,这样会走上 java 的邪路,getset ...

5.2 具体类型

本节只有两条规则,但引入了具体类型和规范类型这两个术语。

这两个术语难以区分,很莫名其妙,本节的内容稍微看看就行。 具体类型(Concrete types),规范类型(regular type)。

根据 C++ Core Guidelines:

具体类型是“最简单的一种类”。它常常被称作值类型,不属于某个类型层次结构的一部分 。

规范类型是一种“行为类似于 int”的类型,因此,它必须支持拷贝和赋值、相等比较,以及可交换。更正式的说法是,一个规范类型 X 行为上像 int,支持下列操作。

  • 默认构造:X()
  • 拷贝构造:X(const X&)
  • 拷贝赋值:operator = (const X&)
  • 移动构造:X(X&&)
  • 移动赋值:operator = (X&&)
  • 析构:~X()
  • 交换操作:swap(X&, X&)
  • 相等运算符:operator ==(const X&, const X&)

C.10 优先使用具体类型而不是类层次结构

如果没有需要类层次结构的用例,就使用具体类型。具体的类型更容易实现,更小,且更快。不必担心继承、虚性、引用或指针,包括内存分配和释放。不会有虚派发,因此也没有额外[1]开销。

长话短说:应用 KISS 原则(“keep it simple,stupid”原则,保持简单,让傻瓜都能理解)。你的类型行为像普通数值一样。

C.11 让具体类型规范化

规范类型(如 int)易于理解,它们本身就很直观。这意味着:

  • 如果你有一个具体类型,可以考虑将它升级为规范类型。

内置类型(如 int 或 double)是规范类型,而用户定义类型(如 std::string)或容器(std::vector 或 std::unordered_map)也是如此。

C++20 支持 regular (规范)概念。

5.3 构造函数、赋值运算符和析构函数

这一节讨论构造函数、赋值运算符和析构函数,在本章范围内,此类规则的数量是目前为止最多的。它们控制着对象的生命周期:创建、拷贝、移动和销毁。简而言之,我们把它们称为“六大”。下面是这六个特殊的成员函数。

  • 默认构造函数:X()
  • 拷贝构造函数:X(const X&)
  • 拷贝赋值运算符:operator = (const X&)
  • 移动构造:X(X&&)
  • 移动赋值运算符:operator = (X&&)
  • 析构函数:~X()

编译器可以为这“六大”生成默认实现。本节从有关默认操作的规则开始;接着是有关构造函数、拷贝和移动操作以及析构函数的规则;最后是不属于前四类的其他默认操作的规则。

根据默认构造函数的声明,你可能有这样的印象:默认构造函数不需要参数。这是不对的。默认构造函数可以在没有参数的情况下被调用,但它可能每个参数都有默认值。

预置操作

默认情况下,如果需要,编译器可以生成“六大”。可以定义这六个特殊的成员函数,但也可明确用 = default(预置)来要求编译器提供它们,或者用 = delete(弃置)来删除它们。

C.20 如果能避免定义默认操作,那么就这么做

这一规则也被称为“零法则”。这意味着你可以通过使用有合适的拷贝/移动语义的类型,来避免自行编写构造函数、拷贝/移动构造函数、赋值运算符或析构函数。有合适的拷贝/移动语义的类型包括规范类型,如内置类型 bool 或 double,也包括标准模板库(STL)的容器,如 std::vector 或 std::string。

cpp
class Named_map{
public:
    // ... 没有声明任何默认操作 ...
private:
    std::string name;
    std::map<int, int> rep;
};

Named_map mm;        // 默认构造
Named_map nm2 {nm};  // 拷贝构造

默认构造和拷贝构造之所以有效,是因为 std::string 和 std::map 已经定义了相应的操作。

  • 编译器所自动生成的拷贝构造函数会调用当前类所有成员的拷贝构造函数

特殊成员函数都是这样,不局限于拷贝构造,这个其实以前在 P.9 提起过。(另外强调一下,这些话全说的是类类型)

C.21 如果定义或 =delete 了任何默认操作,就对所有默认操作进行定义或 =delete

“六大”是紧密相关的。由于这种关系,你应该对所有特殊成员函数进行定义或 =delete。因此,这条规则被称为“六法则”。有时你会听到“五法则”,这是因为默认构造函数很特殊,有时会被排除在外[2]

特殊成员函数之间的依赖关系

Howard Hinnant 在 ACCU 2014 会议的演讲中给出了一张自动生成的特殊成员函数的概览表(见图 5.1)

Howard 的表格需要进一步解释一下。

编译器隐式声明




默认
构造函数
析构函数拷贝
构造函数
拷贝赋值移动
构造函数
移动赋值
全部不声明预置预置预置预置预置预置
任意构造函数不声明预置预置预置预置预置
默认构造函数用户声明预置预置预置预置预置
析构函数预置用户声明预置预置不声明不声明
拷贝构造函数不声明预置用户声明预置不声明不声明
拷贝赋值预置预置预置用户声明不声明不声明
移动构造函数不声明预置弃置弃置用户声明不声明
移动赋值预置预置弃置弃置不声明用户声明

图 5.1 自动生成的特殊成员函数

首先,“用户声明”是指对于这 6 个特殊成员函数中的某一个,你明确地给出了定义,或者用 =default 请求编译器给出预置定义。用 =delete 删除成员函数的操作也被认为进行了定义。从本质上讲,当你只是使用名字,比如默认构造函数的名字时,这也算作用户声明。

  • 当你定义任何构造函数时,默认构造函数就没有了。默认构造函数是可以在没有参数的情况下调用的构造函数

  • 当你用 =default 或 =delete 定义或删除默认构造函数时,其他特殊成员函数都不受影响

  • 当你用 =default 或 =delete 定义或删除析构函数拷贝构造函数拷贝赋值操作符时,编译器不会生成移动构造函数和移动赋值运算符。这意味着移动构造或移动赋值这样的移动操作会回退到拷贝构造或拷贝赋值。这种回退的自动操作在表格中以深色标出。

  • 当用 =default 或 =delete 定义或删除移动构造函数移动赋值运算符时,只能得到定义的 =default 或 =delete 的移动构造函数或移动赋值运算符。后果是,拷贝构造函数和拷贝赋值运算符被设置为 =delete[3]。因此调用一个拷贝操作,如拷贝构造或拷贝赋值,将导致编译错误。

当你不遵循这条规则时,你会得到非常不直观的对象。下面是 Guidelines 中的一个直观的例子。

cpp
#include <cstddef>

class BitArray{
public:
    BitArray(std::size_t len) :len_(len), data_(new int[len]) {}
    ~BitArray(){
        delete[] data_;
    }

private:
    std::size_t len_;
    int* data_;
};

int main(){
    BitArray bitArray1(1000);

    BitArray bitArray2(1000);

    bitArray2 = bitArray1;      //(1)
}                               //(2)

为什么这个程序有未定义行为?例子中默认的拷贝赋值操作 bitArray2 = bitArray1(1)拷贝了 bigArray2 的所有成员。拷贝意味着,在目前情况下,被拷贝的是 data 指针,而不是其指向的数据。因此,bigArray1 和 bigArray2 的析构函数被调用(2),由于重复释放,我们得到了未定义行为。

这个例子中不直观的行为是,编译器生成的 BigArray 的拷贝赋值操作符对 BigArray 进行了浅拷贝,但是 BigArray 的显式实现的析构函数假设了数据的所有权。

运行效果

txt
double free or corruption (!prev)
Program terminated with signal: SIGSEGV

C.22 让默认操作保持一致

默认操作是一个概念上相配合的集合。它们的语义是相互关联的。

  • 如果复制/移动构造和复制/移动赋值所做的是逻辑上不同的事情的话,这会让使用者感觉诡异。

  • 如果构造函数和析构函数并不提供一种对资源管理的统一视角的话,也会让使用者感觉诡异。

  • 如果复制和移动操作并不体现出构造函数和析构函数的工作方式的话,同样会让使用者感觉诡异。

示例,不好

cpp
class Silly {   // 不好: 复制操作不一致
    class Impl {
        // ...
    };
    shared_ptr<Impl> p;
public:
    Silly(const Silly& a) : p{make_shared<Impl>()} { *p = *a.p; }   // 深复制
    Silly& operator=(const Silly& a) { p = a.p; return *this; }     // 浅复制
    // ...
};

这些操作在复制语义上并不统一。这将会导致混乱和出现 BUG。

强制实施

  • 【复杂】 复制/移动构造函数和对应的复制/移动赋值运算符,应当在相同的解引用层次上向相同的成员变量进行写入。
  • 【复杂】 在复制/移动构造函数中被写入的任何成员变量,在其他构造函数中也都应当进行初始化。
  • 【复杂】 如果复制/移动构造函数对某个成员变量进行了深复制,就应当在析构函数中对这个成员变量进行修改。
  • 【复杂】 如果析构函数修改了某个成员变量,在任何复制/移动构造函数或赋值运算符中就都应当对该成员变量进行写入。

C.dtor: 析构函数

“这个类需要析构函数吗?” 这是一个令人惊讶的富有洞察力的设计问题。对于大多数类,答案是“否”,因为该类没有资源,或者因为销毁是按零规则[4] 处理的;也就是说,其成员可以在销毁方面自行解决。如果答案是“是”,则该类的大部分设计都会遵循(请参阅五规则[5])。


构造函数

有 13 条规则涉及对象的构造。粗略来说,它们分为 5 类。

  • 构造函数通用
  • 默认构造函数
  • 单参数构造函数
  • 成员初始化
  • 特殊构造函数,如继承或委托构造函数

最后,我需要警告一下。不要从委托构造函数中调用虚函数。在本章后面的“其他默认操作”一节中,我将在包括析构函数的更广泛的背景下提到这个警告。


构造函数通用

我跳过了规则 “C.40: 如果类有不变式,就定义构造函数”,因为我已经在“C.2: 当类具有不变式时使用 class;如果数据成员可以独立变化,则使用 struct”这条规则中写到了相关内容。因此,还剩下两条密切相关的规则:

"C.41: 构造函数应当创建完全初始化的对象"和“C.42: 如果构造函数无法构造出有效对象,则应抛出异常”。

C.41 构造函数应当创建完全初始化的对象

构造函数的职责就是创建完全初始化的对象。类不应有 init(初始化)成员函数,不然就是自找麻烦。

cpp
class DiskFile{
    FILE* f;
    // ...
public:
    DiskFile() = default;
    void init();        // 初始化 f
    void read();        // 从 f 读取
    // ...
};

int main(){
    DiskFile file;
    file.read();     // 崩溃,或错误读取!
    file.init();     // 太晚了
    // ...
}

用户可能会错误地在 init 之前调用 read,或者只是忘了调用 init。将成员函数 init 设为私有,并从所有构造函数中调用它,这样做好一些,但仍不是最佳选择。当一个类的所有构造函数有共同的操作时,请使用委托构造函数

C.42 如果构造函数无法构造出有效对象,则应该抛出异常

根据前面的规则

  • 如果不能构造出有效的对象,那就该抛异常。

没有太多可补充的东西。如果使用无效的对象,你就总得在使用之前检查对象的状态。这样非常繁琐、低效且容易出错。例子:

cpp
class DiskFile{
    FILE* f;
    bool valid;
    // ...
public:
    explicit DiskFile(const std::string& name) :f{ fopen(name.c_str(),"r") }, valid{ false }{
        if (f)valid = true;
        // ...
    }
    bool is_valid()const { return valid; }
    void read();        // 从 f 读取
    // ...
};

int main(){
    DiskFile file{ "Heraclides" };
    file.read();    // 崩溃,或读取错误!
    // ...
    if(file.is_valid()){
        file.read();
        // ...
    }
    else{
        // ...处理错误...
    }
    // ...
}

默认构造函数

接下来的两条规则回答了这个问题:一个类什么时候需要默认构造函数,什么时候不需要默认构造函数?

C.43 确保可拷贝的(值类型)类有默认构造函数

不正式地说,当类的实例缺少有意义的默认值时,该类就不需要默认构造函数。例如,“人”没有有意义的默认值,但是像“银行账户”这样的类型则有。银行账户的初始值可能是零。拥有默认的构造函数,可以使你的类型更容易使用。STL 容器的许多构造函数都要求你的类型有默认构造函数——例如,有序的关联容器(如 std::map)里的值。如果类的所有成员都有默认构造函数,编译器会尽可能为你的类生成默认构造函数

现在说说不应该提供默认构造函数的情况。

C.45 不要定义仅初始化数据成员的默认构造函数,而应使用默认成员初始化器

代码常常胜过千言万语。

cpp
#include <iostream>
#include <functional>

class Widget {
public:
    Widget() :width(640), height(480), frame(false), visible(true) {}
    explicit Widget(int w) :width(w), height(getHeight(w)), frame(false), visible(true) {}
    Widget(int w, int h) :width(w), height(h), frame(false), visible(true) {}

    void show()const {
        std::cout << std::boolalpha << width << "x" << height
            << ", frame: " << frame
            << ", visible: " << visible << '\n';
    }
private:
    int getHeight(int w) { return w * 3 / 4; }
    int width;
    int height;
    bool frame;
    bool visible;
};

class WidgetImpro {
public:
    WidgetImpro() = default;
    explicit WidgetImpro(int w) :width(w), height(getHeight(w)) {}
    WidgetImpro(int w, int h) :width(w), height(h) {}

    void show()const{
        std::cout << std::boolalpha << width << "x" << height
            << ", frame: " << frame
            << ", visible: " << visible << '\n';
    }
private:
    int getHeight(int w) { return w * 3 / 4; }
    int width{ 640 };
    int height{ 480 };
    bool frame{ false };
    bool visible{ true };
};

int main(){
    std::cout << '\n';

    Widget wVGA;
    Widget wSVGA(800);
    Widget wHD(1280, 720);

    wVGA.show();
    wSVGA.show();
    wHD.show();

    std::cout << '\n';

    WidgetImpro wImproVGA;
    WidgetImpro wImproSVGA(800);
    WidgetImpro wImproHD(1280, 720);

    wImproVGA.show();
    wImproSVGA.show();
    wImproHD.show();
}

类 Widget 仅使用它的三个构造函数来初始化成员。重构后的 WidgetImpro 类直接在类内部初始化其成员,通过将初始化从构造函数移进类的主体,三个构造函数,变得更加容易理解,类也更容易维护。例如,当你在类中添加新成员时,你只需要在类的主体中添加初始化,而不必在所有的构造函数中添加。此外,你也不需要考虑将初始化器按正确的顺序放在构造函数中了。这样,当创建新对象时,也不可能发生对象只是部分初始化的情况了。

当然,这两个对象的行为是相同的。

运行结果

txt

640x480, frame: false, visible: true
800x600, frame: false, visible: true
1280x720, frame: false, visible: true

640x480, frame: false, visible: true
800x600, frame: false, visible: true
1280x720, frame: false, visible: true

我在设计新类时遵循的方法是,在类的主体中定义默认行为。明确定义的构造函数只用来改变默认行为。

你是否注意到了前面那个只有一个参数的构造函数中的关键字 explicit

C.46 默认情况下,把单参数的构造函数声明为 explicit

说得更明确一点,一个没有 explicit 的单参数的构造函数是个转换构造函数。转换构造函数接受一个参数,并从该参数中生成该类的一个对象。这种行为会让人大吃一惊

C++11 后:不以说明符 explicit 声明的构造函数被称为转换构造函数(converting constructor)。

cpp
class String {
public:
    String(int);   // BAD
    // ...
};

String s = 10;   // 惊喜: 大小为 10 的String

简单的说就是,10 会调用转换构造函数,构造出一个临时的 String 对象,然后再初始化 s,即 String s(String(10)),通常我们应该避免这种行为,比如使用 explicit。

下面是函数传参的形式,其实意思是一样的:

cpp
class String {
public:
    String(int);   // BAD
    // ...
};

void f(const String&);

f(10);             // 相当于 f(String(10))

如果你确实想要从构造函数参数类型到类类型的隐式转换,请不要使用 explicit。

cpp
class Complex {
public:
    Complex(double d);   // OK: 我们想要 double 到当前类的转换
    // ...
};

Complex z = 10.7;   // 奇怪的转换

C.47 按成员声明的顺序定义和初始化成员变量

类成员是按照它们的声明顺序进行初始化的。如果你在成员初始化列表以不同的顺序初始化它们,你可能会大吃一惊。

cpp
#include <iostream>

class Foo{
    int m1;
    int m2;
public:
    Foo(int x) :m2{ x }, m1{ ++x }{ // 糟糕:初始化顺序会让人误解
        std::cout << "m1:" << m1 << '\n';
        std::cout << "m2:" << m2 << '\n';
    }
};

int main(){
    std::cout << '\n';
    Foo foo(1);
    std::cout << '\n';
}

运行结果:

txt

m1:2
m2:2

许多人认为,首先是 m2 被初始化,然后是 m1。这样 m2 会得到 1,而 m1 会得到 2。

  • 列表中的成员初始化器的顺序和初始化顺序是不相关的

实际的初始化规则远不止如此,参见文档

C.48 在使用常量来初始化时,优先选择默认成员初始化器,而不是构造函数的成员初始化

这条规则有点类似于之前的规则“C.45:不要定义仅初始化数据成员的默认构造函数,而应使用成员初始化器”。默认成员初始化器使你能更容易地定义构造函数。此外,你也不会忘记初始化某个成员了。

cpp
class X{
    int i;                          // 不好
    std::string s;
    int j;
public:
    X() :i{ 666 }, s{ "qqq" } {}    // j 没有初始化
    explicit X(int ii) :i{ ii } {}  // s 是 "",而 j 没有初始化
    // ...
};

class X2{
    int i{ 0 };
    std::string s{"qqq"};
    int j{ 0 };
public:
    X2() = default;                 // 所有成员都被初始化成默认值
    explicit X2(int ii) :i(ii) {}   // s 和 j 被初始化为默认值
};
  • 虽然默认成员初始化规定了一个对象的默认行为,但构造函数可以改变这一默认行为

C.49 在构造函数里优先使用初始化而不是赋值

初始化对赋值有两个最明显的优点:首先,你不会因为忘记赋值而使用未初始化的成员;其次,初始化可能更快,并且绝不会比赋值慢

cpp
class Bad{
    string s1;
public:
    Bad(const std::string& s2) { s = s2; }    // 不好:先默认初始化再赋值
    // ...
};

特殊构造函数

从 C++11 开始,一个构造函数可把它的工作委托给同一个类的另一个构造函数,并且构造函数可以从父类继承。这两种技术都允许程序员编写更简洁、更具有表达力的代码。


C.51 使用委托构造函数来表示类的所有构造函数的共同动作

一个构造函数可以把它的工作委托给同一类的另一个构造函数。委托是 C++ 中把所有构造函数的共同动作放到一个构造函数中的现代方式。在 C++11 之前,必须使用一个特殊的初始化函数,它通常被称为 init。

cpp
class Degree {
public:
    explicit Degree(int deg) {          //(1)
        degree = deg % 360;
        if (degree < 0)degree += 360;
    }
    Degree() :Degree(0) {}             //(2)
    explicit Degree(double deg) :Degree(static_cast<int>(std::ceil(deg))) { }       //(3)
private:
    int degree;
};

Degree 类的构造函数(2)和(3)将其初始化工作委托给构造函数(1),后者验证其参数。注意,递归调用构造函数是未定义行为。

一个简化的实现在类中初始化 Degree,并使用预置的默认构造函数。

cpp
class Degree {
public:
    explicit Degree(int deg) {                              //(1)
        degree = deg % 360;
        if (degree < 0)degree += 360;
    }

    Degree() = default;                                     //(2)

    explicit Degree(double deg) :                           //(3)
        Degree(static_cast<int>(std::ceil(deg))) { }        
private:
    int degree = 0;
};
int main(){
    std::cout << std::ceil(-2.1);
}

C.52 使用继承构造函数将构造函数导入不需要进一步显式初始化的派生类中

如果可以的话,在派生类中重用基类的构造函数。当派生类没有成员时,这种重用的想法很合适。如果在可重用构造函数时不用,你就违反了 DRY(don't repeat yourself 不要重复自己)原则。

继承的构造函数保留了它们在基类中定义的所有特性,如访问说明符,或属性 explicit 和 constexpr。

cpp
class Rec{
public:
    Rec(std::string,int){}
    // ... 数据和很多漂亮的构造函数 ...
};

class Oper : public Rec {
    using Rec::Rec;
    // ... 没有数据成员 ...
    // ... 很多漂亮的工具函数
};

struct Rec2 : public Rec{
    int x;
    using Rec::Rec;
};

Rec2 r{ "foo",7 };

int val = r.x;  // r.x 没有初始化

如果 using 声明指代了正在定义的类的某个直接基类的构造函数(例如 using Base::Base;),那么在初始化派生类时,该基类的所有构造函数(忽略成员访问)均对重载决议可见。见文档

使用继承构造函数时会遇到一个危险。如果你的派生类(如 Rec2)有自己的成员,如 int x,它们不会被初始化,除非它们有类内初始化器(见“C.48: 在使用常量初始化时,优先选择类内初始化器,而不是构造函数的成员初始化”)。

拷贝和移动

尽管 C++CoreGuidelines 有八条关于拷贝和移动的规则,它们可以归结为三类规则:拷贝和移动赋值操作,拷贝和移动语义,还有臭名昭著的分片问题。

赋值

语法

“C.60:使用拷贝赋值非 virtual 以 const& 传参,并返回非 const 的引用” 和 “C.63:使移动赋值非 virtual 以 && 传参,并返回非 const 的引用”这两条规则明确说明了拷贝和移动赋值运算符的语法。std::vector 遵循建议的语法。下面是一个简化版本:

cpp
// 拷贝赋值
vector& operator = (const vector& other);

// 移动赋值
vector& operator = (vector&& other);            // C++17 前
vector& operator = (vector&& other) noexcept;   // C++17 起

这一小片段代码显示了,移动赋值运算符是 noexcept。在 C++17 中,这条规则非常明显——“C.66:使移动操作 noexcept”。移动操作包括移动构造和移动赋值运算符。一个 noexcept 声明的函数对编译器来说是个优化机会。下面的代码片段显示了 std::vector 的移动操作和声明。

cpp
vector(vector&& other) noexcept;                // C++17 起
vector& operator = (vector&& other) noexcept;   // C++17 起

这一小片段代码显示了,移动赋值运算符是 noexcept。在 C++17 中,这条规则非常明显——“C.66:使移动操作 noexcept”。移动操作包括移动构造函数和移动赋值运算符。一个 noexcept 声明的函数对编译器来说是个优化机会。下面的代码片段展示了 std::vector 的移动操作的声明。

cpp
vector(vector&& other) noexcept;                // C++17 起
vector& operator = (vector&& other) noexcept;   // C++17 起

自赋值

“C.62:使拷贝赋值对自赋值安全”和“C.65:使移动赋值对自赋值安全”这两条规则都涉及自赋值。自赋值安全意味着操作 x = x 不应该改变 x 的值。

对于 STL 容器、std::string 和内置类型,如 int 等,拷贝/移动赋值对于自赋值是安全的。自动生成的拷贝/移动赋值运算符对于自赋值也是安全的。

下面的类 Foo 行为正确,自赋值是安全的。

cpp
class Foo {
    std::string s;
    int i;
public:
    Foo& Foo::operator = (const Foo& a) {
        s = a.s;
        i = a.i;
        return *this;
    }
    Foo& Foo::operator = (Foo&& a) noexcept{
        s = std::move(a.s);
        i = a.i;
        return *this;
    }
    // ...
};

在这种情况下,任何多余、高开销的自赋值检查都会不必要地让性能变差。

cpp
class Foo {
    std::string s;
    int i;
public:
    Foo& Foo::operator = (const Foo& a) {
        if (this == &a)return *this;        // 多余的自赋值检查
        s = a.s;
        i = a.i;
        return *this;
    }
    Foo& Foo::operator = (Foo&& a) noexcept {
        if (this == &a)return *this;       // 多余的自赋值检查
        s = std::move(a.s);
        i = a.i;
        return *this;
    }
    // ...
};

语义

本节的两条规则听起来很明显:“C.61:拷贝操作应该进行拷贝”和“C.64:移动操作应该进行移动,并使源对象处于有效状态”。那么是什么意思呢?

  • 拷贝操作

    • 在拷贝之后(a = b),a 和 b 必须相同(a == b)。
    • 拷贝可深可浅。深拷贝意味着对象 a 和 b 之后是相互独立的(值语义)。
  • 移动操作

    • C++ 标准要求被移动的对象之后必须处于一个未指定但有效的状态。通常情况下,这个被移动的状态是移动操作源对象的默认状态。

C.67 多态类应当抑制公开的拷贝/移动操作

这条规则听起来无伤大雅,但往往是未定义行为的起因。首先,什么是多态类? 多态类是定义或继承了至少一个虚函数的类。 拷贝一个多态类的操作可能会以切片而告终。切片是 C++ 中最黑暗的部分之一。

切片

切片意味着你想要在赋值或初始化过程中拷贝一个对象,但你只得到该对象的一部分。我们给出一个简单的例子:

cpp
struct Base{
    int base{ 1998 };
};

struct Dervied :Base {
    int derived{ 2011 };
};

void needB(Base b){
    // ...
}

int main(){
    Dervied d;
    Base b = d;     // (1)
    Base b2(d);     // (2)
    needB(d);       // (3)
}

表达式(1)、(2)、(3)效果都相同:d 的 Derived 部分被删掉了。我想这不是你的意图吧。


  1. mq 白注:原文写的是“运行期开销”,英文原文是“run-time overhead”,不好,改掉。 ↩︎

  2. mq 白注:在当前版本的 C++Core Guidelines 里,C.21 已经把“默认操作”改成了“拷贝、移动、析构函数”,明确剔除了默认构造函数。 ↩︎

  3. mq 白注:=delete 就是表格中 “弃置” 的意思。 ↩︎

  4. mq白注:有自定义析构函数、复制/移动构造函数或复制/移动赋值运算符的类应该专门处理所有权(这遵循单一责任原则)。其他类都不应该拥有自定义的析构函数、复制/移动构造函数或复制/移动赋值运算符 ↩︎

  5. mq白注:因为用户定义的析构函数、复制构造函数或复制赋值运算符的存在会阻止移动构造函数和移动赋值运算符的隐式定义,所以任何想要移动语义的类必须声明全部五个特殊成员函数 ↩︎