学习20 分钟阅读编辑本文 ↗

Standard C++ 基础回炉|第 1 课(1-1):对象、存储期与生命周期

从对象何时真正存在开始,重新区分作用域、存储期、生命周期与所有权,并用可运行实验观察构造和析构顺序。

Standard C++ 基础回炉|第 1 课(1-1):对象、存储期与生命周期

生命周期问题经常不是“语法不会”,而是对象已经失效,代码却还保留着指针、引用或迭代器。

这一课只解决一个核心问题:

当代码使用某个名字、指针或引用时,它指向的对象是否仍然存在?

1. 先区分四个概念

1.1 作用域 Scope

作用域决定一个名字在哪里可见。

int main() {
    int outer = 1;

    {
        int inner = 2;
        // outer 和 inner 在这里都可见
    }

    // outer 可见,inner 这个名字已经不可见
}

名字不可见,不一定代表对象已经销毁;名字仍然可见,也不代表它引用的对象仍然有效。

1.2 存储期 Storage Duration

存储期描述对象所占用的存储空间会保持多久。常见类型包括:

  • 自动存储期:普通局部变量,离开块作用域时释放。
  • 静态存储期:全局变量、命名空间变量和函数内的 static 对象,通常持续到程序结束。
  • 线程存储期:thread_local 对象,持续到所属线程结束。
  • 动态存储期:通过动态分配获得,直到被显式释放或由资源管理对象释放。

1.3 生命周期 Lifetime

生命周期描述一个对象从构造完成到析构开始的有效存在区间。

存储空间存在,不代表其中一定有一个处于生命周期内的对象。底层内存复用、placement new、union 和手动对象管理都会让两者产生区别。

基础阶段先记住:

只有对象仍在生命周期内,才能通过指针或引用把它当作该类型的对象使用。

1.4 所有权 Ownership

所有权不是 C++ 语法关键字,而是代码设计关系:谁负责让资源最终被释放。

std::unique_ptr<Device> owner = std::make_unique<Device>();
Device* observer = owner.get();

这里 owner 负责对象的生命周期,observer 只是临时观察。observer 不能比 owner 活得更久。

2. 用日志观察对象的一生

先写一个最小跟踪类型:

#include <iostream>
#include <string>

class Tracker {
public:
    explicit Tracker(std::string name)
        : name_(std::move(name)) {
        std::cout << "construct: " << name_ << '\n';
    }

    ~Tracker() {
        std::cout << "destroy:   " << name_ << '\n';
    }

private:
    std::string name_;
};

int main() {
    Tracker a{"a"};

    {
        Tracker b{"b"};
        Tracker c{"c"};
    }

    Tracker d{"d"};
}

运行前先预测输出,再实际执行。块内对象通常按构造的逆序销毁:c 先于 b,最后才轮到外层对象。

这条“逆序销毁”规则是 RAII 能够组合的基础。后创建的对象往往依赖先创建的对象,因此销毁时先解除后建立的依赖。

3. 四种常见存储期

3.1 自动存储期

void work() {
    Tracker task{"task"};
    // 离开 work 时自动析构
}

无论函数正常返回、提前 return,还是异常向外传播,已经构造完成的自动对象都会按规则析构。

3.2 静态存储期

Tracker global_tracker{"global"};

void initialize_once() {
    static Tracker local_static{"local static"};
}

函数内静态对象从 C++11 起具有线程安全的首次初始化保证。它只构造一次,并在程序正常结束时析构。

需要警惕不同编译单元之间静态对象的初始化顺序。基础阶段的简单策略是:尽量用函数内静态对象延迟初始化,避免让全局对象互相依赖。

3.3 线程存储期

thread_local int request_count = 0;

每个线程都有自己的实例。它适合线程私有状态,但不应被误认为是自动线程安全:多个线程共享的其他数据仍然需要同步。

3.4 动态存储期

auto tracker = std::make_unique<Tracker>("heap object");

对象位于动态存储区域,但它的所有权由栈上的 std::unique_ptr 管理。离开作用域时,智能指针析构并释放对象。

现代 C++ 代码中,优先让动态对象进入 RAII 类型,而不是保留裸 new / delete:

// 不推荐:中间出现 return 或异常时容易遗漏 delete
Tracker* raw = new Tracker("raw");
delete raw;

// 推荐:所有权清楚,退出路径统一
auto owned = std::make_unique<Tracker>("owned");

4. 构造完成之前与析构开始之后

对象的生命周期不是“分配到内存”就开始,而是构造达到相应阶段后才开始。

如果一个构造函数抛出异常:

  • 当前对象本身没有构造完成,因此不会调用它的析构函数。
  • 已经构造完成的成员和基类子对象会被正确析构。
  • 尚未构造的成员不会析构。
class Session {
public:
    Session()
        : connection_{"connection"}, parser_{"parser"} {
        throw std::runtime_error("initialize failed");
    }

private:
    Tracker connection_;
    Tracker parser_;
};

这也是成员本身应当使用 RAII 类型的原因。构造中途失败时,已经完成的部分仍然能够自动清理。

