一、延迟计算和延迟实例化

c++中的延迟计算和延迟实例化,说白了都是资源的控制。大家可能都听说过COW,写时复制技术。这个其实原理有些类似,都是为了节省资源来达到速度或者空间等上面的优化。模板中,有些定义初时是编译器认为用不到,就不会进行初始化,这样一个是节省了编译的空间另外一个就是节省了编译的时间。而延迟计算在一些大规模的数据运算中,非常有用。如果一开始把一个非常复杂的数据计算出来,可能耗费大量的时间,如果这个数据计算是一个很小概率的事件也即用到的时候儿很少。那么就会有些得不偿失,不如设计好一个方式,将其转移到实际用到时再进行计算更为妥当。这就是延迟的目的。
在前面的文章“C++20中的lambda表达式”中,有几个例程delay_invoke_xxx系列,其实也是这个意思,有兴趣可以回头翻翻。

二、例程

下面分析一个网上的延迟实例化例程,让大家看明白是怎么回事:

template <typename T>
class Safe {
public:
    //Safe() = delete;
};

template <int N>
class Danger {
public:
    Danger() = delete;
    typedef char Block[N];  //  N<=0 时会出错
};

template <typename T, int N>
class Tricky {
public:
    virtual ~Tricky() {
    }
    void no_body_here(Safe<T> = 3);
    void inclass() {
        Danger<N> no_boom_yet;
    }
    // void error() { Danger<0> boom; }
    // void unsafe(T (*p)[N]);
    T operator->();
    // virtual Safe<T> suspect();
    struct Nested {
        Danger<N> pfew;
    };
    union {  // 匿名union
        int align;
        Safe<T> anonymous;
    };
};

int main()
{
    Tricky<int, 0> ok;
   // ok.inclass();
    return 0;
}

程序现在这种状态是可以运行的,然后打开注释掉的Safe构造函数删除行,就会发现有问题。也就是说,Tricky是要调用默认构造函数的。那么在这里段代码里用到Safe的,只有三处,一处已经注释,所以把另外两处分别注释,发现是匿名union处调用了。这说明匿名union的内容会被实例化(那具名union呢,自己写一个测试一下),而函数调用内部的参数处理不会被实例化(都不用考虑那个Safe = 3到底是否错误)。
但是Danger没有注释却可以使用,这说明整体的函数上没有实例化这个类(Danger no_boom_yet)。然后将Danger的默认构造函数注释。同时,打开注释的error函数,编译会报错。因为定义一个0长度数组,这个就是一个问题(在VS2022上没问题,可以编译过去,看来编译器非常重要)。但是你会发现unsafe中的却没有问题,因为这个参数N没有被替换。如果解开ok.inclass的注释,会发现编译器给一个警告,no_boom_yet没有被使用。明白了吧。
回复到上一步,解开虚拟函数的注释,发现同样,会报错误,是一个链接错误,这说明模板需要一个实例化定义(大多数编译器一般要求模板实例化时,virtual成员函数可实例化。换句话说,这个是种大概率事件,不是百分百,和编译器有关)。另外,嵌套类没有被实例化。
一般来说,默认的参数只有在明确被指定调用,才会被实例化,否则即使指定了默认参数,也不会被实例化。
然后再分析一个网上的延迟计算的例程:

#include <array>
#include <stdio.h>
#include <iostream>

using matrix = std::array<int, 2>;

// write this proxy
struct matrix_add {
    matrix_add(const matrix& a, const matrix& b) : a_(a), b_(b) { std::cout << "do nothing" << std::endl; }

    // an implicit conversion operator from matrix_add to plain matrix
    // the evaluation takes place only when the final result is assigned to a matrix instance
    //operator 隐式转换类型
    operator matrix() const {
        std::cout << "start add" << std::endl;
        matrix result;
        for (int i = 0; i < 2; ++i)
            result[i] = a_[i] + b_[i];

        return result;
    }

    // calculate one element out of a matrix sum
    // it's of course wasteful to add the whole matrices
    int operator ()(unsigned int index) const {
        std::cout << "calculage matrix" << std::endl;
        return a_[index] + b_[index];
    }

private:
    const matrix& a_, b_;
};

// to make this function lazy, it's enough to return a proxy instead of the actual result
matrix_add operator + (const matrix& a, const matrix& b)
{
    return matrix_add(a, b);
}

int main()
{
    // reference: https://stackoverflow.com/questions/414243/lazy-evaluation-in-c
    // 矩阵计算: 主要机制是运算符重载
    matrix mat1 = { 2, 3 }, mat2 = { 7, 8 };
    auto ret = mat1 + mat2;
    std::cout << " implicit conversion from matrix_add to matrix" << std::endl;
    matrix mat3(ret); 

    std::cout << "element sum:"<< ret(1) << std::endl;

    return 0;
}

这里要明白三个问题,然后就明白这个例程的意思了,第一,首先明白operator+运算符的重要,它保证了auto ret = mat1 + mat2计算。第二,operator matrix() 这个是c++11提供的类型转换构造函数,把他当成类型转换也行,反正表面上是一个道理,需要记住的是,这种操作不能有返回值,不能有参数,不能出现数组等(具体看前面的“c++11的类型转换函数”)。第三,operator (),重载小括号,也就是反复提到的仿函数。
把上面的三项看明白,就会明白程序这么设计的道理,就是把真正的矩阵的运算,从名义上的加法,延迟到真正调用加法结果时再真正计算。如果这样还不清楚,下断点跟一下就明白了。

三、总结

其实这些个延迟作用,目的都是为了更好的利用资源。在实际应用中,一般是比较特定的场景下用得比较多。普通的编程中,除了在异步通信中的函数延迟调用可能会用得比较多,其它基本上很少遇到。也就是说,本文介绍的知识,是相对来说专业应用开发者更有可借鉴的可能。
多掌握一些是好事,眼界要开阔,不要做坎井之蛙。
或许哪天用到呢!

12-09 14:36