烧脑 C++ 之消除重复代码

最近偶然看到一篇 2006 年的老文章《Tour de Babel》 (中文翻译),评论各种编程语言,其中提到 C++ 有太多容易引发混乱的特性,因此很难被用好 – 这真是一场灾难。时隔 15 年,历经 C++11 (重大版本),C++14,C++17 几个版本,较新版本的 GCC 和 Clang 甚至开始支持 C++20 , C++ 越加臃肿复杂,难以驾驭。很多理智的编程指南常常告诫 C++ 开发者要高度克制, 禁止使用花哨技巧或高级特性 。本文尝试以一段简单代码为例,说明此告诫的必要性。示例代码使用 Clang 12 编译测试。

v1 常规代码

写一个程序查看 int 类型的最大值,C 语言宏 INT_MAX 记录了此值。代码如下:

#include <limits.h>
#include <iostream>

int main() {
    std::cout << "int max: " << INT_MAX << std::endl;
}

C++ 不推荐使用宏,可使用 std::numeric_limits<T> 查询数值类型相关信息。代码如下:

#include <limits>
#include <iostream>

int main() {
    std::cout << "int max: " << std::numeric_limits<int>::max() << std::endl;
}

这种组织方式更加安全和规范统一。如果要再查看 floatdouble 类型的最大值,你可能想不起对应的宏名称 (不是 FLOAT_MAX) ,但 C++ 中只需要把同样的用法重复 3 次,即得到 v1 版本的常规代码 如下:

#include <limits>
#include <iostream>

int main() {
    std::cout << "int max: " << std::numeric_limits<int>::max() << std::endl;
    std::cout << "float max: " << std::numeric_limits<float>::max() << std::endl;
    std::cout << "double max: " << std::numeric_limits<double>::max() << std::endl;
}

消除重复代码

显然,后两行代码是复制第一行代码后稍加修改得到的,这 3 行代码存在大量重复。有没有办法消除重复呢?我们可能希望有一个 for 循环,类似如下代码:

for (typename T : <int, float, double>) {
    std::cout << type<T>.name() << " max: " << std::numeric_limits<T>::max() << std::endl;
}

可惜,C++ 不支持这种语法。追根究底,int 等是编译时类型 (代码结构),俗称静态类型,for 循环是一个运行时结构,运行时代码无法访问编译时类型 (代码结构) 信息。

v2 模板类 (C++11)

C++ 中有什么代码结构可以接受一个静态类型列表作为参数吗?有!模板。模板是一种通用的编译时代码结构,仅在编译时存在,编译完成后,被使用到的模板必须被实例化为具体的代码结构。注意,模板实例化之后的结构可能仍然是一个编译时结构,如模板类。

编写一个模板类,C++11 可使用 typename... T 表示任意大小的类型列表,代码如下:

template<typename... T>
struct for_each_v2 {
};

处理一个列表时,一次只处理一个元素,这是基本的编程方法。剩下的元素怎么处理呢?递归!使用继承表达递归是常用的 C++ 模板元编程方法。代码如下:

template<typename T, typename... Tn>
struct for_each_v2: for_each_v2<Tn...> {
};

如果尝试实例化这个模板类(如执行 for_each_v2<int, float, double>()),将出现编译报错如下:

error: too few template arguments for class template 'for_each_v2'

别着急,我们的递归结构还没写完整。分析报错原因: typename... Tn 表示任意大小的列表, typename T 表示前面至少有一个元素 T ,递归过程中列表 Tn 的元素逐个减少,直至为空,而模板参数要求至少有一个元素,以空列表 Tn 调用 for_each_v2<Tn...> 时报错 “too few template arguments” 。漏了什么?《The Little Schemer》第一诫:递归处理一个列表时,请先检查列表是否为空。

所以,应定义一个接受空参数列表的模板特化实现,作为递归终止条件 (终结子) 。但直接添加模板特化终结子仍然会报错,因为终结子 (接受空列表) 与递归调用 (要求至少一个元素) 模板签名不匹配。因此需要再引入接受任意多个参数的初始模板作为统一模板签名,相当于模板声明,而覆盖所有分支的模板特化是具体实现。整理之后的代码如下:

