CppCon 2018 学习:Woes of Scope Guards and Unique_Resource
什么是资源?
资源是指在系统中(无论是硬件还是软件)可以被获取、使用和最终释放的任何对象。在编程和系统管理的上下文中,资源通常指的是 内存、文件句柄、网络连接、数据库连接、线程、锁 等系统对象,这些资源的生命周期需要被正确管理。
资源的关键要点:
- 资源的生命周期:
- 获取(Acquire):当请求并分配一个资源时。例如,分配内存、打开文件或获取锁。
- 使用(Use):一旦获取资源,它将在程序执行过程中被使用。例如,访问文件内容或使用内存位置。
- 释放(Release):当资源不再需要时,它应被释放或解除分配,以避免浪费。资源没有被释放可能导致 资源泄漏(如内存泄漏),也可能导致资源过度占用。
- 资源管理的生命周期:
- 操作系统或运行时系统(OS/Runtime System):在许多情况下,操作系统或运行时系统负责管理资源。例如,当应用程序终止时,操作系统会自动释放它所获取的资源,如内存和文件句柄。
- 管理对象(Manager Objects):有时,资源通过代码中的特定对象或管理器来管理。例如,一个 资源管理器 可以控制资源的分配和释放。
- 资源可以是独占的或共享的:
- 独占资源:这些资源只能由单一实体在某一时间使用,例如文件锁或特定的硬件设备。
- 共享资源:这些资源可以被多个实体并发使用,例如共享内存或数据库连接(需要适当的同步)。
- 未释放资源的后果:
- 资源泄漏(Leaks):如果资源在使用后没有被释放,会导致 资源泄漏,这会对程序产生重大问题,例如:
- 内存泄漏:当内存分配后未正确释放时,会导致内存消耗过多,最终由于内存不足而导致程序失败。
- 文件/套接字泄漏:如果文件句柄或网络套接字没有被关闭,可能导致无法继续分配新的资源。
- 资源泄漏带来的问题:
- 安全问题:未正确管理的资源可能导致系统漏洞,例如未经授权的访问。
- 安全风险:管理不当的资源可能被攻击者利用,可能导致 拒绝服务(DoS) 攻击或其他安全漏洞。
- DoS(拒绝服务):未正确管理或释放的资源可能会耗尽系统的容量,从而导致系统无响应或崩溃。
- 资源泄漏(Leaks):如果资源在使用后没有被释放,会导致 资源泄漏,这会对程序产生重大问题,例如:
- 资源管理模式(Patterns):
- 有许多已建立的 资源管理模式,例如 RAII(资源获取即初始化) 模式(在 C++ 中广泛使用),或者使用 智能指针 来自动释放超出作用域的资源。
- 在拥有垃圾回收机制的语言(如 Java 或 C#)中,运行时系统通常会自动管理内存和资源,但开发者仍然需要注意一些 显式资源管理,例如关闭文件或网络连接。
总结:
- 资源管理 是软件开发中的一个关键部分,涉及到资源的获取、使用和释放,以避免诸如内存泄漏、文件句柄耗尽等问题。
- 资源可以是独占的或共享的,如果没有正确释放资源,将导致性能问题、程序崩溃或安全风险。
- 许多编程模式(如 RAII)有助于自动管理资源或使资源管理更加高效。
正确的资源管理是健壮和高效系统设计的基石。
资源管理并不容易
资源管理在软件设计中是一个至关重要的课题,但它也非常复杂,涉及到很多模式和方法。正确的资源管理不仅能保证系统的稳定性,还能避免内存泄漏、资源耗尽等问题。
资源管理中的常见模式:
- Pattern-oriented Software Architecture - Vol. 3: Patterns for Resource Management (2004)
这本书介绍了10种常见的资源管理模式,帮助开发者更好地管理系统中的资源:- Pooling(池化):通过重用资源池中的对象来减少重复分配和释放资源的开销。例如,数据库连接池和线程池。
- Caching(缓存):将频繁访问的数据存储在缓存中,以加快访问速度,并减少资源的重复使用。
- Leasing(租赁):给资源分配一个租期,到期后需要释放资源。类似于定期续约的方式。
- Lazy Acquisition(延迟获取):仅在需要时才获取资源,而不是在程序开始时就一次性获取所有资源。这有助于节省不必要的资源消耗。
- GoF设计模式中的缺失:
- GoF设计模式(Gang of Four Design Patterns) 中包含了一些经典的设计模式,比如 工厂方法模式(Factory Method),但是它并没有涵盖与资源管理相关的模式(如池化、缓存、租赁等)。这些是更复杂的系统中的常见需求,尤其在处理昂贵资源(例如数据库连接或内存)的情况下。
- Factory 和 Disposal Methods (Kevlin Henney, 2004):
- Kevlin Henney 提出了 Factory 和 Disposal(处置) 方法,这些方法强调了如何正确地管理对象的生命周期。在某些语言(如 Smalltalk 和 Java)中,开发人员有时会忽视正确释放资源的问题,导致资源泄漏。
- Manager设计模式(Peter Sommerlad, 1996):
- Manager设计模式 提供了一种将类实例的管理封装到一个独立的管理对象中的方法。通过这种方式,类的管理功能可以独立于类本身进行变化,并且可以复用管理器来管理不同类型的对象。
- 例如,一个 数据库连接管理器 可以管理多个数据库连接的创建、获取和释放,而无需每次都直接操作连接对象。这样可以提高代码的可复用性和维护性。
总结:
资源管理是一个复杂而关键的任务,涉及到多个设计模式和方法。在不同的应用场景中,使用合适的资源管理模式能够大大提高程序的效率和可维护性。常见的资源管理模式包括 池化、缓存、租赁 和 延迟获取 等。而开发人员也需要特别注意及时释放资源,避免因资源泄漏带来的安全、性能等问题。
RAII类在标准库中的应用
RAII(Resource Acquisition Is Initialization)是一种广泛使用的编程技术,它基于资源管理的生命周期控制。它的核心思想是:资源的获取(如内存、文件句柄、互斥锁等)和释放(如销毁对象)与对象的生命周期绑定在一起。利用RAII,我们能够确保在作用域结束时,资源会自动被释放,避免资源泄漏。
在标准库中,RAII被广泛应用于许多类,以帮助开发者更安全和高效地管理资源。以下是一些常见的应用:
RAII的常见应用
- I/O句柄:
fstream
- 在文件操作中,
fstream
(如std::ifstream
、std::ofstream
)使用RAII来自动管理文件句柄。打开文件时,文件句柄被获取;当对象销毁时,文件会自动关闭。这样避免了忘记关闭文件的问题。
std::ifstream file("example.txt"); // 文件打开 // 文件使用期间的操作 // 文件会在file对象销毁时自动关闭
- 在文件操作中,
- 内存管理:
unique_ptr
、string
、容器类std::unique_ptr
和std::shared_ptr
是智能指针的例子,它们通过RAII确保在对象生命周期结束时,所管理的资源(如动态内存)会自动释放。std::string
和 STL容器类(如std::vector
、std::map
)也利用RAII来管理动态内存。
std::unique_ptr<int> p(new int(10)); // 内存分配 // p会在超出作用域时自动释放内存
- 锁对象(互斥锁):
lock_guard
、unique_lock
、scoped_lock
- 在多线程编程中,RAII被用于锁对象的管理。
std::lock_guard
和std::unique_lock
自动锁定和解锁互斥锁,当对象超出作用域时,锁会被自动释放,确保线程同步的安全性。
std::mutex mtx; {std::lock_guard<std::mutex> lock(mtx); // 锁定互斥锁// 临界区代码 } // lock对象析构,自动释放互斥锁
- 在多线程编程中,RAII被用于锁对象的管理。
RAII的优势:
- 避免资源泄漏: 因为资源(内存、文件句柄、锁等)在对象生命周期结束时会自动释放,开发者不再需要手动释放资源。
- 简洁的代码: 不需要显式地调用清理资源的函数,减少了代码复杂度。
- 增强的安全性: RAII确保了无论函数如何退出(如正常返回或异常退出),资源都能得到正确的释放。
- 适用于多种资源类型: RAII不仅适用于内存和文件句柄,也可以应用于任何需要管理生命周期的资源。
RAII的缺失:
尽管RAII在许多资源类型中非常有效,但在一些特殊情况下,RAII的使用仍然受到限制,特别是对于以下资源:
- 其他操作系统资源句柄(如网络套接字)
- 许多操作系统资源,如网络连接和其他硬件接口,并没有现成的RAII支持。标准库没有直接为这些资源提供RAII类。这是“
unique_resource
”的初衷,即为这类资源设计通用的RAII管理类。
- 许多操作系统资源,如网络连接和其他硬件接口,并没有现成的RAII支持。标准库没有直接为这些资源提供RAII类。这是“
- 生命周期管理资源
- 对于一些生命周期由外部因素(如用户输入或复杂的控制流)管理的资源,目前标准库中并没有通用的RAII支持。对于这种资源,开发者需要自行设计相应的RAII类来管理其生命周期。
RAII的设计思路:
- “Sane and Safe”:RAII的核心思想是“理智和安全”(Sane and Safe),它是资源管理的最佳实践,因为它通过对象的生命周期来控制资源的获取和释放,从而避免了许多潜在的资源管理错误,如资源泄漏、竞争条件和错误的清理操作。
总结:
RAII是资源管理的一个重要设计模式,它能够简化资源管理,减少错误,并提高代码的健壮性。在标准库中,RAII被广泛应用于内存管理、I/O操作和多线程同步等方面,确保了资源的自动释放和系统的稳定性。然而,对于一些不常见或复杂的资源,仍然需要开发者自行实现RAII管理方式。
简单析构函数副作用的误用
你提供的 Tracer
类是一个使用析构函数进行日志输出的例子,目的是跟踪对象的创建、复制和销毁。这种做法在调试中有时是有用的,但如果在生产代码中使用,可能会带来一些问题。让我们逐步分析这个概念,为什么它可能会成为问题。
1. 用于调试和追踪(类似printf调试)
在这个例子中,Tracer
类在对象创建、复制和销毁时,分别向输出流(std::ostream
)打印信息。
- 目的:
Tracer
类会在以下几种情况下打印信息:- 对象被 创建(
Tracer created
), - 对象被 销毁(
Tracer destroyed
), - 对象被 复制(
Tracer copied
)。
- 对象被 创建(
示例代码:
struct Tracer {explicit Tracer(std::ostream & out, std::string name = ""): out{out}, name{name} {out << "Tracer created: " << name << std::endl;}~Tracer() {out << "Tracer destroyed: " << name << std::endl;}Tracer(Tracer const& other): out{other.out}, name{other.name + " copy"} {out << "Tracer copied: " << name << std::endl;}void show() const {out << "Tracer: " << name << std::endl;}std::ostream &out;std::string name;
};
2. 为什么这可能是个问题?
虽然这种做法在 调试 中有时有帮助,但如果在生产代码中不当使用,会带来一些问题。
a) 析构函数中的副作用
在 C++ 中,析构函数 主要用于 清理资源,例如释放内存或关闭文件句柄。将日志输出等副作用引入析构函数可能会导致以下问题:
- 不可预测的行为:在对象销毁过程中进行日志记录或输出,可能在异常抛出或栈展开时导致不确定行为。
- 性能问题:每次对象销毁或复制时进行日志记录,可能会引入性能瓶颈,尤其是当大量对象在大规模应用中被创建和销毁时。
- 破坏RAII原则:RAII (Resource Acquisition Is Initialization) 原则要求资源的管理与对象的生命周期紧密绑定。如果在析构函数中做副作用处理,反而可能使RAII的行为变得不可预测。
b) 作为“finally”机制的后置思维
你提到过 Java 中的 finally
机制,在 C++ 中没有直接等价的语法,但是 RAII 可以实现类似的效果。将追踪或在函数结束时做某些操作(类似于“finally”)通过 析构函数 来实现是有风险的:
- 在 C++ 中,RAII 是最佳的资源清理方式。析构函数的主要作用是清理资源,而不是做日志记录。如果你在析构函数中做日志记录,实际上是将资源清理和日志记录混合,导致代码变得不清晰。
c) 不适用于生产代码
在生产代码中使用这种调试类(如 Tracer
)通常是 不推荐的做法:
- 架构问题:如果你依赖
Tracer
来做调试或日志记录,那么可能说明你的架构设计存在问题,资源管理或对象生命周期处理没有做到清晰分离。 - 更好的替代方案:在生产环境中,应该使用专门的日志机制(如 spdlog、log4cpp 等),而不是在析构函数中进行日志记录。
d) 复制操作中的副作用
在例子中,复制 Tracer
对象时也会有副作用(Tracer copied: ...
)。这通常是 不希望发生的行为,因为:
- 复制 一个对象本不应该触发任何日志记录等操作。
- 如果你在很多地方复制对象(例如通过值传递),就会有额外的日志记录和副作用,这使得代码更难理解和维护。
3. 资源管理和日志记录的最佳实践
以下是如何避免上述问题的一些最佳实践:
a) 使用 RAII 进行资源管理
使用 RAII 来进行资源管理,而不是在析构函数中进行副作用操作。例如,使用智能指针(如 std::unique_ptr 或 std::shared_ptr)来管理内存,使用 std::lock_guard 来管理锁。
b) 使用显式的日志机制
对于日志记录或追踪,使用显式的日志机制,如:
- 日志库(如 spdlog、log4cpp、Boost.Log)。
- 专门的跟踪或日志类,它们自行管理生命周期,但不依赖析构函数来进行日志记录。
c) 避免在复制构造函数和析构函数中加入副作用
不要在 复制构造函数 和 析构函数 中引入副作用,如日志记录。它们应该仅仅处理对象的状态和资源管理。日志记录应该在应用逻辑或函数调用中显式进行。
4. 总结
虽然 Tracer
类在 调试 和追踪代码执行时可能很有用,但它不适合在 生产代码 中使用,尤其是在析构函数中引入副作用。设计系统时应遵循 RAII 原则,分离资源管理和日志记录,确保代码清晰且可维护。
关于 unique_ptr<FILE, Deleter>
的理解
在 C++ 中,处理 C 风格的资源(如 FILE*
)时,使用 RAII(资源获取即初始化) 的方式管理资源生命周期是一个很好的实践。C 风格的资源不直接与 C++ 的资源管理系统兼容,因此需要自定义 deleter(删除器) 来正确释放资源。
1. 使用 fclose
和 unique_ptr
的问题
你希望使用 std::unique_ptr
来管理 FILE*
。但由于 FILE*
是 C 风格的资源,unique_ptr
默认不支持直接管理它,因此需要提供一个 deleter 来告诉 unique_ptr
如何在资源生命周期结束时释放它。
然而,直接使用 fclose
作为 unique_ptr
的删除器会有一些问题:
- 问题:NULL 处理:
fclose
作为函数指针并不直接保证我们不会对NULL
指针调用fclose
,而fclose(NULL)
是未定义行为。unique_ptr
的析构函数会自动调用删除器,在unique_ptr
被销毁时释放资源,但是如果资源是NULL
,直接调用fclose
会导致程序崩溃。
- 解决方案:
- 我们需要一种 符合标准的解决方案,确保
fclose
只有在指针有效(即非NULL
)时才会被调用。
- 我们需要一种 符合标准的解决方案,确保
2. 使用 std::unique_ptr<FILE, Deleter>
为了安全地管理 FILE*
,我们可以为 std::unique_ptr
提供一个自定义的 deleter。自定义的删除器会在资源生命周期结束时执行 fclose
,但只会在 FILE*
非空时调用。
基本解决方案:使用函数指针删除器
#include <iostream>
#include <memory>
#include <cstdio>
int main() {// 使用 unique_ptr 和 fclose 作为删除器std::unique_ptr<FILE, int(*)(FILE*)> fp(std::fopen("file.txt", "r"), &std::fclose);if (fp) {std::cout << (char)std::fgetc(fp.get()) << '\n';} // 退出作用域时,fclose() 会被调用,如果 fp 非空
}
- 解释:
std::unique_ptr<FILE, int(*)(FILE*)>
:这告诉编译器,unique_ptr
持有一个FILE*
类型,并且会在unique_ptr
被销毁时调用fclose
作为删除器。std::fopen("file.txt", "r")
:打开文件。&std::fclose
:指定用来关闭文件的删除器函数。
这个解决方案有效,但它仍然可能存在传递NULL
给fclose
的风险,因此我们需要更安全的做法。
3. 更好的解决方案:使用 decltype
和自定义删除器
我们可以使用 decltype
来自动推导删除器类型,这样可以避免手动指定删除器的类型,提高代码的类型安全性。
改进方案:使用 decltype
和自定义删除器
#include <iostream>
#include <memory>
#include <cstdio>
void close_file(std::FILE* fp) { std::fclose(fp); }
int main() {// 使用 decltype 来自动推导删除器类型std::unique_ptr<std::FILE, decltype(&close_file)> fp(std::fopen("demo.txt", "r"), &close_file);if (fp) {std::cout << (char)std::fgetc(fp.get()) << '\n';} // fclose() 会在 fp 作用域结束时被调用,且只有在 fp 非空时才会调用。
}
- 解释:
decltype(&close_file)
:这使得删除器的类型可以自动从close_file
函数推导出来,这样就避免了手动指定类型的麻烦。close_file
:这个函数确保fclose
只在FILE*
非空时被调用,避免了对NULL
指针的操作。
4. 更佳方案:使用 Lambda 作为删除器
我们还可以使用 lambda 函数 来作为删除器,这样代码更加简洁和清晰。
使用 Lambda 删除器的方案
#include <iostream>
#include <memory>
#include <cstdio>
int main() {// 使用 Lambda 函数作为删除器std::unique_ptr<FILE, void(*)(FILE*)> fp(std::fopen("file.txt", "r"),[](FILE* fp) { if (fp) {std::fclose(fp);}});if (fp) {std::cout << (char)std::fgetc(fp.get()) << '\n';} // fclose() 会在作用域结束时被调用,但只有 fp 非空时才会调用。
}
- 解释:
- Lambda
[](FILE* fp) { if (fp) std::fclose(fp); }
:确保只有在FILE*
非空时才会调用fclose
,并且使代码更加简洁。 - 使用 Lambda 删除器避免了多余的函数声明,简化了代码。
- Lambda
5. 总结
- 基础方案 使用
fclose
作为删除器是可行的,但存在潜在的风险,因为fclose(NULL)
会导致未定义行为。 - 使用
decltype
的方案 确保了删除器类型的安全推导,避免了手动指定类型的错误。 - Lambda 删除器方案 是最现代、最安全的做法,既确保了对有效指针的操作,又让代码更加简洁和可读。
结论
使用 std::unique_ptr
和自定义删除器来管理 C 风格资源(如 FILE*
)是 C++ 中一种良好的资源管理方式,有助于防止资源泄漏和未定义行为。在实际应用中,最安全、最简洁的做法是使用 Lambda 函数作为删除器。
unique_resource
在 C++ 中的使用:管理资源句柄
unique_resource
是一种资源管理方法,类似于 C++ 中的 RAII(资源获取即初始化)。它通过在对象作用域结束时自动释放资源来确保资源被正确地释放。其应用非常适合于管理一些类似文件描述符、网络套接字等的低级资源。
在 POSIX 中,我们经常需要手动管理文件描述符,例如使用 open
打开文件,close
关闭文件。使用 unique_resource
可以将这一过程封装起来,从而避免资源泄露的问题。
1. 示例:在 POSIX I/O 中使用 unique_resource
void demontrate_unique_resource_with_POSIX_IO() { const std::string filename = "./hello1.txt"; auto close = [](auto fd){ ::close(fd); }; // 关闭文件的删除器{ // 打开文件并使用 unique_resource 管理文件描述符auto file = unique_resource(::open(filename.c_str(), O_CREAT | O_RDWR | O_TRUNC, 0666), close);// 向文件写入数据::write(file.get(), "Hello World!\n", 12u); ASSERT(file.get() != -1); // 确保文件描述符有效}{ // 读取文件内容并检查是否正确std::ifstream input{ filename }; std::string line;getline(input, line); ASSERT_EQUAL("Hello World!", line);getline(input, line); ASSERT(input.eof()); // 确保文件读取完毕}// 删除文件::unlink(filename.c_str()); { // 打开一个不存在的文件,确保返回 -1auto file = make_unique_resource_checked(::open("nonexistingfile.txt", O_RDONLY), -1, close); ASSERT_EQUAL(-1, file.get()); // 文件打开失败}
}
解析:
unique_resource
是一个模板类,它接受两个参数:- 资源句柄(例如
open
返回的文件描述符)。 - 资源的删除器,负责在
unique_resource
被销毁时释放资源(例如close
)。
- 资源句柄(例如
make_unique_resource_checked
是一种改进,允许在资源无效时(如文件不存在)设置默认值(例如-1
)。
通过这种方式,我们使用unique_resource
来管理文件描述符,而不需要担心在代码的不同地方手动关闭文件,避免了资源泄漏的风险。
2. 如何使用 unique_resource
管理 int
类型的句柄
unique_resource
也可以用于管理其他类型的句柄(例如 int
类型的资源句柄),这些句柄需要在作用域结束时释放。以下是如何使用 unique_resource
管理 POSIX 风格的 int
资源句柄的例子。
代码示例:
auto close_int_resource = [](int handle) { // 在此处释放资源// 比如说,调用类似于 close(handle) 来关闭文件句柄std::cout << "Resource " << handle << " closed." << std::endl;
};
// 使用 unique_resource 管理 int 资源句柄
{int handle = 123; // 假设这是某个资源的句柄auto resource = unique_resource(handle, close_int_resource);// 在此使用资源std::cout << "Resource " << resource.get() << " is in use." << std::endl;
}
// 作用域结束时,资源自动释放
解析:
close_int_resource
是一个 lambda 表达式,它定义了如何释放资源。unique_resource(handle, close_int_resource)
创建了一个unique_resource
对象,自动管理资源handle
的生命周期,并在对象销毁时调用close_int_resource
来释放资源。
3. unique_resource
的实现
unique_resource
的实现通常会采用模板和函数指针的方式。一个常见的实现方法是将删除器作为模板参数传递给 unique_resource
,这样可以灵活地定义资源释放的方式。
template <typename Resource, typename Deleter>
class unique_resource {
public:unique_resource(Resource resource, Deleter deleter): resource_(resource), deleter_(deleter) {}~unique_resource() {deleter_(resource_);}Resource get() const { return resource_; }
private:Resource resource_;Deleter deleter_;
};
解释:
Resource
是资源类型(如int
类型的文件描述符)。Deleter
是一个可调用的类型,通常是一个函数指针或 lambda,用来在资源生命周期结束时释放资源。
4. 优势与实践
- 资源生命周期自动管理: 使用
unique_resource
可以确保资源在其生命周期结束时得到释放,避免了手动释放的疏漏,降低了内存泄漏或文件句柄泄漏的风险。 - 类型安全: 通过模板和删除器函数,可以确保不同类型的资源以正确的方式释放。
- 避免错误和重复代码:
unique_resource
自动管理资源的获取和释放,简化了代码,并减少了重复的错误处理逻辑。
5. 结论
unique_resource
是一个非常有用的工具,特别是在需要管理低级别资源(如文件句柄、网络套接字等)的 C++ 程序中。通过为资源指定一个删除器,unique_resource
能够确保资源在作用域结束时正确释放,减少了资源泄漏的风险。无论是 POSIX 文件描述符还是其他类型的句柄,都可以通过这种方法进行高效、安全的管理。
这个代码段和提案涉及到对 泛型 RAII(资源获取即初始化)管理的进一步探索。特别是,它尝试通过模板类和 std::tuple
来管理多个资源的生命周期。然而,这种方法虽然看起来简单、直接,但在实际应用中可能遇到一些问题。
// Scoped resource 类:用于管理资源,并确保在作用域结束时自动清理资源。
template <typename DELETER, typename... R>
class scoped_resource {// deleter 必须是一个可调用对象(如函数或 lambda),接受资源类型 R... 作为参数// 并且返回 void,同时必须是 noexcept,确保不会抛出异常。DELETER deleter;// 资源存储在一个元组中。资源会在销毁时通过 deleter 进行清理。std::tuple<R...> resource;// 一个标志位,控制资源是否在 scoped_resource 对象销毁时进行清理。bool execute_on_destruction;
public:// 构造函数:接受一个 deleter 函数和多个资源 R...。// 'shouldrun' 用于控制是否在析构时进行资源清理,默认值为 true。explicit scoped_resource(DELETER deleter, R... resource, bool shouldrun = true) noexcept: deleter{std::move(deleter)}, // 将 deleter 移动到类中resource{std::make_tuple(std::move(resource)...)}, // 将资源移动到元组中execute_on_destruction{shouldrun} {} // 设置销毁时是否执行清理的标志// 析构函数:调用 invoke() 方法来清理资源(如果 execute_on_destruction 为 true)。~scoped_resource() { invoke(invoke_it::once); } // 在销毁时进行一次资源清理// invoke() 方法用于执行资源清理。// strategy 控制资源是否仅清理一次,还是可以重复清理。void invoke(invoke_it const strategy = invoke_it::once) noexcept {// 如果 execute_on_destruction 为 true,则执行资源清理if (execute_on_destruction) {// 使用 std::apply 展开元组并将每个资源传递给 deleter 进行清理。std::apply(deleter, resource); }// 如果 strategy 是 invoke_it::again,则设置 execute_on_destruction 为 true,表示需要重复清理。execute_on_destruction = strategy == invoke_it::again;}
};
// 工厂函数:用于创建 scoped_resource 对象,传入 deleter 和资源。
template <typename DELETER, typename... R>
auto make_scoped_resource(DELETER t, R... r) {// 返回一个 scoped_resource 对象,并自动推导资源类型和 deleter 类型。return scoped_resource<DELETER, R...>(std::move(t), std::move(r)...);
}
关键要点
scoped_resource
模板类:- 该类使用 模板参数包(
typename ... R
)来处理多个资源。 - 它通过
std::tuple
来存储所有资源,使用std::apply
来调用删除器(deleter
)。 - 这个类的目标是在作用域结束时释放所有资源,因此在析构函数中调用
invoke()
来处理资源释放。 - 资源的释放由一个单一的 删除器函数(
DELETER
)来管理,这个删除器可以接受多个资源作为参数。
- 该类使用 模板参数包(
- 问题与假设:
- 资源必须是可移动和可复制的:这个实现假定资源在移动或复制时不会引发问题。这对于许多资源(如文件句柄、锁等)可能不成立,因为它们通常是不可复制或不可移动的。
- 资源必须在构造时全部可用:这意味着所有资源必须在创建
scoped_resource
对象时就已经准备好,这也可能限制了某些资源的使用场景。 - 删除器的管理:删除器(
DELETER
)必须能够接受一个 变长参数包,并且不应抛出异常。
- 设计问题:
- 过于简单:设计看似简洁和直观,但假设了很多条件,比如资源能移动和复制、资源可以直接由删除器处理等。现实中很多资源是不能被复制或移动的(如文件句柄、互斥锁等),因此这种方法可能无法处理所有情况。
std::tuple
和std::apply
的使用:这种设计将所有资源存储在一个元组中,并尝试在销毁时使用std::apply
来调用删除器。然而,如果某些资源在删除时需要特殊的处理方式或顺序(例如,某些资源需要先释放其他资源),那么这种简单的方式可能就不够灵活。
- 析构时释放资源:
- 当
scoped_resource
对象析构时,它会调用invoke()
方法,检查execute_on_destruction
标志是否为true
,然后通过std::apply
来应用删除器。 invoke_it::once
和invoke_it::again
表示是否仅在一次析构中释放资源,还是多次释放资源。这种机制可能用于一些需要重复释放资源的特殊场景,但对于大多数普通资源,通常只需要在对象销毁时释放一次。
- 当
- 工厂函数:
make_scoped_resource()
是一个工厂函数,用于创建scoped_resource
对象,它接收删除器和资源作为参数,并返回一个新的scoped_resource
对象。
总结
这个设计的目标是通过模板和 std::tuple
提供一种通用的资源管理方式。尽管从实现上看,它的核心思想是通过 RAII 管理多个资源的生命周期,但它有一些明显的缺点,尤其是对于 不可移动 或 不可复制 的资源,或者需要 特定释放顺序 的资源来说,这种方法可能不适用。
- 过于简单的设计:它没有考虑到很多实际场景中资源的特殊要求,特别是对于线程安全、资源顺序释放等问题的处理。
- 泛型 RAII 可能面临的一些挑战包括如何处理各种不同类型的资源,如何保证资源按正确的顺序释放等。
如果能进一步扩展,加入对资源管理的细粒度控制(如资源的顺序释放,或对不可复制资源的处理),则可能会更为灵活和实用。
这个代码段展示了 Andrei Alexandrescu 在 CPPCon 2015 中介绍的一个 Scope Guard 技巧,旨在确保在作用域退出时执行某些操作。通过使用模板类和宏实现了ScopeGuardForNewException
,其目的是根据异常的数量执行特定的函数。这个实现使得 RAII 模式可以更加灵活地应用于异常处理。
以下是加上中文注释后的代码:
// 定义一个枚举类型,用于标识作用域退出时的状态
enum class ScopeGuardOnFail {};
// ScopeGuardForNewException 类模板:在作用域退出时执行指定的函数
template <typename FunctionType, bool executeOnException>
class ScopeGuardForNewException {FunctionType function_; // 用于在作用域退出时调用的函数UncaughtExceptionCounter ec_; // 用于记录未捕获的异常的计数器
public:// 构造函数,接受一个函数对象,可以是左值或右值explicit ScopeGuardForNewException(const FunctionType& fn): function_(fn) {}explicit ScopeGuardForNewException(FunctionType&& fn): function_(std::move(fn)) {}// 析构函数:当作用域退出时调用函数,如果发生异常并且设置了 `executeOnException`,则执行函数~ScopeGuardForNewException() noexcept(executeOnException) {// 如果 `executeOnException` 为 true 并且发生了新的未捕获的异常,执行指定的函数if (executeOnException == ec_.isNewUncaughtException()) {function_(); // 执行函数}}
};
// 操作符重载,用于结合 `ScopeGuardOnFail` 与函数创建一个 `ScopeGuardForNewException` 对象
template <typename FunctionType>
ScopeGuardForNewException<typename std::decay<FunctionType>::type, true>
operator+(detail::ScopeGuardOnFail, FunctionType&& fn) {return ScopeGuardForNewException<typename std::decay<FunctionType>::type, true>(std::forward<FunctionType>(fn)); // 返回一个新的 ScopeGuard 对象
}
// 宏定义 SCOPE_FAIL,用于在作用域退出时调用指定的函数
#define SCOPE_FAIL \auto ANONYMOUS_VARIABLE(SCOPE_FAIL_STATE) \ = ::detail::ScopeGuardOnFail() + \[&]() noexcept // 使用 lambda 函数,捕获外部变量,并且指定 noexcept{// 这里放置作用域退出时需要执行的代码};
中文注释解析:
ScopeGuardOnFail
枚举:- 这是一个空的枚举类型,仅用于在宏
SCOPE_FAIL
中标识作用域退出时的失败状态。
- 这是一个空的枚举类型,仅用于在宏
ScopeGuardForNewException
类模板:- 这是一个通用的 RAII 类,它接收一个函数对象,并且在析构时(即作用域退出时)根据是否发生异常执行该函数。
executeOnException
是一个布尔值,控制是否在未捕获的异常发生时执行函数。通过UncaughtExceptionCounter
计算未捕获的异常数量。- 析构函数使用
ec_.isNewUncaughtException()
来判断当前作用域退出时是否有新的未捕获的异常发生。如果有,就执行用户提供的函数。
- 操作符重载
operator+
:- 用来将
ScopeGuardOnFail
和用户提供的函数对象结合,生成一个ScopeGuardForNewException
对象。 - 通过完美转发,支持左值和右值函数对象。
- 用来将
- 宏
SCOPE_FAIL
:SCOPE_FAIL
宏是用来方便地创建一个ScopeGuardForNewException
对象,并在作用域退出时执行特定的函数。- 宏通过
ANONYMOUS_VARIABLE
为每次使用SCOPE_FAIL
时生成唯一的变量名,避免了命名冲突。 - 这个宏的优势是使用 lambda 来捕获外部变量,并在作用域退出时自动执行指定的代码。
设计思想:
- 异常安全:这个方案的设计非常关注异常安全性,特别是保证当异常发生时能正确执行资源清理操作。
- RAII 模式:
ScopeGuardForNewException
类实现了 RAII(资源获取即初始化)模式,使得在作用域结束时能够自动执行资源释放或特定的操作。 - 宏的使用:虽然宏在编译时展开,可能导致一些不易调试和可读性差的问题,但它可以提供更简洁的代码和更强的灵活性。
好处与缺点:
- 优点:
- 简化资源管理:通过
SCOPE_FAIL
宏,代码可以非常简洁地添加作用域退出时的操作。 - 异常安全:能确保在异常发生时,定义的清理函数仍然会执行。
- 灵活性高:通过模板和宏的结合,可以根据需要执行不同的操作。
- 简化资源管理:通过
- 缺点:
- 宏的限制:宏展开后可能会导致语法问题,特别是在某些 IDE 中会出现调试困难。
- 复杂的模板:模板的使用增加了代码的复杂性,理解起来需要一定的模板编程经验。
- 可读性:宏的使用可能导致代码可读性较差,特别是在调试时,宏展开后可能会变得不容易理解。
这段代码解释了在 C++ 中使用资源管理时的一些潜在问题,尤其是在处理资源的 移动(move) 和 复制(copy) 时可能遇到的异常。代码的核心思想是:在使用move
或copy
时需要考虑异常安全性,并且应该根据类型特性决定是进行移动还是复制。
关键点解析
- 移动可能抛出异常的问题:
- 如果资源的移动操作(move)是安全的(即不会抛出异常),那么移动资源就没问题。
- 但如果资源的移动操作可能抛出异常(例如在移动资源时可能需要分配新的内存、网络操作等),那么就无法保证资源被正确移动后,原始资源的状态会保持有效。也就是说,如果
move
抛出异常,你就会丢失对原始资源的控制,导致资源泄漏。 - 为了避免这种情况,C++ 提供了
std::move_if_noexcept
,它可以根据类型是否具备noexcept
特性来选择移动或复制。
- 复制操作的失败:
- 如果移动操作失败,通常会退回到复制操作。但是,复制操作也不是没有风险的。复制可能会失败(例如,资源分配失败),这时你仍然可以访问原始资源并进行释放,因此复制的失败没有像移动那样导致不可恢复的错误。
move_if_noexcept
函数的作用:move_if_noexcept
是一个条件移动操作,它根据给定资源是否具有noexcept
特性来选择执行 移动 或 复制 操作。具体实现上,如果类型不支持无异常保证的移动(is_nothrow_move_constructible
为false
),则使用复制操作。- 这个函数利用了 C++ 的
std::conditional
和类型特性(type traits)来决定是否使用move
或copy
。
move_if_noexcept
的实现
template< class T >
constexpr typename std::conditional< !std::is_nothrow_move_constructible_v<T> && std ::is_copy_constructible_v<T>, const T&, T &&
>::type move_if_noexcept(T& x) noexcept;
std::conditional
:这是一个条件选择类型的工具,根据条件返回不同的类型。在这个例子中,它根据资源是否支持无异常的移动构造来选择是进行 移动 还是 复制。- 如果类型
T
不是 无异常的可移动构造(!is_nothrow_move_constructible_v<T>
),但是 可以复制(std::is_copy_constructible_v<T>
),那么选择返回const T&
(即引用类型),因为复制是安全的。 - 如果类型
T
是无异常的可移动构造(std::is_nothrow_move_constructible_v<T>
),则返回T&&
(即右值引用),允许资源的移动。
- 如果类型
unique_resource
类中的 static_assert
template<typename R, typename D>
class unique_resource { static_assert((std::is_move_constructible_v<R> && std::is_nothrow_move_constructible_v<R>) || std::is_copy_constructible_v<R>, "resource must be nothrow_move_constructible or copy_constructible"); static_assert((std::is_move_constructible_v<R> && std::is_nothrow_move_constructible_v<D>) || std::is_copy_constructible_v<D>, "deleter must be nothrow_move_constructible or copy_constructible");
};
static_assert
:这两个static_assert
用来验证resource
和deleter
类型的构造特性,确保它们要么是 无异常的可移动构造,要么是 可复制构造。- 资源类型(
R
) 必须支持无异常的移动构造,或者至少可以复制构造。这样可以确保在资源管理时,资源能够被正确移动或者复制,避免在销毁时发生异常。 - 删除器类型(
D
) 也必须满足类似的要求,确保在资源被销毁时删除器操作不会抛出异常。
- 资源类型(
总结
- 异常安全性:在管理资源时,特别是在处理
move
和copy
操作时,需要格外注意异常安全性。资源管理类(如unique_resource
)需要设计成能够保证在操作失败时,资源依然可以被正确释放。 move_if_noexcept
:这个技巧确保了如果类型不支持无异常的移动构造,则回退到复制构造,避免在无法保证异常安全的情况下直接进行移动。static_assert
:这些断言确保resource
和deleter
类型满足必要的构造要求,避免类型不符合预期导致的错误。
这样设计能确保在 RAII 资源管理模式中,资源可以安全地移动、复制,并且避免由于异常而导致的资源泄漏。
这段代码和描述展示了如何通过 SFINAE(Substitution Failure Is Not An Error) 和 模板元编程 来处理可能出现的失败情况,确保在 异常安全 和 资源管理 中实现稳健的通用代码。Eric Niebler 提供了一些基础设施,帮助开发者编写更健壮和健全的泛型代码,尤其是在处理可能失败的初始化和移动操作时。
关键点解析:
- 模板元编程:
std::enable_if_t
和std::is_constructible_v
使得可以在编译时检查类型的构造能力,确保只有符合特定要求的类型才能参与模板实例化。这是 SFINAE 技术的一个例子。通过这些技术,我们可以确保模板参数满足某些条件,避免不适当的类型传递导致的编译错误。
_box
类:- 这个类用来包装一个值,并提供不同构造函数来保证异常安全。
_box
的构造函数会根据类型是否支持无异常的移动构造,选择使用正常的构造还是移动构造。- 拷贝构造函数:通过
noexcept
标志确保拷贝操作是安全的。 - 移动构造函数:通过
std::move_if_noexcept
确保如果类型不支持noexcept
的移动构造,采用复制构造。 - 构造函数初始化与失败保护:使用
guard.release()
表示构造成功,若出错则会有失败回调进行清理。
- 拷贝构造函数:通过
- 这个类用来包装一个值,并提供不同构造函数来保证异常安全。
basic_scope_exit
类:basic_scope_exit
是一种常见的 RAII 技巧,目的是在某个作用域结束时自动执行清理函数(比如释放资源、日志记录等)。它保证了在初始化过程中出现错误时能够自动进行清理,避免资源泄漏。- 它接受一个 “退出函数”(
exit_function
),当出现异常时,这个函数会被调用来执行必要的清理操作。
_ctor_from
与_make_failsafe
:_ctor_from
和_make_failsafe
是用来检测和确保某些操作是否可以安全完成。具体来说,这些代码确保了只有在没有异常的情况下,资源才会被成功初始化。若初始化失败,则通过release()
方法进行清理。_make_failsafe
会创建一个可以在构造失败时回调的机制,保证即使初始化出错,相关资源或状态也能够被正确处理。
代码示例解析
template<typename T>
class _box {T value; _box(T const &t) noexcept(noexcept(T(t))) : value(t) {} _box(T &&t) noexcept(noexcept(T(std::move_if_noexcept(t)))) : value(std::move_if_noexcept(t)) {}
public:template<typename TT, typename GG, typename = std::enable_if_t<std::is_constructible_v<T, TT>>>explicit _box(TT &&t, GG &&guard) noexcept(noexcept(_box((T &&) t))) : _box(std::forward<TT>(t)) { guard.release(); // all went well }
_box
类 是一个包装类,确保提供的值类型支持move
或copy
构造。它的构造函数会根据是否支持noexcept
来决定是否使用move_if_noexcept
或常规的构造函数。- 构造函数中使用了
std::enable_if_t
来确保模板参数是符合特定要求的类型。 guard.release()
表示构造成功,在这种情况下释放相关资源。
另一部分代码:basic_scope_exit
template<typename EFP, typename = std::enable_if_t<_ctor_from<EFP>::value>>
explicit basic_scope_exit(EFP &&ef) noexcept(_noexcept_ctor_from<EFP>::value) : exit_function(std::forward<EFP>(ef), _make_failsafe(_noexcept_ctor_from<EFP>{}, &ef)) {}
basic_scope_exit
:用于在作用域结束时执行传入的清理函数。通过std::enable_if_t
确保只有符合条件的类型才能被传入。_ctor_from<EFP>::value
检查EFP
是否支持特定的构造方式,_noexcept_ctor_from<EFP>::value
则用于标识构造函数是否具有noexcept
保证。
总结
- 健壮的泛型代码:在处理泛型资源时,通常需要考虑许多边界情况,比如资源是否可以安全移动或复制。通过 SFINAE 和
std::enable_if
可以在编译时进行类型检查,确保代码不会由于不合适的类型而出错。 - 异常安全:通过使用
std::move_if_noexcept
和 RAII 技术(如basic_scope_exit
和_box
),可以确保即使在资源初始化失败或发生异常时,相关资源仍然能够被正确清理,避免资源泄漏。 - 失败保护机制:Eric Niebler 提出的 失败保护机制(例如
basic_scope_exit
和_make_failsafe
)保证了当初始化或操作失败时,程序能够自动回滚或清理资源,从而提高了程序的异常安全性。
这段代码展示了在 C++ 中如何实现一个具有复杂失败保护机制的 RAII 类型。具体来说,代码设计了一种机制,确保在构造函数或移动构造函数失败时,会正确执行一个“退出函数”,并且该退出函数会在资源管理对象的生命周期结束时被调用。这种技术非常复杂,但可以提高异常安全性,避免资源泄漏。
template <typename EFP, typename = std ::enable_if_t<_ctor_from<EFP>::value>>
explicit basic_scope_exit(EFP &&ef) noexcept(_noexcept_ctor_from<EFP>::value): exit_function(std ::forward<EFP>(ef), _make_failsafe(_noexcept_ctor_from<EFP>{}, &ef)) {}
basic_scope_exit(basic_scope_exit &&that) noexcept(noexcept(detail ::_box<EF>(that.exit_function.move(), that))): Policy(that), exit_function(that.exit_function.move(), that) {}
~basic_scope_exit() noexcept(noexcept(exit_function.get()())) {if (this->should_execute()) exit_function.get()();
}
...
static auto _make_failsafe(std ::true_type, const void *) {return detail ::_empty_scope_exit{}; // NOP
}
template <typename Fn>
static auto _make_failsafe(std ::false_type, Fn *fn) {return basic_scope_exit<Fn &, Policy>(*fn);
}
代码解析与注释:
basic_scope_exit
类的定义
template<typename EFP, typename = std::enable_if_t<_ctor_from<EFP>::value>>
explicit basic_scope_exit(EFP &&ef) noexcept(_noexcept_ctor_from<EFP>::value) : exit_function(std::forward<EFP>(ef), _make_failsafe(_noexcept_ctor_from<EFP>{}, &ef)) {}
basic_scope_exit
构造函数:- 构造函数接受一个退出函数
ef
(EFP
类型)。std::enable_if_t<_ctor_from<EFP>::value>
确保只有符合构造要求的类型才能被传入。 std::forward<EFP>(ef)
将传递给构造函数的ef
进行完美转发。_make_failsafe
会根据EFP
类型决定是否返回一个实际的失败保护对象。具体而言:- 如果
EFP
类型的构造函数不抛出异常(_noexcept_ctor_from<EFP>::value
),_make_failsafe
会返回一个空的“无操作”对象。 - 如果
EFP
类型的构造函数可能抛出异常,则创建一个basic_scope_exit
对象以确保在构造失败时能正确执行退出函数。
- 如果
- 构造函数接受一个退出函数
移动构造函数
basic_scope_exit(basic_scope_exit &&that) noexcept(noexcept(detail::_box<EF>(that.exit_function.move(), that))) : Policy(that), exit_function(that.exit_function.move(), that) {}
basic_scope_exit
的移动构造函数:- 这个构造函数将现有的
basic_scope_exit
对象(that
)的exit_function
成员移到新的对象中。 exit_function.move()
表示移动exit_function
,而Policy(that)
和exit_function(that.exit_function.move(), that)
确保Policy
和exit_function
的相关状态被正确转移。noexcept(noexcept(...))
通过检查移动构造是否会抛出异常,确保该构造函数符合noexcept
的要求。
- 这个构造函数将现有的
析构函数
~basic_scope_exit() noexcept(noexcept(exit_function.get()()))
{ if(this->should_execute()) exit_function.get()();
}
- 析构函数:
should_execute()
检查basic_scope_exit
是否需要执行退出函数(例如,是否发生了异常)。exit_function.get()()
调用实际的退出函数(即:exit_function
成员的调用)。这保证了对象生命周期结束时执行清理任务。noexcept
保证析构函数不会抛出异常。
exit
、fail
和 success
策略
Determined by policy (exit,fail,success)
- 这部分代码暗示
basic_scope_exit
通过某种策略来决定是否在构造失败或异常发生时执行退出函数。策略可能包括:exit
:正常退出时执行。fail
:初始化失败时执行。success
:只有在成功时才执行退出函数。
_make_failsafe
函数
static auto _make_failsafe(std::true_type, const void *)
{ return detail::_empty_scope_exit{}; // NOP
}
template<typename Fn>
static auto _make_failsafe(std::false_type, Fn *fn)
{ return basic_scope_exit<Fn &, Policy>(*fn);
}
_make_failsafe
函数:_make_failsafe
函数用于创建一个 失败保护对象,根据传入类型的特性决定返回什么类型的对象:- 如果
EFP
的构造函数是noexcept
的(即std::true_type
),它返回一个“无操作”的空的退出对象(_empty_scope_exit{}
)。这表示如果构造过程中没有问题,就不需要做任何事。 - 如果构造函数可能抛出异常,则返回一个实际的
basic_scope_exit
对象,确保在异常发生时能够执行清理操作。
- 如果
总结
- 复杂的退出机制:这段代码实现了一个复杂的失败保护机制,确保即使在构造过程中发生异常,退出函数也能被正确调用。
basic_scope_exit
类在构造过程中需要做很多检查,并为失败的情况提供额外的保护。 - RAII 和异常安全:通过使用
basic_scope_exit
这种 RAII 类型,代码保证了在对象生命周期结束时,资源会被正确释放或进行清理。无论是因为构造失败、移动失败,还是正常生命周期结束,清理工作都会得到保证。 - SFINAE 和类型特征:使用了 SFINAE、
std::enable_if_t
和其他类型特征(如noexcept
、_ctor_from
)来确保类型在编译时符合预期,从而避免运行时错误。 - 多种策略:策略模式用于确定何时执行退出函数,这包括正常退出、失败退出和成功退出等多种情形,保证了非常高的灵活性和可扩展性。
总体来说,这段代码展示了如何用复杂的模板技术确保在 C++ 中处理资源管理时的异常安全。
这段代码和描述解释了如何在 C++ 中处理复杂的 RAII 和 资源管理,特别是在移动操作(move)和复制(copy)操作可能抛出异常时,如何设计一个安全的、复杂的资源管理类。整个过程体现了 C++ 标准库如何应对一些非常特殊、甚至是异常的资源管理情况。让我们逐步解读这段代码的主要概念:
测试“疯狂”的类
在进行 RAII 设计时,你必须考虑到最糟糕的情况,确保库能够正确处理这些异常行为的类型。这包括:
- 函数对象(
function objects
):可能在复制或移动时抛出异常。 - 资源对象:在移动时抛出异常。
- 无法赋值的资源对象:这些资源对象无法进行赋值操作,因此像
reset(newOne)
操作就变得不可用。
non_assignable_resource
示例
struct non_assignable_resource {non_assignable_resource() = default;non_assignable_resource(int) {}void operator=(const non_assignable_resource) = delete; // 禁止复制赋值non_assignable_resource operator=(non_assignable_resource) noexcept(false) { throw "buh"; } // 移动赋值抛出异常non_assignable_resource(non_assignable_resource) = default; // 默认构造函数
};
这个结构体的目的是模拟一个 无法赋值 且可能在移动操作时抛出异常的资源类。特别注意以下几点:
operator=
被删除:禁止复制赋值。operator=
移动赋值 可能会抛出异常。- 这个类模拟了那些在实际生产中不应该存在的、极端复杂或危险的资源管理场景。
testscopeExitWithNonAssignableResourceAndReset()
void testscopeExitWithNonAssignableResourceAndReset() {std::ostringstream out {};const auto lambda = [o](auto o) { out << "lambda done.\n"; };auto guard = unique_resource(non_assignable_resource{}, std::cref(lambda));//guard.reset(2); // 编译错误ASSERT_EQUAL("lambda done.\n", out.str());
}
这段代码演示了在使用 unique_resource
时,如果 non_assignable_resource
无法进行赋值,调用 reset
会导致编译错误。这个测试确保了 reset()
操作 被正确禁止,避免了对资源管理行为不合适的操作。
unique_resource
的复杂性与移动赋值
在 unique_resource
中,涉及到如何在移动操作中管理两个资源(例如资源本身和删除器)。下面是如何进行 移动赋值 的处理:
基本原则
- 如果移动不会抛出异常,可以直接移动资源。
- 如果移动会抛出异常,则需要首先复制资源,然后在成功复制之后再尝试移动资源。
unique_resource
移动赋值操作
unique_resource &operator=(unique_resource &&that) noexcept(is_nothrow_delete_v && std::is_nothrow_move_assignable_v<R> && std::is_nothrow_move_assignable_v<D>)
{if (&that != this) {reset(); // 释放当前资源if constexpr (is_nothrow_move_assignable_v<detail::_box<R>>) {if constexpr (is_nothrow_move_assignable_v<detail::_box<D>>) {resource = std::move(that.resource); // 移动资源deleter = std::move(that.deleter); // 移动删除器} else {deleter = _as_const(that.deleter); // 如果删除器不能移动,则复制删除器resource = std::move(that.resource); // 移动资源}} else {deleter = _as_const(that.deleter); // 复制删除器resource = std::move(that.resource); // 移动资源}execute_on_destruction = std::exchange(that.execute_on_destruction, false); // 交换执行销毁标志}return *this; // 返回当前对象的引用
}
核心思想与步骤
- 移动赋值的顺序:首先,进行
reset()
以释放当前对象的资源。如果resource
或deleter
类型支持 noexcept 移动构造(即不会抛出异常),就直接执行移动。否则,先执行复制(如果复制也会抛出异常,则需要额外处理)。 execute_on_destruction
的标志:确保在unique_resource
的移动赋值过程中,正确地转移执行销毁的标志位(即是否需要在析构时执行deleter
)。
为什么这样复杂
- 两个资源:在
unique_resource
中,同时管理资源本身和删除器,必须确保两者的移动或复制操作不会相互影响。 - 异常安全:必须确保即使在移动或复制过程中抛出异常,资源也能得到正确处理,避免泄漏或双重释放。
资源管理的“噩梦”
如果你管理多个资源(例如文件句柄和锁),并且每个资源的复制、移动或者赋值行为都不符合常规的标准,管理它们就会变得非常困难,甚至引发不可预期的行为。
总结
- 资源管理的复杂性:通过
unique_resource
和移动赋值操作,我们可以看到 C++ 中复杂的资源管理。设计和实现 RAII 类型时必须考虑各种极端情况,特别是如何处理可能会抛出异常的资源操作。 - 异常安全:确保即使资源复制或移动失败,仍能保证资源不泄漏。这种操作设计的复杂性要求编写安全、稳定的库。
- 千万不要在生产代码中使用不合理的资源管理类:例如,禁止赋值或可能抛出异常的资源类,它们可能导致程序运行时出错,难以维护和调试。
总之,处理复杂资源管理时,C++ 需要考虑异常安全、资源的移动与复制、以及两者在复杂情境下的交互,确保程序不会因错误的操作而崩溃或产生资源泄漏。
这段内容包含了很多关于 C++ 资源管理(特别是 RAII)的测试用例、设计建议以及标准化讨论。它涉及了一些非常复杂的情况,包括 lambda 绑定、移动构造、拷贝构造、异常处理 等。让我们逐一分析,并加以理解:
测试中的“恶心”错误和边界情况
在编写库或模板时,必须考虑到最坏的情况,确保库能够应对 不可预料的异常行为。为了测试这种边界情况,作者设计了一个特殊的删除器 deleter_2nd_throwing_copy
,它在每隔一次进行 拷贝构造 时抛出异常。这种设计暴露了 C++ 资源管理中异常处理的复杂性。
deleter_2nd_throwing_copy
结构体
struct deleter_2nd_throwing_copy {deleter_2nd_throwing_copy() = default;deleter_2nd_throwing_copy(deleter_2nd_throwing_copy const&) {if (copied % 2) {throw nasty{}; // 在每第二次复制时抛出异常}++copied;}void operator()(int const& t) const {++deleted;}static inline int deleted{};static inline int copied{};
};
这个结构体模拟了一个删除器,它会在 拷贝构造 时间隔性地抛出异常。每次复制后,copied
被递增。如果抛出异常,资源就没有被正确删除。
测试用例:test_sometimes_throwing_deleter_copy_ctor
void test_sometimes_throwing_deleter_copy_ctor() {using uid = unique_resource<int, deleter_2nd_throwing_copy>;uid strange{1, deleter_2nd_throwing_copy{}};ASSERT_EQUAL(0, deleter_2nd_throwing_copy::deleted);strange.release();ASSERT_EQUAL(0, deleter_2nd_throwing_copy::deleted);try {uid x{std::move(strange)};FAILM("should have thrown");} catch (nasty const&) {}ASSERT_EQUAL(0, deleter_2nd_throwing_copy::deleted); // 这行代码会失败ASSERT_EQUAL(1, deleter_2nd_throwing_copy::copied);
}
这个测试的目标是模拟一个 unique_resource
类型,在移动时触发可能的异常,确保删除器在合适的时机被调用。特别注意的是:
- 删除器没有被调用:在移动构造发生时,由于某些复制操作抛出了异常,删除器的调用被跳过了。此时,
deleted
没有被增加,测试失败了。 - 拷贝次数:拷贝的次数表明在这次操作中发生了 一次复制操作。
关于 Lambda 与 scope_exit
接下来,我们看到了一个与 lambda 和 scope guard 相关的意外行为测试:
std::string access_returned_from_string(size_t& len) {std::string s{"a string"};scope_exit guard{[&]{ len = s.size(); }}; // 传递引用捕获return s; // 这会降低拷贝省略的效果
}
void DemonstrateSurprisingReturnedFromBehavior() {size_t len{0xffffffff};auto s = access_returned_from_string(len);ASSERT_EQUAL(0, len); // 这里的 len 会变成 0,结果和预期不符
}
行为分析
lambda
捕获变量:在scope_exit
的构造函数中,捕获了一个对len
的引用。这意味着len
会在lambda
被调用时修改。- 返回时的副作用:问题发生在
std::string
s
的返回上。虽然std::string
的返回值优化可能会消除不必要的拷贝,但在此场景中,由于lambda
引用捕获了len
,导致其值没有正确被更新,返回时len
被意外地设置为0
,这与预期不符。
最终反馈与标准化讨论
该部分详细记录了 C++ 标准化委员会(例如,P0052r8)的讨论。这些讨论涉及了许多复杂的设计和编辑问题,例如:
- 命名和文本改进:如何更清晰地表达
scope_guard
、unique_resource
等类型的语义和要求。 - 异常安全性:讨论了如何确保资源管理类在面对异常时能安全地清理资源。
- 规范性问题:一些关于
requires
和constraints
的问题,以及如何将它们更好地表述在标准中。
总结
这些讨论揭示了 C++ 资源管理的复杂性,特别是如何在资源管理类中处理 异常安全、资源拷贝、移动、删除器 以及 lambda 捕获 等问题。标准化讨论和反馈帮助确保这些复杂的设计能够以一种明确且一致的方式实现,并且在面对极端行为时仍能保持安全和有效。
- Lambda 捕获的副作用:即使你以引用的方式捕获一个变量,也可能在返回时导致一些意外行为,这种情况在使用 scope_exit 等复杂资源管理机制时尤为重要。
- 异常安全和拷贝/移动构造的复杂性:特别是在涉及删除器和资源管理的场景中,如何确保在异常情况下仍能正确管理资源,需要仔细设计和测试。
这段代码和讨论帮助我们了解了如何应对复杂的资源管理和异常处理,特别是在涉及 C++ 标准库设计时的挑战。
这部分内容深入探讨了 RAII(资源获取即初始化) 在 C++ 中的设计考虑和规范,尤其是在资源管理方面的通用抽象。以下是对主要观点和设计的解析:
1. SFINAE 与空基优化(Empty Base Optimization)与 [[no_unique_address]]
- 空基优化(EBO):
- C++17 引入了优化技术,旨在减少当基类为空时的内存开销。对于像
unique_resource
这样的类,如果基类是空的(如 deleter),就可以避免多余的内存开销。 - SFINAE(Substitution Failure Is Not An Error) 可以用于优化这种情况,以确保
unique_resource
的内存使用不会过大。 - 在 C++20 中,可以使用
[[no_unique_address]]
属性来进一步优化。此属性会让编译器避免为空的基类(如一个空的 deleter)分配内存,从而保证unique_resource<T, D>
的大小与sizeof(T)
相同(即没有 deleter 的额外开销)。 - 重构当前实现:为了利用这个特性,需要重新设计现有的实现(例如,使用 Eric 提出的 box 技术来处理 deleter)。
- C++17 引入了优化技术,旨在减少当基类为空时的内存开销。对于像
[[no_unique_address]]
的影响:- 使用此属性后,编译器不会为空的对象分配空间。这对那些 无状态的 deleter(比如没有捕获的 lambda)非常有用,可以减少内存开销。
- 这对于优化
unique_resource
特别有帮助,因为它负责管理像内存、文件句柄等资源,但由于 deleter 的存在,可能导致额外的内存消耗。
2. [[nodiscard]]
属性:用于 scope_guard
和 unique_resource
构造函数
[[nodiscard]]
:- 这个属性可以 警告用户:如果返回值(如构造函数返回值)被忽略,可能会导致错误。它能够强制用户不要忽略重要的资源管理操作。
- 目前 C++标准中还没有将其用于
scope_guard
和unique_resource
,但这对于避免一些常见的资源管理错误是非常有用的。 - 错误示例:
scope_exit{[]{ std::cout << "I am done!" << std::endl; }};
- 在这种情况下,如果用户没有 保存 或 使用
scope_exit
,那么函数结束时不会触发清理操作。
- 在这种情况下,如果用户没有 保存 或 使用
- 标准化问题:目前标准没有对此进行规定,可能是因为标准尚未解决相关问题,但这一特性能显著减少因忽视资源清理而引发的 bugs。
3. 来自 C++ 标准委员会的反馈
- 委员会反馈:
- 在之前的 C++标准审查会议上,提出了一些关于库成员规范的建议,特别是 命名 的部分。
- 比如,在规范中某些 章节名称发生了变化,这直接影响了资源管理工具的文档和 API 设计。
- 这种反馈在审查过程中很常见,用于改善规范的清晰度和与现有标准的对接。
4. Andrei Alexandrescu 的观点
- Andrei Alexandrescu(C++专家)认为,“不需要为偶尔使用的 RAII 模式单独创建类型”,即 不需要专门的包装类来实现 RAII。
- 然而,Peter Sommerlad(另一位 C++专家)对此持不同意见,认为应该 创建合适的 RAII 抽象,而不是依赖一些“ hacky”解决方案(如
gsl::finally()
)。 - 事务性文件处理示例:
- 代码示例展示了如何利用 RAII 实现文件操作的事务性管理:
void copy_file_transact(path const& from, path const& to) {path t = to.native() + ".deleteme";auto guard = scope_fail{[t] { remove(t); }};copy_file(from, t);rename(t, to); }
- 行为说明:
- 文件首先被复制,同时创建一个
scope_fail
来确保如果发生错误,临时文件会被删除。这种方式保证了 事务语义:要么文件复制成功,要么系统会 回滚,删除临时文件。
- 文件首先被复制,同时创建一个
- 这里的关键是 RAII 可以帮助有效管理资源,尤其是在需要 回滚 或 清理 逻辑的场景中。
- 代码示例展示了如何利用 RAII 实现文件操作的事务性管理:
5. 总结:资源管理的最佳实践
- 学习并使用 RAII:
- 如果你还不熟悉 RAII,建议尽早学习并在代码中 有意识地使用。RAII 可以确保资源 在正确的时间被获取和释放,通常通过函数或代码块的作用域来实现。
- 了解你的资源:
- 你需要清楚了解 你正在处理的资源类型(例如内存、文件句柄、网络连接),它们的 生命周期 以及程序需要处理的 资源数量。
- 通用资源管理是困难的:
- 设计通用的资源管理解决方案是 困难 的,因为它不仅仅涉及到 单一资源 的管理,而且 多个资源的管理 会增加复杂性。
- 为每个资源单独包装:
- 当需要管理多个资源时,最好考虑 为每个资源单独包装 一个 RAII 结构,以确保 事务语义,这样当一个资源获取失败时,之前获取的资源能够被正确释放。
- 避免过度使用 Scope Guards:
- 尽管 scope guards 很有用,但 不要在代码中随便使用。最好是构建合适的 RAII 抽象,确保资源管理的安全性,而不依赖过多的 scope guards。
- 注意“伪悬挂”引用:
- 当使用 lambda 来管理资源清理时,要小心可能出现的 悬挂引用。这是指 lambda 捕获了一个已经被移动的对象,从而导致不可预测的行为。
结论
本部分强调了在 C++ 中设计 通用资源管理 解决方案的 复杂性,并总结了设计 安全有效的资源管理抽象 的最佳实践。它指出了 RAII 如何帮助有效管理资源,同时也提醒开发者要特别注意可能出现的 悬挂引用 和 异常安全性 问题。
此外,C++标准化 过程中的一些反馈也突出了如何改进现有标准,以更好地支持复杂的资源管理模式,并提供了对 [[nodiscard]]
和 [[no_unique_address]]
等特性未来可能带来的改进的期待。