C++20:Concepts实战:实现一个向量计算模板库 引言Concept 之于 C 泛型编程正如 class 之于 C 面向对象。在传统的 C 面向对象编程中开发者在写代码之前要思考好如何设计“类”同样地在 C20 及其后续演进标准之后我们编写基于模板技术的泛型代码时必须先思考如何设计好“concept”。具体如何设计呢今天我们就来实战体验一下使用 C 模板编写一个简单的向量计算模板库。在开发过程中我们会大量使用 Concepts 和约束等 C20 以及后续演进标准中的特性重点展示如何基于模板设计与开发接口计算上如何通过 SIMD 等指令进行性能优化不是关注的重心。完成整个代码实现后我们会基于今天的开发体验对 Concepts 进行归纳总结进一步深入理解。课程配套代码https://github.com/samblg/cpp20-plus-indepth好话不多说我们直接开始。模块组织方式对于向量计算模板库这样一个项目我们首先要考虑的就是如何组织代码。刚刚学的 C Modules 正好可以派上用场作为工程的模块组织方式。后面是项目的具体结构。实现向量计算库的接口时我们会部分模仿 Python 著名的函数库 NumPy。因此向量库的模块命名为 numcppnamespace 也会使用 numcpp。先梳理一下每个模块的功能。main系统主模块调用 numcpp 模块完成向量计算numcpp工程主模块负责导入其他分区并重新导出numcpp:creation向量创建模块提供了基于 NDArray 类的向量创建接口numcpp:algorithm计算模块负责导入各类算法子模块numcpp:algorithm.aggregate聚合计算模块负责提供各类聚合计算函数。比如 sum、max 和 min 等聚合统计函数numcpp:algorithm.basic基础计算模块负责提供各类基础计算函数。比如加法、减法和点积等聚合统计函数numcpp:algorithm.universal通用计算模块负责提供各类通用计算函数。比如 reduce 和 binaryMap 等向量通用计算接口aggregate 和 basic 中部分函数就会基于该模块实现。有了清晰的模块划分我们先从接口开始编写再使用 Concepts 来约束设计的模板这将是今天的学习重点。接口设计首先我们来设计 numcpp 模块的接口模块 numcpp.cpp。export module numcpp; export import :array; export import :array.view; export import :creation; export import :algorithm;在这段代码中我们使用 export module 定义模块 numcpp然后使用 export import 导入几个需要导出的子分区包括 array、array.view、creation 和 algorithm。接下来还要创建向量的相关接口。auto arr1 numcpp::zerosint32_t({1, 2, 3}); auto arr2 numcpp::array({1, 2, 3}); auto arr3 numcpp::arraystd::initializer_liststd::initializer_listint32_t({ {1, 2}, {3, 4} }); auto arr4 numcpp::arraystd::vectorstd::vectorstd::vectorint32_t({ {{1, 2}, {3, 4}}, {{5, 6}, {7, 8}} }); auto arr5 numcpp::onesint32_t({ 1, 2, 3, 4, 5 });在这段代码中第 1 行通过 zeros 生成三维向量初始化列表中的参数{1, 2, 3}表示这个向量的 shape。其中三个维度的项目数分别为 1、2 和 3而 zeros 表示生成的向量元素初始值都是 0。第 2 行通过 array 创建了一维向量初始化列表中的参数{1, 2, 3}表示这个向量的元素为 1、2 和 3。第 3 行通过 array 创建了二维向量初始化列表中的参数{{1, 2}, {3, 4}}表示这个向量是一个 2 行 2 列的向量第一行的值为 1 和 2第二行的值为 3 和 4。第 4 行通过 array 创建了三维向量。初始化列表中的参数表示这个向量是一个 222 的向量。一共八个元素按照排列顺序分别是 1、2、3、4、5、6、7 和 8。第 8 行通过 ones 创建了五维向量初始化列表中的参数{1, 2, 3, 4, 5}表示这个向量的 shape。接下来是索引接口——用于从数组中获取到特定的值。int32_t value arr1[{0, 1, 2}]; std::cout Value: value std::endl;这一小段代码中通过[]来获取特定位置的元素在{}中需要给定位置的准确索引。那么这行代码的意思就是获取 arr1 中三个维度分别为 0、1、2 位置的元素。从功能上讲numcpp 也支持为多维数组创建视图所谓视图就是一个多维数组的一个切片但是数据依然直接引用原本的数组创建视图的方法是这样的。auto view1 arr1.view( {0, 2} ); auto view2 arr3.view({ {0, 1}, {1, 2} });这段代码中第 1 行调用 view 函数在数组 arr1 中创建了一个子视图从第一个维度切出了 0 到 2 这个区间注意这里的区间是左闭右开。第 5 行类似地调用 view 函数在数组 arr3 中创建了一个子视图第一个维度切出了 0 到 1第二个维度切出了 1 到 2。由于 arr3 是一个 2 × 2 的向量因此最后得到的视图是一个 2 × 1 的向量。现在我们继续为接口添加“基础计算”功能。auto arr6 numcpp::arraystd::vectorstd::vectorint32_t({ { 1, 2, 3 }, { 4, 5, 6} }); printArray(arr6); auto arr7 numcpp::arraystd::vectorstd::vectordouble({ { 3.5, 3.5, 3.5 }, { 3.5, 3.5, 3.5 } }); printArray(arr7); auto arr8 arr6 arr7; printArray(arr8); auto arr9 arr6 - arr7; printArray(arr9); auto arr10 numcpp::dot(arr6, arr7); printArray(arr10);在这段代码中分别在第 6、78、10 行调用了向量基本运算符——向量加法、减法和点积。它们的计算结果分别是返回两个向量所有相同位置元素和、差和乘积并生成一个新的向量。需要注意的是这里用 dot 而非重载 operator* 来实现向量点积。这是因为向量之间的乘法不止一种。我在这里定义一个 dot 函数是为了避免引起误解也减少用户的记忆负担。这些基础计算接口都要求两个输入向量的 shape 完全一致如果不同会直接抛出参数异常。接着我们为接口添加“聚合计算”功能。double sum numcpp::sum(arr10); std::cout Array10 sum: sum std::endl; double maxElement numcpp::max(arr10); std::cout Array10 max: maxElement std::endl; double minElement numcpp::min(arr10); std::cout Array10 min: minElement std::endl;在这段代码中我们分别在第 1 行、第 4 行和第 7 行调用了 sum、max、min 函数计算向量中所有元素的和、最大值和最小值。由于这几个函数都是聚合计算只会返回一个简单的浮点数。现在numcpp 的接口已经定义得差不多了。接下来我们进入 numcpp 的内部实现细节这将会涉及到重要的 C Modules 和 Concepts 的具体应用。Concepts 设计根据前面的接口定义我们先来考虑有哪些 Concepts 需要被应用到实现部分的代码中也就是 concepts.cpp 的编写这是今天最关键的部分。看这里的代码我定义了所有为该工程服务的通用 concept。export module numcpp:concepts; import concepts; import type_traits; namespace numcpp { // 直接使用type_traits中的is_integral_v进行类型判断 template class T concept Integral std::is_integral_vT; // 直接使用type_traits中的is_floating_v进行类型判断 template class T concept FloatingPoint std::is_floating_point_vT; // 约束表达式为Integral和FloatingPoint的析取式 template class T concept Number IntegralT || FloatingPointT; // 预先定义了IteratorMemberFunction这个类型表示一个返回值为T::iterator参数列表为空的成员函数 template class T using IteratorMemberFunction T::iterator(T::*)(); // 用IteratorMemberFunctionT(T::begin)从T中获取成员函数的函数指针 // 使用decltype获取类型判断该类型是否为一个成员函数 template class T concept HasBegin std::is_member_function_pointer_v decltype( IteratorMemberFunctionT(T::begin) ) ; // 使用IteratorMemberFunctionT(T::end)从T中获取成员函数的函数指针 // 使用decltype获取类型判断该类型是否为一个成员函数。 // 如果类型不包含end成员函数或者end函数的函数签名不同都会违反这个约束 template class T concept HasEnd std::is_member_function_pointer_v decltype( IteratorMemberFunctionT(T::end) ) ; // 约束表达式为HasBegin和HasEnd的合取式 template class T concept IsIterable HasBeginT HasEndT; // 首先使用IsIterableT约束类型必须可遍历 // 再使用Numbertypename T::value_type约束类型的value_type // 嵌套类型必须符合Number这个概念的约束 // 因此约束表达式也是一个合取式 template class T concept IsNumberIterable IsIterableT Numbertypename T::value_type; // 使用了requires表达式采用std::common_type_t判断From和To是否有相同的类型如果存在相同类型返回true否则返回false template class From, class To concept AnyConvertible requires { typename std::common_type_tFrom, To; } }这段代码不多但至关重要演示了如何在工程代码中合适地使用 Concepts。我们定义了几个核心 concept。Integral约束类型必须为整型。FloatingPoint约束类型必须为浮点型。Number约束类型必须为数字型接受整型或浮点型的输入。HasBegin约束类型必须存在 begin 这一成员函数。如果类型不包含 begin 成员函数或者 begin 函数的函数签名不同都会违反这个约束。HasEnd约束类型必须存在 end 成员函数。IsIterable约束类型是一个可以遍历的类型。IsNumberIterable约束类型是一个可以遍历的类型并且其值类型必须为数值类型。AnyConvertible约束两个类型任一方向可以隐式转换。好了现在我们有了接口定义和 Concepts 定义。准备工作结束接下来就是根据这些定义来实现具体的功能。我们会涵盖向量模块、构建模块、视图模块和计算模块。这些模块都是 C20 标准后定义的标准 Modules让我们先从向量模块的实现开始。再说明一下模块本身的功能不是今天的重点所以我们只会在涉及到 Concepts 和 C20 及其后续演进标准的部分着重讲解。向量模块向量模块是这个库的主要模块主要定义了多维数组类型而这个类型又使用了一些通用类型和通用工具函数分别定义在 types 和 utils 分区中所以我们接下来就分别看看这些实现。首先我们在 types.cpp 中定义了部分基础类型。export module numcpp:types; import vector; import cstdint; import tuple; namespace numcpp { export using Shape std::vectorsize_t; export class SliceItem { public: SliceItem(): _isPlaceholder(true) {} SliceItem(int32_t value) : _value(value) { } int32_t getValue() const { return _value; } bool isPlaceholder() const { return _isPlaceholder; } std::tupleint32_t, bool getValidValue(size_t maxSize, bool isStart) const { int32_t signedMaxSize static_castint32_t(maxSize); if (_isPlaceholder) { return std::make_tuple(isStart ? 0 : signedMaxSize, true); } if (_value signedMaxSize) { return std::make_tuple(signedMaxSize, true); } if (_value 0) { int32_t actualValue maxSize _value; return std::make_tuple(actualValue, actualValue 0); } return std::make_tuple(_value, true); } private: int32_t _value 0; bool _isPlaceholder false; }; export const SliceItem SLICE_PLACEHOLDER; }在这段代码中第 8 行定义了 Shape 类型该类型是 std::vector 的类型别名用于描述多维数组每个维度的元素数量vector 的长度也就是多维数组的维度数量。接着我们又定义了 SliceItem 类型用于描述视图切片区间的元素具体功能实现与 Python 中的类似。接着是一个简单的工具 utils.cpp 的实现。export module numcpp:utils; import :types; namespace numcpp { export size_t calcShapeSize(const Shape shape) { if (!shape.size()) { return 0; } size_t size 1; for (size_t axis : shape) { size * axis; } return size; } }这个模块非常简单目前只包含 calcShapeSize 函数用于计算一个 shape 的实际元素总数其数字为 shape 中所有维度元素数量之乘积。有了基本工具后我们必须先实现多维数组——这是向量计算的基本单位并将其放入在 array 模块分区源代码在 array.cpp 中export module numcpp:array; import cstdint; import cstring; import vector; import memory; import algorithm; import stdexcept; import tuple; import array; import :concepts; import :types; import :array.view; import :utils; namespace numcpp { export template Number DType class NDArray { public: using dtype DType; NDArray( const Shape shape, const DType* buffer nullptr, size_t offset 0 ) : _shape(shape) { size_t shapeSize calcShapeSize(shape); _data std::make_sharedDType[](shapeSize); if (!buffer) { return; } memcpy(_data.get(), buffer offset, shapeSize * sizeof(DType)); } NDArray( const Shape shape, const std::vectorDType buffer, size_t offset 0 ) : _shape(shape) { size_t shapeSize calcShapeSize(shape); _data std::make_sharedDType[](shapeSize); if (!buffer) { return; } if (offset buffer.size()) { return; } size_t actualCopySize std::min(buffer.size() - offset, shapeSize); memcpy(_data.get(), buffer.data() offset, actualCopySize * sizeof(DType)); } NDArray( const Shape shape, DType initialValue ) : _shape(shape) { size_t shapeSize calcShapeSize(shape); _data std::make_sharedDType[](shapeSize); std::fill(_data.get(), _data.get() shapeSize, initialValue); } NDArray( const Shape shape, std::shared_ptrDType[] data ) : _data(data), _shape(shape) { } const Shape getShape() const { return _shape; } size_t getShapeSize() const { return calcShapeSize(_shape); } NDArrayDType reshape(const Shape newShape) const { size_t originalShapeSize calcShapeSize(_shape); size_t newShapeSize calcShapeSize(newShape); if (originalShapeSize ! newShapeSize) { return false; } return NDArray(newShape, _data); } DType operator[](std::initializer_listsize_t indexes) { if (indexes.size() ! _shape.size()) { throw std::out_of_range(Indexes size must equal to shape size of array); } size_t flattenIndex 0; size_t currentRowSize 1; auto shapeDimIterator _shape.cend(); for (auto indexIterator indexes.end(); indexIterator ! indexes.begin(); --indexIterator) { auto currentIndex *(indexIterator - 1); flattenIndex currentIndex * currentRowSize; auto currentDimSize *(shapeDimIterator - 1); currentRowSize * currentDimSize; -- shapeDimIterator; } return _data.get()[flattenIndex]; } DType operator[](std::initializer_listsize_t indexes) const { if (indexes.size() ! _shape.size()) { throw std::out_of_range(Indexes size must equal to shape size of array); } size_t flattenIndex 0; size_t currentRowSize 1; auto shapeDimIterator _shape.cend(); for (auto indexIterator indexes.end(); indexIterator ! indexes.begin(); --indexIterator) { auto currentIndex *(indexIterator - 1); flattenIndex currentIndex * currentRowSize; auto currentDimSize *(shapeDimIterator - 1); currentRowSize * currentDimSize; --shapeDimIterator; } return _data.get()[flattenIndex]; } NDArrayViewDType view(std::tupleSliceItem, SliceItem slice) { return NDArrayViewDType(_data, _shape, { slice }); } NDArrayViewDType view(std::initializer_liststd::tupleSliceItem, SliceItem slices) { return NDArrayViewDType(_data, _shape, slices); } NDArrayViewDType view(std::initializer_liststd::tupleSliceItem, SliceItem slices) const { return NDArrayViewDType(_data, _shape, slices); } const std::shared_ptrDType[] getData() const { return _data; } std::shared_ptrDType[] getData() { return _data; } NDArrayDType clone() { size_t shapeSize calcShapeSize(_shape); std::shared_ptrDType[] newData std::make_sharedDType[](shapeSize); memcpy(newData.get(), _data.get(), shapeSize); return NDArrayDType(_shape, newData); } private: std::shared_ptrDType[] _data; Shape _shape; }; }从这段代码开始开始使用前面定义的 concept我们重点看。第 16 行定义了 NDArray 类型。这个类型是一个类模板模板参数 DType 使用了名为 Number 的 concept。NDArray 包含两个属性。_data其类型为 shared_ptr 智能指针通过引用计数来避免执行多维数组之间的拷贝几乎没有性能损耗。如果真的想要复制一份新的数据需要调用一百四十行的 clone 成员函数生成一个真正的拷贝。_shape其类型为我们在之前定义的 Shape用于描述多维数组每个维度的元素数量。第 148 行我们定义了一个类型为 DType[]的智能指针这是从 C20 开始支持的一个新特性。构建模块实现了向量模块之后我们来看构建模块的具体实现。构建模块实现在:creation 分区中creation.cpp 中的代码如下所示。export module numcpp:creation; import :array; import :concepts; import cstring; import memory; namespace numcpp { // 使用了名为IsNumberIterable的concept用于获取不包含子数组的数组的元素数量 export template IsNumberIterable ContainerType void makeContainerShape(Shape shape, const ContainerType container) { shape.push_back(container.size()); } // 使用了名为IsIterable的concept用于获取不满足IsNumberIterable约束的数组的元素数量并递归调用makeContainerShape函数获取该数组的第一个子数组的长度直到容器不包含子数组为止 export template IsIterable ContainerType void makeContainerShape(Shape shape, const ContainerType container) { shape.push_back(container.size()); makeContainerShape(shape, *(container.begin())); } /* * 用于帮助调用者获取一个多维容器类型的实际元素类型 * 该结构体定义也是一个递归定义 */ // 如果第34行或第40行都不匹配编译器会选用这一默认版本 export template typename struct ContainerValueTypeHelper { }; // 当模板参数类型符合IsNumberIterable这一concept的时候会选用这一版本 export template IsNumberIterable ContainerType struct ContainerValueTypeHelperContainerType { using ValueType ContainerType::value_type; }; // 当模板参数类型符合IsIterable这一concept的时候会选用这一版本 export template IsIterable ContainerType struct ContainerValueTypeHelperContainerType { using ValueType ContainerValueTypeHelper typename ContainerType::value_type ::ValueType; }; /* * fillContainerBuffer成员函数 * 该成员函数有两个重载版本 * 负责将多维容器中的数据拷贝到多维数组对象的数据缓冲区中 */ // 通过IsNumberIterable这一concept来约束调用该版本的参数必须是元素类型为Number的可迭代容器用于处理一维容器 export template IsNumberIterable ContainerType typename ContainerType::value_type* fillContainerBuffer( typename ContainerType::value_type* dataBuffer, const ContainerType container ) { using DType ContainerType::value_type; DType* nextDataBuffer dataBuffer; for (auto it container.begin(); it ! container.end(); it) { *nextDataBuffer *it; nextDataBuffer; } return nextDataBuffer; } // 通过IsIterable这一concept来约束调用该版本的参数必须是可迭代容器 // 由于存在IsNumberIterable的版本因此如果容器元素类型为Number则不会匹配该版本 export template IsIterable ContainerType typename ContainerValueTypeHelperContainerType::ValueType* fillContainerBuffer( typename ContainerValueTypeHelperContainerType::ValueType* dataBuffer, const ContainerType container ) { using DType ContainerValueTypeHelperContainerType::ValueType; DType* nextDataBuffer dataBuffer; for (const auto element : container) { nextDataBuffer fillContainerBuffer(nextDataBuffer, element); } return nextDataBuffer; } export template IsIterable ContainerType NDArraytypename ContainerValueTypeHelperContainerType::ValueType array( const ContainerType container ) { Shape shape; makeContainerShape(shape, container); size_t shapeSize calcShapeSize(shape); using DType ContainerValueTypeHelperContainerType::ValueType; auto dataBuffer std::make_sharedDType[](shapeSize); fillContainerBuffer(dataBuffer.get(), container); return NDArrayDType(shape, dataBuffer); } export template Number DType NDArrayDType array( const std::initializer_listDType container ) { Shape shape; makeContainerShape(shape, container); size_t shapeSize calcShapeSize(shape); using ContainerType std::initializer_listDType; auto dataBuffer std::make_sharedDType[](shapeSize); fillContainerBuffer(dataBuffer.get(), container); return NDArrayDType(shape, dataBuffer.get()); } export template Number DType NDArrayDType zeros(const Shape shape) { return NDArrayDType(shape, 0); } export template Number DType NDArrayDType ones(const Shape shape) { return NDArrayDType(shape, 1); } }这段代码中你可以重点关注第 17 行我们利用了模板约束的偏序特性实现了一个递归的 makeContainerShape 函数并定义了函数的终止条件。这也是 C 模板元编程中递归函数的常见实现方式。只不过相比传统的 SAFINE 方式concept 为我们提供了更清晰简洁的实现方式。视图模块构建模块实现完后我们来看视图模块的具体实现。对于一个向量计算库来说很多时候都需要从多维数组中进行灵活地切片并生成多维数组的视图。这个时候就需要数组视图的功能这里我们在 array_view.cpp 中实现了 array.view 模块代码如下所示。export module numcpp:array.view; import memory; import stdexcept; import iostream; import algorithm; import :concepts; import :types; namespace numcpp { export template Number DType class NDArrayView { public: NDArrayView( std::shared_ptrDType[] data, Shape originalShape, std::vectorstd::tupleSliceItem, SliceItem slices ) : _data(data), _originalShape(originalShape), _slices(slices) { this-generateShape(); } std::shared_ptrDType[] getData() const { return _data; } DType operator[](std::initializer_listsize_t indexes) { if (indexes.size() ! _shape.size()) { throw std::out_of_range(Indexes size must equal to shape size of array); } size_t flattenIndex 0; size_t currentRowSize 1; auto shapeDimIterator _shape.cend(); auto originalShapeDimIterator _originalShape.cend(); for (auto indexIterator indexes.end(); indexIterator ! indexes.begin(); --indexIterator) { auto currentIndex *(indexIterator - 1); auto currentDimOffset *(originalShapeDimIterator - 1); flattenIndex (currentDimOffset currentIndex) * currentRowSize; auto currentDimSize *(shapeDimIterator - 1); currentRowSize * currentDimSize; --shapeDimIterator; } return _data.get()[flattenIndex]; } bool isValid() const { return _isValid; } const Shape getShape() const { return _shape; } private: void generateShape() { _isValid true; _shape.clear(); _starts.clear(); auto originalShapeDimIterator _originalShape.begin(); for (const std::tupleSliceItem, SliceItem slice : _slices) { auto originalShapeDim *originalShapeDimIterator; SliceItem start std::get0(slice); SliceItem end std::get1(slice); auto [actualStart, startValid ] start.getValidValue(originalShapeDim, true); auto [actualEnd, endValid] end.getValidValue(originalShapeDim, false); if ((!startValid !endValid) || actualStart actualEnd ) { _isValid false; break; } if (actualStart 0) { actualStart 0; } _shape.push_back(static_castsize_t(actualEnd - actualStart)); _starts.push_back(static_castsize_t(actualStart)); originalShapeDimIterator; } } private: std::shared_ptrDType[] _data; Shape _originalShape; std::vectorstd::tupleSliceItem, SliceItem _slices; Shape _shape; std::vectorsize_t _starts; bool _isValid false; }; }这段代码没有使用 concept但使用了 Modules理解它对理解视图很有帮助因此我们简单看下。类成员函数 _data 和 _originalShape 分别来源于原数组的数据指针和 Shape这样在原数组的数据发生变化时视图依然可以引用相关数据毕竟视图的本质就是数组的引用所以存储数据的引用也是合情合理的。_slices 用于生成该视图的切片数据。_shape、_starts 是根据多维数组原始 shape 和切片综合计算得到的新视图的 shape以及视图相对于原数组在各个维度上的起始索引。计算模块了解了向量模块、构建模块和视图模块的实现我们最后讲解一下计算模块。计算模块中主要实现了各类算法算法分为基础算法、聚合算法和通用算法模块的接口代码实现在 algorithm/algorithm.cpp主要导入并重新导出了所有的子模块。因此我们有了如下所示的模块设计。export module numcpp:algorithm; export import :algorithm.basic; export import :algorithm.aggregate; export import :algorithm.universal;基础计算首先我们看一下基础算法的实现基础算法的实现在 algorithm/basic.cpp 中。后面是具体代码。export module numcpp:algorithm.basic; import memory; import stdexcept; import type_traits; import :types; import :concepts; import :array; import :utils; namespace numcpp { export template Number DType1, Number DType2 requires (AnyConvertibleDType1, DType2) NDArraystd::common_type_tDType1, DType2 operator( const NDArrayDType1 lhs, const NDArrayDType2 rhs ) { using ResultDType std::common_type_tDType1, DType2; std::shared_ptrDType1[] lhsData lhs.getData(); Shape lhsShape lhs.getShape(); std::shared_ptrDType2[] rhsData rhs.getData(); Shape rhsShape rhs.getShape(); if (lhsShape ! rhsShape) { throw std::invalid_argument(Lhs and rhs of operator must have the same shape); } size_t shapeSize calcShapeSize(lhsShape); std::shared_ptrResultDType[] resultData std::make_sharedResultDType[](shapeSize); ResultDType* resultDataPtr resultData.get(); const DType1* lhsDataPtr lhsData.get(); const DType2* rhsDataPtr rhsData.get(); for (size_t datumIndex 0; datumIndex ! shapeSize; datumIndex) { resultDataPtr[datumIndex] lhsDataPtr[datumIndex] rhsDataPtr[datumIndex]; } return NDArray(lhsShape, resultData); } export template Number DType1, Number DType2 requires (AnyConvertibleDType1, DType2) NDArraystd::common_type_tDType1, DType2 operator-( const NDArrayDType1 lhs, const NDArrayDType2 rhs ) { using ResultDType std::common_type_tDType1, DType2; std::shared_ptrDType1[] lhsData lhs.getData(); Shape lhsShape lhs.getShape(); std::shared_ptrDType2[] rhsData rhs.getData(); Shape rhsShape rhs.getShape(); if (lhsShape ! rhsShape) { throw std::invalid_argument(Lhs and rhs of operator must have the same shape); } size_t shapeSize calcShapeSize(lhsShape); std::shared_ptrResultDType[] resultData std::make_sharedResultDType[](shapeSize); ResultDType* resultDataPtr resultData.get(); const DType1* lhsDataPtr lhsData.get(); const DType2* rhsDataPtr rhsData.get(); for (size_t datumIndex 0; datumIndex ! shapeSize; datumIndex) { resultDataPtr[datumIndex] lhsDataPtr[datumIndex] - rhsDataPtr[datumIndex]; } return NDArray(lhsShape, resultData); } }我们在代码中实现了向量加法和向量减法。仔细观察两个函数的声明你会发现我们除了在模板参数列表中使用 Number 来限定 T1 和 T2 的基本类型还在参数列表后使用了requires 子句——要求 T1 和 T2 必须是可以相互转换的数值类型才能进行算术运算。聚合计算接下来我们来看一下聚合算法的实现聚合算法实现在 algorithm/aggreagte.cpp 中。该模块实现了 sum 和 max 函数分别用于求一个向量中所有元素的和以及一个向量中所有元素的最大值。由于并不涉及有关 concept 的代码逻辑为了让你聚焦主线代码实现部分我们省略一下这部分你可以参考完整的项目代码。通用函数通用函数是为用户对向量执行计算提供一个计算框架。在基础计算和聚合计算中我们看到了两类通用的计算需求。基础计算中对两个向量的元素逐个计算转换生成新的计算结果并生成新的向量新向量的 shape 和输入向量是保持一致的我们将这种计算需求称之为 binaryMap二元映射。聚合计算中对一个向量中的元素逐个计算处理各个元素的时候还需要考虑前面几个元素的处理结果最后返回聚合计算的结果这种计算需求我们称之为 reduce。对这两个通用函数的实现在 algorithms/universal.cpp 中。export module numcpp:algorithm.universal; import functional; import numeric; import :types; import :concepts; import :array; import :utils; namespace numcpp { export template Number DType using ReduceOp std::functionDType(DType current, DType prev); export template Number DType DType reduce( const NDArrayDType ndarray, ReduceOpDType op, DType init static_castDType(0) ) { using ResultDType DType; std::shared_ptrDType[] data ndarray.getData(); Shape shape ndarray.getShape(); const DType* dataPtr data.get(); size_t shapeSize calcShapeSize(shape); return std::reduce( dataPtr, dataPtr shapeSize, init, op ); } export template Number DType1, Number DType2 requires (AnyConvertibleDType1, DType2) using BinaryMapOp std::function std::common_type_tDType1, DType2(DType1 current, DType2 prev) ; export template Number DType1, Number DType2 requires (AnyConvertibleDType1, DType2) NDArraystd::common_type_tDType1, DType2 binaryMap( const NDArrayDType1 lhs, const NDArrayDType2 rhs, BinaryMapOpDType1, DType2 op ) { using ResultDType std::common_type_tDType1, DType2; std::shared_ptrDType1[] lhsData lhs.getData(); Shape lhsShape lhs.getShape(); std::shared_ptrDType2[] rhsData rhs.getData(); Shape rhsShape rhs.getShape(); if (lhsShape ! rhsShape) { throw std::invalid_argument(Lhs and rhs of operator must have the same shape); } size_t shapeSize calcShapeSize(lhsShape); std::shared_ptrResultDType[] resultData std::make_sharedResultDType[](shapeSize); ResultDType* resultDataPtr resultData.get(); const DType1* lhsDataPtr lhsData.get(); const DType2* rhsDataPtr rhsData.get(); for (size_t datumIndex 0; datumIndex ! shapeSize; datumIndex) { resultDataPtr[datumIndex] op(lhsDataPtr[datumIndex], rhsDataPtr[datumIndex]); } return NDArray(lhsShape, resultData); } }在这段代码中第 36 行定义了 BinaryMap 操作所需的函数类型BinaryMap 函数需要的是两个序列相同位置的两个元素并计算返回一个数值。在这里我们通过 requires (AnyConvertible) 这一约束表达式进行约束。第 42 行定义了 binaryMap 函数。这个函数的内部实现和基础计算模块中的加法减法是一样的只不过最后加减法改成了调用 op 而已。这里我们用跟第 36 行一样的约束表达式对函数进行约束。深入理解 Concepts好的 concept 设计可以从根本上解决 C 泛型编程中缺乏好的接口定义的问题。因此在学习了实际工程中设计和使用了 Concepts 的方法后我们有必要探讨一下什么才是好的 concept 设计对比有助于我们加深理解先看看我们所熟悉的面向对象编程的情况。在类的设计中我们经常会提到三个基本特性封装、继承与多态。在 C 中使用面向对象的思想设计类时需要考虑如何通过组合或继承的方式来提升类的复用性同时通过继承和函数重载实现面向对象的“多态”特性。而这些问题和思想在 Concepts 和泛型编程中也同样存在。首先我们需要考虑通过组合来提升 concept 的复用性。在这一讲中我们先定义了 Integral 和 FloatingPoint 这两个基本 concept然后通过组合定义了 Number 这一 concept。作为类比考虑面向对象的思路设计类时我们可能也会先设计一个 Number 类然后设计继承 Number 类的 Integral 和 FloatingPoint 类。也就是说在面向对象思想中公有继承包含了 is-a 这个隐喻。那么在泛型编程的 concept 组合中我们不也包含了 is-a 的隐喻只不过是倒过来的Integral is a Number, FloatingPoint is a Number同理于 IsNumberIterable 和 IsIterable。所以组合与继承并非面向对象的“专利”——我们可以在泛型编程中使用组合与继承来实现面向模板的类型也就是 Concepts。其次concept 的设计也使得泛型编程能够更好地具备“多态”的特性。作为类比考虑面向对象的思路设计类时对一个 Integral 的 print 函数和一个 Number 类的 print 函数我们可以通过继承与覆盖C 中的虚函数实现“多态”。这一章中我们定义 creation 模块时用到的 fillContainerBuffer、makeContainerShape 和 ContainerValueTypeHelper 这些约束表达式就利用了 concept 的“原子约束”特性选择不同的模版版本实现了泛型编程中的“多态”特性。到这里我们应该就可以理解为什么说 Concepts 与约束是 C20 以及后续演进标准之后实现泛型编程的复用和“多态”特性的重要基石了。此外Concepts 还给模板元编程带来了巨大提升。模板元编程已经成为现代 C 不可或缺的一部分因此学习和掌握模板元编程的基本概念变得越来越重要。模板元编程的本质就是以 C 模板为语言以编译期常量表达式为计算定义以编译期常量包括普通常量与类型的编译期元数据为数据最终实现在编译时完成所有的运算包括类型运算与数值运算。虽然 C11 提供了很多模板元编程的基础设施但缺乏一种标准的抽象手段来描述模板参数的约束这也使得模板元编程中各个模板之间缺乏描述调用关系的简单手段。尤其是递归计算的定义令人更加头痛。对于代码中的 ContainerValueTypeHelper 的实现来说在使用 concept 后代码更加简洁易懂这就是 concept 为模板元编程带来的重要提升。我们知道SFINAE 是自模板技术诞生以来就存在的一个规则。该规则让开发者可以通过一些方式让编译器根据模板参数类型选择合适的模板函数与模板类。但是在 C11 标准中加入了 type_traits 后我们就可以在模板中通过标准库获取静态元数据并决定模板类与函数的匹配与调用路径。不过这种在模板参数或函数参数列表中填充 type_traits 的方式会让开发者的代码变得难以维护而且用户更是难以阅读调用时的错误消息这让 type_traitis 冲突时的偏序规则难以捉摸。而 Concepts 与约束的提出正好完美地解决了这些问题。由此C20 就成了继 C11 后让模板元编程脱胎换骨的一个标准。总结在 C20 及其后续演进标准中提供了使用编译期常量表达式编写模板参数约束的能力并通过 Concepts 提供了为约束表达式起名的能力。设计 Concepts 是一件非常重要的事情。我们通过实战案例展示了如何利用 concept 这一核心语言特性变更实现了编译时模板匹配和版本选择时的 SFINAE 原则并通过“原子约束”的特性实现了根据不同的约束选择不同的模板版本。通过这三章的内容相信你也感受到了我们在现代 C 时代绕不开泛型编程。掌握 C 模板元编程的基础知识并将这些新特性应用到编写的代码中来改善编程体验和编译性能对一名 C 开发者来说至关重要。