视觉教程第二弹:cpp进阶之零开销

视觉教程第二弹:cpp进阶之零开销

机械是血肉,电控是大脑,视觉是灵魂。


此节内容需要相当的cpp基础,故请各位选学。


cpp作为视觉开发的主要编程语言,有必要让各位对于cpp有着更深刻的认识。而熟练掌握cpp一个要点就是:眼见代码,心有汇编。这里的心有汇编不一定是对应到每个实际的汇编语句,不仅不同平台的汇编指令都不尽相同;这里指的是伪代码级别的汇编,比如看到代码a->b = c;要想到,这个语句由3条基本指令完成,即:取内存、计算地址偏移量、写内存。实际情况可能会因为编译优化等发生变化,能做到上述程度就以及可以了。


一、什么是零开销

零开销一直是c++语言的设计理念,这也是为什么c++被人绝大多数人认为是运行速度最快的编程语言。

零开销原则是 C++ 设计原则,所说的是:

  1. 你无需为你所不用的付出。
  2. 你所用的正与你所能合理手写的效率相同。

上文引用于cppreference.com

说的实际一点,也就是能不在程序运行期进行的计算就不在运行期计算。这里我们通过Eigen3库(一个矩阵运算库)的使用举个例子:

using namespace Eigen;
// 这里定义了一个3x2,数据类型是double的矩阵。
// 注意,由于使用了模板参数,矩阵的大小是在编译期确定的。
Matrix<double, 3, 2> x1;
// 这里再定义一个4x3的矩阵,同样在编译器指定其大小。
Matrix<double, 4, 3> x2;
// 由于我们知道,4x3的矩阵和3x2的矩阵相乘的结果是4x2
// 所以这里我们直接定义一个4x2的矩阵来接收计算结果
Matrix<double, 4, 2> y = x2 * x1;

// 作为对比,下面给出一个矩阵大小不是在编译期确定的例子
// resize函数用于设置矩阵大小
Matrix<double, Dynamic, Dynamic> a1;
a1.resize(3, 2);
Matrix<double, Dynamic, Dynamic> a2;
a1.resize(4, 3);
Matrix<double, Dynamic, Dynamic> b = a2 * a1;

分析一下,由于参与矩阵乘法的两个矩阵的大小都是确定的,这个代码可以节省哪些运行期的计算?

  • 不需要在运行期检查矩阵的维数是否匹配。该检查可以在编译期进行,如果不匹配,则直接编译报错。
  • 类似的,不需要计算相乘结果的维数。该计算可以在编译期进行。
  • 在取矩阵的某个元素的值时,可以节省几次内存访问。我们知道,矩阵在内存中的存储是一片连续的空间,为了计算某个元素的实际内存地址,我们需要首先知道矩阵的维度,而如果矩阵的维度信息是以变量的形式存储的话,那么为了得到矩阵维度,需要首先访问这个变量所在的内存。而如果矩阵维度是一个编译期常量,那么CPU在取指令时,这个常量就所为指令的操作数出现,节省了内存访问次数。

那么这样做的缺点是什么呢?考虑一下这种状况,假如编写一个程序,让用户输入两个矩阵,并输出其乘积。

using namespace Eigen;
int x1, y1, x2, y2;
std::cin >> x1 >> y1 >> x2 >> y2;
//Matrix<double, x1, y1> a1;			该语句会编译错误
Matrix<double, Dynamic, Dynamic> a1;
a1.resize(x1, y1);
//Matrix<double, x2, y2> a2;			该语句会编译错误
Matrix<double, Dynamic, Dynamic> a2;
a2.resize(x2, y2);
std::cout << a2 * a1 << std::endl;

很显然,在这种情况下,如果采用编译期确定大小的矩阵,那么根本就无法达到需求。

从上面两个例子可以看出,零开销的根本在于确定编译期常量

二、编译期常量

编译期常量指的是所有可以在编译期确定下来的量,一般需要满足两个条件:

  • 该量的初始值是一个确定的量,不会受到程序输入的影响。
  • 该量在程序运行过程中不会发生变化。

