C++ 语言基础、对象模型与 RAII:从初始化到资源管理

C++ 中许多难以解释的错误,并不是“语法记错了”,而是把几个不同层次混在了一起:初始化语法决定候选构造函数,表达式的值类别影响引用绑定,类型决定对象需要怎样的存储,而生命周期又决定某次访问是否合法。

本文以 C++17 与 C++20 的通用语义为主,把初始化、常量求值、类型推导、值类别、转换、存储期和对象表示串成第一条主线,再沿着“地址—借用—所有权—多态—RAII”继续讨论资源管理。目标不是背完术语,而是在看到一行代码时,能够按固定顺序判断它到底做了什么。

统一分析顺序:先看初始化或表达式的语法形式,再确定参与推导与重载的类型,接着判断值类别和引用绑定,最后检查目标对象是否已经开始生命周期、存储是否满足大小与对齐、访问类型是否合法。

一张地图:类型、表达式、对象和存储

从源码到一次合法访问
语法{}决定使用列表初始化、直接初始化还是其他初始化形式
类型系统Tcv&决定推导结果、可行重载、转换和引用绑定
表达式lvaluexvalueprvalue值类别描述表达式如何指向对象或产生值
对象lifetime只有生命周期中的对象才能按规则访问
存储sizealignbytes底层地址还必须满足大小、对齐和类型可访问性

四种初始化:相似写法会选择不同规则

初始化不是赋值。初始化让对象进入生命周期并确定初始状态;赋值面对的则是一个已经存在的对象。四种常见写法看似接近,语义并不相同。

形式示例主要效果常见风险
默认初始化int x;类类型进行默认构造;自动存储期标量通常没有确定初值在 C++17/20 中读取未初始化标量可能触发未定义行为
值初始化int x{};标量得到零值;类类型按值初始化规则处理不能把所有类的 {} 简化理解成“清零全部字节”
直接初始化T x(arg);直接进行构造函数重载决议T x(); 会被解析为函数声明
列表初始化T x{arg};执行列表初始化,检查窄化转换类类型可能优先匹配 initializer_list 构造函数
int uninitialized;    // 不要读取:自动存储期标量没有被初始化
int zero{};           // 0
int direct(42);       // 42
int listed{42};       // 42

double ratio = 3.5;
int a(ratio);         // 允许转换,a 变成 3
// int b{ratio};      // 编译错误:列表初始化拒绝这种窄化转换

initializer_list 的优先级会改变构造结果

std::vector<int> a(3, 7);  // 3 个元素:7, 7, 7
std::vector<int> b{3, 7};  // 2 个元素:3, 7

第二行不是第一行的“现代写法”。在列表初始化的第一阶段,只要存在可行的 std::initializer_list 构造函数,它就会被优先考虑。选择圆括号还是花括号,必须以想表达的语义为准。

  • 标量和聚合对象希望得到确定初值时,优先写 {}
  • 需要拒绝窄化转换时,使用列表初始化;
  • 调用容器的“数量 + 初值”构造函数时,不要机械地把圆括号改成花括号;
  • 读代码时先识别初始化类别,再讨论会调用哪个构造函数。

const、constexpr 与 consteval:三种不同保证

关键字保证不保证
const不能通过这个名字或接口修改对象值一定在编译期已知,或对象不会被其他别名修改
constexpr变量可用于常量表达式;函数具备参与常量求值的资格constexpr 函数的每次调用都在编译期执行
consteval立即函数的调用必须满足立即调用的常量表达式要求能够依赖运行期输入
constexpr int square(int x) {
    return x * x;
}

consteval int checkedSquare(int x) {
    return x * x;
}

int runtime = 6;
const int readOnly = runtime;          // const,但值来自运行期
constexpr int compileTime = square(6); // 编译期常量
int alsoRuntime = square(runtime);     // constexpr 函数也能在运行期执行
constexpr int forced = checkedSquare(6);
// int error = checkedSquare(runtime); // 错误:无法在编译期求值

