第6章 优化动态分配内存的变量 摘录

除了使用非最优算法外,乱用动态分配内存的变量就是C++程序中最大的性能杀手。必须知道减少对内存管理器的调用。

优化内存管理的目标并不是避免使用到动态分配内存的变量的C++特性,它的目标是通过巧妙地使用这些特性移除对内存管理器无谓的,会降低性能的调用。

6.1 变量回顾

每个C++变量(每个普通数据类型的变量;每个数组、结构体或类实例)在内存中的布局都是固定的,它们的大小在编译时就已经确定了。C++允许程序获得变量的字节单位的大小和指向该变量的指针,但并不允许指向变量的每一位的布局。C++的规则允许开发人员讨论结构体成员变量的顺序和内存布局,C++也提供了多个变量可以共享同一个内存块的联合类型,但是程序所看到的联合是依赖于实现的。

6.1.1 变量的存储期

每个变量都有它的存储期,也称为生命周期。只有在这段时间内,变量所占用的存储空间或者内存字节中的值才是有意义的。为变量分配内存的开销取决于存储期。

虽然C++并不能直接指定变量的存储期,但可以从变量声明中推断出来:

1. 静态存储期:

        具有静态存储期的变量被分配在编译器预留的内存空间中。在程序编译时,编译器会为每个静态变量分配一个固定位置和固定大小的内存空间。静态变量的内存空间在程序的整个生命周期内都会被一直保留。所有的全局静态变量都会在程序进入main中被构建,在退出main函数后被销毁。在函数内声明的静态变量则会在程序执行第一次进入函数前被构建,这表示它可能和全局静态变量同时被构建,也可能直到第一次调用该函数时才会被构建。

        为静态变量创建存储空间是没有运行时开销的。不过,我们无法再利用这段存储空间。因此,静态变量适用于那些在整个程序的生命周期内都会使用数据。

        在命名空间作用域定义的变量以及被声明为static或是extern的变量具有静态存储期。

2. 线程局部存储期

        自C++11开始,程序可以声明具有线程局部存储期的变量。在C++11之前,有些编译器和框架也以一种非标准的形式提供了类似的机制。

        线程局部变量在进入线程时被创建,在退出线程时被析构。它们的声明周期与线程的生命周期一样。每个线程都包含一份这类变量的独立的副本。

        访问线程局部变量可能会比访问静态变量的开销更高 ,这取决于操作系统和编译器。在某些系统中,线程局部存储空间是由线程分配的,所以访问线程局部变量的开销比访问全局变量的开销多一次指令。而在其他系统中,则必须通过线程ID索引一张全局表来访问线程局部变量。尽管这个操作的时间是常量时间,但是会发生一次函数调用和一些计算,导致访问线程局部变量的开销变得更大。

        自C++11开始,用thread_local存储类型指示符关键字声明的变量具有线程局部存储期。

3. 自动存储期

        具有自动存储期的变量被分配在编译器在函数调用栈上预留的内存空间中。在编译时,编译器会计算出距离栈指针的偏移量,自动变量会以该偏移量为起点,占用一段固定大小的内存,但是自动变量的绝对地址直到程序进入变量的作用域内才会确定下来。

        当变量被析构后,指向该变量的指针和引用可能存在,而解引它们会导致未定义的程序行为。

        与静态变量一样,为自动变量分配存储空间不会发生运行时开销。但与静态变量不同的是,自动变量每次可以占用总的存储空间是有限的。当递归不收敛或是发生深度函数的嵌套调用导致自动变量占用的存储空间大小超出这个最大值时,会发生栈溢出,导致程序会突然终止。自动变量适合于那些只在代码块附近被使用的对象。

        函数的形参变量具有自动存储期。除非使用了特殊的关键字,那些声明在可执行代码块内部的变量也具有自动存储期。

