C++标准系列2—C++11之表现优化

1.前言

编译期表现的加强有外部模板
运行期表现强化有右值引用、move语义、constexpr – 泛化的常量表示式、对POD定义的修正。这些特性用来提升内存或者速度上性能表现。

2.右值引用和move语义

2.1.左值右值

在C++11中,有部分特性需要用到左值右值的概念,所以讲特性前先讲一下什么是左值什么是右值。
左值的英文简写为"L-value",右值的英文简写为"R-value"。它们看上去是"left value"、"right value" 的缩写,虽然从结果看上去有点像,但是表达的意思不够精准。"L-value" 是"loactor value”的缩写,意思是内存中可以明确寻址的数据;而"R-value" 译为 "read value",指的是可读的数据(可读不一定存储在内存中,也有可能存储在寄存器中,所以不一定能寻址)
判断左值右值的方式:

  • 可以在C/C++赋值运算符左侧的就是左值,只能在C/C++赋值运算符右侧的就是右值
  • 有名称且可以获得存储地址的表达式即为左值,反之则为右值

2.2.右值引用

所谓右值引用,就是对R-value的引用,用"&&"表示(int && a = 10;)。在C++11中,右值引用主要是应用在move语义和perfect function forwarding中。
对比引用和右值引用,加上常量属性,可以参考:


image.png

出于安全的考虑,推行了一些限制。具名的变量被认定为左值,即使它是被声明为右值引用数据类型;为了获得右值必须使用显式类型转换,如模板函数std::move()。右值引用所绑定的对象应该只在特定情境下被修改,主要用于move构造函数中。再根据上表,我们可以写出判断是否为右值引用的方法:

#include 

bool is_r_value(int &&) { return true; }
bool is_r_value(const int &) { return false; }

int main()
{
    int&& rr = 0;
    std::cout << is_r_value(rr) << std::endl; // 0
    std::cout << is_r_value(std::move(rr)) << std::endl; // 1
}

2.3.move构造函数

在C++细节积累--返回值优化一文中我们得知:

C++98/03低性能问题的之一,就是在以传值方式传递对象时隐式发生的耗时且不必要的深度拷贝。编译器对这个过程进行了优化,且不会影响程序的正确性,因此我们几乎不会去关注。

C++11中,对这个性能问题,进行了优化,引入了move构造函数。在《C++细节积累--返回值优化》一文的demo中,我们添加move构造函数,进行测试。

#include 

struct CDemo
{
public:
    CDemo(int n) : p_var(new int(n))
    {
        std::cout << "constructor" << std::endl;
    }

    CDemo(const CDemo& other): p_var(new int(*other.p_var))
    {
        std::cout << "copy constructor function" << std::endl;
    }

    // add move constructor function
    CDemo(CDemo&& other) : p_var(other.p_var)
    {
        other.p_var = nullptr;
        std::cout << "move constructor function" << std::endl;
    }

    ~CDemo()
    {
        if (p_var != nullptr)
            delete p_var;
            p_var = nullptr;
    }
private:
    int* p_var;
};

CDemo create()
{
    return CDemo(1);
}

int main()
{
    std::cout << "begin" << std::endl;
    CDemo obj = create();
}

运行结果:


image.png

两次move构造函数做的事情:

  • 匿名对象的指针成员赋值给临时对象的指针成员,匿名对象的指针成员设置为nullptr。由于匿名对象不会再次被使用,所以不会有代码访问该nullptr指针;又因为匿名对象的指针成员是nullptr,所以销毁时不会影响到临时对象的指针成员。因此,该操作不仅无形中免去了深拷贝的开销,而且还很安全。
  • 临时对象用同样的逻辑初始化了新对象,高效且安全。

3.constexpr – 泛化的常量表示式

3.1.常量表达式与泛化的常量表达式

constexpr是C++11标准新引入的关键字,在讲constexper之前有必要先了解一下什么是常量表达式。
在C++03已经有常量表达式的定义,所谓常量表达式,指的就是由多个(≥1)常量组成的表达式。常量表达式的应用场景有很多,比如定义数组、匿名枚举、switch-case 结构中的 case 表达式等。
C/C++的执行过程可以分为5个步骤,分别是预编译、编译、汇编、链接和运行。其中常量表达式和非常量表达式的计算时机不同,非常量表达式只能在运行阶段计算出结果;而常量表达式的计算往往发生在编译阶段,可以提高程序的运行效率。
举个简单例子,返回值用const修饰的函数并非常量表达式,在编译阶段不会计算。

const int fun()
{
    return 1 + 2;
}

int main()
{
    const int a = 1 + 10;
    int arr0[a];
    const int b = fun();
    // int arr1[b]; // 非常量表达式,会编译失败
}
image.png

通过这个例子也简单回顾一下什么是const,const修饰的变量可以表示常量,但是更加精确的语义描述是只读变量。
眼尖的同学会发现其实fun()只返回了一个常量,但是C++会把他丢到运行阶段再计算。那么有什么办法可以在编译期间就计算类似这种只返回一个常量表达式的函数呢?是有的,就是使用C++11的新特性constexpr