template<typename... T>
struct for_each_v2 {
};

// empty?
// specialization terminator
template<>
struct for_each_v2<> {
};

// else
template<typename T, typename... Tn>
struct for_each_v2<T, Tn...>: for_each_v2<Tn...> {
};

这才是完整的模板类递归结构。当然,这不是我想出来的,是 C++ 模板元编程大牛 (折腾爱好者) 想出来的通用办法。 (参考 Boost C++ Metaprogramming )。

接下来,在模板类中写一个函数处理列表元素 T ,这个函数不需要返回值,这里可简单使用构造函数,构造函数会自动调用基类的构造函数,相当于自动完成了递归调用。 C++ typeid 操作可查询编译时类型或运行时数据的类型信息,但其返回的类型名称为内部名称,可再使用 boost::core::demangle() 转为可读名称。为了避免外部依赖,这里暂简单使用原始内部名称吧。最后 v2 版本使用模板类 (C++11) 的代码 如下:

#include <limits>
#include <typeinfo>
#include <iostream>

template<typename... T>
struct for_each_v2 {
};

// empty?
// specialization terminator
template<>
struct for_each_v2<> {
};

// else
template<typename T, typename... Tn>
struct for_each_v2<T, Tn...>: for_each_v2<Tn...> {
    for_each_v2() {
        std::cout << typeid(T).name() << " max: " << std::numeric_limits<T>::max() << std::endl;
    }
};

int main() {
    for_each_v2<int, float, double>();
}

调试跟踪代码,可看到调用栈展示了“递归”处理过程。同时看到这里使用构造函数有个小问题,递归最深的基类构造函数最先执行,导致列表元素的处理顺序与传入顺序相反。同时看到此时还引入了递归函数调用的额外开销 (构造函数不能被内联?)。

for_each_v2<double>::for_each_v2 mp11-foreach.cpp:19
for_each_v2<float, double>::for_each_v2 mp11-foreach.cpp:18
for_each_v2<int, float, double>::for_each_v2 mp11-foreach.cpp:18
main mp11-foreach.cpp:24

v3 模板函数 (C++17)

能不能直接使用模板函数编写递归调用呢?使用 sizeof...(Tn) 操作可获取模板参数列表大小,大小为 0 时即停止递归,代码简洁很多。初步尝试如下:

template<typename T, typename... Tn>
void for_each_v3() {
    std::cout << typeid(T).name() << " max: " << std::numeric_limits<T>::max() << std::endl;
    if (!!sizeof...(Tn)) {
        for_each_v3<Tn...>();
    }
}

很遗憾,尝试实例化模板函数时发生编译错误:

error: no matching function for call to 'for_each_v3'

错误信息不够清晰,实际上与之前模板类递归报错的原因一样,递归到模板参数 Tn 为空时模板实例化失败。 Tn 为空时 if 分支不会被执行了呀,为什么还会继续实例化?因为 if 语句是运行时结构,编译时编译器不知道 if 分支是否会执行。

那,还是需要写模板特化终结子吗?不用! C++17 引入了 constexpr if 特性,俗称 (编译时) 静态 if 。在 if 条件前加 constexpr 关键字,即可把 if 语句从运行时结构变成编译时结构,显然,此时要求 if 条件必须在编译时有确定的结果值。特别是,模板实例化时将自动移除不会执行的分支代码,从而不会触发被移除代码中的模板实例化。模板参数列表在编译时确定, sizeof...(Tn) 的值在编译时确定,因此上述代码只需给 if 语句添加 constexpr 修改为静态 if 即可。最后 v3 版本使用模板函数 (C++17) 的代码 如下:

#include <limits>
#include <typeinfo>
#include <iostream>

template<typename T, typename... Tn>
void for_each_v3() {
    std::cout << typeid(T).name() << " max: " << std::numeric_limits<T>::max() << std::endl;
    if constexpr(!!sizeof...(Tn)) {
        for_each_v3<Tn...>();
    }
}

int main() {
    for_each_v3<int, float, double>();
}

调试跟踪代码,可发现运行时已经没有 if 语句了 (已被编译时执行) 。可看到调用栈如下:

