C++ 类与特殊成员函数

最后更新: 2024-08-28 06:08:01 +0800 +0800
原文以英文撰写。您正在阅读AI翻译版本。

C++ 类定义表示、不变量,以及建立和保持这些不变量的操作。构造函数建立对象的不变量,成员函数保持该不变量,析构函数释放对象所拥有的资源。应先设计所有权策略,因为它决定编译器生成的特殊成员是否正确。

构造与析构 #

构造函数具有类名且没有返回类型。默认构造函数不接受参数,或者其所有参数都有默认值。复制构造函数和移动构造函数分别从同一类的左值或右值初始化新对象;复制赋值和移动赋值则更新已有对象。

cpp
#include <string>
#include <utility>

class Connection {
public:
    explicit Connection(std::string endpoint) : endpoint_(std::move(endpoint)) {}

private:
    std::string endpoint_;
};

成员不会按照构造函数初始化列表中的文本顺序初始化。构造按以下顺序进行:

  1. 构造最派生对象时的虚基类。
  2. 按基类说明符顺序构造直接基类。
  3. 按声明顺序构造非静态数据成员。
  4. 构造函数体。

因此,应按照声明顺序书写初始化器,并且不要使用后声明的成员来初始化其他成员:

cpp
struct Valid {
    int first;
    int second;

    Valid(int value) : first(value), second(first + 1) {}
};

析构会逆序执行已完成的构造。应让自动存储期对象和RAII所有者执行正常析构;显式调用析构函数只适用于受控存储管理,并且此时必须已经明确对象的生命周期以及之后对存储的复用。

特殊成员函数与Rule of Zero #

特殊成员函数包括默认构造函数、复制构造函数、移动构造函数、复制赋值运算符、移动赋值运算符和析构函数。实现会隐式声明类尚未由用户声明的操作,并在需要时定义这些操作。函数可以被隐式声明、显式默认化或显式删除;具有用户编写定义的函数称为用户提供函数。

以下抑制规则需要有意识地审查:

操作重要规则
默认构造函数用户声明构造函数或构造函数模板会抑制它。
移动构造函数和移动赋值用户声明复制构造函数、复制赋值、移动操作或析构函数会抑制两个隐式移动操作。
复制构造函数和复制赋值在未由用户声明时会被隐式声明,但如果基类或成员无法执行所需操作,则可能被定义为已删除。

引用成员和不适用的const成员通常会导致隐式赋值被删除。声明任一移动操作会使两个隐式声明的复制操作都被定义为已删除。基类或成员中不可访问、已删除或不可用的操作可能导致其他隐式特殊成员被删除。当基类和成员满足标准的结构要求时,编译器生成的函数也可能是trivial的;trivial性是类型属性,而不是所有权策略。

优先采用Rule of Zero:组合具有正确所有权行为的类型,并且不要在领域类型中声明特殊成员。

cpp
#include <string>
#include <utility>
#include <vector>

class Document {
public:
    explicit Document(std::string title) : title_(std::move(title)) {}

private:
    std::string title_;
    std::vector<std::string> paragraphs_;
};

自定义资源所有者需要明确且一致的策略。只能移动的所有者会删除复制操作,只释放资源一次;当转移不会失败时,应将移动操作声明为noexcept。定义传统“Rule of Three”中的任一操作,或定义任一移动操作,都应触发对所有复制、移动和析构行为的审查,而不是自动实现五个函数。

继承与虚派发 #

public继承表达可替代性:对于Base正确的代码,对于Derived也必须继续正确。对于不属于“is-a”关系的实现复用,应使用组合。private继承和protected继承会改变继承成员的可访问性,但不会公开public可替代性;基类的private成员仍不能由派生类通过名称访问。

cpp
struct Shape {
    virtual ~Shape() = default;
    virtual double area() const = 0;
};

struct Circle final : Shape {
    double area() const override;
};

没有最终覆盖函数的纯虚函数会使类成为抽象类。纯虚析构函数仍然需要定义,因为每个派生类析构函数都会调用它。虚派发会为对象的动态类型选择最终覆盖函数,但构造和析构期间除外;在这两个阶段,调用只会在构造函数或析构函数所属的类层次结构中进行派发。避免在基类构造函数或析构函数中进行依赖派生类状态的虚调用。

对所有预期的覆盖都使用override,因为它可以拒绝签名不匹配。如果多态基类可能通过基类指针销毁,则需要public虚析构函数;否则应禁止这种销毁路径。

运算符与字面量后缀 #

只有在保持运算符既有含义时才重载运算符。operator=和operator->必须是非静态成员函数。在C++23之前,operator[]和operator()也必须是非静态成员函数;C++23允许这两个运算符使用静态重载。前置递增通常返回T&,后置递增接受一个未使用的int区分参数,并通常返回之前的T值。

cpp
class Flag {
public:
    explicit operator bool() const noexcept;
    Flag& operator++();   // prefix
    Flag operator++(int); // postfix
};

除非隐式转换是接口有意提供的一部分,否则应将转换运算符标记为explicit。运算符不能改变优先级、元数或短路行为,也不能引入新的运算符标记。

用户定义字面量运算符在命名空间作用域声明;成员函数不能定义字面量后缀。参数依赖查找不会找到这些运算符。其后缀必须以下划线开头:不带下划线的后缀保留给标准库,而以下划线后跟大写字母的标识符,以及以下划线开头后再跟另一个下划线的标识符,均属于保留标识符。

cpp
#include <cstddef>

struct Bytes {
    std::size_t value;
};

constexpr Bytes operator"" _bytes(unsigned long long value) {
    return {static_cast<std::size_t>(value)};
}

constexpr Bytes cache = 64_bytes;

经过处理的整数和浮点字面量运算符分别接受unsigned long long和long double。字符串字面量运算符接受相应的字符指针和std::size_t长度;字符字面量形式使用适用的字符类型。只能选择字面量类别允许的参数形式,并明确后缀的单位和转换规则。

评论

在 GitHub 上阅读或评论(发表评论需要登录)