5. 析构顺序

一个派生类对象销毁时,可以先按下面的简化顺序理解:

  1. 执行派生类析构函数体。
  2. 按成员声明顺序的逆序销毁成员。
  3. 销毁基类子对象。

成员的构造顺序由声明顺序决定,不由初始化列表中的书写顺序决定:

class Example {
public:
    Example()
        : second_{"second"}, first_{"first"} {}

private:
    Tracker first_;
    Tracker second_;
};

实际仍然是 first_ 先构造,second_ 后构造。不要依赖初始化列表的视觉顺序,最好始终按成员声明顺序书写初始化列表。

6. 最常见的生命周期错误

6.1 返回局部对象的引用或指针

const std::string& bad_name() {
    std::string name = "device";
    return name; // 返回后 name 已销毁
}

正确做法通常是按值返回:

std::string make_name() {
    std::string name = "device";
    return name;
}

不要因为担心拷贝而返回局部引用。返回值优化和移动语义会处理大部分成本问题。

6.2 保存临时对象的观察指针

const char* pointer = std::string("temporary").c_str();
// 完整表达式结束后,临时 string 销毁,pointer 悬空

6.3 容器扩容导致引用失效

std::vector<int> values{1, 2, 3};
int& first = values[0];

values.push_back(4); // 可能触发重新分配
std::cout << first;  // first 可能已经悬空

重点不只是记住 vector 会失效,而是养成习惯:任何改变容器容量或结构的操作之后,都重新检查已有指针、引用和迭代器是否还有效。

6.4 Lambda 捕获局部引用后异步执行

std::function<void()> make_task() {
    std::string message = "done";
    return [&message] {
        std::cout << message << '\n';
    };
}

返回的任务执行时,message 已经销毁。需要根据语义改为值捕获,或让共享状态拥有足够长的生命周期。

7. 值、引用与指针的选择

可以先使用下面的基础判断:

  • 对象很自然地属于当前作用域:直接按值保存。
  • 参数只读且不接管所有权:使用 const T&;小型标量通常按值传递。
  • 参数可能为空且不接管所有权:使用 T*,并明确它只是观察者。
  • 独占动态所有权:使用 std::unique_ptr<T>。
  • 确实存在共享生命周期:才使用 std::shared_ptr<T>。
  • 观察 shared_ptr 管理的对象但不延长生命周期:使用 std::weak_ptr<T>。

“裸指针不安全”这个说法不够准确。真正的问题是:裸指针没有表达所有权,而且无法自动证明它指向的对象仍然存在。

8. 动手实验

实验 A:预测构造和析构顺序

给 Tracker 增加拷贝构造、移动构造、拷贝赋值和移动赋值日志,然后观察:

Tracker make_tracker() {
    Tracker value{"result"};
    return value;
}

int main() {
    Tracker result = make_tracker();
}

分别使用默认编译选项和 -fno-elide-constructors 编译,记录输出差异。不要把“标准允许省略”误解为“编译器偷偷优化但语义不变”;某些拷贝消除在 C++17 中已经是语言保证。

实验 B:让 ASan 找到悬空访问

写一个函数返回局部变量地址,并在调用端解引用。先阅读编译器警告,再开启 AddressSanitizer 运行。

记录三件事:

  1. 编译器是否给出警告?
  2. 程序是否每次都会立即崩溃?
  3. Sanitizer 报告指出对象在哪里创建、在哪里失效、在哪里被再次访问?

未定义行为“看起来能运行”不代表代码正确。

实验 C:观察 vector 失效

记录 values.data() 和 values.capacity(),持续 push_back,直到地址发生变化。每次变化都表示底层存储发生重新分配。

再调用一次 reserve,比较重新分配次数。

9. 自测问题

不运行代码,先口头回答:

  1. 局部变量的名字离开作用域,与对象生命周期结束,总是同一件事吗?
  2. 为什么按值返回局部 std::string 通常是正确的?
  3. 构造函数抛出异常时,哪些析构函数会执行?
  4. 成员构造顺序由初始化列表还是声明顺序决定?
  5. std::vector::push_back 为什么可能让已有引用失效?
  6. 裸指针能否安全使用?它应当表达什么关系?
  7. shared_ptr 是否应该作为动态对象的默认选择?为什么?
  8. 异步任务按引用捕获局部变量,风险来自哪里?

如果只能说出结论,不能解释对象从创建到销毁的时间线,就还没有真正掌握。

10. 本课完成标准

  • 能准确区分作用域、存储期、生命周期和所有权。
  • 能预测局部对象、成员和基类的基本析构顺序。
  • 能识别返回局部引用、临时对象指针、容器失效和异步引用捕获。
  • 完成三个实验,并保存输出与结论。
  • 能用一张时间线解释一个悬空引用是如何产生的。
  • 所有实验在 -Wall -Wextra -Wpedantic 下编译,并至少运行一次 ASan/UBSan。

下一课进入 RAII:把“知道对象何时销毁”进一步变成“利用析构函数可靠管理资源”。