4. 动态存储期

        具有动态存储期的变量被保存在程序请求的内存中。程序会调用内存管理器,即C++运行时系统函数和代表程序管理内存的数据结构的集合。与自动变量类似,但与静态变量不同的是,动态变量的地址是在运行时确定的。

        在C++中,这是唯一一种在编译时变量所占用的内存大小不固定的情况。

        动态变量没有自己的名字。当它被构建后,C++内存管理器会返回一个动态变量的指针。程序必须将这个指针赋值给一个变量,这样就可以在最后一个指向该变量的指针被析构之前,将动态变量返回给内存管理器,否则就会因不断地创建动态变量而耗尽内存的危险。如果没有正确地返回动态变量,现代处理器可能会在数分钟内耗尽数吉字节内存。

        不同于静态变量和线程局部变量的是,动态变量的数量和类型可以随着时间改变,而不受到它们所消耗的内存总量的限制。另外,与静态变量和自动变量不同的是,管理动态变量使用的内存时会发生显著的运行时开销。

6.1.2 变量的所有权

C++变量的另一个重要概念是所有权。变量的所有者决定了变量声明时候会被创建,声明时候会被析构。

1. 全局所有权:

        具有静态存储期的变量整体上被程序所有。程序会在进入main()之前构建它们,并在从main()返回后销毁它们。

2. 词法作用域所有权:

        具有自动存储期的变量被一段由大括号括起来的代码块构成的词法作用域所拥有。词法作用域可能是函数体,if,while,for或者do控制语句块,try或者catch字句,抑或是由大括号括起来的多条语句。这些变量在程序进入词法作用域时会被构建,在程序退出词法作用域时会被销毁。

3. 成员所有权;

        类和结构体的成员变量由定义它们的类实例所有。当类的实例被构建时,它们会被类的构造函数构建;当类的实例被销毁时,它们也会随之被销毁。

4. 动态变量所有权:

        动量变量没有预定义的所有者。取而代之,new表达式创建变量并返回一个必须由程序显式管理的指针。动态变量必须在最后一个指向它的指针被销毁之前,通过delete表达式返回给内存管理器销毁。因此,动态变量的生命周期是可以完全通过编程控制的。

        C++中动态变量所有权对于性能优化非常重要。具有强定义所有权的程序会比所有权分散的程序更高效。

6.1.3 值对象和实体对象

有些变量通过它们的内容体现出它们在程序中的意义,这些变量被称为值对象。其他变量通过在程序中所扮演的角色体现出它们的意义,这些变量被称为实体或实体对象。

C++不会指定某个变量表现为值对象还是实体对象,开发人员需要在程序逻辑中编写变量所扮演的角色。

1. 实体对象

实体对象的下列共通特性识别出它们:

实体是独一无二的:

        程序中有些对象在概念上具有唯一标识符,典型的例子有:守护某个特定临界区的互斥锁和有许多表元素的符号表。

实体是可变的:

        程序可以加入互斥锁加锁或是解锁,但是它仍然是同一个互斥锁。

实体是不可复制的:

        实体不是通过复制得到的。它们的本质来源于使用它们的方式,而不是来自于它们内部的每个位。

实体是不可比较的:

        比较两个实体是否相等的运算没有任何意义。实体的本质上是独立的。对两个实体的比较必须永远返回false。

2. 值对象

值的共通特性:

值是可交换和可比较的:

        整数4和字符串“Hello, World!”都是值。

值是不可变的:

        没有任何一种运算可以将4变为5。可以改变一个整数变量,让它保存的值从4变为5。这个操作可以改变变量,因为变量是一个带有唯一名字的实体。

值是可以复制的

        复制一个值是有意义的。

一个变量是实体对象还是值对象决定了复制以及比较相等是否有意义。实体不应当被复制和比较。一个类的成员变量是实体还是值决定了应该如何编写该类的构造函数。类实例可以共享实体的所有权,但无法有效地复制实体。

6.2 C++动态变量API回顾

C++有一个完善的工具箱用于管理动态变量,这些工具允许对内存管理和动态分配内存的C++变量的构建,选择进行自动控制还是精确控制。

1. 指针和引用:

        指针抽象了硬件的地址来隐藏计算机架构的复杂性和变化性。在C++11中有一个称为nullptr的指针,根据C++标准,它绝对不会指向有效的内存地址。在C++11之前,整数0代表nullptr,而在C++11中,它可以被转换为nullptr。不过即使指针变量中保存的所有位都是0,也不一定等于nullptr。而由于声明引用变量时必须初始化它,因此它们总是指向有效地址。

2. new和delete表达式

        new表达式会分配存储变量所需的空间,在存储空间中构建指定类型的变量,并返回一个指向新构建的变量的带类型的指针。

        动态变量是通过delete表达式释放的。delete表达式会销毁变量并释放它的存储空间。用于释放数组的delete表达式与用于销毁单实例的delete表达式不同。