编译期常量包括但不限于,整数类型,浮点类型,字符串类型,结构体类型。

编译期常量的定义方式有很多种,下面列举一些:

// 一个字面量是一个编译期常量
1; 2.0; "3";
// 一个用constexpr定义的变量是编译期常量
constexpr int v1 = 1;
constexpr double v2 = 2.;
constexpr const char* v3 = "3";
// 一个模板参数是一个编译期常量
template<int VAL>
void func();

此外,通过以下方式,也可以间接定义编译期常量:

  • 如果一个表达式的每个参数都是编译期常量,则表达式的结果也是一个编译期常量。
  • 如果一个constexpr函数的每个参数都是编译期常量,则函数的返回值也是一个编译期常量。

有人看到这里,可能会想,这个简单嘛,不就是C语言中的宏定义常量吗。这里必须指出,编译期常量≠宏定义常量,宏定义常量只是一种编译期常量。下面举一个例子:

// 这里VAL是一个编译期常量
template<int VAL>
int sum_val(int x){
    return x + VAL;
}

// 这里的VAL也是一个编译期常量
#define VAL 10
int sum_val2(int x){
    return x + VAL;
}

有看出区别吗?对于第一个模板函数而言,模板参数VAL是一个编译期常量,但它在作为一个常量的同时,又是一个“变量”,因为对于函数的编写者而言,并不能确定调用者会给VAL设置一个怎样的值。

同样可以注意到,constexpr定义的变量,其表现形式和C语言中的宏定义常量基本一致。能体现出区别的主要是模板参数。(当然constexpr函数也能体现区别,但目前使用场景较少)。

三、零开销与模板

在我们分析清楚程序中哪些量可以作为编译器常量后,我们就可以着手写出零开销的代码了。而零开销的代码实现方式大多需要借助与模板。这里举一个典型的求若干个数的最小(大)值的问题。首先我们分析哪些量是编译期常量,对于求最值,基本参数有:需要求最值的数的个数、需要求最值的数的值。显然,通常来说,值不会是一个编译期常量,但个数有可能是,这里我们假定个数就是编译期常量。我们可以写出如下代码:

template<class T, class ...Ts>
auto Max(T v, Ts ...vs){
    if constexpr(sizeof...(Ts) > 0){
        return std::max(v, Max(vs...));
    }else{
        return v;
    }
}

在上述代码中,出现了两个比较特殊的语法if constexpr和sizeof…(),这里介绍一下:

  • if constexpr要求判断的对象是一个编译期常量。如果满足,则在编译期时,就会保留该分支,删除else分支,反之依然。
  • sizeof…()针对可变模板,可以求出可变模板参数的个数,其值同样是一个编译期常量。

根据前面提到的条件,“如果一个表达式的每个参数都是编译期常量,则表达式的结果也是一个编译期常量”,这里if constexpr中显然是一个编译期常量。

if constexpr是一个很好的零开销的例子。由于判断条件是一个编译期常量,那么显然在编译期时,我们就可以知道哪个分支会运行,而哪个分支不会运行,所以这个判断过程应该在编译期进行,而非运行期进行。

再举一个例子,现在我们的程序里有一个枚举类型,我们希望打印出枚举变量的值。显然,如果直接打印,需要将其转换成整数类型,而打印整数类型是很不直观的,我们希望的是能打印出一个值所对应的枚举项的名称。首先我们要明确,零开销需要有编译期常量,所以我们这里设计一个含有编译期常量的场景。

enum MyEnum{
    ENUM_0, ENUM_1, ENUM_2;
}

// 这里val是一个编译期常量
template<MyEnum val>
void print_val(){
    if constexpr(val == ENUM_0) std::cout << "ENMU_0" << std::endl;
    else if constexpr(val == ENUM_1) std::cout << "ENMU_1" << std::endl;
    else if constexpr(val == ENUM_2) std::cout << "ENMU_2" << std::endl;
}

上面也是一个利用if constexpr实现的零开销的例子。