成员函数末尾的 const 修饰隐式对象参数,表示不能通过该成员函数修改非 mutable 成员。它约束的是接口视角,并不意味着对象在整个程序中绝对不可变。

auto、decltype 和 decltype(auto) 到底保留什么

auto 通常像模板按值参数一样推导:会丢掉引用和顶层 const。如果显式写成 auto&const auto&,引用和底层限定才会按对应规则保留。

const int value = 42;

auto copy = value;        // int
auto& reference = value;  // const int&
auto* pointer = &value;   // const int*

static_assert(std::is_same_v<decltype(copy), int>);
static_assert(std::is_same_v<decltype(reference), const int&>);

decltype 的括号陷阱

对未加括号的变量名或成员访问,decltype(name) 直接得到声明类型;对其他表达式,它根据值类别返回类型:左值对应 T&,将亡值对应 T&&,纯右值对应 T

int x = 1;

static_assert(std::is_same_v<decltype(x), int>);
static_assert(std::is_same_v<decltype((x)), int&>); // (x) 是左值表达式
static_assert(std::is_same_v<decltype(std::move(x)), int&&>);

decltype(auto) 能保留引用,也能制造悬挂引用

decltype(auto) front(std::vector<int>& values) {
    return (values.front()); // 返回 int&
}

// 错误示例:返回局部对象的引用
decltype(auto) dangling() {
    int local = 42;
    return (local);          // 返回 int&,函数结束后悬挂
}

decltype(auto) 适合实现精确转发接口,但不能看到它就默认“更高级、更安全”。返回表达式多一层括号,可能直接把按值返回改成按引用返回。

值类别是表达式的属性,不是变量的标签

值类别直观含义典型表达式
左值 lvalue通常指向有身份、可再次定位的对象变量名、解引用表达式、返回 T& 的调用
将亡值 xvalue有身份,但其资源可以被复用std::move(x)、返回 T&& 的调用
纯右值 prvalue用于计算值或初始化结果对象字面量、返回非引用类型的函数调用

泛化左值由左值和将亡值组成;右值由纯右值和将亡值组成。最容易出错的一点是:任何有名字的变量表达式都是左值,即使变量的声明类型是右值引用。

std::string text = "hello";
std::string&& rr = std::move(text);

consume(rr);            // rr 是有名字的表达式,因此按左值传递
consume(std::move(rr)); // 显式产生将亡值,才允许匹配右值路径
std::move 本身不移动任何东西。它本质上是一次类型转换,产生允许资源被复用的将亡值;真正的移动发生在随后选中的移动构造或移动赋值中。

临时对象生命周期延长不是万能规则

const std::string& ok = std::string("temporary");
// 直接绑定到临时对象,生命周期延长到 ok 的生命周期

const std::string& bad() {
    return std::string("temporary");
}
// 返回时临时对象已经销毁,调用者得到悬挂引用

const T& 能延长临时对象生命周期”必须结合具体初始化语境使用。经过函数返回、成员初始化等路径时存在例外,不能只看最终引用类型。

隐式转换、重载决议与 explicit

一次函数调用会先形成候选函数集合,再筛选可行函数,最后比较每个实参所需的转换序列。标准转换通常优于用户定义转换;如果不存在唯一更优项,结果是二义性,而不是编译器“猜一个最像的”。

class UserId {
public:
    explicit UserId(int value) : value_(value) {}
    int value() const { return value_; }

private:
    int value_;
};

void load(UserId id);

UserId id{42}; // 直接初始化可以使用 explicit 构造函数
// load(42);    // 错误:不允许悄悄把 int 转成 UserId
load(UserId{42});

explicit 的价值是切断容易误用的隐式转换路径。它不会禁用构造函数,而是要求调用点明确写出目标类型。对“ID、长度、单位、句柄”等强语义类型,这通常能在编译期阻止参数传错。

