学习C++模版完全是因为Nvidia。之前看一个Nvidia的视频,里面提到了要好好学习C++知识;最近学cutlass又用到了大量的C++模版;在知乎上看有人说看了 C++ Templates - The Complete Guide, 2nd Edition 之后神清气爽,惊叹还有这种操作。于是,C++ template,启动!
雾里看花:真正意义上的理解 C++ 模板
我看的入坑文章,很有意思!
四个主题:
- 代码生成 (Code Generation)
- TMP (Template meta programming 模板元编程)
- 类型约束 (Type Constraint)
- 通过requires我们能阻止编译错误的传播
- 编译时计算 (Compile-time Computing)
- C++ 允许在一个函数前面直接加上constexpr关键字修饰。表示这个函数既可以在运行期调用,也可以在编译期调用,而函数本身的内容几乎不需要任何改变。这样一来,我们可以直接把运行期的代码复用到编译期。
- 操纵类型 (Type Manipulation)
- 在 C++ 中类型不是一等公民,只能作为模板参数,在涉及到类型相关的计算的时候,我们就不得不编写繁琐的模板元代码
- 类型约束 (Type Constraint)
模板设计之初的意图并不是实现后面这三个功能,但是最后却通过一些奇怪的 trick 实现了这些功能,代码写起来也比较晦涩难懂,所以一般叫做元编程。
Code Generation
在加入模板之前,我们只能通过宏来模拟泛型。
1 |
|
把普通函数中的类型替换成宏参数,通过宏符号拼接来为不同的类型参数生成不同的名字。再通过IMPL宏来为特定的函数生成定义,这个过程可以叫做实例化 (instantiate)。
而这主要有如下几个缺点
- 代码可读性差,宏的拼接和代码逻辑耦合,报错信息不好阅读
- 很难调试,打断点只能打到宏展开的位置,而不是宏定义内部
- 需要显式写出类型参数,参数一多起来就会显得十分冗长
- 必须手动实例化函数定义,在较大的代码库中,往往一个泛型可能有几十个实例化,全部手动写出过于繁琐
这些问题,在模板中都被解决了: 1
2
3
4
5
6
7
8
9
10
11
12template <typename T>
T add(T a, T b) {
return a + b;
}
template int add<>(int, int); // explicit instantiation
int main() {
add(1, 2); // auto deduce T
add(1.0f, 2.0f); // implicit instantiation
add<float>(1, 2); // explicitly specify T
}
- 模板就是占位符,不需要字符拼接,和普通的代码别无二致,仅仅多了一项模板参数声明
- 报错和调试都能准确的指向模板定义的位置,而不是模板实例化的位置
- 支持模板参数自动推导,不需要显式写出类型参数,同时也支持显式指定类型参数
- 支持隐式实例化 (implicit instantiation),即由编译器自动实例化使用到的函数。也支持显式实例化 (explicit instantiation),即手动实例化。
除此之外,还有诸如偏特化 (partial specialization),全特化 (full specialization),可变模板参数 (variadic template),变量模板 (variable template) 等等一系列特性,这些仅凭宏都是做不到的。正是由于模板的出现,才使用 STL 这样的泛型库的实现成为可能。
小试牛刀 Templates in C++
C++ template is a powerful tool that allows you to write a generic code that can work with any data type. The idea is to simply pass the data type as a parameter so that we don't need to write the same code for different data types.
Templates are expanded at compiler time.
Templates vs Function Overloading
在 C++中,函数重载和函数模板都允许我们创建可以操作不同类型数据的函数。虽然它们看起来相似,但它们用于不同的目的。
当你需要对不同类型或数量的输入执行相似操作时,使用函数重载。
当你需要一个单一的函数来处理不同的数据类型,而无需为每种类型重写函数时,使用函数模板。
Feature | Function Overloading | Function Templates |
---|---|---|
Purpose | Define multiple functions with the same name but different parameters | Define a single function to work with different data types |
Syntax | void functionName(int a); void functionName(double a); void functionName(int a, int b); |
template void functionName(T a); template T functionName(T a, T b); |
Code Duplication | Multiple functions, potentially similar code | Single function, no code duplication |
Type Safety | Checked at compile-time | Checked at compile-time |
Readability | Clear which function is called based on parameters | More abstract, but cleaner for multiple types |
Flexibility | Limited to predefined function signatures | More flexible, works with any data type |
Maintenance | Harder to maintain due to multiple functions | Easier to maintain, single function definition |
结论:
Function overloading and function templates are both important features of C++ that allow for flexible and reusable code. We can use function overloading when we have similar functions that operate on different types or numbers of parameters and use function templates when we want a single function to work with different data types, reducing code duplication and increasing flexibility.
- 当我们有相似但操作不同类型或参数数量的函数时,可以使用函数重载;
- 当我们希望一个函数能处理不同的数据类型,减少代码重复并提高灵活性时,可以使用函数模板。
C++ Templates - The Complete Guide, 2nd Edition
第 1 章 函数模板(Function Templates)
函数模板是被参数化的函数,因此他们代表的是一组具有相似行为的函数。
严格地从语言角度来看,inline 只意味着在程序中函数的定义可以出现很多次。不过它也给了编译器一个暗示,在调用该函数的地方函数应该被展开成 inline 的:这样做在某些情况下可以提高效率,但是在另一些情况下也可能降低效率。
从 C++11 开始,你可以通过使用关键字 constexpr 来在编译阶段进行某些计算。
总结: 函数模板定义了一组适用于不同类型的函数。
- 当向模板函数传递变量时,函数模板会自行推断模板参数的类型,来决定去实例化出那种类型的函数。
- 你也可以显式的指出模板参数的类型。
- 你可以定义模板参数的默认值。这个默认值可以使用该模板参数前面的模板参数的类型,而且其后面的模板参数可以没有默认值。
- 函数模板可以被重载。
- 当定义新的函数模板来重载已有的函数模板时,必须要确保在任何调用情况下都只有一个模板是最匹配的。
- 当你重载函数模板的时候,最好只是显式地指出了模板参数得了类型。
- 确保在调用某个函数模板之前,编译器已经看到了相对应的模板定义。
第 2 章 类模板(Class Templates)
总结
类模板是一个被实现为有一个或多个类型参数待定的类。
- 使用类模板时,需要显式或者隐式地传递相应的待定类型参数作为模板参数。之后类模板会被按照传入的模板参数实例化(并且被编译)。
- 对于类模板,只有其被用到的成员函数才会被实例化。
- 可以针对某些特定类型对类模板进行特化。
- 也可以针对某些特定类型对类模板进行部分特化。
- 从 C++17 开始,可以(不是一定可以)通过类模板的构造函数来推断模板参数的类型。
- 可以定义聚合类的类模板。
- 调用参数如果是按值传递的,那么相应的模板类型会 decay。
- 模板只能被声明以及定义在 global 或者 namespace 作用域,或者是定义在其它类的定义里面。
第 3 章 非类型模板参数
- 模板的参数不只可以是类型,也可以是数值。
- 不可以将浮点型或者 class 类型的对象用于非类型模板参数。使用指向字符串常量,临时变量和子对象的指针或引用也有一些限制。
- 通过使用关键字 auto,可以使非类型模板参数的类型更为泛化。
第 4 章 变参模板
- 通过使用参数包,模板可以有任意多个任意类型的参数。
- 为了处理这些参数,需要使用递归,而且需要一个非变参函数终结递归(如果使用编译期判断,则不需要非变参函数来终结递归)。
- 运算符 sizeof...用来计算参数包中模板参数的数目。
- 变参模板的一个典型应用是用来发送(forward)任意多个任意类型的模板参数。
- 通过使用折叠表达式,可以将某种运算应用于参数包中的所有参数。
第 5 章 基础技巧
typename 关键字
关键字 typename 在 C++标准化过程中被引入进来,用来澄清模板内部的一个标识符代表的是某种类型,而不是数据成员。
通常而言,当一个依赖于模板参数的名称代表的是某种类型的时候,就必须使用typename。
zero initialization
在定义模板时,如果想让一个模板类型的变量被初始化成一个默认值,那么只是简单的定义是不够的,因为对内置类型,它们不会被初始化
对于内置类型,最好显式的调用其默认构造函数来将它们初始化成0(对于bool 类型,初始化为 false,对于指针类型,初始化成 nullptr)
使用 this->
对于类模板,如果它的基类也是依赖于模板参数的,那么对它而言即使x 是继承而来的,使用 this->x 和 x 也不一定是等效的。
使用裸数组或者字符串常量的模板
如果参数是按引用传递的,那么参数类型不会退化(decay)。也就是说当传递”hello” 作为参数时,模板类型会被推断为 char const[6]。这样当向模板传递长度不同的裸数组或者字符串常量时就可能遇到问题,因为它们对应的模板类型不一样。只有当按值传递参数时,模板类型才会退化(decay),这样字符串常量会被推断为 char const *。
为了使用依赖于模板参数的类型名称,需要用 typename 修饰该名称。
为了访问依赖于模板参数的父类中的成员,需要用 this->或者类名修饰该成员。
嵌套类或者成员函数也可以是模板。一种应用场景是实现可以进行内部类型转换的泛型代码。
模板化的构造函数或者赋值运算符不会取代预定义的构造函数和赋值运算符。
使用花括号初始化或者显式地调用默认构造函数,可以保证变量或者成员模板即使被内置类型实例化,也可以被初始化成默认值。
可以为裸数组提供专门的特化模板,它也可以被用于字符串常量。
只有在裸数组和字符串常量不是被按引用传递的时候,参数类型推断才会退化。(裸数组退化成指针)
可以定义变量模板(从 C++14 开始)。
模板参数也可以是类模板,称为模板参数模板(template template parameters)。
模板参数模板的参数类型必须得到严格匹配。
第 6 章 移动语义和enable_if<>
移动语义(move semantics)是 C++11 引入的一个重要特性。在 copy 或者赋值的时候,可以通过它将源对象中的内部资源 move(“steal”)到目标对象,而不是copy 这些内容。当然这样做的前提是源对象不在需要这些内部资源或者状态(因为源对象将会被丢弃)。移动语义对模板的设计有重要影响,在泛型代码中也引入了一些特殊的规则来支持移动语义。本
第 7 章 按值传递还是按引用传递?
从一开始,C++就提供了按值传递(call-by-value)和按引用传递(call-by-reference)两种参数传递方式,但是具体该怎么选择,有时并不容易确定:通常对复杂类型用按引用传递的成本更低,但是也更复杂。C++11 又引入了移动语义(move semantics),也就是说又多了一种按引用传递的方式
当按值传递参数时,原则上所有的参数都会被拷贝。因此每一个参数都会是被传递实参的一份拷贝。对于 class 的对象,参数会通过 class 的拷贝构造函数来做初始化。调用拷贝构造函数的成本可能很高。但是有多种方法可以避免按值传递的高昂成本:事实上编译器可以通过移动语义(move semantics)来优化掉对象的拷贝,这样即使是对复杂类型的拷贝,其成本也不会很高。
当按值传递参数时,参数类型会退化(decay)。也就是说,裸数组会退化成指针,const 和 volatile 等限制符会被删除(就像用一个值去初始化一个用 auto 声明的对象那样)
按引用传递不会拷贝对象(因为形参将引用被传递的实参)。而且,按引用传递时参数类型也不会退化(decay)。不过,并不是在所有情况下都能使用按引用传递,即使在能使用的地方,有时候被推断出来的模板参数类型也会带来不少问题
为在底层实现上,按引用传递还是通过传递参数的地址实现的。地址会被简单编码,这样可以提高从调用者向被调用者传递地址的效率。不过按地址传递可能会使编译器在编译调用者的代码时有一些困惑:被调用者会怎么处理这个地址?理论上被调用者可以随意更改该地址指向的内容。
第 8 章 编译期编程
模板的实例化发生在编译期间(而动态语言的泛型是在程序运行期间决定的)。事实证明C++模板的某些特性可以和实例化过程相结合,这样就产生了一种 C++自己内部的原始递归的“编程语言”。因此模板可以用来“计算一个程序的结果”。
C++11 引入了一个叫做 constexpr 的新特性,它大大简化了各种类型的编译期计算。如果给定了合适的输入,constexpr 函数就可以在编译期间完成相应的计算。虽然C++11 对constexpr 函数的使用有诸多限制(比如 constexpt 函数的定义通常都只能包含一个return 语句),但是在 C++14 中这些限制中的大部分都被移除了。当然,为了能够成功地进行constexpr 函数中的计算,依然要求各个计算步骤都能在编译期进行:目前堆内存分配和异常抛出都不被支持。
- 模板提供了在编译器进行计算的能力(比如使用递归进行迭代以及使用部分特例化或者?:进行选择)。
- 通过使用 constexpr 函数,可以用在编译期上下文中能够被调用的“常规函数(要有constexpr)”替代大部分的编译期计算工作。
- 通过使用部分特例化,可以基于某些编译期条件在不同的类模板实现之间做选择。
- 模板只有在被需要的时候才会被使用,对函数模板声明进行替换不会产生有效的代码。这一原理被称为 SFINAE。
- SFINAE 可以被用来专门为某些类型或者限制条件提供函数模板。
- 从 C++17 开始,可以通过使用编译期 if 基于某些编译期条件启用或者禁用某些语句。
第 9 章 在实践中使用模板
模板代码和常规代码有些不同。从某种程度上而言,模板介于宏和常规函数声明之间。虽然这样说可能过分简化了。它不仅会影响到我们用模板实现算法和数据结构的方法,也会影响到我们日常对包含模板的程序的分析和表达。
将源代码分成头文件和 CPP 文件是为了遵守唯一定义法则(one-definition rule, ODR)
破译大篇幅的错误信息
第 10 章 模板基本术语
对那些是模板的类,函数和变量,我们称之为类模板,函数模板和变量模板。
模板实例化过程是一个用实参取代模板参数,从而创建常规类或者函数的过程。最终产生的实体是一个特化。
类型可以是完整的或者非完整的。
根据唯一定义法则(ODR),非 inline 函数,成员函数,全局变量和静态数据成员在整个程序中只能被定义一次。
第 23 章 元编程
为什么需要元编程?和其它编程技术一样,目的是用尽可能少的“付出”,换取尽可能多的功能,其中“付出”可以用代码长度、维护成本之类的事情来衡量。元编程的特性之一是在编译期间(at translation time,翻译是否准确?)就可以进行一部分用户定义的计算。其动机通常是性能(在 translation time 执行的计算通常可以被优化掉)或者简化接口(元-程序通常要比其展开后的结果短小一些),或者两者兼而有之。
在纷繁多变的世界里茁壮成长:C++ 2006–2020
学习期间解锁了 C++ 之父 Bjarne Stroustrup 的 HOPL4 论文。
设计 C++ 是为了回答这样的一个问题:
- How do you directly manipulate hardware and also support efficient high-level abstraction?
- 如何直接操作硬件,同时又支持高效、高级的抽象?
Abstractions are represented in code as functions, classes, templates, concepts, and aliases.
- 抽象在代码中体现为函数、类、模板、概念和别名。