因为枚举值是编译期常量,而枚举项的名字同样是一个编译期常量。我们应该可以期望,打印这样一个枚举的值可以转换成直接打印其对应枚举项名字,而不经过额外计算。

再次总结一下,由于编译期常量的存在,我们通常可以期望某些行为的发生是必定的,且不需要引入额外计算。而零开销则是实现上述期望的过程。

以上两个例子都是十分简单的例子,而且第二个例子很可能还会让很多人觉得这是在脱裤子放屁,既然知道想打印的枚举值是多少,为何不直接输出对应字符串。要知道,模板的意义就是保证了调用者传递的模板参数是一个编译期常量,当函数体内有着复制的逻辑,甚至模板调用模板时,零开销才会发挥其真正的作用。

四、零开销与静态检查

静态检查本应该作为上一点中的一个例子而存在,但其在零开销编程中的作用之大,使得我认为还是将其单独设立为一个小节会比较好。

众说周知,程序的开发过程中充满了错误检查,而这些错误检查往往都和外部输入相关,因为外部输入是我们所不能掌握的。从小方面看,函数的编写者可能对函数参数有限制;从大方面看,程序的编写者可能对用户输入有限制。

零开销的第一条说到“你无需为你所不用的付出”,如果一个输入量是一个编译期常量,那么显然这样一个输入是否合法不需要放在运行期进行。

我们希望静态检查可以做到以下几点:

  • 在编译期发现尽可能多的错误。
  • 不占用运行期资源。
  • 报错信息让人可以方便的定位到问题。

下面同样以Eigen3库给出一个例子

using namespace Eigen;
Matrix<double, 2, 3> x1;
Matrix<double, 3, 4> x2;
// 由于我们知道3x4的矩阵无法右乘2x3的矩阵,显然下面的语句应该产生一个编译错误,并且提示矩阵维度不匹配。
auto y = x2 * x1;

Matrix<double, Dynamic, Dynamic> a1;
a1.resize(2, 3);
Matrix<double, Dynamic, Dynamic> a2;
a2.resize(3, 4);
// 虽然我们知道下面的语句会产生错误,但由于矩阵a1,a2的维度是在运行期指明的,所以在编译期无法判定其是否错误,故不会报错。
// 但显然,下面的语句应该会在运行时产生一个报错。
auto b = a2 * a1;

那么,这样一种静态检查机制应该如何实现呢?这个主要靠static_assert()进行。static_assert()的参数应该是应该编译期常量,如果为false,则再编译到这个语句的时候,会产生一个编译错误。下面给出一个例子,假设由我们自己实现一个矩阵乘法的功能。

// 定义矩阵类,让矩阵的行数和列数作为模板参数
template<int row, int col>
class Matrix;

// 在实现矩阵乘法函数时,有多种实现方式,对应了不同的静态检查
// 方法一:
template<class MatrixTypeA, class MatrixTypeB>
auto matrix_mul1(MatrixTypeA a, MatrixTypeB b){
    // static_assert(MatrixTypeA is a Type of Matrix);
    // static_assert(MatrixTypeA is a Type of Matrix);	
    // static_assert(MatrixTypeA::col == MatrixTypeB::row);
    // ...
}
// 在上述例子中,我们需要进行三个静态检查。由于函数的两个参数都是模板类型,所以我们需要检测这个模板类型是不是Matrix类型。
// 这里用伪代码表示,因为在c++20 concept机制出现之前,为了实现这样一个判断,我们需要一些特殊的技巧
// 此外,假如确定两个类型都是Matrix类型后,还需要判断两个矩阵的维度是否相同。
// 所以共计三个静态检查。

// 方法二
template<int R, int N, int C>
auto matrix_mul2(Matrix<R, N> a, Matrix<N, C> b){
    // ...
}
// 这是一个比较取巧的方式,因为我们知道,参数一定是Matrix类型,且维度匹配。所以我们将模板参数设置成不知道的地方,即矩阵维度
// 可以看出,参数一是一个RxN的Matrix,参数二是一个NxC的Matrix。这样上面做的三个静态检查都可以省略了。