for_each_v3<double> mp11-foreach-v3.cpp:7
for_each_v3<float, double> mp11-foreach-v3.cpp:9
for_each_v3<int, float, double> mp11-foreach-v3.cpp:9
main mp11-foreach-v3.cpp:14

尾递归优化与内联优化

在 Scheme 中,尾递归将被自动优化为线性迭代,不存在性能问题 (因为递归是如此重要)。而 C 家族语言似乎都不保证 (甚至完全不提供) 尾递归优化。仔细想想,上面代码看起来是 “递归” ,其实不是,因为模板实例化后将变成多个不同的函数,编译后本质上是多个函数的串联调用 (如调用栈所示) 。

简短的函数调用,应该可以进行内联优化,然而此时并没有,显式添加 inline 关键字,尝试使用 -O3 -g 编译,调试跟踪似乎也没有进行内联优化 (inline 关键字本身也不保证内联优化),但函数返回时貌似从调用最深层处直接返回到了 main 函数 (可能作了某种调用优化?) 。

函数调用的开销应该是可以被优化到极低的,可以忽略不计 (?) 。但如果没有尾递归优化,调用层次过深时可能导致栈溢出。当然,对由模板参数列表长度控制的栈深度而言,这种情况也不可能发生。

v4 通用 lambda (C++20)

C++ 模板类和模板函数不能在函数内定义,这常常不太方便 – 将内部操作定义在函数内可以更好的组织代码,同时减少命名冲突。幸运的是,函数内 (及内部作用域) 可以定义通用 lambda 。术语 lambda 源自 Alonzo Church 发明的 lambda 演算,最早被 LISP 使用。实际上 lambda 就是函数,只是它没有名字 (或者你不知道它的名字)。通用 lambda 实际上也是模板函数, C++20 支持为通用 lambda 显式指定模板参数。

v4 预备

尝试将上述模板函数修改为通用 lambda ,代码如下:

constexpr auto for_each_v4 = []<typename T, typename... Tn>() {
    std::cout << typeid(T).name() << " max: " << std::numeric_limits<T>::max() << std::endl;
    if constexpr(!!sizeof...(Tn)) {
        for_each_v4<Tn...>();
    }
};
  1. 注意: lambda 没有名字,for_each_v4 只是另一个保存了 lambda 对象的变量的名字,不是 lambda 自身的名字。
  2. 我们说模板是编译时代码结构,只在编译时存在,通用 lambda 为什么能保存到运行时可用的变量呢?这是因为 C++ lambda 实际上是一个 闭包 (Closure)) 对象, lambda 定义的函数实际上是闭包类型下名为 operator() 的函数。
  3. 我们不知道闭包对象的具体类型,但编译器知道,使用 auto 类型让编译器自动设置类型。
  4. 不捕获外部数据的 lambda 可保存为编译时常量 constexpr ,还可以转化为普通函数 (非闭包) 使用。

遗憾的是,此代码将在编译时报错如下:

error: variable 'for_each_v4' declared with deduced type 'const auto' cannot appear in its own initializer
                        for_each_v4<Tn...>();
                        ^

可恶,它知道我们想做什么,但就是不让我们这么做。实际上,上述代码没有捕获外部数据,应该看不见 for_each_v4 这个名字 (看不见这个变量) 。如果尝试在 lambda 捕获表达式添加捕获这个变量,无论值捕获还是引用捕获,都会出现同样的报错。尽管我们代码没写对,编译器已经察觉到我们想做什么,并说这样不行。

v4 沉思

报错说: 一个自动推导类型的 auto 变量不能出现在它自身的初始化表达式中 (即不能被其自身的初始化表达式使用) 。这似乎是为了避免数据依赖循环:变量的值需要执行初始化表达式后才能知道,初始化表达式又要使用变量的值,不是就死循环了吗。