3. 内存管理函数:

        new和delete表达式会调用C++标准库的内存管理函数,在C++标准中称为“自由存储区”的内存池中分配和归还内存。这个函数是new()运算符的重载。

4. 类构造函数和析构函数:

        C++允许每个类定义一个构造成员函数,在创建该类的实例时会调用这个函数来进行初始化。另一个成员函数--析构函数--则会在每次销毁类实例时被调用。

5. 智能指针:

        C++标准库提供了“智能指针”模板类。它的行为与原始指针类型类似,但是它们在超过作用域还会删除它们所指向的变量。智能指针可以记住分配的存储空间是数组还是一个单实例,它们会根据智能指针类型调用正确的delete表达式。

6. 分配器模板:

        C++标准库还提供分配器模板,它是new和delete表达式的泛化形式,可以与标准容器一起使用。

6.2.1 使用智能指针实现变量所有权的自动化

动态变量的所有权即不受编译器控制,也不由C++定义。在程序中的某个地方声明一个指针变量,然后在另一个地方使用new表达式将一个动态变量赋值给指针,再在另一个地方复制这个指针到另一个指针中,然后再在另一个地方。。。后果则是这样编写出来的程序难以测试和调试,因为动态变量的所有权太分散了。动态变量的所有权是由开发人员赋予,并编码在程序逻辑中的。当所有权很分散时,每行代码都可能会创建出动态变量,添加或是移除引用,或是销毁变量。开发人员必须追踪所有的执行路径,确保动态变量总是正确地返回给了内存管理器。

使用编程惯用法可以降低这种复杂性。一种常用的方法是将指针变量声明为某个类的私有成员变量。

1. 动态所有权的自动化

智能指针会通过耦合动态变量的生命周期与拥有该动态变量的智能指针的生命周期,来实现动态变量所有权的自动化。动态变量将会被正确地销毁,其所占用的内存也会被自动地释放,这些取决于指针的声明:

        当程序执行超过智能指针实例所属的作用域时,具有自动存储期的智能指针实例会删除它所拥有的动态变量,物理者发生在执行break或是continue语句时,退出函数时,还是在作用域内抛出异常。

        声明为类的成员函数的智能指针实例在类被销毁时会删除它所拥有的的动态变量。除此之外,由于没有必要再显式地在析构函数找那个编写销毁动态变量的代码,智能指针会被C++的内建机制所删除。

        当线程正常终止时(通常不包括操作系统终止线程的情况),具有线程局部存储性的智能指针实例会删除它所拥有的的动态变量。

        当程序结束时,具有静态存储期的智能指针实例会删除它所拥有的的动态变量。

2. 共享动态变量的所有权的开销更大

任何时候,只要有多个指针指向同一变量,开发人员必须注意那个指针时变量的所有者。开发人员不应当显式地通过非所有者指针来删除动态变量,在删除动态变量后不应当在解引任何一个指针,也不应进行两个指针拥有相同对象的操作。

C++标准库模板std::shared_ptr<T>提供了一个智能指针,可以在所有权被共享时管理被共享的所有权的。shared_ptr的实例包含一个指向动态变量的指针和一个指向含有引用计数的动态对象的指针。由于在引用计数会发生性能开销昂贵的原子性的加减运算。因此shared_ptr可以工作于多线程程序中。std::shared_ptr也因此比C风格指针std::unique_ptr的开销更大。

开发人员不能将C风格的指针赋值给多个智能指针,而只能将其从一个智能指针赋值给另一个智能指针。如果将同一个C风格的指针赋值给多个智能指针,那么该指针会被多次删除,导致发生C++标准中所谓的“未定义的行为”。由于智能指针可以通过C风格的指针创建,因此传递参数时所进行的隐式的类型转换会导致这种情况发生。

6.2.2 动态变量有运行时开销

由于C++代码会被编译为机器码,并由计算机直接执行,因此大多数C++语句的开销都不过是几次内存访问而已。不过,为动态变量分配内存的开销则是数千次内存访问。

