C++20:理解Concepts:C++泛型编程
引言
谈到编程范式,C++ 自诞生之初就自诩为一种“多范式”语言,而泛型编程,作为一种重要的编程范式,是 C++ 诞生时就支持的一种核心特性。
也许你觉得自己离泛型很远,平时也没有在自己的库或者应用中,使用泛型编程作为模块接口或对外接口,其实不是,我们平时用的 C++ 标准库 STL,甚至最常使用的 std::string,都是以泛型编程作为理念设计并实现的。
那泛型编程到底是什么?C++ 如何支持泛型能力,又存在哪些问题?这是我们今天要解决的问题。学完你就会明白,为何 Concepts 会是 C++ 泛型编程中兼具颠覆性与实用性的一种新特性。
课程配套代码,点击这里即可获取:https://github.com/samblg/cpp20-plus-indepth
模板:C++ 泛型编程的基石
长期以来,软件重用一直都是软件工程追求的目标,而泛型编程为软件重用创造了可能性。
所谓泛型编程,指的是通过组件的灵活组合来实现软件,而这些组件通过对定义做出最小“假设”来实现最大灵活性。在讨论泛型编程问题的时候,我们需要区分弱类型语言和强类型语言。
对于脚本语言,如 Perl、PHP、Python、JavaScript 或 Ruby 都属于弱类型语言,对它们来说,其变量本身并不区分类型,所有类型都是在运行时确定的,因此泛型能力被推迟到了运行时。这是一种语言设计的技巧,把这些复杂性交给运行时再决定。
但是这并不符合 C++ 的设计哲学,也就是那句话:“不为任何抽象付出不可接受的多余运行时性能损耗”。因此,像 C++ 这种强类型静态语言,所有的类型都必须要在编译时确定。但是在很多场景下,尤其是在我们编写“库”的时候,并不知道用户实际使用的是什么类型。
最典型的就是,数组这类“容器”或“集合类型”,如果语言本身不支持泛型编程,在强类型语言中,就要为用户可能使用的每种类型都定义相关实现,对于库的开发者来说,这是无法接受的。
现代化的高级编程语言,势必要对泛型编程提供语言层面的支持。那么 C++ 中是如何提供泛型编程支持的呢?就是我们所熟悉的——模板。
模板
在 C++ 中,我们可以在任何函数与类的定义 / 声明前,加上模板列表,在列表中指定在函数以及类的定义 / 声明中使用的模板参数。比如这段代码:
template <size_t Size, class T, typename U> void fillContainer(T& collection, U value) { for (size_t i = 0; i != Size; ++i) { collection.push_back(value); } }template 用于定义模板列表,<> 中就是定义的模板列表,其中,通过 class 定义的 T、typename 定义的 U 都是类型参数,通过 size_t 定义的 Size 是非类型参数。
可以看到,如果函数定义中包含模板参数,我们在定义时是不知道具体类型的,因此无论访问这些类型的成员变量还是成员函数,都不可能在解析模板函数定义的时候,得知这些成员变量的偏移与成员函数的地址。
那么在实际调用函数时,我们必须指定这些模板参数的具体类型或者值。
void c11() { std::vector<int32_t> vec; fillContainer<10>(vec, 0); std::list<int32_t> lst; fillContainer<10, std::list<int32_t>>(lst, 1); std::deque<int32_t> deq; fillContainer<10, std::deque<int32_t>, int32_t>(deq, 2); }在这段代码中,我多次调用了 fillContainer 函数,但每次的具体参数都不一样。对于 C++ 来说,像这段代码一样,只有在调用模板函数时,才知道模板参数对应的真实类型与值,并生成真正的函数代码。这个过程就是“模板实例化”。
但每次都要指定比较麻烦,为了方便开发者,函数调用时的模板参数,可以通过函数调用中的参数隐式推断出来,也就是“模板参数推导”。这样一来,我们只需要在实际需要告知编译器的时候,再明确指出就行了,很多时候不需要指定模板参数。当然,与函数的默认参数类似,只能省略右侧的模板参数,无法跳跃式地省略参数。
显式实例化
除了在调用时再实例化,也可以在全局范围内实例化特定版本的模板。
template <class T, typename U> void fillContainer(T& collection, U value, size_t size) { for (size_t i = 0; i != size; ++i) { collection.push_back(value); } } template void fillContainer<std::vector<int32_t>, int32_t>(std::vector<int32_t>& collection, int32_t value, size_t size);在这段代码中,我们直接对 fillContainer 模板进行了特化(见第 8 至 9 行),指定了 class T 和 typename U 的具体类型。实例化所在的编译单元,会以我们指定的模板参数进行实例化,并生成实例化后的符号——这就是所谓的“显式实例化”。
那么为什么需要这种实例化的能力呢?不是实际调用的时候就可以自动实例化吗?
究其原因,正是由于模板函数和类需要在调用时进行实例化。
我们知道 C++ 在生成的二进制文件中,基本不会留下任何不必要的源代码与元数据,因此,为了在调用模板时完成实例化,C++ 要求,模板定义必须写在头文件中,供编译单元通过 #include 指令包含到编译单元中。
这就导致了一个严重问题,所有定义了模板函数和模板类的库,如果想要把这些接口暴露给调用者使用,就必须要通过源代码的形式发布。这对很多不希望公布源代码的库开发者非常不利。同时,哪怕是内部项目,由于编译单元是彼此独立编译的,不同编译单元中相同版本的模板,实例化都是独立进行的。相同版本的模板实例化过程可能会进行多次,降低了编译速度。
因此,C++ 允许我们在编译单元中,实例化特定版本的模板函数和函数类,这样,其他编译单元就可以在编译时跳过实例化过程,并在链接阶段直接使用其他编译单元中“显式实例化”的符号。
特化与偏特化
模板还支持特化(full specializations)与偏特化(partial specializations)这两种特性,允许我们为特定类型的参数提供特定的实现版本,更好地提供类似于函数重载的支持,避免定义不同名称的函数。
在现代 C++14 之前,只有类型模板参数支持特化与偏特化。但在现代 C++14 之后,开发者也可以针对非类型模板参数,提供特化与偏特化版本,可以满足大多数的应用场景。对此,我们来看个例子,在模板函数 fillContainer 的第一个非类型参数 size_t Size。
template <size_t Size, class T, typename U> void fillContainer(T& collection, U value) { std::cout << "Universal" << std::endl; for (size_t i = 0; i != Size; ++i) { collection.push_back(value); } } template <> void fillContainer<10, std::vector<double>, double>( std::vector<double>& collection, double value ) { std::cout << "Explicit (full) template specialization" << std::endl; for (size_t i = 0; i != 10; ++i) { collection.push_back(value + 2.0); } } void c13() { std::vector<int32_t> intVec; fillContainer<5>(intVec, 10); std::vector<double> doubleVec; fillContainer<10>(doubleVec, 10.0); }在代码中,我们在第 11 行进行了非类型模板参数的特化,这个能力在 C++14 之后才被支持。
不定模板参数
现代 C++11 之后还提供了不定模板参数的能力,允许我们在模板定义中接受任意数量的参数列表,这在字符串格式化、函数对象等应用场景非常实用。
比如说,我们可以这样定义一个 sum 函数,求任意多个参数的和。
double sum() { return 0.0; } template <typename T, typename... Targs> double sum(T value, Targs... Fargs) { return static_cast<double>(value) + sum(Fargs...); }可以看到,模板参数的列表很特殊,它在 Targs 的模板参数列表定义中使用了 typename…,这种语法在 C++11 中称之为参数包(parameter pack),一个参数包允许接受 0 到多个模板参数,所以对于这个函数,下面所有的调用都是合法的。
double a1 = sum(); double a2 = sum(1); double a3 = sum(2, 3); double a4 = sum(4, 5, 6); double a5 = sum(7, 8.0f, 9, 10.0);C++ 允许模板参数列表接受包含不定参数的参数包,那我们如何在代码中使用参数包呢?C++ 提供的方案就是“参数包展开(pack expansion)”。还是这个例子,在 sum 函数中可以看到,使用 Fargs 时语法是 Fargs…,作用就是将 Fargs 这个参数包“展开”,也就是函数调用会依照下面的示例代码一样不断展开,形成一个“递归展开”的过程。
sum(1, 2, 3, 4); 1.0 + sum(2, 3, 4); 2.0 + sum(3, 4); 3.0 + sum(4); 4.0 + sum();这段代码值得我们仔细推敲。首先,在调用时传入的是 4 个参数,此时 Fargs 就包含 3 个参数;接着,递归调用后传入的是 3 个参数,Fargs 就包含 2 个参数……最终调用了参数数量为 0 的版本,此时递归返回,得到最后的和。
一般情况下,不定模板参数基本都会使用递归的方式实现,这样可以让编译器自动递归生成不同版本的函数,不需要开发者关心用户到底使用了什么类型的参数。这个特性为 C++ 提升类型安全,做出了进一步语言层面的支持,设计极具远见,C++ 中标准库 STL 就是以模板为基础设施,提供了大量的容器与算法支持,具体内容你可以看文末的小知识。
模板编程的优势与挑战
总的来说,泛型编程因 C++ 中的标准模板库而发展壮大,这一点是没有争议的。在现代 C++20 及后续演进标准出现前,C++ 已经很好地解决了两大泛型编程问题。
一是有强大的泛化能力和表达能力;二是相较于开发者手写代码,性能更好,几乎是零开销。绝大多数情况下,手写代码的性能远不及模板生成代码的性能,C++ 模板不仅能够在编译时完成海量计算任务,它还提供了前所未有的灵活性,而且没有性能损失——因为计算都发生在编译时。虽说如此,但是 C++ 模板因为严重缺乏良好的接口定义和规范,有一些问题没能妥善解决,主要有 4 大方面。
- 类型约束晦涩难懂
- 生成的代码急速膨胀
- ABI 兼容性糟糕
- 错误消息很难理解
晦涩难懂的类型约束
使用模板时我们经常会遇到两类需求,第一类是希望对传入的类型参数进行约束,第二类是在类型参数约束的基础上,为满足不同约束的参数提供不同的实现版本。
C++ 在匹配选择模板的过程采用的是 SFINAE 规则(感兴趣,可以参考文末的小知识),目的是为了能匹配到最合适的模板函数版本,避免出现不必要的编译错误。这种特性乍一看好像没什么价值,但后来大家发现模板自身其实是一种“图灵完备”的语言,由此才掀起“模板元编程”这一子领域,想来 C++ 之父自己都是没料到的。
从 C++11 开始提供了一套 type_traits 库,它可以帮助开发者在模板中,执行各种编译时元数据判定、类型诊断等操作,比如下面的代码就运用了 SFINAE 结合 type_traits。
template <typename T, std::enable_if_t< std::is_same< std::list<typename T::value_type>, T >::value, bool > = true> void printCollection(const T& a) { std::cout << "List version" << std::endl; for (auto element : a) { std::cout << element << " "; } std::cout << std::endl; } template <typename T, std::enable_if_t< std::is_same< std::vector<typename T::value_type>, T >::value, bool > = true> void printCollection(const T& a) { std::cout << "Vector version" << std::endl; for (auto element : a) { std::cout << element << " "; } std::cout << std::endl; } void c14() { std::vector<int32_t> arr1{ 1, 2, 3, 4, 5 }; printCollection(arr1); std::list<int32_t> arr2{ 1, 2, 3, 4, 5 }; printCollection(arr2); }我们定义了两个版本的 printCollection,第一个版本通过 is_same 匹配类型为 vector 的容器,第二个版本匹配类型为 list 的容器,这样通过类型判定,可以在模板中选择不同的实现版本。
虽然我们可以借助 SFINAE 和 type_traits 完成模板类型参数的诊断,但编写过程实在太复杂了,代码晦涩难懂。同时,这种机制也很难提供完善的报错信息,极端情况下,如果编译器发现调用者不匹配任何版本的函数,甚至连有用的报错信息都没有,需要调用者逐个检查函数版本的匹配情况——这太糟糕了,解决这个问题迫在眉睫。
急速膨胀的生成代码
模板带来的另一个问题就是会生成大量的冗余代码。
我在前面提到过,C++ 会根据模板标识(template-id)为同一个模板函数或者模板类生成不同版本的实现。模板标识,由模板名称(template-name)和其参数列表对应的实际类型 / 参数组成,也就是在一个编译单元中,模板标识相同的函数或者类,就会复用相同的代码。
这种机制可以为不同的模板标识生成较好的优化代码(理论上可以跨函数调用进行深度优化),尤其在编译器察觉需要内联的时候,但同样也存在两个问题。
- 虽然模板标识相同的函数都会复用相同的生成代码,但如果调用者调用使用的参数列表比较多,必然会为一个模板函数生成较多的实现代码。
- 最后链接生成的二进制文件中甚至可能会由两份完全相同的模板函数实现。
不幸的事情还在上演,模板还在 ABI 层面存在严重的兼容性问题。
糟糕的 ABI 兼容性
当开发者编写一些库想要导出某些符号给第三方使用的时候,我们经常会给一个建议:不要在对外暴露的接口中使用 STL 的类型。这是为什么呢?
STL 基本是我们在 C++ 中最常使用的基础设施,而且相比一些 C 类型具有更强的健壮性。比如,相比于 C 风格字符串的 char*,我们更愿意也更推荐使用 std::string 来传递字符串参数;相比于 C 的数组,我们自然也喜欢使用 std::vector 或者 std::array。
但是,很多时候库并不是以源代码形式发布的,而是以二进制形式发布。由于 STL 是 C++ 标准库的基础设施,允许我们以二进制形式发布自己的库。如果库的使用者和开发者使用的编译器版本完全一致,没有任何问题,但如果不一致呢?
那么,模板类在二进制层面的类型标识是完全不同的,结果就是链接错误。
目前,我们暂时没有很好的办法解决这个问题,所以只能建议开发者通过源代码的方式发布使用模板的库,并且在二进制发布的库的外部接口中,不要使用包括 STL 在内的任何模板类型。
难以理解的错误消息
除了这 3 个问题,模板给开发者或调用者带来的最大问题就是:出错时错误消息是难以理解的。比如下面这段代码,编译就肯定会出错。
#include <vector> #include <cstdint> class TestClass { public: TestClass(const TestClass&) = delete; int32_t getValue2() const { return 0; } }; void c10() { std::vector<TestClass> v(10); v[0] = v[1]; } int main() { c10(); return 0; }如果我们用 GCC,编译错误信息是这样的。
如果用 Visual C++,编译错误信息是这样的(只展示一页)。
对于这样的报错,如果你了解 STL 的实现方式,其实很容易想到错误的原因,因为代码中将拷贝构造函数标记为 delete,而 v[0]=v[1]这行代码必定会产生拷贝,因此会出现编译错误。
但如果开发者没有提前了解 STL 的实现方式呢?
如果你是第一次遇到这种错误,并想要从编译器给出的报错信息中找到有效的信息,那实在是太困难了。在截图中,g++ 这个版本可能还稍微好一点,虽然错误信息层次很深,但是信息还是比较简要的,一眼能够看出最后的原因(但如果是 C++ 开发新手,怕是看到最后一行也不明白发生了什么)。
而 Visual C++ 的错误信息,为了便于开发者了解模板实例化的过程,提供了每个层次的实例化,导致通篇错误信息中都没有打印出真正的错误到底是什么。如果我们拿到一个大规模的项目中,当出现一些模板错误后,想从这些海量的错误消息中找到有效的错误消息,简直是不可能完成的任务。
为什么会这样呢?究其原因,所有的模板代码是在实例化的时候再进行具体编译的。因此,只有遇到具体可能出现错误的问题点时,编译器才能(才会)报告相应的错误信息。
但是,通常来说,模板库的实现都非常复杂,嵌套层次很深。基本不是自己开发的模板库,想要找出错误,都很困难的。由于没有为开发者提供任何有效的手段定制错误消息,因此报错信息是很难让人理解的。
总结
今天我们首先了解了什么是泛型编程,它基于这样一个原则,即软件由组件构成,而组件只做出最小假设(约束),从而得到最大的组合灵活性。而强类型语言 C++ 中是如何提供泛型编程支持的呢,就是模板。
模板带来了强大的泛化能力和表达能力,也比开发者手写代码性能更好,几乎是零开销。但同时也有一些问题无法解决。
- 第一,缺乏语言内置支持的模板参数约束能力。
- 第二,报错信息难以理解,难以寻找错误根源。
- 第三,容易造成代码生成急剧膨胀。
- 第四,ABI 兼容性导致难以在接口中使用模板类型。
这些就是 Concepts 提出的重要原因。下一讲,我们将一起漫游 Concepts,看一看这个将对泛型编程世界和模板元编程带来翻天覆地变化以及深远影响的新特性…