构造、析构与对象初始化
本节目标
掌握 C++23 的构造初始化顺序、析构合同和部分构造失败。
构造函数建立对象的初始状态,析构函数在对象生命周期结束时清理其拥有的资源。理解子对象的确定顺序,才能把初始化、异常和资源释放放进正确的边界。
默认、普通与复制移动构造概览
构造函数在对象初始化时运行;不同类别的构造函数都在建立新对象,但取得初始状态的来源不同:
| 构造类别 | 典型触发 | 状态来源 |
|---|---|---|
| 默认构造 | Widget item; 或 Widget item{}; | 不从调用点取得显式实参,按所选默认构造语义建立状态 |
| 普通构造 | Widget item{7}; | 从与形参匹配的实参建立状态;这里的来源是 7 |
| 复制构造 | Widget copy{source};,其中 source 是同类型左值 | 从仍保留自身状态的源对象初始化新对象 |
| 移动构造 | Widget moved{std::move(source)}; | 从允许转移状态的同类型对象初始化新对象;源对象之后仍须满足其类型合同 |
默认构造函数可在不提供实参时调用;它可以具有默认实参。也就是说,判断标准是调用时能否省略全部实参,而不是声明的形参个数一定为零。普通构造函数则让 Widget item{7}; 这样的初始化选择与实参匹配的构造函数;它不是先默认构造,再在函数体内逐个“补齐”成员。
构造函数可被隐式声明、默认定义、删除或由用户提供;它们的可用性受成员和基类的可构造性共同约束。复制构造从同类型左值取得状态,移动构造面对可转移对象;生成和删除规则将在下一章单独展开。
成员初始化列表
成员初始化列表直接建立基类和成员子对象,构造函数体只能在这些子对象已经完成初始化后运行。下面两种写法因此不是同一过程:
Widget(int n) : size_{n} {} // 直接初始化 size_
Widget(int n) { size_ = n; } // 先尝试初始化 size_,进入函数体后再赋值
在 Widget(int n) : size_{n} {} 中,size_ 在进入函数体前已经完成初始化。Widget(int n) { size_ = n; } 则会先执行成员的默认初始化(若可行),再做一次赋值;它可能不可编译,也可能引入额外工作。
对 const 成员、引用成员和没有默认构造函数的成员,体内赋值更不是初始化列表的可选替代品。把建立合法初值的逻辑放在列表中,函数体只处理依赖已初始化对象的工作。
基类与成员初始化顺序
代表程序把一个基类和两个成员放进同一个初始化过程:
explicit Ordered(std::vector<std::string>& events)
: BaseProbe{events}, first_{events, "first"}, second_{events, "second"} {}
MemberProbe first_;
MemberProbe second_;
对最派生对象,实际顺序先经过虚基类和直接基类,再按类中声明顺序初始化非静态数据成员,最后才进入构造函数体。就这个没有虚基类、只有一个直接基类的例子而言,轨迹是 BaseProbe → first_ → second_ → 函数体,所以固定输出记录 base,first,second。基类子对象先于非静态数据成员初始化,非静态数据成员再严格按其声明顺序初始化;这不由成员初始化列表的书写顺序决定。
即使把列表改写成 second_{events, "second"}, first_{events, "first"}, BaseProbe{events},真实顺序也不会随文字位置改变。成员初始化列表乱序仍是良构程序,标准不强制要求诊断。常见编译器在本项目的警告选项下通常会诊断这种次序不一致。在需要互相依赖的成员中,应调整声明或提取独立的初始化值,而不是试图借列表顺序控制结果。
默认成员初始化器
类内默认成员初始化器给构造函数留下一项后备值:
class RetryPolicy {
public:
RetryPolicy() = default; // retries_ 使用 3
explicit RetryPolicy(int retries) : retries_{retries} {} // 显式覆盖
private:
int retries_{3};
};
未在成员初始化列表中提到 retries_ 的构造函数使用 3;列表中显式给出的 retries_{retries} 则覆盖该成员的类内默认成员初始化器。这样多个构造函数可以共享同一个默认值,而不必把 3 复制到每个构造函数体。
默认成员初始化器不是对象构造完成后的运行时“重置”步骤,也不会覆盖复制或移动构造所选择的成员初始化方式。判断某个成员取哪项初值时,先看所选构造函数是否显式初始化它,再考虑类内的后备表达式。
委托构造函数
无参构造可以把共同初始化交给同类的另一个构造函数:
class DelegatedValue {
public:
DelegatedValue() : DelegatedValue{42} {}
explicit DelegatedValue(int value) : value_{value} {}
private:
int value_;
};
执行 DelegatedValue() 时,先进入接收 int 的目标构造函数,由它初始化 value_ 并执行自己的函数体;目标构造函数成功返回后,才继续执行无参委托构造函数的函数体。代表程序的 delegated_value 因而来自目标构造函数设置的状态,并输出 42。
委托构造函数的成员初始化列表只能指定同类的一个目标构造函数。不能在 DelegatedValue{42} 旁边再同时初始化 value_ 或其他成员。若两个构造函数需要不同的不变量,应分别表达它们,不要让委托链掩盖失败路径。
转换构造与 explicit
没有 explicit 的单实参构造函数可能参与隐式转换,于是接收 DelegatedValue 的函数也可能悄悄接受一个 int:
void consume(DelegatedValue value);
// consume(7); // 若构造函数允许隐式转换,这里会临时构造业务对象
把接口声明为 explicit DelegatedValue(int value); 后,这条隐式路径被阻止,但调用者仍可明确选择直接初始化:
DelegatedValue value{7};
consume(DelegatedValue{7});
explicit 单实参构造函数仍可用于直接初始化。是否允许省略类型名,取决于该转换是否应成为稳定、无歧义的接口;默认把单实参构造设为 explicit,只有在隐式值语义确实自然时再放宽。不要仅为缩短调用语法,就让任意 int 自动变成业务对象。
析构函数合同
析构函数承担的是已构造对象结束生命周期时的清理合同。审查一个析构路径时,应分别确认以下边界:
- 它可靠地释放该对象拥有的资源,不依赖调用者手动记住每条返回或异常路径上的清理操作。代表程序的
~DestructionProbe() { destroyed_ = true; }在对象离开作用域后记录清理已经发生。 - 隐式声明的析构函数,或任何未写出
noexcept说明的析构函数,会有隐式异常说明。 - 只要任一潜在构造的子对象的析构函数可能抛出,或者该析构函数是 virtual 且任一虚基类的析构函数可能抛出,该隐式异常说明就是可能抛出的;否则才是
noexcept(true)。这里必须独立检查“潜在构造的子对象”与“virtual 析构函数的虚基类”两个分支,不能只观察普通成员。 - 析构函数抛出异常会在栈展开期间导致
std::terminate;即使不在栈展开期间,析构失败也很难由调用者安全处理。 - 可能失败的工作应放进可显式调用并报告结果的操作中,析构函数只做不抛出的收尾。
把资源释放绑定到析构过程,正常离开作用域、提前返回和异常展开才能沿同一责任边界完成清理;手工要求每个调用者补一条释放语句并不具备这项保证。
部分构造失败
代表程序先构造 CleanupProbe 成员,再从外层构造函数体抛出受控异常:
class FailingObject {
public:
FailingObject(bool& memberDestroyed, bool& destructorRan)
: member_{memberDestroyed}, destructorRan_{destructorRan} {
throw std::runtime_error{"controlled failure"};
}
~FailingObject() { destructorRan_ = true; }
private:
CleanupProbe member_;
bool& destructorRan_;
};
CleanupProbe 已经完成构造,所以异常离开时会被自动清理。若在失败点之前还有多个已经构造成功的基类或成员,它们会沿完成顺序的逆序销毁。构造函数抛出时,已成功构造的基类和成员子对象会按完成顺序的逆序销毁。
本例不是委托构造,且 FailingObject 这个最外层对象尚未完成构造,因此其析构函数不会运行。不能假设最外层析构函数还能回收一个从未完成构造的对象;清理责任必须由已经构造成功的子对象各自承担。代表程序用局部布尔状态同时验证异常被接住、成员清理发生、而最外层析构探针没有运行。
若目标构造函数已经成功返回,随后委托构造函数的函数体抛出异常,该完整对象已经构造完成,析构函数会在栈展开时运行。成员本身应满足析构安全,才能让这些自动清理路径可靠完成。
对象生命周期的开始与结束
存储与活对象是两个不同层次。一块存储可以已经可用,却还没有相应对象在其中生存;初始化完成后,对象才进入可使用的生命周期。对象的生命周期在初始化(包括真空初始化)完成时开始,在析构开始时结束。
析构之后,底层存储可能仍然存在,但先前那个对象已经不再存活。对类类型,完成析构后不能再把那块存储当作原对象使用,更不能继续调用原对象的成员函数。{ DestructionProbe probe{destroyed}; } 展示的是普通自动对象:花括号结束触发 probe 析构,随后程序只读取仍存活的外部观察状态 destroyed,而不再访问 probe。
自动存储期对象离开作用域会析构,动态存储期对象则需要由其所有权管理者安排销毁。存储重用、placement new 和对象模型细节属于更低层专题,不能从普通局部对象规则简单外推。
常见陷阱
构造与析构问题通常可以沿对象建立、清理和生命周期三条路径排查:
- 依赖成员初始化列表的文字顺序。真实顺序由基类关系和成员声明顺序决定;若成员互相依赖,应调整声明或先计算独立初值。
- 把初始化退化为构造函数体内赋值。成员在进入函数体前已经历自己的初始化;对
const、引用或没有默认构造函数的成员,这种写法还可能根本无法成立。 - 让析构函数传播异常。栈展开期间再次抛出会终止程序;把可能失败的动作移到显式接口,析构只承担不抛出的收尾。
- 遗漏成员的合法初值。每条成功构造路径都必须建立完整不变量;默认成员初始化器可以提供后备值,但不能替所选构造函数修复矛盾状态。
- 删除
explicit只为缩短调用语法。const DelegatedValue explicitValue{7};是合法而明确的直接初始化,不需要开放被禁止的隐式转换。
#include <cstddef>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
class BaseProbe {
public:
explicit BaseProbe(std::vector<std::string>& events) : events_{events} {
events_.push_back("base");
}
private:
std::vector<std::string>& events_;
};
class MemberProbe {
public:
MemberProbe(std::vector<std::string>& events, const char* name) { events.push_back(name); }
};
class Ordered : public BaseProbe {
public:
explicit Ordered(std::vector<std::string>& events)
: BaseProbe{events}, first_{events, "first"}, second_{events, "second"} {}
private:
MemberProbe first_;
MemberProbe second_;
};
class DelegatedValue {
public:
DelegatedValue() : DelegatedValue{42} {}
explicit DelegatedValue(int value) : value_{value} {}
[[nodiscard]] int value() const { return value_; }
private:
int value_;
};
class CleanupProbe {
public:
explicit CleanupProbe(bool& destroyed) : destroyed_{destroyed} {}
~CleanupProbe() { destroyed_ = true; }
private:
bool& destroyed_;
};
class FailingObject {
public:
FailingObject(bool& memberDestroyed, bool& destructorRan)
: member_{memberDestroyed}, destructorRan_{destructorRan} {
throw std::runtime_error{"controlled failure"};
}
~FailingObject() { destructorRan_ = true; }
private:
CleanupProbe member_;
bool& destructorRan_;
};
class DestructionProbe {
public:
explicit DestructionProbe(bool& destroyed) : destroyed_{destroyed} {}
~DestructionProbe() { destroyed_ = true; }
private:
bool& destroyed_;
};
int main() {
std::vector<std::string> constructionOrder;
Ordered ordered{constructionOrder};
static_cast<void>(ordered);
std::cout << "order=";
for (std::size_t index = 0; index < constructionOrder.size(); ++index) {
if (index != 0) std::cout << ',';
std::cout << constructionOrder[index];
}
std::cout << '\n';
const DelegatedValue delegated{};
const DelegatedValue explicitValue{7};
std::cout << "delegated_value=" << delegated.value() << '\n';
std::cout << "explicit_value=" << explicitValue.value() << '\n';
bool memberDestroyed{false};
bool mostDerivedDestructorRan{false};
bool caughtFailure{false};
try {
FailingObject failed{memberDestroyed, mostDerivedDestructorRan};
static_cast<void>(failed);
} catch (const std::runtime_error&) {
caughtFailure = true;
}
std::cout << "partial_cleanup="
<< (caughtFailure && memberDestroyed && !mostDerivedDestructorRan ? "true" : "false")
<< '\n';
bool destroyed{false};
{
DestructionProbe probe{destroyed};
static_cast<void>(probe);
}
std::cout << "destroyed=" << (destroyed ? "true" : "false") << '\n';
}
输出:
order=base,first,second
delegated_value=42
explicit_value=7
partial_cleanup=true
destroyed=true
当初始化涉及多个成员时,先写出成员声明顺序和不变量,再写成员初始化列表。这样编译器诊断、异常清理和代码评审会指向同一套对象生命周期模型。