为什么“一次最多一个用户定义转换”很重要

一个隐式转换序列中至多包含一次用户定义转换,前后可以各有标准转换。这条限制避免编译器沿着任意长的构造函数链搜索,也解释了为什么某些看似“可以转两次”的调用仍然不可行。

存储期、作用域和生命周期不能互换

概念回答的问题典型误解
作用域 scope这个名字在源码的什么区域可见?名字不可见就代表对象已经销毁
存储期 storage duration承载对象的存储最少持续多久?“栈”和“堆”就是标准中的完整分类
生命周期 lifetime这个具体对象从何时存在到何时结束?地址还有效就可以继续按旧类型访问

常见存储期包括自动、静态、线程和动态存储期。动态分配首先取得存储,构造让对象生命周期开始,析构让生命周期结束,释放操作再把存储归还。把这些步骤压缩成“在堆上 new 了一个对象”虽然方便交流,却不足以分析底层问题。

void example() {
    std::string local = "alive"; // 自动存储期,对象生命周期开始
    const char* view = local.c_str();
} // local 析构,view 即使还保存着地址也已经悬挂

在旧存储中创建新对象

placement new 可以在一块满足要求的既有存储中开始新对象的生命周期。旧对象与新对象是否满足透明可替换条件,会影响旧指针能否自动指向新对象;复杂情况下可能需要 std::launder。这类代码适合封装在容器、内存池等基础设施中,不应散落在业务代码里。

对象表示、对齐和类型可访问性

对象占据一段存储,其对象表示由若干字节组成。sizeof(T) 包括实现为满足对齐而加入的填充,因此成员大小相加不一定等于对象大小;一个地址即使拥有足够字节,也未必满足 alignof(T)

struct Record {
    char tag;
    std::uint32_t value;
};

static_assert(sizeof(Record) >= sizeof(char) + sizeof(std::uint32_t));
// 常见实现会在 tag 后插入填充,但具体布局由实现和 ABI 决定

reinterpret_cast 不会自动创造合法对象

std::uint32_t bits = 0x3f800000u;

// 错误思路:违反类型可访问性,还可能牵涉对齐与生命周期
// float value = *reinterpret_cast<float*>(&bits);

float value = std::bit_cast<float>(bits); // C++20,明确表达等长位复制

为了检查对象表示,可以通过 charunsigned charstd::byte 观察字节。std::memcpy 适合搬运可平凡复制类型的对象表示,但不是所有类对象的复制构造替代品;C++20 的 std::bit_cast 则为等长、可平凡复制类型之间的位级转换提供了明确接口。

一份可编译的 C++20 综合示例

#include <bit>
#include <cassert>
#include <cstdint>
#include <iostream>
#include <type_traits>
#include <utility>
#include <vector>

constexpr int square(int value) {
    return value * value;
}

consteval int checkedSquare(int value) {
    return value * value;
}

class UserId {
public:
    explicit UserId(int value) : value_(value) {}
    int value() const { return value_; }

private:
    int value_;
};

decltype(auto) front(std::vector<int>& values) {
    return (values.front());
}

int main() {
    int zero{};
    std::vector<int> repeated(3, 7);
    std::vector<int> listed{3, 7};

    constexpr int compileTime = square(6);
    constexpr int forced = checkedSquare(7);
    static_assert(compileTime == 36);
    static_assert(forced == 49);

    const int original = 42;
    auto copy = original;
    auto& reference = original;
    static_assert(std::is_same_v<decltype(copy), int>);
    static_assert(std::is_same_v<decltype(reference), const int&>);

    int number = 9;
    static_assert(std::is_same_v<decltype(number), int>);
    static_assert(std::is_same_v<decltype((number)), int&>);
    static_assert(std::is_same_v<decltype(std::move(number)), int&&>);

    front(listed) = 10;
    UserId id{1001};

    const std::uint32_t bits = 0x3f800000u;
    const float one = std::bit_cast<float>(bits);

    assert(zero == 0);
    assert(repeated == std::vector<int>({7, 7, 7}));
    assert(listed == std::vector<int>({10, 7}));
    assert(copy == 42 && reference == 42);
    assert(id.value() == 1001);
    assert(one == 1.0f);

    std::cout << "C++ object model checks passed\n";
}