从概念上说,分配内存的函数会从内存块集合中寻找一块可以使用的内存来满足请求。如果函数找到一块正好符合大小的内存,它会将这块内存从集合中移除并返回这块内存。如果函数找到一块比需求更大的内存,它可以选择拆分内存块然后只返回其中一部分。如果没有可用的内存块来满足请求,那么分配函数会调用操作系统内核,从系统的可用内存池中请求额外的大块内存,这次调用的开销非常大。内核返回的内存可能会被缓存在物理RAM中,可能会导致除此访问时发生更大的延迟,遍历可使用的内存块列表,这一操作自身的开销也是昂贵的。这些内存块分散在内存中,而且与那些运行中的程序正在使用的内存块相比,它们也不太会被缓存起来。

未被使用内存块的集合是由程序中所有线程所共享的资源。如果若干线程频繁地调用内存管理器分配内存或是释放内存,那么它们会将内存管理器视为一种资源进行竞争,导致除了一个线程外,其余线程必须等待。

在实际实现中,内存释放函数的行为更加复杂。绝大多数实现方式都会尝试将刚释放的内存块与临近的未使用的内存块合并,这样可以防止向未使用内存中放入过多太小的内存块。调用内存释放函数与调用内存分配函数有着相同的问题:降低缓存效率和争夺对未使用的内存块的多线程访问。

6.3 减少动态变量的使用

动态变量对于许多问题来说是一种强大的解决方法。不过,有时使用它们解决某些问题太过于昂贵。静态创建的变量常常可以用于替代动态变量。

6.3.1 静态地创建类实例

大多数非容器类实例都能够且应当被静态地创建(不使用new)。

当类成员变量也是类时,可以在创建类时静态地创建这些成员变量。这样可以节省为这些成员变量分配内存的开销。

有时候看起来必须动态地创建类实例,因为它是其他类的成员变量,而且用于创建该成员变量的资源在创建类实例还未就绪。一种解决模式是将这个问题类声明为其他类的成员,并在创建其他类时部分初始化这个问题类。然后,在问题类中定义一个用于在资源准备就绪时完全地初始化变量的成员函数。最后,在原来使用new表达式动态创建实例的地方,插入一段调用这个初始化成员函数的代码,这种解决模式被称为两段初始化。

6.3.2 使用静态数据结构

1. 用std::array代替std::vector

vector允许程序定义任意类型的动态大小的数组。如果在编译时能够知道数组的大小,或是最大的大小,那么可以使用与vector接口,但数组大小固定且不会调用内存管理器的std::array。

2. 在栈上创建大块缓冲区

尽管在栈上可以声明的总存储空间是有限的,但这种限制往往非常大。担心可以发生局部数组溢出的谨慎的开发人员,可以事先检查字符串或者数组长度,如果发现参数长度大于局部数组变量的长度,那么就使用动态构建的数组。

为什么这种复制速度会比std::string等动态数据结构要快?其中一个原因是变值操作通常会复制输入数据。另一个原因则是,相比于为中间结果分配动态存储空间的开销,在桌面级硬件复制上千字节的开销更小。

3. 静态地创建链式数据结构

可以使用静态初始化的方式构建具有链式数据结构的数据。

4. 在数组中创建二叉树

在每个节点包含指向左侧和右侧节点的指针,以该方式定义二叉树的不幸的结果是,即使对于存储小至一个字符的类也需要足够的存储空间来存储两个指针,另外还需要加上内存管理器的开销。

一种解决方法是在数组中构建二叉树,然后不再子节点保存子节点的链接,而是利用节点的数组索引来计算子节点的数组索引。

以数组方式实现树中的节点需要一种机制来判断它们左节点和右节点是否有效,或是它们是否等于空指针。

能够计算子节点和父节点的能力使得对于堆数据结构而言,在数组中构建树是一种高效的实现方式。

对于平衡二叉树而言,数组形式的数可能会比链式数低效。如果节点的大小小于三个指针时,数组形式的数可能会在性能上领先。

5 用环形缓冲区替代双端队列

环形缓冲区上实现双端队列。只要缓冲区消费者更得上缓冲区生产者,环形缓冲区就无需重新分配内存。环形缓冲区的大小并不决定它能够处理多少输入数据,而是决定了缓冲区生产者能领先多少。

环形缓冲区与链表或双端队列的不同在于,环形缓冲区使得缓冲区中元素数量限制变得可见。

6.3.3 使用std::make_shared替代new表达式