实际上并非如此:

  1. 按引用捕获闭包对象 (引用本质上是指针) 可避免数据存储结构循环。实际上 C++ 允许变量初始化表达式访问自身地址 (如支持 boost::any ref{&ref}; 这样的写法) 。
  2. 创建 lambda 闭包对象时只依赖被捕获对象,并不依赖 lambda 函数的实现,编译 lambda 函数时也不依赖被捕获对象的具体值,因此按引用捕获时不存在数据依赖循环。实际上 C++ 似乎并没有真正足够关注数据依赖问题,以至于像 int n = n + 1; 这样真正有数据依赖问题的代码也能编译通过。

其实 C++ 很容易实现允许 lambda 闭包对象按引用捕获自身 (?) (这是个危险的想法,C++ 已经够复杂了,还想让它更复杂吗?) ,但它就是不允许这样做。

换个角度想,一个对象的函数不是可以直接使用 this 指针访问自身对象吗,干嘛还要捕获自身的引用 (指针) 呢? 因为 C++ 虽然使用闭包对象作为 lambda 的底层实现,但 C++ 尊重了 lambda 的本义 – lambda 只是一个函数,不是对象。所以 lambda 只能看到被捕获的数据,不能看到闭包对象自身,不能使用 this 指针访问 lambda 自身,如果愿意的话倒是可以捕获访问外层对象 (如果有的化) 的 this 指针。

如果 lambda 有确定的函数签名 (非通用 lambda ,即非模板函数) ,虽然同样不允许捕获 auto 类型的自身引用,但可以将其包装到一个 std::function<...> 对象,再捕获 std::function<...> 对象以间接调用自身。注意: std::function<...> 不能构造为编译时常量,同时这里又需要按引用捕获 (想一想,为什么?),而对象销毁后捕获的引用将失效,这是一个暗坑!

为什么 lambda 闭包对象不能被直接捕获,却可以被间接捕获?实际上这与类型有关,被捕获数据的类型将影响 lambda 闭包对象的类型定义 (?) ,当尝试捕获闭包对象自身时,其类型影响其类型,因此产生了类型依赖死循环。当将 lambda 保存到 std::function<...> 等对象时,std::function<...> 是确切类型,切断了类型依赖死循环。 lambda 闭包对象类型也是一个匿名类型, C++ 是否可以直接自动生成一个与捕获数据类型或函数签名无关的确切类型,从而避免自身类型依赖死循环呢 (会不会增加复杂性?) ,或者 C++ 真的只是单纯的不想让你在 lambda 中引用其自身 (?) 。

有确定函数签名的 lambda (即非通用 lambda) 可以保存到 std::function<...> 对象,但通用 lambda 却不行,因为通用 lambda 是模板函数,模板是仅在编译时存在的代码结构。这正是 C++ 底层使用闭包对象表示 lambda 的聪明之处,闭包对象可以保存为运行时数据,同时可通过闭包对象关联的类型访问其关联的模板函数。如果希望在代码中传递通用 lambda 时保持其通用 lambda 的特性,那么保持其原始 lambda 闭包对象似乎是唯一选择。

v4 起飞

有名字的函数可以通过名字递归调用自己,没有名字的 lambda 函数如何递归调用自己呢?《The Little Schemer》第 9 章介绍了一种方法:将 lambda 自身的某种表示形式作为参数传递回 lambda 函数。 lambda 闭包对象就是 lambda 函数的一种表示形式,既然不能捕获 lambda 闭包对象,就将其作为函数参数传入吧。我们不知道 lambda 闭包对象的确切类型,所以这个参数仍使用 auto 类型。 (参考 C++ lambda 递归的三种写法 )。代码框架如下:

constexpr auto f = []<typename T1, typename... Tn>(auto self) -> void {
    // ... ...
};
f<int>(f);

想一想:这个写法与直接捕获 lambda 闭包对象有什么区别呢?是编译时的区别还是运行时的区别?

这个 lambda 函数的第一个参数必须为其闭包对象,这一要求是一个内部实现细节,可以再定义一个外层 lambda 函数将此内部细节隐藏起来。代码框架如下:

constexpr auto for_each_v4 = []<typename... T>() {
    constexpr auto f = []<typename T1, typename... Tn>(auto self) -> void {
        // ... ...
    };
    f<T...>(f);
};

最后得到 v4 版本使用通用 lambda (C++20) 的代码 如下:

#include <limits>
#include <typeinfo>
#include <iostream>