指针与引用:先区分地址、借用和所有权

对象存放在某段存储中,地址用来定位这段存储。指针是一个真正的对象,它保存地址值;引用则为某个既有对象提供别名。二者都能间接访问对象,但它们表达的接口契约不同。

性质指针 T*引用 T&
能否表示“没有对象”可以使用 nullptr合法引用必须绑定有效实体
能否改指向可以重新赋值初始化后不能重新绑定
自身是否是对象是,可以取指针自身地址标准把它作为引用类型,是否需要存储未作统一要求
是否拥有目标裸指针默认不表达所有权不拥有,只是借用
算术只在同一数组及尾后一位规则内有定义不支持引用算术

接口参数按语义选择

接口含义推荐形式调用者看到的契约
一定存在,只读借用大型对象const T&不能为空,不复制,不修改
一定存在,需要修改T&函数会操作原对象
对象可选const T*T*nullptr 有明确语义
小型标量只读输入按值传递简单且没有别名关系
转移独占所有权std::unique_ptr<T> 按值调用后所有者发生变化
共享所有权std::shared_ptr<T> 按值确实需要共同延长生命周期
void print(const std::string& text);          // 必有对象,只读借用
void normalize(std::string& text);            // 必有对象,允许修改
void setLogger(Logger* logger);               // 可传 nullptr
void consume(std::unique_ptr<Task> task);     // 接收独占所有权

int* find(std::vector<int>& values, int target) {
    for (int& value : values) {
        if (value == target) {
            return &value;                     // 借用容器元素
        }
    }
    return nullptr;                            // 明确表示没找到
}
最重要的生命周期问题:指针或引用可能仍保存一个地址,但目标对象已经销毁。返回局部变量的地址或引用、保存已经重分配的 vector 元素指针,都会形成悬垂访问。

如何读 const T*、T* const 与 const T* const

从变量名向外读最稳妥。先看星号右侧有没有 const,判断“指针自身能否改变”;再看所指类型是否带 const,判断“能否通过它修改目标”。

int first = 1;
int second = 2;

const int* pointerToConst = &first;
pointerToConst = &second;       // 可以改指向
// *pointerToConst = 3;         // 不可经它修改 int

int* const constPointer = &first;
*constPointer = 3;              // 可以修改所指 int
// constPointer = &second;      // 指针自身不可改

const int* const bothConst = &first;
// *bothConst = 4;              // 两项限制同时存在
// bothConst = &second;

T* const 中的 const 是顶层限定,描述指针对象本身;const T* 的限定属于所指类型,常称为底层 const。按值复制指针时顶层限定不会进入新对象的类型,所指对象的只读约束则会保留。

int value = 1;
const int* readOnly = &value;
int* writable = const_cast<int*>(readOnly);
*writable = 2;                  // 合法,因为原对象 value 不是 const

const int fixed = 3;
const int* fixedView = &fixed;
int* unsafe = const_cast<int*>(fixedView);
// *unsafe = 4;                 // 未定义行为:原对象确实是 const

const_cast 只改变访问表达式的类型,不会把真正的常量对象变成可写对象。需要频繁去掉 const,通常说明接口设计与真实修改需求不一致。

数组何时退化为指针,为什么需要 span

内建数组的类型包含元素数量,例如 int[4]。但在大多数表达式中,它会转换为指向首元素的 int*,长度信息随之丢失。函数形参写成 int values[] 也会被调整为 int*

void oldStyle(int values[]) {
    // 这里的 values 实际是 int*,sizeof(values) 是指针大小
}