std::shared_ptr<MyClass> p(new MyClass("hello", 123));

上述语句调用了两次内存管理器:第一次用于创建MyClass的实例,第二次用于创建被隐藏起来的引用计数对象。

C++编写人员了解到开发人员的痛苦后,编写了一个称为std::make_shared()模板函数,这个函数可以分配一块独立的内存来同时保存引用计数和MyClass的一个实例。

std::shared_ptr<MyClass> p = std::make_shared<MyClass>("hello", 123);
auto p = std::make_shared<MyClass>("hello", 123);

6.3.4 不要无谓地共享所有权

当各个指针的生命周期会不可预测地发生重叠时,shared_ptr非常有效。但它的开销也很昂贵,增加和减少shared_ptr中的引用计数并不是执行一个简单的增量指令,而是使用完整的内存屏蔽进行一次非常昂贵的原子性增加操作,这样shared_ptr才能工作于多线程程序中。

void fiddle(std::shared_ptr<Foo> f);
...
shared_ptr<Foo> myFoo = make_shared<Foo>();
...
fiddle(myFoo);

当程序调用fiddle()时,会创建第二个指向动态变量Foo实例的链接,并增加shared_ptr的引用计数。当fiddle()返回时,shared_ptr参数会释放它对动态变量Foo实例的所有权,但调用方仍然拥有指针。这次调用的最小开销是一次无谓的原子性增加和减少,而且这两次操作都带有完整的内存屏障。如果在程序中传递每个指向Foo的指针的函数都使用shared_ptr,那么整个开销就是非常巨大。将传递给fiddle()的参数改为一个普通指针可以避免这种开销:

void fiddle(Foo* f);

在C++编程世界中有一个常识,那就是永远不要在程序中使用C风格的指针,除非是要实现智能指针。在C++的编程世界中,引用是一种习俗,它表示无主且非空的指针。

6.3.5 使用“主指针”拥有动态变量

一个单独的数据结构在它的整个生命周期内拥有动态变量。指向动态变量的引用或指针可能会被传递给函数或被函数返回,或是被赋值给变量,等等。但是在这些引用中,没有那个的寿命比“主引用”长。

如果存在主引用,那么我们可以使用std::unique_ptr高效地实现它。可以在函数调用过程中,用普通的C风格或是C++引用来引用该对象。如果在程序中贯彻了这种指针,那么普通指针和引用就会被记录为无主指针。

6.4 减少动态变量的重新分配

当使用标准容器时,总是有技术可以帮助我们减少内存分配的次数。

6.4.1 预分配动态变量以防止重新分配

随着在std::string或是std::vector上数据的增加,它内部的动态分配的存储空间终究会枯竭。下一个添加操作会导致需要分配更大的存储空间,以及将旧的数据复制到新存储空间中。对内存管理器的调用以及复制操作将会多次访问内存并消耗很多指令。

string和vector都有成员哈数reserve(size_t n),调用该函数会告诉string或是vector请确保至少存储n个元素的空间。如果可以事先计算或是预估这个大小,那么调用reserve()为string或是vector预留足够的内部存储空间,可以避免出现它们达到增长极限后需要重新分配存储空间的情况。

即使过小地估计了预留的容量,惩罚也不过是额外的重新分配。而即使过大地估计了预留的容量,只要string或是vector短暂地使用后被销毁,就都没有问题。还可以使用shrink_to_fit()成员函数将未使用的空间返回给内存管理器。

6.4.2 在循环外创建动态变量

for(auto& filename : namelist)
{
    std::string config;
    ReadFileXML(filename, config);
    ProcessXML(consfig);
}

提高这段循环的性能的一种方法是将config的声明移至循环外部:

std::string config;
for(auto& filename : namelist)
{
    config.clear();
    ReadFileXML(filename, config);
    ProcessXML(consfig);
}

对于某些开发人员而言,这种修改方式像是滥用全局变量。不过,延长动态分配内存的变量的生命周期可以显著提升性能。

这种技巧不仅适用于std::string,也适用std::vector和其他任何拥有动态大骨架的数据结构。

6.5 移除无谓的复制

所有可以被直接赋值的实体都是char,int,float和指针等基本类型,它们会被保存在一个单独的寄存器中。因此,类似a=b这样的赋值语句是高效的。

