这是bert hubert的系列文章,旨在帮助c代码人快速了解c++实用的新特性。原文链接:https://berthub.eu/
这可能是最后的第5部分,我们将介绍现代C++中一些最强大的功能:“完美”的引用计数和std::move
概念。请注意,本篇引入了一些非常不熟悉的概念,所以阅读起来可能会比前面的部分更困难。
内存通常是决定程序速度和可靠性的最重要因素。现在的CPU往往比其连接的RAM快得多,所以防止不必要的复制和内存碎片可以提供数量级的速度提升。
C++的作者非常清楚这一点,并提供了使对象能够“就地”传递或构造的功能,从而节省了大量内存带宽。
这些技术中大多数需要一些思考,但我们将从一个几乎无形的技术开始,它解释了一些C代码通过重新编译为C++而变快的原因。
思考以下代码:
struct Object
{
// many fields
};
Object getObject(size_t i)
{
Object obj;
// retrieve object #i
return obj;
}
int main()
{
Object o = getObject(271828);
}
有经验的C程序员会被告诫不要这样做,从K&R的光荣页面开始。“通过栈传递结构会导致不必要的复制”。
相反,我们会传递一个指向Object
的指针,仔细地将其重置为默认状态,然后填充它。
在C中,编译器不允许像上面这样优化代码,并且在从getObject
返回时会复制struct Object
。然而,在C++中,不仅允许编译器进行优化,实际上所有编译器都会进行优化。实际上,Object o
在调用方的栈上构造并填充,没有复制发生。
这使得这样的代码能够高效:
Vector
我们会在后面看到:C++是如何显式的转换所有权而不是拷贝。
在第2部分中,我们接触了智能指针。我们也注意到内存泄漏是每个项目的祸根,可能是用纯C编写的最令人头痛的事情。我知道的每个C(和C++)程序员都至少花了一个完整的周期来追踪一个难以察觉的内存泄漏。
这些问题非常大,以至于大多数现代编程语言决定承担大量的开销来实现垃圾收集(GC)。当GC工作时,它是惊人的,尤其是最近,开销现在至少是可管理的。但截至2018年,所有环境仍在为GC运行造成的停顿所苦,这些停顿总是恰恰发生在你不希望发生的时候。公平地说,这是一个非常困难的问题,尤其是当涉及许多线程时。
因此,C++没有实现垃圾收集。相反,有一些明智选择的智能指针可以执行自己的清理。在第2部分中,我们将std::shared\_ptr
描述为“最符合用户期待”的智能指针,这是正确的。
然而,这样的魔法并非免费的。如果我们“查看”std::shared_ptr
内部,它承载了大量的管理工作。首先当然是对所包含对象的实际指针。然后是引用计数,它需要始终以原子方式更新和检查。接下来,还可能有一个自定义析构函数。出于良好的理由,这些元数据本身是动态分配的(在堆上)。所以虽然std::shared_ptr
的sizeof
可能只有16字节(在64位系统上),但它实际上使用了更多内存。在一个具体的测试中,std::shared_ptr
平均使用了47字节的内存。
问题是:我们能做得更好吗?
当C++采用其初始标准化形式时,通用引用计数指针的开销是众所周知的。当时,定义了一个古怪的智能指针std::auto_ptr
,但结果证明在1998年的C++中不可能创建一些有用的东西。创建“完美的智能指针”需要C++ 2011才提供的功能。
首先,让我们试试一些简单的事情:
std::unique_ptr testUnique;
uint32_t* testRaw;
std::shared_ptr testShared;
cout << "sizeof(testUnique):\t" << sizeof(testUnique) << endl;
cout << "sizeof(testRaw):\t" << sizeof(testRaw) << endl;
cout << "sizeof(testShared):\t" << sizeof(testShared) << endl;
输出如下:
sizeof(testUnique): 8
sizeof(testRaw): 8
sizeof(testShared): 16
您看到的没错。std::unique_ptr
与“原始”指针相比没有开销。事实上,通过一些明智的转换,您可以发现它包含的除您放入其中的指针外什么也没有。这是零开销。
用法如下:
void function()
{
auto uptr = std::make_unique(42);
cout << *uptr << endl;
} // uptr contents get freed here
第一行可以表示为:
std::unique_ptr uptr = std::unique_ptr(new uint32_t(42));
通常,对于智能指针,总是优先选择std::make_*
形式。对于std::shared_ptr
,它将两个分配合并为一个,这在CPU周期和内存消耗方面都有优势。_值得注意的是,std::unique_ptr
是一个智能指针,但它不是一个通用的引用计数指针。或者,更准确地说,始终只有一个地方拥有std::unique_ptr
。这就是为什么没有开销的魔力:没有引用计数要存储,它总是“1”。
std::unique_ptr
仅在离开作用域或被重置或替换时进行清理。要访问智能指针的内容,可以取消引用它(使用*或->),也可以在需要内部指针时使用get()方法。智能指针也可以“unset
”在这种情况下,评估为“假”:
std::unique_ptr iptr;
auto p = [](const auto& a) {
cout << "pointer is " << (a ? "" : "not ") << "set\n";
};
p(iptr);
cout << (void*) iptr.get() << endl;
iptr = std::make_unique(12);
p(iptr);
iptr.reset();
p(iptr);
结果如下:
pointer is not set
0
pointer is set
pointer is not set
编译器不允许我们拷贝“std::unique_ptr
”,但是却允许我们“move
”它。
std::unique_ptr uptr2;
uptr2 = uptr; // error about 'deleted constructor'
uptr2 = std::move(uptr); // works
因为简单的拷贝会导致unique
变为non-unique
,两个ptr
指向一个实例。那么move
到底做了什么?
SmartFP
是RAII的一个实例。我们可以这样用它:
int main()
{
try
{
string line;
SmartFP sfp("/etc/passwd", r");
stringfgets(line, sfp.d_fp); // simple wrapper
// do stuff
}
catch(std::exception& e)
{
cerr << "Fatal error: " << e.what() << endl;
}
}
SmartFP
底层只不过是fopen
和fclose
的封装。它也会对fopen
的错误抛出异常。RAII的好处在于它保证文件描述符不会泄漏,即使在错误条件下也是如此。
在第2部分中,我们还注意到,按定义,SmartFP
存在一个问题。当它超出作用域时,它执行fclose
,但是如果有人复制了我们的SmartFP
实例怎么办?然后我们会关闭同一个FILE
指针两次,这非常糟糕。加入移动构造函数:
struct SmartFP
{
SmartFP(const char* fname, const char* mode)
{
d_fp = fopen(fname, mode);
if(!d_fp)
throw std::runtime_error("Can't open file: " + stringerror());
}
SmartFP(SmartFP&& src) // move constructor. Note "&&"
{
d_fp = src.d_fp;
src.d_fp = 0;
}
~SmartFP()
{
if(d_fp)
fclose(d_fp);
}
FILE* d_fp{0};
};
move constructor
是非常重要的一个环节,它高速C++类不能被拷贝,只能被移动。move
的语义就是所有权的转移。
SmartFP sfp("/etc/passwd", "ro");
cout << (void*) sfp.d_fp << endl; // prints a pointer
SmartFP sfp2 = sfp; // error
SmartFP sfp2 = std::move(sfp); // transfer!
cout << (void*) sfp.d_fp << endl; // prints 0
cout << (void*) sfp2.d_fp << endl; // prints same pointer
当此代码运行时,我们在第一行创建的FILE
指针将完全关闭一次fclose
。这是因为在移动过程中,FILE*
被设置为零,在析构函数中,我们确保不要关闭0。
move
在返回时会自动执行:
SmartFP getTmpFP()
{
// get tmp name
return SmartFP(tmp, "w");
}
...
SmartFP fp = getTmpFP();
此外,c++标准容器也是用move
的,根据移动语义原地构造元素:
vector vec;
vec.emplace_back("move.cc", "r");
emplace_back
的参数全部转发给SmartFP
构造函数,后者直接在std::vector
中构造实例,全部不需要复制。在填充大容器时,这可以产生巨大的差异。
注意,如果需要,一个类可以同时具有移动构造函数和常规构造函数。一个很好的例子是所有的C++标准容器,包括std::string
。这为您提供了选择 – 进行实际复制还是转移所有权。
我们将东西存储为指针的主要原因是为了受益于多态性。指针的缺点当然是内存管理,所以如果智能指针可以与基类和派生类互操作,那就太好了。确实,它们可以。
基于我们在第3部分的Event类:
std::deque> eventQueue;
eventQueue.push_back(std::make_unique("1.2.3.4"));
eventQueue.push_back(std::make_unique());
for(const auto& e : eventQueue) {
cout << e->getDescription() << endl;
}
这一切都按预期工作,当容器超出作用域时,eventQueue
的内容会被清理。
使用多态类时,确保不存在~析构函数,或者将其声明为虚拟的。否则std::unique_ptr
将调用基类析构函数。有关更多详细信息,请参阅第3部分。
auto ptr = new SmartFP("/etc/passwd", "ro");
这会做两件事:
分配内存以存储SmartFP
实例
使用该内存调用SmartFP
构造函数
通常这就是我们需要的。然而,有时候我们的内存来自其他地方,但我们仍然希望在上面构造对象。进入 placement new
。这是一个来自PowerDNS dumresp
实用程序的实际用例:
std::atomic* g_counter;
auto ptr = mmap(NULL, sizeof(std::atomic), PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
g_counter = new(ptr) std::atomic();
for(int i = 1; i < atoi(argv[3]); ++i) {
if(!fork())
break;
}
这使用mmap
来分配将与任何子进程共享的内存,然后使用花哨的placement new
语法在该共享内存中构造一个std::atomic
实例。
然后代码fork
了argv[3]
中描述的进程数。在所有这些进程中,一个简单的++(*g_counter)
工作后,所有进程都将更新相同的计数器。
基于这种技术,可以创建高效且易于使用的进程间通信库,例如Boost Interprocess
。
mmap
允许在进程间共享内存区域。placement new
使得可以直接在mmap
的共享内存中构造对象,而不需要额外的内存分配和复制。结合使用二者,可以避免不必要的内存操作,提高多进程程序的性能。
一些一般建议
许多现代C++项目只会有少数显式调用new或delete(或malloc/free)。审计这几个调用很容易。仅将手动内存分配限于您真正必须的情况。
对于其余情况,如果可以的话,请使用std::unique_ptr
,如果不行,请使用std::shared_ptr
。请注意,您可以有效地将std::unique_ptr
转换为std::shared_ptr
,可以这样改变主意:
auto unique = std::make_unique("test");
std::shared_ptr shared = std::move(unique);
此外,std::unique_ptr
也可以release()
它拥有的指针,这意味着它不会被自动delete
。
以低廉的代价将std::unique_ptr
转换为std::shared_ptr
或原始指针的易用性,意味着函数可以返回std::unique_ptr
并让所有人满意。
对于移动构造函数,理解这个不太熟悉的构造是有价值的。
代表资源的类(如socket、文件描述符、数据库连接)自然适合具有移动构造函数,因为这使它们的语义与这些资源的工作方式紧密匹配:应该确切地打开和关闭一次,并且应当在合适的时机。
内存分配很困难,C++提供的各种智能指针使其更容易。
std::shared_ptr
很奢侈但带来包袱,std::unique_ptr
通常就足够好了,完全没有开销。
C++努力避免对象的不必要复制,添加移动构造函数使这一点明确。通过使用std::move
,可以将std::unique_ptr
实例存储在容器中,这既安全又快速。
智能指针简化了内存管理,unique_ptr
用于独占所有权,shared_ptr
用于共享所有权。
移动语义通过转移资源所有权优化性能,避免复制开销。
结合移动语义和右值引用,可以安全高效地在容器中存储unique_ptr
。
C++提供了各种机制来尽量避免不必要的内存分配和对象复制。明智地使用可以写出高性能和安全的代码。