template<std::size_t N>
void keepExtent(const int (&values)[N]) {
    static_assert(N > 0);       // N 在编译期保留下来
}

int values[4]{1, 2, 3, 4};
static_assert(sizeof(values) == 4 * sizeof(int));
keepExtent(values);

sizeof(array)、取数组地址以及绑定数组引用等语境不会执行普通的数组到指针转换。指向尾后一位的指针可以用于比较和循环终点,但不能解引用。

span 是非拥有的连续序列视图

#include <span>

int sum(std::span<const int> values) {
    int result = 0;
    for (int value : values) {
        result += value;
    }
    return result;
}

int builtIn[]{1, 2, 3};
std::vector<int> dynamic{4, 5, 6};

sum(builtIn);                    // 自动携带 3
sum(dynamic);                    // 自动携带 vector 的元素数量

std::span 同时携带首地址和元素数量,能够接入数组、vector 等连续序列。但它不拥有元素,也不会延长底层容器的生命周期;容器销毁、移动或重分配后,原来的 span 可能悬垂。

new/delete 与 malloc/free 的本质差异

new 表达式面对的是“创建 C++ 对象”:先取得满足大小和对齐的存储,再在其中初始化对象。delete 表达式先结束对象生命周期,再释放匹配的存储。mallocfree 只管理一段原始字节,不会调用构造函数或析构函数。

操作存储管理对象生命周期失败方式
new T(args)通常调用分配函数取得存储初始化并创建 T 对象普通形式通常抛出 std::bad_alloc
delete pointer调用匹配的释放函数先调用析构函数要求来源和类型满足删除规则
malloc(bytes)取得原始存储不构造类对象失败返回空指针
free(pointer)归还匹配存储不调用析构函数只能释放相应 C 分配函数得到的存储
Widget* one = new Widget(42);
delete one;

Widget* many = new Widget[10];
delete[] many;

// 错配都属于未定义行为:
// free(one);
// delete[] (new Widget);
// delete (new Widget[10]);

构造函数抛异常时发生什么

如果 new T(...) 已取得存储,但 T 的构造随后抛出异常,匹配的释放函数会回收这块存储。对象没有完整构造,所以不会调用 T 自己的析构函数;已经完整构造的基类和成员子对象则会按规则销毁。

class Session {
public:
    Session()
        : buffer_(1024),        // vector 自己负责异常清理
          socket_(openSocket()) // 假设 Socket 也是 RAII 类型
    {
        validate();             // 即使这里抛异常,成员也会自动析构
    }

private:
    std::vector<char> buffer_;
    Socket socket_;
};

若构造函数体内手动取得裸资源,却没有立即交给 RAII 成员,后续异常可能让资源泄漏。非分配式 placement new 使用调用者提供的存储,销毁对象后也不会替调用者自动归还那块外部存储。

现代 C++ 默认选择:业务代码优先使用局部对象、标准容器、std::make_uniquestd::make_shared。裸 new/delete 更适合被封装在容器、智能指针或底层资源组件内部。

虚函数如何实现运行时多态

静态类型来自声明,动态类型来自对象实际构造出的最派生类型。当通过基类指针或引用调用虚函数时,程序根据动态类型选择最终覆写函数;普通非虚函数主要按静态类型解析。

class Shape {
public:
    virtual double area() const = 0;
    virtual ~Shape() = default;
};

class Circle final : public Shape {
public:
    explicit Circle(double radius) : radius_(radius) {}
    double area() const override {
        return 3.141592653589793 * radius_ * radius_;
    }

private:
    double radius_;
};

void printArea(const Shape& shape) {
    std::cout << shape.area(); // 根据动态类型调用 Circle::area
}

虚表和虚表指针是常见实现方式,但 C++ 标准规定的是可观察的动态分派行为,并不强制对象采用某一种具体内存布局。override 可以让编译器检查函数是否真的完成覆写,避免因遗漏 const、参数不同或引用限定不同而悄悄产生新函数。