int main() {
    constexpr auto for_each_v4 = []<typename... T>() {
        constexpr auto f = []<typename T1, typename... Tn>(auto self) -> void {
            std::cout << typeid(T1).name() << " max: " << std::numeric_limits<T1>::max() << std::endl;
            if constexpr(!!sizeof...(Tn)) {
                self.template operator()<Tn...>(self);
            }
        };
        f.template operator()<T...>(f);
    };
    for_each_v4.operator()<int, float, double>();
}

如果 C++ 定义 lambda 时支持命名,或者支持以某种特殊形式调用自身 (这是个危险的想法,C++ 已经够复杂了,还想让它更复杂吗?) ,或者支持在函数内定义模板函数,那就简单多了呀。

v5 通用 for_each (C++20)

以上代码中,实现 for_each 的框架代码和业务代码混在了一起,这样不仅看起来不清晰,而且不能代码复用。我们应该将 for_each 框架代码抽取成一个可复用的通用操作,其使用方式类似如下代码:

for_each<typename... Tn>([]<typename T>() {
    // 操作类型 T 的业务函数代码
});

对通用 lambda 代码稍加修改,不难实现这个 for_each 操作 (这个练习留给读者吧) 。

实际上, C++ 标准库有一个 std::for_each 函数可以遍历运行时数据列表。 Boost mp11 库有一个 boost::mp11::mp_for_each 函数 (下文简称 mp_for_each) 可以遍历一个编译时类型列表。但 mp_for_each 与我们想要的这个 for_each 有一些区别:

  1. mp_for_each 将要遍历的类型列表进一步封装到一个 L<...> 模板类中。
  2. mp_for_each 自动调用被遍历类型的默认构造函数,生成默认对象作为业务函数的参数,因此要求被遍历的类型必须有可用的默认构造函数。

我们不希望 for_each 自动生成被遍历类型的对象,而且不要求被遍历类型有默认构造函数。能否适配复用 mp_for_each 呢?一个办法就是将被遍历类型封装到一个空模板类型中,创建空模板类型对象时实际上不会执行任何操作, boost::mp11::mp_list<...> 就是一个专门为此设计的空模板类型 (同时可用于封装任意类型列表)。再改造业务函数增加一个 boost::mp11::mp_list<T> 空对象参数。创建通用 lambda 作为业务函数 (C++20 支持定义通用 lambda 时显式指定模板参数),调用通用 lambda 时,通过参数类型 (被遍历类型的空模板包装类型) 自动推导模板参数 。

引入 Boost mp11 依赖 (这是一个纯头文件依赖) ,得到复用 mp_for_eachv5 版本通用 for_each (C++20) 代码如下:

#include <limits>
#include <typeinfo>
#include <iostream>
#include <boost/mp11.hpp>

int main() {
    constexpr auto for_each_v5 = []<typename... T>(auto f) {
        using L = boost::mp11::mp_transform<boost::mp11::mp_list, boost::mp11::mp_list<T...>>;
        boost::mp11::mp_for_each<L>(f);
    };

    for_each_v5.operator()<int, float, double>([]<typename T>(boost::mp11::mp_list<T>) {
        std::cout << typeid(T).name() << " max: " << std::numeric_limits<T>::max() << std::endl;
    });
}

结语

回想一下 v1 版本最初的常规代码,我们只是想简单重构一下代码,却意外经历了如此烧脑的冒险历程。你现在感觉如何?我再也不想写 C++ 了。这个示例可能还有更好的实现方法,但这一切到底是为了什么?重构之后的代码真的更好吗? C++ 不够好,因此 D 语言 、Go 语言、 Rust 语言 等新语言不断出现,而同时 C++ 自身也在不断膨胀和演进。也许足够克制的编程语言才是智慧的,比如伟大的 Scheme (Racket 是一种成熟的 Scheme 方言) , C 语言,以及 Java 。

回到最初的告诫, 编写 C++ 代码时请保持克制,只在浅水区玩耍,不要靠近深水区,不要使用技巧或高级特性 。你说是吗?

上一篇:Github中watch、star、fork的用法


下一篇:使用 spring boot 开发通用程序