Modern C++ for C 程序员 第5部分

文章目录

  • Modern C++ for C 程序员 第5部分
    • 内存管理
    • 复制省略或返回值优化
    • 智能指针
    • 初识: std::unique_ptr
    • std::move
    • 智能指针和多态性
    • placement new
    • 其他的一些建议
  • 总结

这是bert hubert的系列文章,旨在帮助c代码人快速了解c++实用的新特性。原文链接:https://berthub.eu/

Modern C++ for C 程序员 第5部分

这可能是最后的第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 getAll(); // "Vector" from part 3
..
  auto all = getAll(); // returns millions of Objects
 
  

我们会在后面看到:C++是如何显式的转换所有权而不是拷贝。

智能指针

在第2部分中,我们接触了智能指针。我们也注意到内存泄漏是每个项目的祸根,可能是用纯C编写的最令人头痛的事情。我知道的每个C(和C++)程序员都至少花了一个完整的周期来追踪一个难以察觉的内存泄漏。

这些问题非常大,以至于大多数现代编程语言决定承担大量的开销来实现垃圾收集(GC)。当GC工作时,它是惊人的,尤其是最近,开销现在至少是可管理的。但截至2018年,所有环境仍在为GC运行造成的停顿所苦,这些停顿总是恰恰发生在你不希望发生的时候。公平地说,这是一个非常困难的问题,尤其是当涉及许多线程时。

因此,C++没有实现垃圾收集。相反,有一些明智选择的智能指针可以执行自己的清理。在第2部分中,我们将std::shared\_ptr描述为“最符合用户期待”的智能指针,这是正确的。

然而,这样的魔法并非免费的。如果我们“查看”std::shared_ptr内部,它承载了大量的管理工作。首先当然是对所包含对象的实际指针。然后是引用计数,它需要始终以原子方式更新和检查。接下来,还可能有一个自定义析构函数。出于良好的理由,这些元数据本身是动态分配的(在堆上)。所以虽然std::shared_ptrsizeof可能只有16字节(在64位系统上),但它实际上使用了更多内存。在一个具体的测试中,std::shared_ptr平均使用了47字节的内存。

问题是:我们能做得更好吗?

初识: std::unique_ptr

当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到底做了什么?

std::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底层只不过是fopenfclose的封装。它也会对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部分。

placement new

auto ptr = new SmartFP("/etc/passwd", "ro");

这会做两件事:

  1. 分配内存以存储SmartFP实例

  2. 使用该内存调用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实例。

然后代码forkargv[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++提供了各种机制来尽量避免不必要的内存分配和对象复制。明智地使用可以写出高性能和安全的代码。

你可能感兴趣的:(c++,c语言,java)