bitdewy

[译] GotW #102: Exception-Safe Function Calls (Difficulty: 7/10)

[这是 GotW #56 的 C++11 更新版]

原文在这里 GotW #102: Exception-Safe Function Calls (Difficulty: 7/10)

 

JG问题

1. 考虑下面的语句,函数 f,g,h 和表达式 expr1 还有 expr2,它们的执行顺序是什么?假设 expr1expr2 不包含函数调用。

// Example 1(a)
//
f( expr1, expr2 );
 
// Example 1(b)
//
f( g( expr1 ), h( expr2 ) );

 

Guru问题

2. 当你翻看公司的陈年老代码的时候,你发现了如下的代码片段:

// Example 2
 
// In some header file:
void f( T1*, T2* );
 
// At some call site:
f( new T1, new T2 );

这段代码包含潜在的异常安全问题吗?请解释。

3. 当你继续查看的时候,你发现了有人不喜欢 Example 2 的代码,因为上面那个问题提到的文件的后续版本已经被改成了下面这个样子:

// Example 3
 
// In some header file:
void f( std::unique_ptr<T1>, std::unique_ptr<T2> );
 
// At some call site:
f( std::unique_ptr<T1>{ new T1 }, std::unique_ptr<T2>{ new T2 } );

这比之前的 Example 2 的版本有哪些改进,如果有的话? 异常安全问题是否仍然存在?请解释。

4. 如何写出一个 make_unique 使它能够解决 Example 3 中的问题,并能以如下方式使用:

// Example 4
 
// In some header file:
void f( std::unique_ptr<T1>, std::unique_ptr<T2> );
 
// At some call site:
f( make_unique<T1>(), make_unique<T2>() );

 

解决方案

1. 考虑下面的语句,函数 f,g,h 和表达式 expr1 还有 expr2,它们的执行顺序是什么?假设 expr1expr2 不包含函数调用。

答案遵循下面的基本规则:

  • 函数调用之前,所有的参数必须都已经求值。这也包括任何有副作用的作为函数参数的表达式。
  • 当函数开始执行的时候,在调用该函数的代码中不会有任何表达式开始或继续求值,直到函数调用结束。函数调用永远都不会交叉执行。
  • 用作函数参数的表达式可能以任意的顺序求值,包括交叉执行,除非另有其他规则限制。

在标准 C++11 中,这些线程内的顺序约束由“sequenced before” 关系决定,它约束了编译器和硬件在单线程执行中的行为。取代了旧的 C/C++ “sequence point”(序列点) ,但它们的目的是相同的。

给出了这些规则,让给我看看在我们的例子中发生了什么:

// Example 1(a)
//
f( expr1, expr2 );

在例子 1(b) 中,函数和表达式可能以任何的顺序来计算,考虑下面的规则:

  • epxr1 必须在 g 调用之前计算。
  • expr2 必须在 h 调用之前计算。
  • gh 必须在 f 调用之前完成。

表达式 expr1expr2 的求值有可能交错,但不可能与函数调用交错。举个例子,无论是表达式 expr2 的一部分,还是函数 h 的执行,都不可能发生在 g 的执行过程中;但是,h 的调用有可能发生在 g 调用之前,也可能是 g 调用之后。

 

一些函数调用的异常安全问题

2. 当你翻看公司的陈年老代码的时候,你发现了如下的代码片段:

// Example 2
 
// In some header file:
void f( T1*, T2* );
 
// At some call site:
f( new T1, new T2 );

这段代码包含潜在的异常安全问题吗?请解释。

是的,这有一些潜在的异常安全问题。

简要归纳:像 new T1 这样的调用,很简单,一个 new 表达式。回忆一下 new 表达式所做的事情(简单起见,忽略定位new 和数组形式的 new,因为这个我们讨论的没有太大关系。)

  • 分配内存
  • 在分配的内存中构造一个新的对象;然后
  • 如果由于异常而构造失败,分配的内存将被释放

所以每个 new 表达式本质上是两个函数调用:一个是 operator new(可能是全局的,或者这个类型所提供的),另一个是构造函数。

在 Example 2 中,考虑如果编译器生成的代码将按照下面的步骤执行时,将会发生什么:

  1. 给 T1 分配内存
  2. 构造 T1
  3. 给 T2 分配内存
  4. 构造 T2
  5. 调用函数 f

它存在的问题是:如果第 3 步或第 4 步由于异常而失败了,C++ 标准没有要求 T1 对象必须销毁和释放内存,这是典型的内存泄漏,很明显这不是件好事。

另一个可能的执行顺序:

  1. 给 T1 分配内存
  2. 给 T2 分配内存
  3. 构造 T1
  4. 构造 T2
  5. 调用函数 f

这个执行顺序不仅仅含有一个,而是两个异常安全的问题并且他们会产生不同的结果:

  • 如果第 3 步由于异常而失败,那么给 T1 分配的内存将自动释放(第 1 步会回滚),但是标准没有要求释放给 T2 分配的内存,内存泄漏了。
  • 如果第 4 步由于异常而失败,T1 的内存已分配且构造完成,但是标准没有要求销毁和释放它的内存,T1 泄漏了。

“呃”,你可能想知道,“为什么这个异常安全漏洞会存在?为什么标准不要求清理时编译器做正确的事来防止这个问题?”

C++ 拥有 C 语言的灵魂——效率,因此 C++ 标准允许编译器某些情形下自行决定表达式的求值顺序,因为这可以允许编译器进行可能的性能优化。为了保证这一点,一些表达式求值规则不是异常安全的,所以如果你想要写异常安全的代码,那么你就需要知道它,并且避免它。

幸运的是,你可以做一些简单的工作来避免这个问题。也许像 unique_ptr 这样的智能指针会有帮助?

 

3. 当你继续查看的时候,你发现了有人不喜欢 Example 2 的代码,因为上面那个问题提到的文件的后续版本已经被改成了下面这个样子:

// Example 3
 
// In some header file:
void f( std::unique_ptr<T1>, std::unique_ptr<T2> );
 
// At some call site:
f( std::unique_ptr<T1>{ new T1 }, std::unique_ptr<T2>{ new T2 } );

这比之前的 Example 2 的版本有哪些改进,如果有的话? 异常安全问题是否仍然存在?请解释。

这个代码试图把问题直接丢给 unique_ptr。很多人相信智能指针是异常安全的万能药,是试金石和护身符,只要有它的出席,就能解决编译器的消化不良。

不是的。没有任何改变。Example 3 仍然不是异常安全的,和之前的原因一模一样。

特别的,只有当资源真的由 unique_ptr 接管时,这些资源才是安全的,但是当还没有到达 unique_ptr 的构造函数时,同样的问题还会存在。因为上面提到的两种执行顺序还是有可能的,只是在调用函数 f 之前需要调用 unique_ptr 的构造函数而已。一个例子:

  1. 给 T1 分配内存
  2. 构造 T1
  3. 给 T2 分配内存
  4. 构造 T2
  5. 构造 unique_ptr<T1>
  6. 构造 unique_ptr<T2>
  7. 调用函数 f

在上面的情况下,如果第 3 步或第 4 步异常了,相同的问题仍会存在,下面的也是一样:

  1. 给 T1 分配内存
  2. 给 T2 分配内存
  3. 构造 T1
  4. 构造 T2
  5. 构造 unique_ptr<T1>
  6. 构造 unique_ptr<T2>
  7. 调用函数 f

还是一样,如果第 3 步或第 4 步抛出异常,还是会存在异常安全问题。

幸运的是,这不是 unique_ptr 的问题,只是 unique_ptr 的误用而已。让我们看看如果更好的使用它。

 

Enter make_unique

4. 如何写出一个 make_unique 使它能够解决 Example 3 中的问题,并能以如下方式使用:

// Example 4
 
// In some header file:
void f( std::unique_ptr<T1>, std::unique_ptr<T2> );
 
// At some call site:
f( make_unique<T1>(), make_unique<T2>() );

基本思路:

  • 我们需要同一个线程中的函数调用不要产生交错,所以我们使用函数来完成分配、构造和构造 unique_ptr 对象的的工作。
  • 因为我们需要函数可以配合任何类型工作,所以我们需要使用函数模板。
  • 由于需要通过 make_unique 传递构造参数给需要构造的对象,我们要使用 C++11 的完美转发,把参数传递给 make_unique 函数中的 new 表达式。
  • shared_ptr 已经有一个类似的设施 std::make_shared,为了一致性我们把这个函数叫做 make_unique。(C++ 11 中没有 make_unique 是某种程度上的疏忽,几乎可以确定,不就的将来它会被添加到标准中,在这段时间内,我们使用下面的实现。)

把上面的要素组装到一起,我们可以得到:

template<typename T, typename ...Args>
std::unique_ptr<T> make_unique( Args&& ...args )
{
    return std::unique_ptr<T>( new T( std::forward<Args>(args)... ) );
}

这解决了异常安全问题,任何的执行顺序都不会造成资源泄露,因为现在我们只有两个函数,并且我们知道他们必须一个一个的执行。考虑下面的求值顺序:

  1. 调用函数 make_unique<T1>
  2. 调用函数 make_unique<T2>
  3. 调用函数 f

如果第 1 步异常了,不会产生泄露因为 make_unique 是异常安全的。

如果第 2 步异常了,那么第一步构造出来的 unique_ptr<T1> 临时对象会被清理掉吗?

是的,它会被清理。可能有人会认为:这难道和 Example 2 是不一样的吗?而 Example 2 是不能正确清理的。不,这和 Example 2 是不同的。因为在这里,unique_ptr<T1> 事实上是一个临时对象,而临时对象的销毁在标准中是明确说明的。在标准 12.2/3 节中(从 C++98 之后没有更改过):

在表达式求值完成之后临时对象将被销毁,包括他们的创建点。即使表达式求值以异常而终止时,也是一样。

准则:

  • 优先考虑使用 make_shared 来构造 shared_ptr 对象来管理内存,用 make_unique 来构造 unique_ptr 对象。
  • 虽然现在标准 C++ 中还没有 make_unique,这是一个疏忽,几乎可以确定它最终会加入到标准中。在这期间,使用上面的版本,你的代码会向前兼容 C++ 标准的。
  • 避免直接使用 new 或其他的原始的分配内存的方法。取而代之的是,使用类似于 make_unique 的工厂方法,它会包装原始的内存分配然后直接传给需要接管资源的对象,通常是智能指针,它会自动清理资源,或者其他析构函数负责安全的删除资源的对象。

感谢

这个问题是在 comp.lang.c++.moderated 的讨论中提出的。解决方案中的观点是由 James Kanze, Steve Clamage, 和 Dave Abrahams 在该讨论以及其他讨论和私人邮件中提出的。同时也感谢 Clark Nelson,他起草了 C++11 中的“sequenced before” 的概念,并阐明了其中的疑问。

posted on 2012-07-07 00:06 bitdewy 阅读(382) 评论(0)  编辑 收藏 引用


只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理


公告

导航

<2024年4月>
31123456
78910111213
14151617181920
21222324252627
2829301234
567891011

统计

常用链接

留言簿

随笔档案

Interested human

搜索

最新评论

阅读排行榜

评论排行榜