对象切片会丢掉派生部分

void sliced(ShapeValue base);       // 按值接收基类对象
void polymorphic(const Shape& base); // 引用保留动态对象身份

DerivedValue derived;
ShapeValue copy = derived;          // 只复制基类子对象,发生切片

派生对象按值复制为基类对象后,只剩下独立的基类子对象,原来的派生数据和动态多态身份都不会保留。多态对象集合通常保存智能指针:

std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(2.0));

for (const auto& shape : shapes) {
    std::cout << shape->area() << '\n';
}

构造和析构期间的虚调用

基类构造期间,派生部分尚未完成;基类析构期间,派生部分已经销毁。因此这两个阶段中的虚调用只会分派到当前正在构造或析构的层次,不会调用尚未构造或已经析毁的派生覆写。不要依赖构造函数中的虚调用初始化派生状态。

基类析构函数为什么常常需要 virtual

如果对象可能通过基类指针删除,基类析构函数必须允许正确的多态销毁。否则派生部分无法按规定完成析构,程序行为未定义。

class Interface {
public:
    virtual void run() = 0;
    virtual ~Interface() = default; // 允许通过 Interface* 删除派生对象
};

并非每个类都需要虚析构。一个不承担运行时多态基类角色的值类型,通常不应仅为“以防万一”加入虚函数。常见设计选择是:

  • public 且 virtual:允许外部通过基类指针销毁对象;
  • protected 且 non-virtual:阻止外部通过基类指针执行 delete

纯虚析构函数仍然需要定义

class Abstract {
public:
    virtual ~Abstract() = 0;
};

Abstract::~Abstract() = default; // 仍需提供定义

把析构函数声明为纯虚可以让类保持抽象,但派生对象销毁时仍然会执行基类析构,所以函数体不能缺失。std::unique_ptr<Base> 的默认删除器最终仍通过 Base* 删除对象;智能指针不会自动修复错误的基类析构设计。

RAII、Rule of Zero 与 Rule of Five

RAII 的核心不是“智能指针技巧”,而是把资源的有效期绑定到对象生命周期:构造成功意味着资源已经处于可用状态,析构负责释放。函数正常返回、提前返回或异常栈展开时,局部对象都会按规则销毁,因此清理路径不再散落在每个分支里。

void updateSharedState() {
    std::lock_guard<std::mutex> lock(mutex);
    // 构造 lock 时加锁
    changeState();
} // 离开作用域自动解锁,包括异常离开

资源不限于内存,还包括文件、互斥锁、套接字、数据库连接、事务和系统句柄。析构函数通常不应让异常逃出,否则在另一个异常传播期间可能导致程序终止。

Rule of Zero:让成员类型承担资源责任

class Document {
public:
    Document(std::string title, std::vector<std::string> lines)
        : title_(std::move(title)), lines_(std::move(lines)) {}

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

Document 不需要手写析构、复制或移动函数。stringvector 已经知道怎样管理自己的资源,编译器生成的组合行为通常正是需要的。Rule of Zero 不是“没有析构”,而是把析构责任交给可靠的成员类型。

Rule of Five:自定义一个,就系统审查五个

如果类直接管理裸资源,或者需要与成员默认行为不同的复制语义,就要一起审查析构、复制构造、复制赋值、移动构造和移动赋值。重点不是强迫五个函数全部手写,而是防止只写析构后,复制仍在偷偷复制同一个裸句柄。

class ByteBuffer {
public:
    explicit ByteBuffer(std::size_t size)
        : size_(size),
          data_(size == 0 ? nullptr
                          : std::make_unique<std::byte[]>(size)) {}

    ByteBuffer(const ByteBuffer& other)
        : ByteBuffer(other.size_) {
        if (size_ != 0) {
            std::copy_n(other.data_.get(), size_, data_.get());
        }
    }