看到上面两种实现方式,你觉得那种方式比较好呢?

在我看来,实现一是一种比较好的方式。因为在实现二中,假如用户错误地把两个维度不匹配的矩阵相乘,得到的报错可能是一长串不明所以的报错,增加了DEBUG的困难,而第一种实现中,却可以直接告诉用户,你的矩阵乘法维度不匹配。

五、零成本与多态

多态是面向对象编程中经常用到的一个方法,而在c++中,多态的实现方式通常是继承和虚函数。

在说零成本抽象前,我们首先重温一下广为人知的虚函数多态。下面给出一个例子,假如我们在做一个GUI界面,界面上有不同的控件需要显示,我们采用虚函数的方式实现:

class Widget{
public:
    virtual void show() = 0;
}

class TextWidget : public Widget{
public:
    virtual void show() override {
        // ...
    }
}

class ImageWidget : public Widget{
public:
    virtual void show() override {
        // ...
    }
}

// ... other widgets

std::vector<Widget*> widgets = {
    new TextWidget(), new ImageWidget()
};

for(auto widget: widgets){
    widget->show();
}

在上面的代码中,我们首先定义了控件的接口类,有一个show()函数负责把自己显示在屏幕上。然后额外定义了一个Text控件和Image控件,继承于Widght,并实现自己的show()函数。而随后,我们定义了一个控件列表,将所有控件存储起来,并通过一个for循环将所有控件显示在屏幕上。很好的实现!

但可以注意到,在上面的例子中,widgets控件列表中的每个控件都是直接显式定义的,也就是说,在后面for循环显示控件时,对于列表中的每一项,它具体是什么控件是已知的。

回忆上文提到的零开销使用场景:“由于编译期常量的存在,我们通常可以期望某些行为的发生是必定的。”。在这里,我们可以说,widgets的第一项一定是Text控件,而第二项一定是Image控件,因为列表就是这样定义的。再回忆零开销的第一条:“你无需为你所不用的付出。”,既然已知第一个widgets是Text,那我不必为了多态,而付出额外的虚函数的调用开销。

所以我们可以发现,在这个情况下,widgets列表其实本可以是一个编译期常量。所以上面代码的实现方法可以改成下面这样:

class TextWidget{
public:
    void show() {
        // ...
    }
}

class ImageWidget{
public:
    void show() {
        // ...
    }
}

auto widgets = std::make_tuple(TextWidget(), ImageWidget());

std::get<0>(widgets).show();
std::get<1>(widgets).show();

以上的代码中,std::make_tuple即定义一个编译期常量序列,即std::tuple,序列长度和每个元素的类型都是编译期常量,但每个元素的值不是。std::get<>()则是一个用于访问std::tuple的元素的函数。

注意到上述代码中,没有使用到for循环遍历列表,因为for(int i=0; i<2; i++){}中,i并不是一个编译期常量,所以std::get<i>(widgets);会产生编译错误。

显然上述代码中,由于没有用for循环,导致代码维护成本变高,如果在widgets列表中做了增添或删减,需要在下方进行相应的增添和删减,并不是一个好的设计。

在形如for(int i=0; i<2; i++){}的代码中,由于循环的开始、结束以及步长都是确定的,那这个循环次数就是确定的,是一个编译期常量。虽然目前编译器没有一个语法能直接支持这样的操作,使得循环变量同样成为一个编译期常量,但显然是可以有替代手段的。这里的替代手段留作思考题。

六、作业与思考题

  • 编写一个代码,用来遍历std::tuple的每个元素。
  • 编写一个代码,给同一个类型的不同对象一个编译期常量的“名字”(一个字符串),并提供一个函数,给定一个编译期常量的字符串(对象的名字),返回该对象
  • 编写一个代码,给不同类型的不同对象一个编译期常量的“名字”(一个字符串),并提供一个函数,给定一个编译期常量的字符串(对象的名字),打印该对象的类型

作者:唐欣阳,github主页:传送门