如果a和b都是类实例,那么赋值语句会调用类的赋值运算符的成员函数。如果类中有动态变量,复制它们可能引发调用内存管理器。

初始化声明Foo a=b;语句会调用复制构造函数。复制构造函数和赋值运算符是两个紧密相关的成员函数,它们所做的事情几乎相同;将一个类实例中的字段复制到另一个类实例中去。而且与赋值运算符一样,复制构造函数的开销是没有上限的。

复制可以发生在以下任何一种情况中:

        初始化(构造函数);

        赋值(赋值运算符);

        函数参数(移动构造或复制构造);

        函数返回(移动构造或复制构造);

        插入一个元素到标准库容器中(移动构造或复制构造);

        插入一个元素到vector中(如果vector重新分配内存,所有元素都会通过移动构造或复制构造函数);

6.5.1 在类定义中禁止不希望发生的复制

并非程序中所有的对象都应当被复制,例如具有实体行为的对象不应当被复制,否则会失去它们的意义。

如果复制类实例过于昂贵或者不希望这样做,那么一种可以有效地避免发生这种昂贵开销的方法就是禁止复制。将复制构造函数和赋值运算符的可见性声明为private。既然它们无法被调用,那么也就不需要任何定义,只需要声明即可。

class BigClass
{
private:
    BigClass(BigClass const&);
    BigClass& operator=(BigClass const&);
public:
    ...
};

在C++11中,我们可以在复制构造函数和赋值运算符后面加上delete关键字来达到目的。将带有delete关键字的复制构造函数的可见性设为public更好,因为这种情况下调用复制构造函数的话,编译器会报告出明确的错误消息:

class BigClass
{
public:
    BigClass(BigClass const&) = delete;
    BigClass& operator=(BigClass const&) = delete;
    ...
};

任何企图对以这种方式声明的类的实例赋值--或通过传值方式传递给函数,或通过传值方式返回,或是将它用作标准库容器的值时--都会发生编译错误。

指针和引用是存储在寄存器中。

6.5.2 移除函数调用上的复制

当程序调用函数时,会计算每个参数表达式,并以相对应的参数表达式的值作为初始化器创建每个形参。当形参是诸如int、double或是char*等基本类型时,由于基本类型的构造函数是概念上而非实际的函数,因此程序只会简单地将值复制到形参的存储空间中。

但是当形参是某个类实例时,程序将调用这个类的复制构造函数之一来初始化实现。例如:

int Sum(std::list<int> v)
{
    int sum = 0;
    for (auto it : v)
        sum += *it;
    return sum;
}

std::list的复购构造函数会为链表中所有元素创建一份副本。通过引用访问实例产生开销:每次访问实例时,都必须解引实际该引用的指针。如果函数很大,而且在函数体内多次使用了参数值,那么连续地解引用的开销会超过所节省下来的复制开销,导致性能改善收益递减。但是对于小型函数,处理特别小的类以外,通过引用传递参数总是能获得更好的性能。

引用参数在函数体内发生的改变会导致它所引用的实例也发生改变。引用参数还会引起别名,这会导致不曾预料到的影响。

6.5.3 移除函数返回上的复制

如果函数返回一个值,那么这个值会被复制构造到一个未命名的与函数返回值类型相同的临时变量中。不幸的是,如果在函数体内计算出返回值后,将其复制给了一个具有自动存储期的变量,那么这个技巧将无法适用。

按值返回时,除了在函数内部调用复制构造外,在调用方法还会调用构造函数或赋值运算符。

函数必须返回一个局部对象。编译器必须能够确定在所有的控制路径上返回的都是相同的对象。返回对象的类型必须与所声明的函数返回值的类型相同。

有一种办法可以移除函数内部的类实例的构造以及从函数返回时发生的两次复制构造。就是不用return语句返回值,而是在函数内更新应用参数,然后通过输出参数返回该引用参数。

void scalar_product(const std::vector<int> & v, int c, std::vector<int>& result)
{
    result.clear();
    result.reserve(v.size());
    for (auto val : v)
        result.push_back(val * c);
}