constexpr int fun()
{
    return 1 + 2;
}

int main()
{
    const int a = 1 + 10;
    int arr0[a];
    constexpr int b = fun();
    int arr1[b];
}
image.png

可以看到编译阶段,就已经把fun计算出来了,从而提高运行效率。
用constexpr修饰的表达式可以用来定义数组,所以用constexpr可以泛化常量表达式的范围。

constexpr 关键字的功能是使指定的常量表达式获得在程序编译阶段计算出结果的能力,而不必等到程序运行阶段。C++ 11 标准中,constexpr 可用于修饰普通变量、函数(包括模板函数)以及类的构造函数。
需要注意的是,用constexpr修饰的表达式并不是一定会在编译阶段被执行,也有可能在运行阶段才执行。具体哪些情况会在运行阶段执行继续往下探讨。

3.2.constexpr修饰普通变量

  • constexpr修饰的普通变量必须初始化
  • constexpr修饰的普通变量只能用常量表达式来初始化
  • constexpr修饰的浮点常量表达式在编译阶段计算的精度要至少等于(或者高于)运行阶段计算出的精度

3.3.constexpr修饰函数

实际上是修饰函数的返回值,用法参考上面例子的constexpr int fun。
想要用constexpr来修饰函数需要满足下面几个条件:

  • 函数必须要有返回值,不能是返回void。
  • 整个函数的函数体中,除了可以包含 using 、typedef 以及 static_assert 外,只能包含一条 return 返回语句。(C++14标准有所修改)
  • 函数在使用之前,必须有对应的定义语句。我们知道,函数的使用分为“声明”和“定义”两部分,但常量表达式函数在使用前,必须要有该函数的定义。
  • return 返回的表达式必须是常量表达式("return expr")。

对于类的成员函数,也可以用constexpr修饰,也需要满足上面4个条件。
所有被声明为constexpr的非静态成员函数也隐含声明为const(即函数不能修改*this的值)(C++14有所修改)

在常量表达式函数的 return 语句中,不能包含赋值的操作(例如 return x=1 在常量表达式函数中不允许的)。另外,用 constexpr 修改函数时,函数本身也是支持递归的

constexpr int sub_fun()
{
    static_assert(true, "hell");
    return 3;
}

constexpr int fun()
{
    return 1 + 2 + sub_fun();
}

int main()
{
    constexpr int b = fun();
    int arr1[b];
}

3.4.constexpr修饰类的构造函数

对于 C++ 内置类型的数据,可以直接用 constexpr 修饰,但如果是自定义的数据类型(用 struct 或者 class 实现),直接用 constexpr 修饰是不行的。比如下面代码会编译报错:

constexpr struct Base{};

我可以用constexpr来修饰构造函数,从而生成一个constexpr的对象

struct Base 
{
    constexpr Base(int b) : a(b) {}
    int a;
};

constexpr int fun()
{
    return 1 + 2;
}

int main()
{
    constexpr Base obj = Base(fun());
}
  • constexpr修饰构造函数时,要求构造函数体必须为空
  • constexpr修饰构造函数时,需要在初始化列表为成员变量赋值时,必须使用常量表达式
  • C++11 标准中,不支持用 constexpr 修饰带有 virtual 的成员方法。

3.4.constexpr修饰模板函数

C++11 语法中,constexpr 可以修饰模板函数,但由于模板中类型的不确定性,因此模板函数实例化后的函数是否符合常量表达式函数的要求也是不确定的。
针对这种情况下,C++11 标准规定,如果 constexpr 修饰的模板函数实例化结果不满足常量表达式函数的要求,则 constexpr 会被自动忽略,即该函数就等同于一个普通函数。
这也就解释了,前面说的“用constexpr修饰的表达式并不是一定会在编译阶段被执行,也有可能在运行阶段才执行”

3.5.const与constexpr

  • C++11之前,可以在常量表达式中使用的的变量必须被声明为const,用常量表达式来初始化,并且必须是整型或枚举类型。C++11中,用constexpr关键字来定义变量,去除了必须是整型或枚举类型的限制
  • 参数列表中可以用const修饰,但是不能用constexpr。构造函数可以用constexpr修饰,不能用const
  • 大部分场景中,const和constexpr都可以用。但是如果可以使用constexpr建议使用constexpr

4.对POD定义的修正

太长不看版:

  • 如果std::is_standard_layout::value是true,则表明T是standard-layout,可以与C程序交互
  • 只要std::is_trivial::value是true,则表明T是trivial,从而能够使用memcpy来复制
  • 一个类、结构体、联合体既是standard-layout又是trivial,且所有非静态成员和基类都是POD才是POD。可以直接用std::is_pod::value判断。

在C++03中,一个类(class)或结构(struct)要想被作为POD(与C兼容的对象内存布局,并且可以被静态初始化)。但C++03标准对POD的判定标准很严格,如果创建一个C++03 POD类型,然后为其添加一个非虚成员函数,这个类型就不再是POD类型了,从而无法被静态初始化,也不再与C兼容,虽然其内存布局并没有发生变化。

C++11通过把POD概念划分成两个概念:平凡的(trivial)和标准布局(standard-layout),放宽了关于POD的定义。