    ByteBuffer& operator=(const ByteBuffer& other) {
        if (this != &other) {
            ByteBuffer copy(other);
            swap(copy);              // 先成功复制,再替换当前状态
        }
        return *this;
    }

    ByteBuffer(ByteBuffer&&) noexcept = default;
    ByteBuffer& operator=(ByteBuffer&&) noexcept = default;
    ~ByteBuffer() = default;

    void swap(ByteBuffer& other) noexcept {
        std::swap(size_, other.size_);
        data_.swap(other.data_);
    }

private:
    std::size_t size_ = 0;
    std::unique_ptr<std::byte[]> data_;
};

这个示例没有直接使用裸 new[],释放仍交给 unique_ptr;只有“深复制”这一业务语义需要自定义。复制赋值采用先复制后交换,复制失败时原对象保持不变;移动操作可以安全地使用成员的默认实现。

成员初始化与销毁顺序

成员按照它们在类中声明的顺序初始化,与构造函数初始化列表的书写顺序无关;销毁顺序则相反。让初始化列表故意采用不同顺序,只会制造警告和误导。

class Connection {
private:
    Config config_;   // 先初始化
    Socket socket_;   // 后初始化,并且可以依赖 config_

public:
    explicit Connection(Config config)
        : config_(std::move(config)),
          socket_(config_) {}
};

新增内容的可编译 C++20 示例

#include <algorithm>
#include <cassert>
#include <cstddef>
#include <iostream>
#include <memory>
#include <span>
#include <utility>
#include <vector>

int sum(std::span<const int> values) {
    int result = 0;
    for (int value : values) {
        result += value;
    }
    return result;
}

class Shape {
public:
    virtual double area() const = 0;
    virtual ~Shape() = default;
};

class Circle final : public Shape {
public:
    explicit Circle(double radius) : radius_(radius) {}

    double area() const override {
        return 3.141592653589793 * radius_ * radius_;
    }

private:
    double radius_;
};

class ByteBuffer {
public:
    explicit ByteBuffer(std::size_t size)
        : size_(size),
          data_(size == 0 ? nullptr
                          : std::make_unique<std::byte[]>(size)) {}

    ByteBuffer(const ByteBuffer& other)
        : ByteBuffer(other.size_) {
        if (size_ != 0) {
            std::copy_n(other.data_.get(), size_, data_.get());
        }
    }

    ByteBuffer& operator=(const ByteBuffer& other) {
        if (this != &other) {
            ByteBuffer copy(other);
            swap(copy);
        }
        return *this;
    }

    ByteBuffer(ByteBuffer&&) noexcept = default;
    ByteBuffer& operator=(ByteBuffer&&) noexcept = default;
    ~ByteBuffer() = default;

    std::byte& operator[](std::size_t index) {
        return data_[index];
    }

    const std::byte& operator[](std::size_t index) const {
        return data_[index];
    }

    void swap(ByteBuffer& other) noexcept {
        std::swap(size_, other.size_);
        data_.swap(other.data_);
    }

private:
    std::size_t size_ = 0;
    std::unique_ptr<std::byte[]> data_;
};

int main() {
    int builtIn[]{1, 2, 3};
    std::vector<int> dynamic{4, 5, 6};
    assert(sum(builtIn) == 6);
    assert(sum(dynamic) == 15);

    std::unique_ptr<Shape> shape =
        std::make_unique<Circle>(2.0);
    assert(shape->area() > 12.0 && shape->area() < 13.0);

    ByteBuffer original(2);
    original[0] = std::byte{0x2a};
    ByteBuffer copy = original;
    copy[0] = std::byte{0x01};
    assert(original[0] == std::byte{0x2a});
    assert(copy[0] == std::byte{0x01});

    ByteBuffer moved = std::move(copy);
    assert(moved[0] == std::byte{0x01});

    std::cout << "C++ ownership checks passed\n";
}

这一段把新增主题连在一起:span 表达非拥有视图,unique_ptr<Shape> 同时保留多态和所有权,虚析构保证基类接口可安全销毁派生对象,ByteBuffer 则展示深复制、移动和 RAII 成员如何配合。

接口与所有权速查

看到的类型默认理解必须继续确认
T独立值或按值参数复制与移动成本、是否发生切片
T&必有对象的可写借用目标生命周期是否覆盖使用期
const T&必有对象的只读借用是否意外绑定短命临时对象
T*可选或可重新指向的借用能否为空、谁拥有目标
std::span<T>连续序列的非拥有视图底层范围是否仍存活且未失效
std::unique_ptr<T>独占所有权所有权何时转移
std::shared_ptr<T>共享所有权是否真的需要共享,是否形成环

遇到陌生代码时的检查流程