在函数参数列表中加入一个result参数。这种机制有以下几个优点:

        当函数被调用时,该对象已经被构建。有时,该对象必须被清除或是重新初始化,但是这些操作不太可能比构造操作的开销更大。

        在函数内被更新的对象无需在return语句中被复制到未命名的临时变量中。

        由于实际数据通过参数返回了,因此函数的返回类型可以是void,也可以用来返回状态或是错误信息。

        由于在函数中被更新的对象已经调用方中的某个名字绑定了一起,因此当函数返回时不再需要复制或是赋值。

编译器在处理返回实例的函数时,会将其转换为一种带有额外参数的形式,这个额外的参数是一个引用,它指向为用于保存函数所返回的未命名的临时变量的未初始化的存储空间。

在C++中有一种情况只能通过值返回对象:运算符函数。在实现运算符函数时必须格外的小心,确保它们会使用RVO和移动语义,这样才能实现最高效率。

6.5.4 免复制库

当需要填充的缓冲区,结构体或其他数据结构是函数参数时,传递引用穿越多层库调用的开销很小。因为这种设计的开销太大了;当数据结构或缓冲区被传递于库的各层之间时,它们会不止一次被复制。

6.5.5 实现写实复制惯用法

写时复制(copy on write, COW)用于搞笑的复制那些含有复制开销昂贵的动态变量类实例。

写时复制的核心思想是,在其中一个副本被修改之前,一个对象的两个副本一直都是相同的。因此,直到其中一个实例或了另外一个实例被修改,两个实例能够共享那些指向复制开销昂贵的字段的指针。

类的构造函数复制具有共享所有权的指针,将动态变量的一份新的复制的创建延迟到任何一份复制想要修改该动态变量时。

作用于类上的任何变值操作都会真正改变类之前先检查共享指针的引用计数。引用计数大于1表明所有权被共享了,那么这个操作会为对象创建一份新的副本,用指向新副本的共享指针交换之前的共享指针成员,并释放旧的副本和减小引用计数。由于已经确保了动态变量没有被共享,现在可以进行变值操作了。

在COW类中使用std::make_shared()构建动态变量是非常重要的。否则,使用共享指针会发生额外的调用内存管理器来获取引用计数对象的开销。如果在类中有许多动态变量,那么这个开销与简单地将动态变量复制到新的存储空间中并赋值给一个智能指针的开销无异。

6.5.6 切割数据结构

切割是编程惯用法,它指的是一个变量指向另外一个变量的一部分。

被切割的对象通常都是小的,容易复制的对象,将其内容复制至子数组或子字符串中而分配存储空间的开销不大。

6.6 实现移动语义

移动语义解决了以下问题:

        将一个对象赋值给一个变量时,会导致其内部的内容被复制。这个运行时开销非常大,而在这之后,原来的对象立即被销毁了。复制的努力也随之化为乌有,因为本来可以复制原来对象的内容的。

        开发人员希望将一个实体,在这个对象中,赋值语句中的“复制”操作是未定义的,因为这个对象具有唯一的标识符。

        问题的起因在于,复制构造函数和赋值运算符执行的复制操作对于基本类型和无主指针没有问题,但是对于实体则没有意义。

6.6.1 非标准复制语义:痛苦的实现

当一个变量表现为实体时,创建它的一个副本通常都是一张通往未定义行为大陆的单程票。大部分较好的做法是对这类变量禁用复制构造函数和赋值运算符。但是当std::vector等容器重新分配时,又需要复制其中所容纳的对象,因此禁止复制意味着无法在容器使用这类对象。

对于移动语义出现之前,想把实体放进标准库容器的绝望的设计人员来说,一种解决方法是以非标准形实现赋值,例如:

hacky_ptr& hacky_ptr::operator=(hacky_ptr& rhs)
{
    if (*this != rhs)
    {
        his->ptr_ = rhs.ptr_;
        rhs.ptr_ = nullptr;
    }
    return *this;
}

6.6.2 std::swap():穷人的移动语义

交换操作的强大之处在于它可以递归地应用于类的成员变量上。交换并不会复制指针所指向的对象,而是交换指针自身。对于那些指向大的,动态分配内存的数据结构,交换比复制更加高效。

交换的问题在于,尽管对于带有需要深复制的动态变量的类而言,交换比复制更加高效。但对于其他类则比较低效。

6.6.3 共享所有权的实体

让一个shared_Ptr指向实体也是一种方法。但是它充满了不必要的复杂性和运行时开销。