4.1.trivial

一个平凡的类型可以被静态初始化,意味着使用memcpy来复制数据是合法的,而无须使用复制构造函数。平凡的类型对象的生命周期开始于其存储空间被分配时,而不是其构造函数完成时。使用模版类std::is_trivial::value来判断数据类型是否为平凡类型

一个平凡的的类别或结构符合以下定义:

  1. 平凡的默认构造函数。这可以使用默认构造函数语法,例如SomeConstructor() = default;
  2. 平凡的复制构造函数和move构造函数,可使用默认语法(default syntax)
  3. 平凡的赋值运算符和move赋值操作符,可使用默认语法(default syntax)
  4. 平凡的析构函数,不可以是虚函数(virtual)
  5. 类没有虚基类和虚成员函数
  6. 复制构造函数和赋值操作符还额外要求所有非静态数据成员都是平凡的。

4.2.standard-layout

一个符合标准布局的类封装成员的方式与C兼容。使用模版类std::is_standard_layout::value来判断类型是否是一个标准布局类型。一个标准布局(standard-layout)的类别或结构符合以下定义:

  1. 所有non-static成员有相同的访问控制(public,private,protected)
  2. 没有虚函数
  3. 没有虚基类
  4. 所有基类符合标准布局
  5. 所有非静态的(non-static)资料成员属于符合标准布局的类别
  6. 类中第一个非静态类别与基类不是同一个类别。例如:struct A:B{ B b; int c;}不符合要求
  7. 两种情况必局其一:或者所有基类都没有non-static成员;或者最派生类别没有non-static资料成员且至多一个带有non-static成员的基类。基本上,在该类别的继承体系中只会有一个类别带有non-static成员。

4.3.POD

一个类、结构、联合只有在其是平凡的、符合标准布局,并且所有非静态成员和基类都是POD时,才被视为POD。使用中的is_pod::value判断T是不是POD类型

POD类型的class、union、struct具有下面几种特征:

  • 没有用户自定义的构造函数、析构函数、拷贝构造函数和移动构造函数。
  • 不能包含虚函数和虚基类。
  • 非静态成员必须声明为 public。
  • 类中的第一个非静态成员的类型与其基类不同
  • 在类或者结构体继承时,满足以下两种情况之一:一、派生类中有非静态成员,且只有一个仅包含静态成员的基类;二、基类有非静态成员,而派生类没有非静态成员。
  • 所有非静态数据成员均和其基类也符合上述规则(递归定义),也就是说 POD 类型不能包含非 POD 类型的数据。
  • 此外,所有兼容C语言的数据类型都是 POD 类型(struct、union 等不能违背上述规则)。

通过划分,使得放弃一个特性而不失去另一个成为可能。一个具有复杂的复制和move构造函数的类可能不是平凡的,但是它可能符合标准布局,从而能与C程序交互。类似地,一个同时具有public和private数据成员的类不符合标准布局,但它可以是平凡的,从而能够使用memcpy来复制。

5.外部模板

5.1.模板带来的编译问题

参考下面extern.h代码,我们定义了一个模板函数,然后t1.cpp和t2.cpp都用到了这个模板函数。我们分别对t1.cpp和t2.cpp编译,然后再生成一个动态链接库。我们用工具nm来看一看编译和链接的结果。
extern.h:

//extern.h
template
void template_fun(T)
{
    // do something
}

t1.cpp:

//t1.cpp
#include "extern.h"

//template void template_fun(int);
void test1()
{
    template_fun(0);
}

t2.cpp:

//t2.cpp
#include "extern.h"

//extren template void template_fun(int);
void test2()
{
    template_fun(0);
}
image.png

从图中我们看到,在编译t1.cpp和t2.cpp后,各自生成了一个void template_fun(int)实例。最终链接出来的动态链接库只有一个void template_fun(int)实例。这是因为编译时,会各自生成void template_fun(int)实例;在链接时,编译器做了优化,链接器通过一些编译器辅助的手段将重复的模板函数代码template_fun(int)删除掉,只保留了一个实例。
显然这种重复生成实例,然后再去重的工作是多余的,如果在一个广泛使用模板的项目中,可能会大大增加编译器的编译时间和链接时间。为了解决这个问题,C++11引入了外部模板的特性。

5.2.显式实例化和外部模板声明

显示实例化,在C++98标准就已经存在。比如对上面extern.h的模板函数,我可以这样显示实例化:

template void template_fun(int);

外部模板声明就是在显式实例化的前面加一个extern关键字,例如:

extern template void template_fun(int);

对外部模板的应用就是,可能在多处实例化时,可以显示实例化一个地方,其他地方可以做外部模板声明。直接把t1.cpp和t2.cpp的显示实例化和外部模板声明的注释去掉,重新编译和链接再测一测:


image.png

如箭头所示,在定义了外部模板后,在编译时,就不再生成实例。
外部模板不仅仅适用模板函数,也适用模板类,就不举例子了。通过外部模板特性,可能会大大降低编译和链接时间。

你可能感兴趣的:(C++标准系列2—C++11之表现优化)