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::vectorint32_t vec; fillContainer10(vec, 0); std::listint32_t lst; fillContainer10, std::listint32_t(lst, 1); std::dequeint32_t deq; fillContainer10, std::dequeint32_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 fillContainerstd::vectorint32_t, int32_t(std::vectorint32_t collection, int32_t value, size_t size);在这段代码中我们直接对 fillContainer 模板进行了特化见第 8 至 9 行指定了 class T 和 typename U 的具体类型。实例化所在的编译单元会以我们指定的模板参数进行实例化并生成实例化后的符号——这就是所谓的“显式实例化”。那么为什么需要这种实例化的能力呢不是实际调用的时候就可以自动实例化吗究其原因正是由于模板函数和类需要在调用时进行实例化。我们知道 C 在生成的二进制文件中基本不会留下任何不必要的源代码与元数据因此为了在调用模板时完成实例化C 要求模板定义必须写在头文件中供编译单元通过 #include 指令包含到编译单元中。这就导致了一个严重问题所有定义了模板函数和模板类的库如果想要把这些接口暴露给调用者使用就必须要通过源代码的形式发布。这对很多不希望公布源代码的库开发者非常不利。同时哪怕是内部项目由于编译单元是彼此独立编译的不同编译单元中相同版本的模板实例化都是独立进行的。相同版本的模板实例化过程可能会进行多次降低了编译速度。因此C 允许我们在编译单元中实例化特定版本的模板函数和函数类这样其他编译单元就可以在编译时跳过实例化过程并在链接阶段直接使用其他编译单元中“显式实例化”的符号。特化与偏特化模板还支持特化full specializations与偏特化partial specializations这两种特性允许我们为特定类型的参数提供特定的实现版本更好地提供类似于函数重载的支持避免定义不同名称的函数。在现代 C14 之前只有类型模板参数支持特化与偏特化。但在现代 C14 之后开发者也可以针对非类型模板参数提供特化与偏特化版本可以满足大多数的应用场景。对此我们来看个例子在模板函数 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 fillContainer10, std::vectordouble, double( std::vectordouble 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::vectorint32_t intVec; fillContainer5(intVec, 10); std::vectordouble doubleVec; fillContainer10(doubleVec, 10.0); }在代码中我们在第 11 行进行了非类型模板参数的特化这个能力在 C14 之后才被支持。不定模板参数现代 C11 之后还提供了不定模板参数的能力允许我们在模板定义中接受任意数量的参数列表这在字符串格式化、函数对象等应用场景非常实用。比如说我们可以这样定义一个 sum 函数求任意多个参数的和。double sum() { return 0.0; } template typename T, typename... Targs double sum(T value, Targs... Fargs) { return static_castdouble(value) sum(Fargs...); }可以看到模板参数的列表很特殊它在 Targs 的模板参数列表定义中使用了 typename…这种语法在 C11 中称之为参数包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 中的标准模板库而发展壮大这一点是没有争议的。在现代 C20 及后续演进标准出现前C 已经很好地解决了两大泛型编程问题。一是有强大的泛化能力和表达能力二是相较于开发者手写代码性能更好几乎是零开销。绝大多数情况下手写代码的性能远不及模板生成代码的性能C 模板不仅能够在编译时完成海量计算任务它还提供了前所未有的灵活性而且没有性能损失——因为计算都发生在编译时。虽说如此但是 C 模板因为严重缺乏良好的接口定义和规范有一些问题没能妥善解决主要有 4 大方面。类型约束晦涩难懂生成的代码急速膨胀ABI 兼容性糟糕错误消息很难理解晦涩难懂的类型约束使用模板时我们经常会遇到两类需求第一类是希望对传入的类型参数进行约束第二类是在类型参数约束的基础上为满足不同约束的参数提供不同的实现版本。C 在匹配选择模板的过程采用的是 SFINAE 规则感兴趣可以参考文末的小知识目的是为了能匹配到最合适的模板函数版本避免出现不必要的编译错误。这种特性乍一看好像没什么价值但后来大家发现模板自身其实是一种“图灵完备”的语言由此才掀起“模板元编程”这一子领域想来 C 之父自己都是没料到的。从 C11 开始提供了一套 type_traits 库它可以帮助开发者在模板中执行各种编译时元数据判定、类型诊断等操作比如下面的代码就运用了 SFINAE 结合 type_traits。template typename T, std::enable_if_t std::is_same std::listtypename 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::vectortypename 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::vectorint32_t arr1{ 1, 2, 3, 4, 5 }; printCollection(arr1); std::listint32_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::vectorTestClass 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看一看这个将对泛型编程世界和模板元编程带来翻天覆地变化以及深远影响的新特性…