6.6.4 移动语义的移动部分

标准库的实现人员认识到,他们需要将移动操作作为C++的基础概念。移动应当负责处理所有权的转移,它需要比复制更加高效,而且无论对于值还是实体都应当有良好的定义。

C++编译器需要能够识别一个变量在什么时候只是临时值。例如函数返回的对象或new表达式的结果。该对象可以被初始化,赋值给一个变量或是作为表达式或函数的参数。但是接下来它会立即被销毁。这样的无名指被称为右值,因为它与赋值语句右侧的表达式的结果类似。相反,左值是指通过变量命名的值。

C++的类型系统被扩展了,它能够从函数调用上的左值识别出右值。如果T是一个类型,T&&则是指向T的右值引用。函数重载的解析规则也被扩展了,这样当右值是一个实参时,优先右值引用重载,而当左值是实参时,则需要左值引用重载。

特殊成员函数的列表被扩展了,现在它包含了移动构造函数和一个移动赋值运算符。

编译器只会在当程序没有指定复制构造函数、赋值运算符或是析构函数,而且父类或是任何类成员都没有禁止移动运算符的简单情况下,才会自动生成移动构造函数和移动赋值预算符。

由于自动生成的规则太过复杂,因此最好还是显式地声明、默认声明或是禁用所有特殊函数(构造和析构)。

6.6.6 移动语义的微妙之处

1. 移动实例至std::vector

开发人员必须将移动构造函数和移动赋值函数声明为noexcept,因为std::vector提供了强异常安全性。

2. 右值引用参数是左值

当一个函数接收一个右值引用为参数时,它会使用右值引用来构建形参,因为形参是有名字的,所以尽管它构建于一个右值引用,它仍然是一个左值。

幸运的是,开发人员可以显式地将左值转换为右值引用。利用std::move()。

std::string MoveExample(const std::string&& s)
{
    std::string tmp(std::move(s));
    return tmp;
}

    std::string s1 = "hello";
    std::string s2 = "everyone";
    std::string s3 = MoveExample(s1 + s2);

调用std::move(s)会创建一个指向s的内容的右值引用。由于右值引用是std::move()的返回值,因此它没有名字。右值引用会初始化tmp,调用std::string的移动构造函数。此时,s已经不再指向MoveExample()的实参字符串。它可能是一个空字符串,当返回tmp时。从概念上说,tmp的值会被复制到匿名返回值中。通常RVO比移动构造更高效。

3. 不要返回右值引用

移动语义的另外一个微妙之处在于不应当定义函数返回右值引用。直觉上,返回右值引用的临时变量是合理的,因为返回右值引用会高效地将返回值从未命名的临时变量中复制到赋值目标中。

但是实际上,返回右值引用会妨碍RVO,即允许编译器向函数传递一个指向目标的的引用作为隐藏参数,来移除未命名的临时变量到目标的复制。返回右值引用会执行两次移动操作,人一旦使用了返回值优化,返回一个值只会执行一次移动操作。

4. 移动父类和类成员

要想为一个类实现移动语义,你必须为所有父类的类成员也实现移动语义。否则,父类和类成员将会被复制,而不会被移动。

6.7 扁平数据结构

当一个数据结构中的元素被存储在连续的存储空间汇总时,我们称这个数据结构为扁平的。相比于通过指针链接在一起的数据结构,扁平数据结构具有显著的性能优势:

        创建扁平数据结构实例时调用内存管理器的开销更小;

        扁平数据结构所需的内存比基于节点的数据结构少,因为在基于节点的数据结构中存在这链接指针的开销。即使消耗的总字节数没有问题,紧凑的数据结构仍然有助于改善缓存局部性。

        移动语义移除了在分配智能指针和它所指对象的存储空间时产生的显著的运行时开销。

6.8 小结

乱用动态分配内存的变量是最大的性能杀手。当发生性能问题时,new不在是你的朋友;

通过new'和delete可以整体地改变程序分配内存的方式;

智能指针实现了动态变量所有权的自动化;

共享了所有权的动态变量更加昂贵;

静态地创建类实例;

静态地创建类成员并且在有必要时采用两段初始化;

让主指针来拥有动态变量,使用无主指针代替共享所有权;

编写通过输出参数返回值的免复制函数;

实现移动语义;

扁平的数据结构更好;

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值