  1. 识别语法:这是默认、值、直接、拷贝还是列表初始化?
  2. 写出精确类型:包括顶层和底层 const、引用、指针以及数组边界。
  3. 判断表达式值类别:尤其注意有名字的右值引用变量仍然是左值表达式。
  4. 列出候选与转换:是否存在 initializer_list 优先、用户定义转换或二义性?
  5. 检查生命周期:被引用对象是否仍然存在,临时对象的生命周期是否真的被延长?
  6. 检查底层访问:存储大小、对齐、对象生命周期和访问类型是否同时合法?
  7. 检查所有权:谁负责释放资源,裸指针或视图是否只是借用?
  8. 检查多态:是否发生对象切片,基类析构是否支持预期的删除方式?
  9. 让编译器帮助证明:使用 static_assertdecltype、警告、Sanitizer 和小型实验验证判断。

高频错误清单

  • 读取未初始化的自动存储期标量;
  • 把所有圆括号初始化机械改成花括号,意外选中 initializer_list
  • 认为 const 对象一定是编译期常量;
  • 认为 constexpr 函数一定在编译期执行;
  • 忽略 decltype((x))decltype(x) 的区别;
  • 从使用 decltype(auto) 的函数返回局部对象引用;
  • 认为 std::move 已经完成了移动;
  • 依赖多级用户定义转换,或让非 explicit 单参构造产生意外匹配;
  • 把“内存地址还在”误认为“原对象仍然存活”;
  • reinterpret_cast 解引用来实现位级类型转换;
  • 返回局部对象的地址或引用,或长期保存已经失效的 span
  • const_cast 修改原本确实为 const 的对象;
  • 混用 new/deletenew[]/delete[]malloc/free
  • 把派生对象按值放入基类容器,意外发生对象切片;
  • 通过没有虚析构函数的多态基类指针删除派生对象;
  • 手写析构管理裸资源,却没有同步审查复制和移动行为。

最后的记忆线索

  1. 初始化语法会参与构造函数选择,{} 还会拒绝窄化并优先考虑 initializer_list
  2. const 约束修改路径,constexpr 提供常量求值能力,consteval 强制立即调用满足编译期要求;
  3. auto 偏向按值推导,decltype 观察声明类型或表达式值类别;
  4. 值类别属于表达式,有名字的变量表达式是左值;
  5. 作用域管名字,存储期管存储,生命周期管对象;
  6. 合法的底层访问必须同时满足对象存在、对齐正确和类型可访问;
  7. 引用、裸指针与 span 通常表达借用,不会延长目标生命周期;
  8. new/delete 管对象与存储,malloc/free 只管原始字节,不能交叉配对;
  9. 多态依赖指针或引用保留动态对象身份,按值转成基类会切片;
  10. 优先遵循 Rule of Zero;确需自定义资源语义时,系统审查 Rule of Five。

标准条款: 初始化constexpr 与 constevaldecltype值类别与类型可访问性对象生命周期引用数组到指针转换spannew 表达式虚函数析构函数,以及 C++ Core Guidelines 的 Rule of Zero