当你正在研究最近新加入的项目的代码时,你发下了下面的工厂方法的声明:
widget* load_widget( widget::id desired );
JG 问题
1. 返回值的类型有什么问题?
Guru 问题
2. 返回值的推荐类型是是什么? 解释你的答案,包括任何的权衡。
3. 你希望更改返回值的类型为问题 #2 中推荐的类型,但是你担心破坏了原有代码的函数调用的兼容性;重新编译现有的函数调用是可行的,但修改所有的函数调用就……。然后你灵光一闪,意识到这是一个相当新的项目,所有的代码都使用现代 C++ 惯用法,然后你继续修改返回值类型,而没有一点担心,知道重构仅仅需要一点点或者更不不需要更改调用函数的代码。是什么让你如此自信?
解决方案
1. 返回值的类型有什么问题?
第一,我们能够从两行的问题描述中知道些什么?
- 我们说过,load_widget 是一个工厂方法。它通过“loading” 生产出一个对象,然后把它返回给调用者。由于返回值类型是指针类型,所以结果可能是 null。
- 调用者会适当的使用该对象,可能是调用它的成员函数,或者把它传给其他的函数,等等。这是不安全的,除非调用者确保该对象存活——调用者管理对象的生命周期;或者共享所有权,如果工厂方法负责维护内部的强引用和弱引用的话。
- 因为调用者持有或共享所有权,它必须在对象不再需要时做一些工作。如果是直接持有,掌管生命周期的话,它应该销毁该对象,通常是一个 delete 或者也可能是类似于 unload_widget 的一个函数调用。不然的话,如果是共享所有权,它应该减少共享的引用计数。
不幸的是,返回一个 widget* 有两个主要的问题。第一,默认情况下它是不安全的,因为默认的操作模式(比如不保存返回值)会造成 widget 泄露:
// Example 1: Leak by default. Really, this is just so 20th-century...
//
widget* load_widget( widget::id desired );
:::
load_widget( some_id ); // oops
Example 1 的代码可以干净的编译,运行,(不)开心的泄露 widget。
第二,函数签名传达出来的信息太少了,“一个 widget ?是的,开始吧!享受它。”文档中会指出调用者应该持有该对象,掌管对象的生命周期(或者其它),但是函数声明没有给出这些信息——它掌管对象的生命周期,还是共享所有权,到底是哪个?阅读文档然后记下它吧,因为函数声明没办法告诉我们到底是哪种情况。
2. 返回值的推荐类型是是什么? 解释你的答案,包括任何的权衡。
推荐的返回值类型是 unique_ptr 或者也可能是 shared_ptr。注意现在 C++ 中返回值类型是很平常的了,但是还有一点不太合适,它改变了语义,因为返回指针类型允许返回 null (无对象),而返回一个值得话不能轻易地处理这个语义,除非加个类似于 optional<> 的东西。
准则:工厂方法应该默认返回 unique_ptr 类型,或当需要共享所有权时使用 shared_ptr 类型。
这同时解决了上面两个问题:安全,自描述。
第一,考虑如何立即解决 Example 1 中的安全问题:
// Example 2: Clean up by default. Much better...
//
unique_ptr<widget> load_widget( widget::id desired );
:::
load_widget( some_id ); // cleans up
Example 2 的代码可以干净的编译,运行,开心的清理 widget。但它不仅仅在这种情况下正确——构造也是正确的,因为现在没有办法去制造出一个导致泄露的错误。
画外音:可能会有人说,“难道不会有人一直写 load_widget(some_id).release()?”如果这些人发神经的话,当然可以;正确的答案是,“不要这么做。” 记住我们关心的是什么——bug 和错误,而不是精心设计的犯罪——显然病态的滥用属于后一类。这与在 C# 的 using 块中显式的调用 Dispose,或在 Java 的 try-with-resources 块中调用 close,没什么区别,不会比它们存在更多的类型安全问题。
如果清理工作不是一个单独的 delete 语句,那么该怎么做?简单:使用自定义的 deleter。锦上添花的是例子中的工厂方法知道使用哪个 deleter,并且能够在构造返回值的时候确定;调用者不需要关心这些,尤其是,当调用者使用 auto 关键字作为返回值类型的时候。
第二,这是自描述的:一个返回值类型是 unique_ptr 的函数,清楚的说明这是一个纯“source” 函数,如果一个函数的返回值是 shared_ptr,那么清楚的说明它返回的是一个共享的所有权和/或一个观察者。
最后,为什么默认情况下,如果你不需要表达共享所有权的语义时,优先考虑使用unique_ptr?因为不管对于性能还是正确性来说,这都是对的事情,并且也给调用者留下了一些余地:
- 返回值类型为 unique_ptr 表示返回唯一所有权,这是纯“source” 工厂方法的标准形式。
- unique_ptr 的性能不会被打败——转移一个 unique_ptr 和转移/复制一个原始指针一样的廉价。
- 如果调用者想要通过 shared_ptr 来管理对象的声明周期,可以通过隐式的转移操作来转换为 shared_ptr 类型——不需要显式的 std::move 因为编译器知道返回值是一个临时对象。
- 如果调用者想要用其他的办法来维护对象的生命周期,可以通过调用 .release() 来得到原始指针。这是有用的,但是 shared_ptr 没有。
看下面的实现:
// Example 2, continued
//
auto up = load_widget(1); // unique_ptr (by default)
shared_ptr<widget> sp = load_widget(2); // shared_ptr (if desired)
my::smart_ptr<widget> msp = load_widget(3).release(); // your own smart pointer (if desired)
widget* p = load_widget(4).release(); // or even old-school manual management (not recommended)
delete p;
当然,如果工厂方法持有一些共享所有权的对象,不论是通过内部的 shared_ptr 还是 weak_ptr,返回 shared_ptr。这时调用者只能被迫使用 shared_ptr,但在这种情形下,这样是合适的。
3. 你希望更改返回值的类型为问题 #2 中推荐的类型,但是你担心破坏了原有代码的函数调用的兼容性;重新编译现有的函数调用是可行的,但修改所有的函数调用就……。然后你灵光一闪,意识到这是一个相当新的项目,所有的代码都使用现代 C++ 惯用法,然后你继续修改返回值类型,而没有一点担心,知道重构仅仅需要一点点或者更不不需要更改调用函数的代码。是什么让你如此自信?
现代可移植的 C++ 代码使用 unique_ptr,shared_ptr,和 auto。返回 unique_ptr 类型可以和这三个协同工作,返回 shared_ptr 只能与后两个协同工作。
如果调用者接受返回值时使用了 auto,例如 auto w = load_widget(whatever);那么类型自然会是正确的,正常的解引用也可以工作,只有在调用者试图把它存储到一个其他类型的非局部变量中时代码才会出问题。
准则:优先考虑使用 auto 声明变量,除非需要显式的类型转换。它更短,而且可以避免类型轻微更改时引起的不必要的波澜。
否则:如果调用者没有使用 auto ,那么它应该已经使用返回结果初始化了 unique_ptr 或 shared_ptr, 因为现代 C++ 代码不使用非参数的原始指针变量(下次详细讨论)。每种情况下,返回一个 unique_ptr 可以工作:一个 unique_ptr 可以无缝的转换成这些类型,如果语义上需要返回共享的所有权,那么调用者应该已经使用了 shared_ptr,这样的话,再次工作还会是正常的。(可能比之前的更好,因为为了让原本的返回原始指针的版本正常工作,返回类型大概会被 enable_shared_from_this 所胁迫,但如果我们显示的返回一个 shared_ptr 的话,这些就是不需要的了。)
JG 问题
1. 什么情况下你会使用 shared_ptr 和 unique_ptr ?尽可能多的列出你能想到的情况。
Guru 问题
2. 为什么要使用 make_shared 来构造 shared_ptr 对象?请解释。
3. 如何处理 auto_ptr ?
解决方案
1. 什么情况下你会使用 shared_ptr 和 unique_ptr ?尽可能多的列出你能想到的情况。
当不确定时,优先考虑使用 unique_ptr,你可以在之后有需要的时候转换成 shared_ptr。如果你一开始就可以确定需要共享所有权,那么你可以直接使用 make_shared(看下面的 #2)来构造 shared_ptr 对象。
“当不确定时,优先考虑使用 unique_ptr” 的主要原因包括一下几点:
语义:选择正确的智能指针,尽可能的直接表达你的意图,和(当前)你所需要的。如果你正在创建一个对象并且不需要共享所有权(当时看来),使用表达唯一所有权的 unique_ptr,你仍然可以把它放到容器中,(例如:vector<unique_ptr<widget> >),同时也可以做大部分原始指针能做的事情,而且更安全。如果之后你又需要共享所有全,那么你通常都可以直接把 unique_ptr 转换为 shared_ptr。
效率:unique_ptr 比 shared_ptr 有更好的性能,因为它不需要维护一个引用计数和控制块;unique_ptr 的转移几乎和原始指针一样廉价。如果你没有比你需要的要求更多,那么就不会招致额外的开销。
适应性:如果你一开始就使用 unique_ptr,你通常稍后可以把它转换成 shared_ptr,或者甚至一个原始指针。(通过 get 或者 release)。
2. 为什么要使用 make_shared 来构造 shared_ptr 对象?请解释。
shared_ptr 的实现需要维护被 shared_ptrs 和 weak_ptrs 引用的对象的内部信息控制块。特别的,内部信息包含的不是一个引用计数,而是两个:
- 一个“强引用”计数,用来记录当前有多少个存活的 shared_ptrs 正持有该对象。共享的对象会在最后一个强引用离开的时候销毁(也可能是释放)。
- 一个“弱引用”计数,用来记录当前有多少个正在观察该对象的 weak_ptrs。当最后一个弱引用离开的时候,共享的内部信息控制块会被销毁和释放(共享的对象也会被释放,如果还没有释放的话)。
如果你通过使用原始的 new 表达式分配对象,然后传递给 shared_ptr,shared_ptr 的实现没有办法选择,而只能单独的分配控制块,看下面的 Example 2(a) 和 Fugure 2(a)。
// Example 2(a): Separate allocation
auto p = new widget();
shared_ptr sp1{ p }, sp2{ sp1 };
我们应该在此避免两次单独的分配,如果你使用 make_shared 来分配对象,那么shared_ptr 的构造就仅需要一步,他的实现可以把两次分配合并成一次分配,看下面的 Example 2(b) 和 Figure 2(b)。
// Example 2(b): Single allocation
auto sp1 = make_shared(), sp2{ sp1 };
通常情况下,一个单独的函数调用可以表达的比你想要的东西更多,你给了系统一个更好的机会去以更好的效率完成这项工作。这是毫无疑问的,使用单独的函数调用 make_shared 代替 new widget() 和 shared_ptr(widget *),就好像当你插入 100 个元素到 vector 中时,使用单独的一个指定插入区间的函数调用 v.insert(first, last) 来代替调用 100 次 v.insert(value) 一样。
3. 如何处理 auto_ptr ?
auto_ptr 是最……的特性,它在 C++ 还没有转移语义的时候,勇敢的尝试创建一个 unique_ptr。
现在 auto_ptr 是被反对的,并且不应该在新的代码中使用它。如果你有机会,应该试着在你的代码中全局替换,把 auto_ptr 都替换成 unique_ptr;大部分使用的地方会像之前一样工作,而且它可能暴露出问题(比如编译期错误)或者修复(悄悄地)一个或两个你自己都不知道的 bug。
[这是 GotW #56 的 C++11 更新版]
原文在这里 GotW #102: Exception-Safe Function Calls (Difficulty: 7/10)
JG问题
1. 考虑下面的语句,函数 f,g,h 和表达式 expr1 还有 expr2,它们的执行顺序是什么?假设 expr1 和 expr2 不包含函数调用。
// 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,它们的执行顺序是什么?假设 expr1 和 expr2 不包含函数调用。
答案遵循下面的基本规则:
- 函数调用之前,所有的参数必须都已经求值。这也包括任何有副作用的作为函数参数的表达式。
- 当函数开始执行的时候,在调用该函数的代码中不会有任何表达式开始或继续求值,直到函数调用结束。函数调用永远都不会交叉执行。
- 用作函数参数的表达式可能以任意的顺序求值,包括交叉执行,除非另有其他规则限制。
在标准 C++11 中,这些线程内的顺序约束由“sequenced before” 关系决定,它约束了编译器和硬件在单线程执行中的行为。取代了旧的 C/C++ “sequence point”(序列点) ,但它们的目的是相同的。
给出了这些规则,让给我看看在我们的例子中发生了什么:
// Example 1(a)
//
f( expr1, expr2 );
在例子 1(b) 中,函数和表达式可能以任何的顺序来计算,考虑下面的规则:
- epxr1 必须在 g 调用之前计算。
- expr2 必须在 h 调用之前计算。
- g 和 h 必须在 f 调用之前完成。
表达式 expr1 和 expr2 的求值有可能交错,但不可能与函数调用交错。举个例子,无论是表达式 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 中,考虑如果编译器生成的代码将按照下面的步骤执行时,将会发生什么:
- 给 T1 分配内存
- 构造 T1
- 给 T2 分配内存
- 构造 T2
- 调用函数 f
它存在的问题是:如果第 3 步或第 4 步由于异常而失败了,C++ 标准没有要求 T1 对象必须销毁和释放内存,这是典型的内存泄漏,很明显这不是件好事。
另一个可能的执行顺序:
- 给 T1 分配内存
- 给 T2 分配内存
- 构造 T1
- 构造 T2
- 调用函数 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 的构造函数而已。一个例子:
- 给 T1 分配内存
- 构造 T1
- 给 T2 分配内存
- 构造 T2
- 构造 unique_ptr<T1>
- 构造 unique_ptr<T2>
- 调用函数 f
在上面的情况下,如果第 3 步或第 4 步异常了,相同的问题仍会存在,下面的也是一样:
- 给 T1 分配内存
- 给 T2 分配内存
- 构造 T1
- 构造 T2
- 构造 unique_ptr<T1>
- 构造 unique_ptr<T2>
- 调用函数 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)... ) );
}
这解决了异常安全问题,任何的执行顺序都不会造成资源泄露,因为现在我们只有两个函数,并且我们知道他们必须一个一个的执行。考虑下面的求值顺序:
- 调用函数 make_unique<T1>
- 调用函数 make_unique<T2>
- 调用函数 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” 的概念,并阐明了其中的疑问。
原文在这里 GotW #101: Compilation Firewalls, Part 2 (Difficulty: 8/10)
Guru问题
GotW #100 展示了仅使用标准 C++11 的 Pimpl 惯用法最佳实现:
// in header file
class widget {
public:
widget();
~widget();
private:
class impl;
unique_ptr<impl> pimpl;
};
// in implementation file
class widget::impl {
// :::
};
widget::widget() : pimpl{ new impl{ /*...*/ } } { }
widget::~widget() { } // or =default
有办法把 Pimpl 模式包装到库中使得 widget 类更容易实现吗?如果可以?应该怎么做?试着让 widget 的实现更方便更简洁,让编译器默认生成的函数有正确的行为,或者当 widget 的实现存在问题时给出编译错误。
解决方案
是的,我们有一些办法。可能最简单的办法之一是使用一个类似 unique_ptr 的 helper 类,但是我还是会把它叫做 pimpl 而不是 pimpl_ptr 因为他还要负责分配和释放 impl 对象。
让我们朝着我们的目标前进,即简化 widget 的代码。由于 Pimpl 惯用法同时影响可见类的定义的头文件和隐藏私有部分的实现的头文件,我们把 Pimpl 也放到两个文件中。
首先,我们引入一个 pimpl_h.h 然后添加 pimpl<myclass::impl> 类型的数据成员。然后在我们的实现文件中引入 pimpl_impl.h 然后定义我们的 impl 类。
// in header file
#include "pimpl_h.h"
class widget {
class impl;
pimpl<impl> m;
// ...
};
// in implementation file
#include "pimpl_impl.h"
class widget::impl {
// ...
};
为什么这比手工实现的 Pimpl 惯用法更好?
- 首先,代码变得简单了,因为我们头文件中减少了代码:在手工实现的版本中,你还需要声明构造函数,然后在实现文件中显式的分配 impl 对象。你应该还记得需要声明析构函数,然后在实现文件中显式的实现,Got #100 中已经解释过这个晦涩的问题。
- 第二,代码更健壮了:在手工实现的版本中,如果你忘了实现非内联的析构函数,Pimpl 类将能够独立的编译,但是如果调用者试图销毁这个对象时,将会的到一个有用的编译错误“无法生成析构函数,由于 impl 呃,你知道的,不是完整类型”,这可能会使代码调用者挠着头离开,然后找你检查一下这到底是什么问题。
pimpl<T> 模板会完成这些工作,不过,我们还要再加点料:它在可见类和实现类之间而不知道任何一个的定义,并且它把所有东西都连接到一起,完成了那些可见类和实现类可能去要做的事。幸运的是,没有什么是 pimpl<T> 不能控制的。
现在,我们来看看 pimpl_h.h 和 pimpl_impl.h 把我们的思绪连接起来。
pimpl_h.h : 可见类需要的东西
pimpl_h.h 提供不透明的 impl 和它的指针(在这里,我们使用 m 代替 pimpl)。它同时也声明两个构造函数和一个析构函数,这些将在 pimpl_impl.h 中以非内联的方式实现,并掌管 impl 对象的生命周期。
// pimpl_h.h
#ifndef PIMPL_H_H
#define PIMPL_H_H
#include <memory>
template<typename T>
class pimpl {
private:
std::unique_ptr<T> m;
public:
pimpl();
template<typename ...Args> pimpl( Args&& ... );
~pimpl();
T* operator->();
T& operator*();
};
#endif
第二个构造函数提供了一种给 impl 对象传递初始化参数的方式。由于 pimpl 对 impl 一无所知,这时候完美转发立功了!这是一个有意思的惯用法,它会逐渐的被人所熟知:所有的参数用使用 (&&) 右值引用,然后传递参数时使用 std::forward,这些会在 pimpl_impl.h 中实现。
pimpl_impl.h:提供内部实现
pimpl_impl.h 提供创建和销毁 impl 对象的其他内部实现,就像上面说的,下面我们将看到完美转发的实现部分。
首先,如果你知道非内联的完美转发模板声明语法,给你发一朵小红花。注意构造函数需要的模板不是一个,而是两个,一个是 pimpl 模板参数,另一个是构造函数的参数的模板参数。
// file pimpl_impl.h
#ifndef PIMPL_IMPL_H
#define PIMPL_IMPL_H
#include <utility>
template<typename T>
pimpl<T>::pimpl() : m{ new T{} } { }
template<typename T>
template<typename ...Args>
pimpl<T>::pimpl( Args&& ...args )
: m{ new T{ std::forward<Args>(args)... } } { }
template<typename T>
pimpl<T>::~pimpl() { }
template<typename T>
T* pimpl<T>::operator->() { return m.get(); }
template<typename T>
T& pimpl<T>::operator*() { return *m.get(); }
#endif
每个构造函数都可以在不知道 impl 类型细节的时候创建出一个新的 impl 对象。类型只要调用者在其他地方定义就可以了。
转发构造函数可以简单的转发参数,pimpl<widget>::impl 类型可以幸福的提供带着任何参数的构造函数,可见类 widget 的构造函数可以接受这些参数然后安全的正确的转发给 impl,而不需要 pimpl<>。所有需要的“智慧”的东西都由标准中的&&右值引用和 std::forward 提供——这是避免左值需要了解太多右值业务的漂亮的标准实现,而且它能很好地工作。
注解
变长模板参数使得转发构造函数写起来比固定模板参数更容易。如果你使用的编译器支持右值语法但是还不支持变长模板参数,有仍然可以实现相同的效果,只是需要一些重复——决定你准备支持的参数个数,然后写一组模板构造函数的重载,来代替单个变长模板构造函数。前三个看起来可能是这个样子:
template<typename T>
template<typename Arg1>
pimpl<T>::pimpl( Arg1&& arg1 )
: m( new T( std::forward<Arg1>(arg1) ) ) { }
template<typename T>
template<typename Arg1, typename Arg2>
pimpl<T>::pimpl( Arg1&& arg1, Arg2&& arg2 )
: m( new T( std::forward<Arg1>(arg1), std::forward<Arg2>(arg2) ) ) { }
template<typename T>
template<typename Arg1, typename Arg2, typename Arg3>
pimpl<T>::pimpl( Arg1&& arg1, Arg2&& arg2, Arg3&& arg3 )
: m( new T( std::forward<Arg1>(arg1), std::forward<Arg2>(arg2), std::forward<Arg3>(arg3) ) ) { }
主要更改记录
2011-12-06: 把 pimpl<> 从一个基类更改为成员——我真的讨厌不必要的继承,你也应该是的。
2011-12-13: 增加解释,为什么这比手工实现的 Pimpl 惯用法更好。
[这是 GotW #24 的 C++11 更新版]
原文在这里 GotW #100: Compilation Firewalls (Difficulty: 6/10)
JG 问题
1. Pimpl 惯用法是什么?为什么要使用它?
Guru 问题
2. 怎样才是 Pimpl 惯用法在 C++11 中的最佳实现?
3. 类中的那些部分应该放到 impl 对象中?一些可能的选择包括:
它们的每一个的缺点和优点是什么?你怎么选择?
4. impl 对象是否需要一个指向公开对象的反向指针?如果需要的话,它的最佳实现是什么?如果不需要的话,为什么?
解决方案
1. Pimpl 惯用法是什么?为什么要使用它?
在 C++ 中,当头文件中的类定义改变时,所有使用了这个类的用户都必须重新编译,甚至仅仅更改了那些用户无法访问的类的私有成员,也是如此。这是因为 C++ 的构建模型是基于文本包含的,并且 C++ 假定调用者知道以下两件事,而这些是会受到私有成员影响的:
-
大小和布局:调用的代码必须知道一个类的大小和布局,包括私有数据成员。调用者总是知道被调用者的实现,这个约束导致了调用者和被调用者之间更紧密的耦合,但这是 C++ 对象模型的核心和哲学,因为保证编译器默认情况下可以直接访问对象,是允许编译器进行深度性能优化的重要组成部分。
-
函数:调用的代码必须决定调用类的哪个成员,包括那些不可访问的,重载了非私有函数的私有函数——如果私有函数时更好的匹配,那么调用代码将编译失败。(C++ 经过深思熟虑的设计决定,由于安全问题,重载决议将在访问控制权限之前进行。举个例子,这可以保证当改变访问权限,把私有函数改为公有时,它不会改变合法的已有的代码的行为。)
为了减小编译依赖,最通常的技术是使用一个不透明的指针,隐藏实现的细节,下面是基本思路:
// Pimpl idiom - basic idea
class widget {
// :::
private:
struct impl; // things to be hidden go here
impl* pimpl_; // opaque pointer to forward-declared class
};
类 widget 使用了 handle/body 惯用法的变体,参见 Coplien 的文章[1],handle/body 过去主要用途是引用计数共享的实体,但是它还有更通用的隐藏实现的功能。为了方便,下面我会把类 widget 叫做可见类,impl 叫做 "Pimpl class” [2]
这个惯用法最大的好处是打破了编译时的依赖。首先,由于使用 Pimpl 可以消除多余的 #include,这使得编译过程更快了。我曾经在一个项目中仅仅把一些使用广泛的可见类转换为使用 Pimpls 就使得编译生成时间减少了一半。第二,它使得代码更改对编译的影响减小了,因为在类的 Pimpl 中的这一部分可以自由更改了——成员可以自由的添加或者删除,而不用重新编译客户端的代码。因为它可以在仅仅更改隐藏的成员时避免重新编译客户代码,所以它又被称为“编译防火墙”。
但是这还留下了一些问题:pimpl 应当是裸指针吗?哪些东西应该放到 Pimpl class 中?让我们看看这些和其他重要的细节吧。
2. 怎样才是 Pimpl 惯用法在 C++11 中的最佳实现?
避免使用裸指针和显式的 delete。仅仅使用标准 C++ 设施,最适当的选择是使用只能指针 unique_ptr 持有这个 Pimpl 对象,因为可见类是 Pimpl 对象的唯一所有者。使用 unique_ptr 也可以是代码变得简单。[3]
// in header file
class widget {
public:
widget();
~widget();
private:
class impl;
unique_ptr<impl> pimpl;
};
// in implementation file
class widget::impl {
// :::
};
widget::widget() : pimpl{ new impl{ /*...*/ } } { }
widget::~widget() { } // or =default
注意这个模式的关键部分:
- 使用 unique_ptr 来持有 Pimpl 对象是更好的选择。它比使用 shared_ptr 更高效,而且更能正确表达意图,Pimpl 对象是不应该共享的。
- 在你自己的实现文件中定义和使用 Pimpl 对象。隐藏细节。
- 在可见类的非内联构造函数中分配 Pimpl 对象。
- 你仍然需要自己在可见类的实现文件中定义和实习它的析构函数,即使它和编译器生成的版本一模一样。这是因为虽然 unique_ptr 和 shared_ptr 可以实例化不完整的类型,但是 unique_ptr 的析构函数需要完整的类型,以便调用 delete(与 shared_ptr 不同,shared_ptr 可以在构造时获得更多的信息)。在实现 impl 的同时,自己实现它的析构函数,这可以有效的防止编译器自动生成析构函数。
- 上面的模式没有生成默认的拷贝和转移语义,因为 C++11 编译器没有那么热心为你生成拷贝和转移操作。由于我们已经自己定义了析构函数,这使得编译器不再为我们生成转移构造和转移赋值。如果你想要支持拷贝和/或转移语义,和析构函数一样,你需要在实现中定义拷贝和转移操作。
C++11 中 Pimpl 另一个优势是,它是转移友好的,因为它仅仅需要拷贝单个指针就可以了。so cool。
让我们考虑一下上面提出的选择,哪些东西应该放到隐藏的 Pimpl 中。
什么是 Pimpl ?[4]
3. 类中的那些部分应该放到 impl 对象中?一些可能的选择包括:
选择1(得分:6/10):这是一个好的开始,因为我们可以前向声明任何数据类,这比 #include 类的实际声明要好,它会造成代码依赖。
但是它也存在缺点:一个小问题,我们需要在可见类的实现中不停的写 pimpl-> 。还有个大问题,当增加和删除私有成员函数时,我们还是需要重新编译,而且在极少数情况下,如果重载了非私有函数的话,它会干扰重载决议。
有更好的选择吗?
选择2(得分:9/10):这(几乎)是我最近的一贯做法。毕竟,在 C++中,我们说的“外部代码不应该也不能关心的部分”就是私有 private。
下面是三条注意事项,第一个就是我上面所说的“几乎”的原因:
- 你不能隐藏虚函数到 Pimpl 中,即使虚函数是私有的。如果重写了继承来的虚函数,它必须出现在派生中,即使这个类是 final 类。如果虚函数是 new 函数,那么它必须出现在可见类中,这样才能让派生类重写。
- 如果你需要使用可见类中的函数,那么 Pimpl 类的函数就需要一个指向可见类对象的反向指针,这增加了间接层。
- 一个好的折中办法是,使用“选择2”的同时,把那些私有成员需要调用的非私有成员函数一起放到 Pimpl 中(参考下面的“反向指针”)。
选择3(得分:0/10):多余的把保护成员移动到 Pimpl 中式绝对错误的。虚成员和保护成员不应该移动到 Pimpl 中,因为这会让他们变得没有价值。毕竟,保护成员仅仅可以让派生类访问,如果让他们不可见或不可用那就没有任何价值了。派生类只能强制了解 Pimpl 的细节,然后再继承 Pimlp,然后维护一个并行的两个对象的继承体系。
但是,仍然有合理的原因把虚函数放到 Pimpl-like(body/implementation) 类中。但是它的动机和 Pimpl 惯用法是不同的,这是桥接模式[5],它把一个类分成两部分,这两部分都可以包含实现以及独立的可扩展的虚函数。但是这是和 Pimpl 动机不同的另外的模式。
选择4(得分:10/10):这是理想的情况。为了避免保存或者传递一个反向指针,你可能需要把那些私有函数调用的公有函数也放到 Pimpl 中,然后在可见类中提供一个接口转发。但是你不应该移动保护成员和虚函数到 Pimpl 中,就像上面提到的。
- 全部都放到 impl 对象中,然后实现公有接口,每个实现仅仅简单的做函数转发(handle/body 的变体)
选择5(得分:8/10某种情况下):这在某种情况下是有用的,并且它可以有效避免反向指针,因为所有东西都在 Pimpl 类中了。主要的缺点是多了一层封装,并且它使得可见类无法通过继承来扩展了。
它们的每一个的缺点和优点是什么?你怎么选择?
完整的答案事实上比上面我们讨论的要简单的多。抛开经验和具体的分析,我们需要回顾,然后从第一条原则开始回答。
深呼吸,放松,好的。
主要考察的是面向对象语言[6]中的三个部分。它们是:
- 调用者的接口=公有成员。挺合适所有外部调用者可见和可用的部分。
- 派生类的接口=保护和虚成员,这些是仅派生类可见和可用的。
- 其他=私有和非虚成员,根据定义,这些是属于实现的细节。
仅仅第3条,所有第3条涵盖的内容,可以隐藏也应该隐藏到 Pimpl 中。这样我们就能继承所有上面提到的其他东西;举个例子,我们不能把虚成员放到 Pimpl 中,因为我们在第2条中提到的,派生类需要它可见。
上面的表格描述了不同设计的选择。包括 Pimpl,以及引出的其他设计,Coplien 提出的 Handle/Body ,还有桥接模式,虽然某些地方和 Pimpl 类似,但是它的动机和结构是非常不同的。
4. impl 对象是否需要一个指向公开对象的反向指针?如果需要的话,它的最佳实现是什么?如果不需要的话,为什么?
答案是:有时候,很不幸,是的。毕竟,我们所做的就是(人为地,不自然的)把对象分为两部分,来隐藏其中一部分。
考虑下面的情况:当可见类的一个函数被调用的时候,通常隐藏部分的函数或者数据是需要协助完成请求的。这没问题也很合理。但是上面已经讨论过,有时候 Pimpl 中的函数必须调用可见类中的非私有或者虚函数。这时候,需要一个指向可见类的指针。
有两个选择:
- 在 Pimpl 中保存一个反向指针。这会带来轻微的开销,而且会一直保存着这个指针无论你用还是不用。此外,when you repeat yourself you can lie——如果你不小心维护指针的正确性让它指向正确的可见对象,反向指针会存在同步问题,举个例子,默认情况下转移操作之后,它就不再正确了。
- (推荐)通过参数传递 this 指针给 Pimpl 中的函数(例如:pimpl->func(this, params))。这仅仅会在函数调用(简单)时,带来栈上(廉价的)很小的空间开销,而且也不可能存在同步问题。但是,这意味着给每一个需要的隐藏函数增加个多余的参数。
感谢
感谢 Edd, pizer, and Howard Hinnant 阐明为什么 ~unique_ptr<T> 需要 T 是完整类型, 并需要用户自己在外部实现类的析构函数;感谢 Stephan Lavavej and Alisdair Meredith 提醒我在转移构造和转移赋值时使用 =default;感谢 Howard Hinnant 指出即使使用了 =default,转移赋值函数仍然需要在实现文件中以非内联函数形式实现,因为它需要类型完整(确保能够 delete)。
注解
[1] James O. Coplien. “C++ Idioms” (EuroPLoP98).
[2] 一开始我使用 impl_ 作为指针的变量名。pimpl 是从 1996 年开始使用的,分享给我这个名字的是同学兼好友 Jeff Sumner,使用字母 “p” 作为指针变量的前缀,同时我也发现了之前变量名的可怕的双关。
[3] 这是 C++ 11 中最简单的模型。最主要的替代方案是使用 shared_ptr 或者裸指针来代替 unique_ptr,这两个的正确实现都比它复杂,存在潜在的错误,编译器生成的函数会产生不正确的行为:如果你使用 shared_ptr,你可以默认得到正确的析构函数,转移构造函数转移赋值函数,但是编译器生成的拷贝操作将是错误的,所以你需要手工明确的实现,或者使用 =delete 禁止它们(如果你忘记了,那你就默默得到了错误的语义),一个无用但是却存在的引用计数。如果你使用裸指针,你需要手动实现5个操作,析构函数,拷贝构造函数,拷贝赋值函数,转移构造函数,还有转移赋值函数。
[4] 不要发关于这个标题的笑话给我,大部分答案我都能想到。
[5] Gamma et al. Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994).
[6] 一个类中不需要包含全部特性,举个例子,#2 不适用于那些不存在继承的值语义类。
主要更改记录
2011-11-27: 从模式中去除了转移操作,因为不是所有的 Pimpl 类需要转移语义,它不是模式真正的核心内容。
前言: qt wiki 中这篇文章3月份再次更新,文章对 QThread 的用法,使用场景,有很好的论述,可以作为 Qt 多线程编程的使用指南,原文在这里,原作者 peppe 开的讨论贴在这里。
原文以姓名标识-相同方式分享 2.5 通用版发布
Creative Commons Attribution-ShareAlike 2.5 Generic
背景
在 #qt IRC channel [irc.freenode.net] 中,讨论最多的话题之一就是多线程。很多同学选择了多线程并行编程,然后……呃,掉进了并行编程的无尽的陷阱中。
由于缺乏 Qt 多线程编程经验(尤其是结合Qt 信号槽机制的异步网络编程)加上一些现有的其他语言(工具)的使用经验,导致在使用 Qt 时,一些同学有朝自己脚开枪的行为。Qt 的多线程支持是一把双刃剑:虽然 Qt 的多线程支持使得多线程编程变得简单,但同时也引入了一些其他特性(尤其是与 QObject 的交互),这些特性需要特别小心。
本文的目的不是教你如何使用多线程,加锁、并行、扩展性,这不是本文的重点,而且这些问题已经有非常多的讨论,可以参考这里 [doc.qt.nokia.com] 的推荐。本文作为 Qt 多线程的指南,目的是帮助开发者避免常见的陷阱,开发出更健壮的程序。
知识背景
本文不是介绍多线程编程的文章,继续阅读下面的内容你需要以下的知识背景:
- C++ 基础 (强烈推荐,其他语言亦可)
- Qt 基础:QObject,信号槽,事件处理
- 什么是线程,以及一个线程和其他线程、进程和操作系统之间的关系
- 在主流的操作系统上,如何启动和停止一个线程,如何等待线程结束
- 如何使用互斥量(mutex),信号量(semaphore),条件等待(wait condition)创建线程安全/可重入的函数,结构和类。
本文中使用 Qt 的名词定义 [doc.qt.nokia.com]:
- 可重入 如果多个线程同时访问某个类的(多个)对象且一个对象同时只有一个线程访问,是安全的,那么这个类是可重入的。如果多个线程同时调用一个函数且只访问该线程可见的数据,是安全的,那么这个函数是可重入的。换句话说,访问这些对象/共享数据时,必须通过外部加锁机制来实现串行访问,保证安全。
- 线程安全 如果多个线程同时访问某个类的对象是安全的,那么这个类是线程安全的。如果多个线程同时调用一个函数(即使访问了共享数据)是安全的,那么这个函数时线程安全的。
事件和事件循环
作为一个事件驱动的系统,事件和事件分发在 Qt 的架构中扮演着核心角色。本文不会全面覆盖这个主题;我们主要阐述和线程相关的一些概念(有关 Qt 事件系统的文章,请看这里,还有这里)。
在 Qt 中,一个事件是一个对象,它表示一些有趣的事情发生了;信号和事件的主要区别在于,在我们的程序中事件的目标是确定的对象(这个对象决定如何处理该事件),但信号可以发到“任何地方”。从代码级别来讲,所有的事件对象都是 QEvent [doc.qt.nokia.com] 的子类,所有继承自 QObject 的类都可以重写 QObject::event() 虚函数,来作为事件的目标处理者。
事件即可以来自应用程序内部,也可以来自外部;例如:
- QKeyEvent 和 QMouseEvent 对象代表鼠标、键盘的交互,这些事件来自于窗口管理器。
- QTimerEvent 对象会在计时器超时的时候,发送给另一个 QObject,这些事件(通常)来自于操作系统。
- QChildEvent 对象会在添加或删除一个child时,发送给另一个 QObject,这些事件来自于你的程序中。
关于事件,有一个很重要的事情,那就是事件不会一产生就发送给需要处理这个事件的对象;而是放到事件队列中,然后再发送。事件分发器会循环处理事件队列,把每个在队列中的事件分发给相应的对象,因此这个又叫做事件循环。从概念上讲,事件循环看起来是这样的:
while (is_active)
{
while (!event_queue_is_empty)
dispatch_next_event();
wait_for_more_events();
}
在 Qt 的使用中,通过调用 QCoreApplication::exec() 进入 Qt 的主消息循环;这个函数会阻塞,直到调用 QCoreApplication::exit() 或 QCoreApplication::quit(),结束消息循环。
函数 "wait_for_more_events()" 会阻塞(不是忙等)直到有事件产生。稍加考虑,我们就会发现,在这时事件一定是从外部产生的(事件分发器已经结束并且也没有新的事件在事件队列中等待分发)。因此,事件循环可以在以下几种情况下被唤醒:
- 窗口管理器(键盘/鼠标点击,和窗口的交互,等)
- 套接字(sockets)(数据可读、可写、有新连接,等)
- 计时器(计时器超时)
- 从其他线程发送来的事件(稍后讨论)
在 Unix-like 系统中,窗口管理器的活动(例如 X11)是通过套接字(socket)(Unix Domain or TCP/IP)通知给应用程序的,因为客户端是通过套接字和 X Server 通信的。如果我们使用内部的 socketpair(2) 来实现跨线程的消息发送,那么我们要做的就是通过某些活动唤醒消息循环:
系统调用 select(2) 是这么工作的:它监听着一个活动描述符的集合,如果一段时间(可配置超时事件)内都没有活动那么它就会超时。Qt 所需要做的就是把 select 返回的结果转化为一个 QEvent 对象(子类对象)然后把它放入事件队列中。现在你应该知道消息循环内部事怎么回事儿了。
哪些东西需要事件循环?
下面不是完整的列表,不过稍微思考一下,你就能猜出那些类需要消息循环了。
- Widget 绘图(painting)和交互:当接收到 QPaintEvent 对象时,函数 QWidget::paintEvent() 会被调用,QPaintEvent 对象的产生,有可能是调用 QWidget::update() (应用程序内部调用) 函数,或者来自窗口管理器(例如:把一个隐藏的窗口显示出来)。其他类型的交互(鼠标、键盘,等)也是一样的:这些事件都需要一个事件循环来分发事件。
- 计时器:简单说,当 select(2) 或类似的调用超时的时候,计时器超时事件被触发,因此你需要消息循换来处理这些调用。
- 网络通信:所有 low-level 的 Qt 网络通信类(QTcpSocket, QUdpSocket, QTcpServer,等)都设计为异步的。当调用 read() 函数时,它们仅仅返回当前可用的数据,当调用 write() 函数时,它们会安排稍后再写。仅仅当程序返回消息循换的时候,读/写操作才真正发生。注意虽然提供有同步的方法(那些以 waitFor* 命名的函数),但是它们并不好用,因为在等待的同时他们阻塞了消息循换。像 QNetworkAccessManager 这样的 high-level 类,同样需要消息循换,但不提供任何同步调用的接口。
阻塞消息循换
在讨论为什么我们不应该阻塞消息循换之前,先说明一下“阻塞”的含义是什么。想像一下,有一个在点击时可以发送信号的按钮,信号绑定到我们的工作类对象的一个槽函数上,这个槽函数会做很多工作。当你点击按钮时,函数调用栈看起来应该像下面这样(栈底在上):
main(int, char **)
QApplication::exec()
[…]
QWidget::event(QEvent *)
Button::mousePressEvent(QMouseEvent *)
Button::clicked()
[…]
Worker::doWork()
在 main() 函数中,我们通过调用 QApplication::exec() (第2行) 启动了一个消息循换。窗口管理器发送一个鼠标点击的事件,Qt 内核会得到这个消息,然后转化为一个 QMouseEvent 对象,通过 QApplication::notify()(此处没有列出)函数发送给 widget 的 event() 函数(第4行)。如果按钮没有重写 event() 函数,那么他的基类(QWidget)实现的 event() 函数会被调用。QWidget::event() 检测到鼠标点击事件,然后调用相应的事件处理函数,就是上面代码中的 Button::mousePressEvent()(第5行)函数。我们重写了这个函数,让他发送一个 Button::clicked() 信号(第6行),这个信号会调用 Worker 类对象的槽函数 Worker::doWork() (第8行)。
当 Worker 对象正在忙于工作的时候,消息循换在做什么?我们可能会猜测:什么也不做!消息循换分发了鼠标点击事件然后等待,等待消息处理者返回。我们阻塞了消息循换,这意味在槽函数 doWork() 返回之前,不会再有消息被分发出去,消息会不断进入消息队列而不能的得到及时的处理。
当事件分发被卡住的时候,窗口不会刷新(QPaintEvent 对象在消息队列中),不能响应其他的交互行为(和前面的原因一样),定时器超时事件不会触发、网络通信变慢然后停止。此外,很多窗口管理器会检测到你的程序不再处理事件,而提示程序无响应。这就是为什么迅速的处理事件然后返回消息循环如此重要的原因。
强制分发事件
那么,如果有一个耗时的任务同时我们又不想阻塞消息循换,这时该如何去做?一个可能的回答是:把这个耗时的任务移动到其他的线程中:下一节中我们可以看到如何做。我们还有一个可选的办法,那就是在我们耗时的任务中通过调用 QCoreApplication::processEvents() 来手动强制跑起消息循换。QCoreApplication::processEvents() 会处理所有队列上的事件然后返回。
另一个可选的方案,我们可以利用 QEventLoop [doc.qt.nokia.com] 强制再加入一个消息循环。通过调用 QEventLoop::exec() 函数,我们加入一个消息循换,然后连接一个信号到 QEventLoop::quit() 槽函数上,来让循环退出。例如:
QNetworkAccessManager qnam;
QNetworkReply *reply = qnam.get(QNetworkRequest(QUrl(...)));
QEventLoop loop;
QObject::connect(reply, SIGNAL(finished()), &loop, SLOT(quit()));
loop.exec();
/* reply has finished, use it */
QNetworkReply 不提供阻塞的接口,同时需要一个消息循环。我们进入了一个局部的 QEventLoop,当 reply 发出 finished 信号时,这个事件循环就结束了。
通过“其他路径”重入消息循换时需要特别小心:这可能导致不期望的递归!回到刚才的按钮例子中。如果我们再槽函数 doWork() 中调用 QCoreApplication::processEvents() ,同时用户再次点击了按钮,这个槽函数 doWork() 会再一次被调用:
main(int, char **)
QApplication::exec()
[…]
QWidget::event(QEvent *)
Button::mousePressEvent(QMouseEvent *)
Button::clicked()
[…]
Worker::doWork() // first, inner invocation
QCoreApplication::processEvents() // we manually dispatch events and…
[…]
QWidget::event(QEvent * ) // another mouse click is sent to the Button…
Button::mousePressEvent(QMouseEvent *)
Button::clicked() // which emits clicked() again…
[…]
Worker::doWork() // DANG! we’ve recursed into our slot.
一个快速简单的规避办法是给 QCoreApplication::processEvents() 传入一个参数 QEventLoop::ExcludeUserInputEvents,它会告诉消息循换不要分发任何用户输入的事件(这些事件会停留在队列中)。
幸运的是,同样的问题不会出现在删除事件中(调用 QObject::deleteLater() 会发送该事件到事件队列中)。事实上,Qt 使用了特别的办法来处理它,当消息循环比 deleteLater 调用发生的消息循环更外层时,删除事件才会被处理。例如:
QObject *object = new QObject;
object->deleteLater();
QDialog dialog;
dialog.exec();
这不会导致 object 空悬指针(QDialog::exec() 中的消息循环,比 deleteLater 调用发生的地方层次更深)。同样的事情也会发生在 QEventLoop 启动的消息循环中。我只发现过一个例外(在 Qt 4.7.3 中),如果在没有任何消息循环的时候调用了 deleteLater,那么第一个启动的消息循环会处理这个消息,删除该对象。这是很合理的,因为 Qt 知道不会有任何会执行删除动作的“外层”循环,因此会立即删除该对象。
Qt 线程类
Qt 支持多线程已经很多年(2000 年9月22日发布的 Qt 2.2 引入了 QThread 类),4.0 版本在所有平台上都默认开启多线程支持(多线程支持是可以关闭的,更多细节看这里[doc.qt.nokia.com])。Qt 现在提供了很多类来实现多线程;下面就来看一下。
QThread
QThread [doc.qt.nokia.com] 是 Qt 中多线程支持的核心的 low-level 类。一个 QThread 对象表示一个执行的线程。由于 Qt 的跨平台特性,QThread 设法隐藏了不同操作系统在线程操作中的所有平台相关的代码。
为了使用 Qthread 在一个线程中执行代码,我们继承 QThread 然后重写 QThread::run() 函数:
class Thread : public QThread {
protected:
void run() {
/* your thread implementation goes here */
}
};
然后这么使用
Thread *t = new Thread;
t->start(); // start(), not run()!
来启动一个新的线程。注意,从 Qt 4.4 开始,QThread 不再是抽象类,现在虚函数 QThread::run() 有了调用 QThread::exec() 的默认实现;它会启动线程自己的消息循环(稍后详细说明)。
QRunnable 和 QThreadPool
QRunnable [doc.qt.nokia.com] 是一个轻量级的抽象类,它可以在另一个线程中启动一个任务,适用于“运行完就丢掉”这种情况。实现这个功能,我们需要做的就是继承 QRunnable 然后实现纯虚函数 run():
class Task : public QRunnable {
public:
void run() {
/* your runnable implementation goes here */
}
};
我们使用 QThreadPool [doc.qt.nokia.com] 类,它管理着一个线程池,来真正运行一个 QRunnable 对象。当调用 QThreadPool::start(runnable) 时,我们将 QRunnable 对象放入 QThreadPool 的执行队列中;当线程可用时,QRunnable 对像会启动,然后在线程中执行。所有的 Qt 应用程序都有一个全局的线程池,可以通过调用 QThreadPool::globalInstance() 来获得,但是也可以创建一个私有的 QThreadPool 对象来显式的管理。
注意,QRunnable 不是一个 QObject,因此没有QObject内建的和其他一些组建通信的机制;你不得不使用 low-level 线程原语手工处理(例如用互斥量保护队列来收集结果等)。
QtConcurrent
QtConcurrent [doc.qt.nokia.com] 是 high-level API,在 QThreadPool 基础上构建而成,它可以应用在大部分常用的并行计算范式中:map [en.wikipedia.org]), reduce [en.wikipedia.org]), 和 filter [en.wikipedia.org]);它同时提供 QtConcurrent::run() 方法,可以简单的在另一个线程中启动一个函数。
与 QThread 和 QRunnable 不同,QtConcurrent 不需要我们使用 low-level 的同步原语:所有 QtConcurrent 函数返回一个 QFuture [doc.qt.nokia.com] 对象,它可以用来查询计算状态(进展),暂停/恢复/取消计算,同时它也包含计算的结果。QFutureWatcher [doc.qt.nokia.com] 类可以用来监测 QFuture 的进展,也可以通过信号槽来和 QFuture 交互(注意,QFuture 作为一个值语义的类,没有继承自 QObject)。
特性对比
\ |
QThread |
QRunnable |
QtConcurrent1 |
high level 接口 |
n |
n |
y |
面向任务 |
n |
y |
y |
内建支持暂停/恢复/取消 |
n |
n |
y |
支持优先级 |
y |
n |
n |
可以运行消息循环 |
y |
n |
n |
|
|
|
|
1 QtConcurrent::run 是个例外,因为它是使用 QRunnable 实现的,所以带有 QRunnable 的特性。
线程和QObject
每个线程一个消息循环
到现在为止,我们已经讨论过“消息循环”,但讨论的仅仅是在一个 Qt 应用程序中只有一个消息循换的情况。但不是下面这种情况:QThread 对象可以启动一个自己代表的线程中的消息循换。因此,我们把在 main() 函数中通过调用 QCoreApplication::exec()(该函数只能在主线程中调用)启动的消息循换叫做主消息循环。它也叫做 GUI 线程,因为 UI 相关的操作只能(应该)在该线程中执行。一个 QThread 局部消息循换可以通过调用 QThread::exec() 来启动(在 run() 函数中):
class Thread : public QThread {
protected:
void run() {
/* ... initialize ... */
exec();
}
};
上面我们提到,从 Qt 4.4 开始,QThread::run() 不再是一个纯虚函数,而是默认调用 QThread::exec()。和 QCoreApplication 一样,QThread 也有 QThread::quit() 和 QThread::exit() 函数,来停止消息循换。
一个线程的消息循环为所有在这个线程中的 QObject 对象分发消息;默认的,它包括所有在这个线程中创建的对象,或者从其他线程中移过来的对象(接下来详细说明)。同时,一个 QObject 对象的线程相关性是确定的,也就是说这个对象生存在这个线程中。这个适用于在 QThread 对象的构造函数中创建的对象:
class MyThread : public QThread
{
public:
MyThread()
{
otherObj = new QObject;
}
private:
QObject obj;
QObject *otherObj;
QScopedPointer<QObject> yetAnotherObj;
};
在创建一个 MyThread 对象之后,obj,otherObj,yetAnotherObj 的线程相关性如何?我们必须看看创建这些对象的线程:它是运行 MyThread 构造函数的线程。因此,所有这三个对象都不属于 MyThread 线程,而是创建了 MyThread 对象的线程(MyThread 对象也属于该线程)。
我们可以使用线程安全的 QCoreApplication::postEvent() 函数来给对象发送事件。它会把事件放入该对象所在消息循环的事件队列中;因此,只有这个线程有消息循环,消息才会被分发。
理解 QObject 和它的子类不是线程安全的(虽然它是可重入的)这非常重要;由于它不是线程安全的,所以你不能同时在多个线程中同时访问同一个 QObject 对象,除非你自己串行化了所有对这些内部数据的访问(比如使用了互斥量来保护内部数据)。记住当你从其他线程访问 QObject 对象时,这个对象有可能正在处理它所在的消息循环分发给它的事件。同样的,你也不能从另一个线程中删除一个 QObject 对象,而必须使用 QObject::deleteLater() 函数,它会发送一个事件到对象所在线程中,然后在该线程中删除对象。
此外,QWidget 和它的所有子类,还有其他的 UI 相关类(非 QObject 子类,比如 QPixmap)还是不可重入的:他们仅仅可以在 UI 线程中使用。
我们可以通过调用 QObject::moveToThread() 来改变 QObject 对象和线程之前的关系,它会改变对象本身以及它的孩子与线程之前的关系。由于 QObject 不是线程安全的,所以我们必须在它所在的线程中使用;也就是说,你仅仅可以在他们所处的线程中把它移动到另一个线程去,而不能从其他线程中把它从所在的线程中移动过来。而且,Qt 要求一个 QObject 对象的汉子必须和他的父亲在同一个线程中,也就是说:
- 如果一个对象有父亲,那么你不能使用 QObject::moveToThread() 把它移动到其他线程
- 你不能在 QThread 类中以 QThread 为父亲创建对象
class Thread : public QThread {
void run() {
QObject *obj = new QObject(this); // WRONG!!!
}
};
这是因为 QThread 对象所在的线程是另外的线程,即,QThread 对象所在的线程是创建它的线程。
Qt 要求所有在线程中的对象必须在线程结束之前销毁;利用 QThread::run() 函数,在该函数中仅创建栈上的对象,这一点可以很容易的做到。
跨线程信号槽
有了这些前提,我们如何调用另一个线程中 QObject 对象的函数?Qt 提供了一个非常漂亮和干净的解决方案:我们发送一个事件到线程的消息队列中,事件的处理,将调用我们感兴趣的函数(当然这个线程需要启动一个事件循环)。该设施围绕 Qt 的元对象编译器(MOC)提供的方法内省而构建:因此,信号,槽,函数,只要使用了 Q_INVOKABLE 宏,那么就可以从另外的线程调用它。
QMetaObject::invokeMethod() 静态方法为我们实现了这个功能:
QMetaObject::invokeMethod(object, "methodName",
Qt::QueuedConnection,
Q_ARG(type1, arg1),
Q_ARG(type2, arg2));
注意,由于参数需要在消息传递时拷贝,这些类型的参数需要提供公有的构造函数,析构函数和拷贝构造函数,而且要使用 qRegisterMetaType() 函数将类型注册到 Qt 类型系统中。
跨线程的信号槽工作方式是类似的。当我们将信号和曹连接时,QObject::connect 函数的第5个参数可以指定连接的类型:
- direct connection:意思是槽函数会在信号发送的线程中直接被调用。
- queued connection:意思是事件会发送到接收者所在线程的消息队列中,消息循环会稍后处理该事件然后调用槽函数。
- blocking queued connection:和 queued connection 类似,但是发送线程会阻塞,直到接收者所在线程的消息循环处理了该事件,调用了槽函数之后,才会返回;
在任何情况下,记住发送者所在的线程一点都不重要!在自动连接的情况下,Qt 会检查信号调用的线程,然后与接收者所在线程比较,然后决定使用哪种连接类型。特别的,Threads and QObjects [doc.qt.nokia.com] (4.7.1) 在下面的情况下是错误的:
自动连接(默认值),如果发送者和接收者在同一线程它和直接连接(direct connection)的行为是一样的;如果发送者和接收者在不同的线程它和队列连接(queued connection)的行为是一样的。
因为发送者所在的线程和无关紧要的。例如:
class Thread : public QThread
{
Q_OBJECT
signals:
void aSignal();
protected:
void run() {
emit aSignal();
}
};
/* ... */
Thread thread;
Object obj;
QObject::connect(&thread, SIGNAL(aSignal()), &obj, SLOT(aSlot()));
thread.start();
信号 aSignal() 会在一个新的线程中发送(Thread 对象创建的线程);因为这不是 Object 对象所在的线程(但这时,Object 对象与 Thread 对象在同一个线程中,再次强调,发送者所在线程是无关紧要的),这时将使用 queued connection。
另一个常见的陷阱:
class Thread : public QThread
{
Q_OBJECT
slots:
void aSlot() {
/* ... */
}
protected:
void run() {
/* ... */
}
};
/* ... */
Thread thread;
Object obj;
QObject::connect(&obj, SIGNAL(aSignal()), &thread, SLOT(aSlot()));
thread.start();
obj.emitSignal();
当“obj” 发送 aSignal() 信号时,将会使用哪种连接类型?你应该已经猜到了:direct connection。这是因为 Thread 对象所在线程就是信号发送的线程。在槽函数 aSlot() 中,我们可能访问 Thread 类的成员,而同时 run() 函数可能也在访问,他们会同时进行:这是完美的灾难配方。
另一个例子,或许也是最重要的一个:
class Thread : public QThread
{
Q_OBJECT
slots:
void aSlot() {
/* ... */
}
protected:
void run() {
QObject *obj = new Object;
connect(obj, SIGNAL(aSignal()), this, SLOT(aSlot()));
/* ... */
}
};
在上面的情形中,连接类型是 queued connection,因此你需要在 Thread 对象所在线程启动一个消息循环。
下面是一个你经常可以在论坛、博客或其他地方看到的解决方案。那就是在 Thread 的构造函数中增加一个 moveToThread(this) 函数:
class Thread : public QThread {
Q_OBJECT
public:
Thread() {
moveToThread(this); // WRONG
}
/* ... */
};
这确实可以工作(因为现在线程对象所在的线程的确改变了),但是这是个非常糟糕的设计。错误在于我们误解了 thread 对象(QThread 子类)的目的:QThread 对象不是线程本身;它是用于管理线程的,因此它应该在另一个线程中使用(通常就是创建它的线程)。
一个好的办法是:把“工作”部分从“控制”部分分离出来,创建 QObject 子类对象,然后使用 QObject::moveToThread() 来改变对象所在的线程:
class Worker : public QObject
{
Q_OBJECT
public slots:
void doWork() {
/* ... */
}
};
/* ... */
QThread *thread = new QThread;
Worker *worker = new Worker;
connect(obj, SIGNAL(workReady()), worker, SLOT(doWork()));
worker->moveToThread(thread);
thread->start();
应该做&不应该做
你可以…
- 在 QThread 子类中添加信号。这是很安全的,而且可以“正确工作”(前面提到;发送者所在线程是无关紧要的)。
你不应该…
- 使用 moveToThread(this)
- 强制连接类型:这通常说明你在做一些错误的事情,例如混合了 QThread 控制接口和程序逻辑(它应该在该线程创建的对象中)
- 在 QThread 子类中增加槽函数:它们会在“错误的”线程中被调用,不是在 QThread 管理的线程中,而是在 QThread 对象创建的线程,迫使你使用 direct connection 或使用 moveToThread(this) 函数。
- 使用 QThread::terminate 函数。
禁止…
- 在线程还在运行时退出程序。使用 QThread::wait 等待线程终止。
- 当 QThread 管理的线程还在运行时,删除 QThread 对象。如果你想要“自动析构”,你可以将 finished() 信号连接到 deleteLater() 槽函数上。
什么时候应该使用线程?
当使用阻塞 API 时
如果你需要使用没有提供非阻塞API的库(例如信号槽,事件,回调函数,等),那么避免阻塞消息循环的唯一解决方案就是开启一个进程或线程。由于创建一个工作进程,让它完成任务并通过进程通信返回结果与开启一个线程相比是困难并且昂贵的,所以创建一个线程是更普遍的做法。
地址解析(只是举个例子,不是在讨论蹩脚的第三方 API。这是每一个 C 语言函数库中包含的东西)就是一个很好的例子,它把主机名转换为地址。它会调用域名解析系统(DNS)来查询。虽然一般情况下,它会立即返回,但是远程服务器有可能故障,有可能丢包,有可能网络突然中断,等等。简而言之,它可能需要等待很长时间才相应我们发出的请求。
UNIX 系统中的标准 API 是阻塞的(不仅仅是旧的 API gethostbyname(3),新的更好的 getservbyname(3) 和 getaddrinfo(3) 也是一样)。QHostInfo [doc.qt.nokia.com] 是处理主机名解析的 Qt 类,它使用 QThreadPool 来使得请求在后台运行(看这里 [qt.gitorious.com];如果线程支持被关闭的话,它会切换为阻塞方式)。
另一个简单的例子是图像加载和缩放。QImageReader [doc.qt.nokia.com] 和 QImage [doc.qt.nokia.com] 只提供阻塞方法来从设备读取图像,或改变图像的分辨率。如果你正在处理非常大的图像,这些操作可能会花费数十秒。
当你想要充分利用多CPU时
多线程可以让你的程序更好的利用多处理器系统。每个线程是由操作系统独立调用的,如果你的程序运行在这样的机器上,线程调度就可以让多个处理器同时运行不同的线程。
比如,考虑一个批量生成缩略图的程序。一个有 n 个线程的线程农场(有固定线程数目的线程池),n 是系统中可用 CPU 的数量(可参考 QThread::idealThreadCount()),它可以将处理任务分布到多个cpu上,这样我们就可以获得与cpu数量有关的效率线性增长(简单的,我们把CPU考虑为瓶颈)。
当你不想被阻塞时
呃…从一个例子开始会更好。
这是一个高级话题,你可以暂时忽略。Webkit 中的 QNetworkAccessManager 是一个很好的例子。Webkit 是一个流行的浏览器引擎,它是处理网页布局和显式的一组类的集合,Qt 中 QwebView 类使用了它。
QNetworkAccessManager 是 Qt 中处理 HTTP 请求和响应的类,我们可以把它当作浏览器的引擎。Qt 4.8 之前,它没有使用任何工作线程;所有的处理都在 QNetworkAccessManager 和 QNetworkReply 所在的同一个线程。
虽然在网络通信中使用线程是一个好办法,但是它也存在问题:如果你没有尽快从 socket 中读取数据,内核缓冲会被其他数据填充,数据包将被丢掉,可想而知,数据传输速率将下降。
socket 活动(也就是 socket 是否可读)是由 Qt 的事件循环还管理的。阻塞事件循环会导致传输性能下降,因为这时没有人会被告知现在数据已经可读(所以没有人会去读取数据)。
但是什么会阻塞消息循环?可悲的是:WebKit 自己阻塞了消息循环。一旦消息可读,Webkit 开始处理网页布局。不幸的是,这个处理是复杂而昂贵的,它会阻塞消息循换一(小)会儿,但足以影响传输效率(宽带连接这里起到了作用,在短短几秒内就可填满内核缓存)。
总结一下,这个过程发生的事情:
- Webkit 发起请求;
- 一些响应数据开始到达;
- Webkit 开始使用到达的数据来网页布局,阻塞了事件循环;
- 没有了事件循环,操作系统接收到了数据,但没有人从 QNetworkAccessManager 的 socket 中读取数据;
- 内核缓冲将被其他数据填充,从而导致传输效率下降。
整个页面的加载时间由于 Webkit 自己引起的问题而变得很慢。
注意,由于 QNetworkAccessManager 和 QNetworkReply 都是 QObject,它们都不是线程安全的,因此你不能将它移动到另一个线程然后继续在你的线程中继续使用它,因为你可能从两个线程中同时访问它:你自己的线程和它所在的线程,因为它所在的消息循环会将事件分发给它处理。
在 Qt 4.8 中,QNetworkAccessManager 现在默认使用单独的线程处理 HTTP 请求,因此 UI 反应慢和系统缓冲被填充过快的问题得以解决。
什么时候不应该使用线程?
计时器
这可能是最糟糕的线程滥用。如果你不得不重复调用一个方法(例如,每秒调用一次),很多人会这么做:
// VERY WRONG
while (condition) {
doWork();
sleep(1); // this is sleep(3) from the C library
}
然后会发现这阻塞了事件循环,然后决定使用线程来解决:
// WRONG
class Thread : public QThread {
protected:
void run() {
while (condition) {
// notice that "condition" may also need volatiness and mutex protection
// if we modify it from other threads (!)
doWork();
sleep(1); // this is QThread::sleep()
}
}
};
一个更好更简单的办法是使用计时器,一个超时时间为1秒的 QTimer [doc.qt.nokia.com] 对象,和 doWork() 槽函数:
class Worker : public QObject
{
Q_OBJECT
public:
Worker() {
connect(&timer, SIGNAL(timeout()), this, SLOT(doWork()));
timer.start(1000);
}
private slots:
void doWork() {
/* ... */
}
private:
QTimer timer;
};
我们所需要做的就是启动一个消息循环,然后 doWork() 函数会每一秒调用一次。
网络通信/状态机
下面是一个非常常见的网络通信的设计:
socket->connect(host);
socket->waitForConnected();
data = getData();
socket->write(data);
socket->waitForBytesWritten();
socket->waitForReadyRead();
socket->read(response);
reply = process(response);
socket->write(reply);
socket->waitForBytesWritten();
/* ... and so on ... */
不用多说,这些 waitFor*() 函数调用会阻塞消息循环,冻结 UI,等等。注意,上面的代码没有任何的错误处理,不然它会更繁琐。上面的错误在于我们忘记了最初网络设计的就是异步的,如果我们使用同步处理,那就是朝自己的脚开枪。解决上面的问题,许多人会简单的把它移动到不同的线程中。
另一个更抽象的例子:
result = process_one_thing();
if (result->something())
process_this();
else
process_that();
wait_for_user_input();
input = read_user_input();
process_user_input(input);
/* ... */
它和上面网络的例子有着同样的陷阱。
让我们退一步,从更高的视角来看看我们构建的东西,我们构建了一个状态机来处理输入。
- 空闲 –> 连接中(调用 connectToHost())
- 连接中 –> 已连接 (发出 connected() 信号)
- 已连接 –> 发送登陆数据(发送登陆数据到服务器)
- 发送登陆数据 –> 登陆成功(服务器返回 ACK)
- 发送登陆数据 –> 登陆失败(服务器返回 NACK)
等等。
现在,我们有很多办法来构建一个状态机(Qt 就为我们提供了一个可使用的类:QStateMachine [doc.qt.nokia.com]),最简单的办法就是使用枚举(整型)来记录当前的状态。我们可以重写上面的代码:
class Object : public QObject
{
Q_OBJECT
enum State {
State1, State2, State3 /* and so on */
};
State state;
public:
Object() : state(State1)
{
connect(source, SIGNAL(ready()), this, SLOT(doWork()));
}
private slots:
void doWork() {
switch (state) {
case State1:
/* ... */
state = State2;
break;
case State2:
/* ... */
state = State3;
break;
/* etc. */
}
}
};
“source” 对象和“ready()”信号是什么?我们想要的是:拿网络例子来说,我们想要把 QAbstractSocket::connected() 和 QIODevice::readyRead() 连接到我们的槽函数上。当然,如果再多些槽函数更好的话,我们也可以增加更多(比如错误处理的槽函数,由 QAbstractSocket::error() 信号来发起)。这是真正的异步,信号驱动的设计!
把任务分解成小块
想想一下我们有个很耗时但是无法移动到其它线程的任务(或者根本不能移动到其它线程,因为它可能必须在 UI 线程中执行)。如果我们把任务分解成小块,那么我们就可以返回消息循环,让消息循环分发事件,然后让它调用处理后续任务块的函数。如果我们还记得 queued connection 如何实现的话,那就很容易解决这个问题了:事件发送到接收者所在的事件循环中,当事件被分发的时候,相应的槽函数被调用。
我们可以使用 QMetaObject::invokeMethod() 函数,用参数 Qt::QueuedConnection 指定连接类型,来实现这个功能;这需要函数可调用,也就是说函数必须是个槽函数或者使用了 Q_INVOKABLE 宏修饰。如果我们还要给函数传递参数,那么我们要保证参数类型已经通过函数 qRegisterMetaType() 注册到了 Qt 的类型系统中。下面的代码给我们展示了这种做法:
class Worker : public QObject
{
Q_OBJECT
public slots:
void startProcessing()
{
processItem(0);
}
void processItem(int index)
{
/* process items[index] ... */
if (index < numberOfItems)
QMetaObject::invokeMethod(this,
"processItem",
Qt::QueuedConnection,
Q_ARG(int, index + 1));
}
};
因为这里没有线程调用,所以它可以很容易的暂停/恢复/取消任务,也可以很容易的得到计算结果。
一些例子
MD5 hash
参考
分类