在没有创建型设计模式的时候,在C++中创建对象总是一个很复杂的问题------在堆上还是在栈上?用裸指针还是智能指针?还是彻底地用其他对象管理?
不论选择那种方式,创建对象总是一个麻烦的工作,尤其是创建规则极其复杂或需要遵从某些规定的时候。
由此引出了本篇我们要介绍的创建型设计模式
目录
[RVO 优化后](#RVO 优化后)
[C++17 标准保证的拷贝省略(Guaranteed Copy Elision)](#C++17 标准保证的拷贝省略(Guaranteed Copy Elision))
[RVO 的影响与注意事项](#RVO 的影响与注意事项)
[如何确认 RVO 是否发生](#如何确认 RVO 是否发生)
栈分配
对象再栈上创建,离开作用域自动销毁并清理(用一对花括号即可构造局部作用域)。
cpp
MyClass obj; // 在栈上创建对象
MyClass obj(args); // 调用带参构造函数
栈分配的对象有以下特点:
-
生命周期自动管理 :对象在作用域(大括号
{}内)结束时自动调用析构函数并释放内存,无需程序员手动干预。 -
分配速度快:栈分配仅需移动栈指针,比堆分配快得多。
-
内存大小受限:栈空间通常较小(例如 Windows 默认 1MB,Linux 默认 8MB),不适合分配大对象或大量对象,否则可能栈溢出。
-
无需担心内存泄漏:因为自动销毁,只要析构函数正确,不会泄漏。
-
不能返回局部对象的指针/引用:离开作用域后对象已销毁,返回其地址或引用将导致悬垂指针。
堆分配
堆又名自由存储区,堆分配就是在堆上创建的对象并使用原始指针等方式在堆上存储的对象
cpp
MyClass* p = new MyClass; // 堆上创建对象
MyClass* p2 = new MyClass(args);
//必须手动释放
delete p;
delete p2;
它的特点如下:
-
生命周期手动控制 :对象一直存在,直到显式调用
delete。可以跨函数、跨作用域存活。 -
分配速度较慢:需要向堆管理器申请内存,可能涉及系统调用或复杂的内存查找算法。
-
内存大小灵活:堆空间大(受限于虚拟内存),适合大对象或运行时数量不确定的对象。
-
容易导致内存泄漏 :如果忘记
delete,或者因异常等原因未执行到delete,对象内存将无法回收。 -
容易产生悬垂指针 :
delete后未将指针置空,后续再使用指针会导致未定义行为。 -
需要显式处理异常安全 :在可能抛出异常的代码中,
new和delete需要配合 try-catch 或 RAII 手法来保证释放。
在现代C++中,应当尽力避免使用野指针
unique_ptr
unique_ptr掌管堆上对象的唯一所有权,只允许移动不允许拷贝。它的特点如下:
-
轻量级,开销与裸指针几乎相同。
-
当
unique_ptr被销毁(离开作用域或被 reset)时,其管理的对象自动delete。 -
可自定义删除器(例如用于释放数组、文件句柄等)。
-
常用于工厂函数返回值、容器中存储多态对象、替代裸指针管理单一所有权资源。
share_ptr
share_ptr允许多个share_ptr管理同一个对象,拥有两个控制块------强引用计数和弱引用计数,只有当两者均为0时才会释放资源
它的特点如下:
-
引用计数操作是线程安全的(但管理的对象本身需要自行保证线程安全)。
-
支持自定义删除器。
-
相比
unique_ptr,有额外内存开销(控制块 + 原子操作计数)。 -
可能产生循环引用 问题:两个
shared_ptr互相指向对方,导致引用计数永远不为 0,内存泄漏。解决方法见weak_ptr。
weak_ptr
weak_ptr不拥有对象的所有权,只是作为"监视器"观察share_ptr管理的资源的状态,解决循环引用问题。
它的特点如下:
-
不能直接通过
weak_ptr访问对象,必须调用lock()返回一个shared_ptr(如果对象还活着)或空指针。 -
不参与所有权,不延长对象生命周期。
-
主要用于打破循环引用 (例如父节点持有子节点的
shared_ptr,子节点持有父节点的weak_ptr)。 -
还可用于实现观察者模式、缓存(当对象被释放时自动失效)。
函数返回值优化
我们来讨论一个函数返回对象问题------当我们需要返回一个大对象的时候,我们有几种方式呢?
直接的方法
cpp
//我们假设Foo是一个大对象
Foo make_foo(int n)
{
return Foo{n};
}
这种写法返回Foo的一个完整的副本,这样会浪费很多宝贵的资源。当然我们也可以将Foo的定义写为这样
cpp
Foo
{
Foo(int n)
{}
Foo(const Foo&){std::cout<<"拷贝构造函数"<<std::endl;}
};
我们会发现Foo的拷贝构造函数会执行0次或1次或2次,这取决于具体的编译器。
这就是返回值优化(RVO),一项用于防止发生额外的额外的拷贝的编译器技术(不影响代码功能)
裸指针
这种方式不强制我们必须清理分配的对象,但是会发送一条明确的信息,即清理对象是调用者的责任
cpp
Foo* make_foo(int n)
{
return new Foo(n);
}
智能指针
这种方式固然安全,但是作为接口有些死板,且不具备跨平台性,在不同的编译器下编译出来的ABI不兼容
cpp
unique_ptr<Foo> make_foo(int n)
{
return make_unique<Foo>(n);
}
RVO
RVO 是编译器在函数返回局部对象时,避免生成临时对象和调用拷贝/移动构造函数的一种优化手段。其核心目标是消除不必要的拷贝开销,直接将要返回的对象构造在调用方准备的存储位置上。
场景示例
未优化前
cpp
MyClass create()
{
MyClass local;
// ... 设置 local
return local; // 理论上:构造 local -> 拷贝到临时对象 -> 拷贝到调用方变量
}
MyClass obj = create(); // 可能涉及多次拷贝
在没有优化的情况下,上述过程可能发生:
-
local在create的栈帧中构造; -
return时拷贝构造一个临时对象(作为返回值); -
调用方再用该临时对象拷贝构造
obj; -
析构临时对象和
local。
RVO 优化后
编译器将 obj 的地址(或一个隐藏的引用参数)传递给 create,让 local 直接构造在 obj 的内存上,从而完全消除拷贝/移动。等效于:
cpp
void create(void* target) { // 伪代码
new(target) MyClass; // 在目标位置直接构造
}
NRVO
当被返回的对象具有名字 (即局部变量)时,称为 NRVO 。RVO 有时特指返回无名临时对象 (如 return MyClass();),而 NRVO 是更一般化的形式。现代编译器(GCC、Clang、MSVC)在大多数优化级别下都能执行 NRVO,但仍受限于具体的代码结构(例如多个可能的返回路径可能导致优化失效)。
cpp
MyClass create(bool cond) {
MyClass a;
MyClass b;
if (cond) return a; // NRVO 可能失效(两个可能的返回对象)
else return b;
}
此时编译器可能无法决定将哪个对象直接构造到目标位置,只能退化为移动或拷贝。
C++17 标准保证的拷贝省略(Guaranteed Copy Elision)
C++17 引入了一条强制规则:对纯右值(prvalue)的临时对象初始化同类型对象时,不再要求拷贝/移动构造函数可访问。也就是说,以下写法从语言层面保证了零拷贝:
cpp
MyClass obj = MyClass(); // 直接构造 obj,无临时对象
MyClass create() { return MyClass(); } // 返回值直接构造到调用方
但对于 NRVO(返回有名字的局部变量),标准仍不强制,只是允许编译器作为优化执行。C++17/20 未将 NRVO 提升为强制行为。
RVO 的影响与注意事项
-
性能收益显著:对于大对象(如容器、字符串),避免深拷贝可大幅减少内存分配和 CPU 开销。
-
不改变程序语义:如果拷贝/移动构造函数有副作用(如打印日志),优化后这些副作用可能消失------这是允许的,因为标准明确授权编译器执行拷贝省略,即使构造函数有副作用。
-
与移动语义的关系 :在无法应用 RVO 时(例如多路径返回),C++11 的移动语义可以接替减少开销(但仍不如完全消除)。优先依赖 RVO,而非滥用
std::move(std::move有时反而会阻碍 RVO)。
如何确认 RVO 是否发生
-
在拷贝/移动构造函数中添加打印语句,观察调用次数。
-
查看汇编代码(如
-S选项)或使用编译器优化报告(如 Clang 的-Rpass=...)。 -
使用静态分析工具(如
-fno-elide-constructors可强制关闭拷贝省略,用于对比)。
本期内容到这里就结束了,下篇,我们就会深入介绍创建型设计模式的几种经典的设计模式
封面图自取:
