第二章 语言可用性的强化——模板
C++ 的模板被称作 C++ 的黑魔法,甚至可以独立作为一门新的语言来进行使用。
模板的哲学在于一切能够在编译期处理的问题全部都丢到编译期进行处理,仅在运行时处理那些最核心的动态服务,进而大幅度优化性能。
外部模板
传统 C++ 中,模板只有在使用时才会被编译器实例化。只要在每个编译单元(文件)中遇到了被完整定义的模板,都会实例化。这就产生了重复实例化而导致编译时间增加。并且,我们没有办法通知编译器不要触发模板的实例化。
C++11 引入了外部模板,扩充了原来的强制编译器在特性位置实例化模板的语法,使我们能够显示的通知编译器何时进行模板的实例化。如下代码:
template class vector<bool>; //强行实例化
extern template class vector<double>; //不在当前文件中实例化模板
尖括号">"
在传统C++的编译器中,>>一律被当作右移运算符来进行处理。但是,很容易就会出现下面的代码:
vector<vector<int>> matrix;
传统 C++ 编译器下不能通过编译,从 C++11 开始,连续右尖括号是合法的。甚至,可以写出如下代码:
template<bool T>
class MagicType
{
bool magic = T;
};
vector<MagicType<(1 < 2)>> magic; //合法,但是非常难看,不建议
有些代码合法,但是从可读性的角度是不合理的,要杜绝。除非为了性能考虑,必须写成可读性不好的代码,但是这种情况非常少。
类型别名模板
模板是用来产生类型的。这句话应该是 C++ 模板的核心了。
在传统 C++ 中,typedef 可以为类型定义一个新的名称,但是没有办法为模板定义一个新的名称。因为模板不是类型。如下代码:
template<typename T, typename U>
class MagicType
{
public:
T dark;
U magic;
};
typedef MagicType<vector<T>, string> FakeDarkMagic; //不合法
通常,使用 typedef 定义别名的语法是:typedef 原名称 新名称;但是对函数指针的别名定义的语法却不一样。
C++11 使用 using 引入了下面的写法:
typedef int (*process)(void *);
using NewProcess = int(*)(void *); //和上面的效果一样
using TrueDarkMagic = MagicType<vector<T>, string>; //合法
变长参数模板
在 C++11 之前,无论是类模板还是函数模板,都只能是一组固定的模板参数。C++11 允许任意个数任意类型的模板参数,同时也不需要在定义时将参数的个数确定。
template<typename... Ts> class Magic
{
//这块代码书上的没有定义,跑不了,添加一个空定义
};
class Magic<int,
vector<int>,
map<string, vector<int>>> darkMagic;
class Magic<> nothing; //参数个数为 0 也是可以的
如果不希望产生的模板参数为 0 ,可以手动定义至少任意数量的模板参数,如下代码:
template<typename T1, typename T2, typename... Ts> class Magic
{
};
class Magic<int, int, string, string> darkMagic1; //可以正常调用
class Magic<int> darkMagic2; //错误,参数过少
变长函数参数包
变长参数模板可以直接用到模板函数上。比如传统 C++ 的 printf 函数虽然也能进行不定参数的调用,但是并非类型安全,我们可以自己定义一个,如下代码:
template<typename... Args>
void printf(const string &str, Args... args)
{
//个人还是喜欢分行写,这样可以避免单行代码过长,造成阅读困难
}
变长参数的个数
使用 sizeof… 来计算参数个数,如下代码:
template<typename... Ts>
void magic(Ts... args)
{
cout << sizeof...(args) << endl;
}
magic(); //输出 0
magic(1); //输出 1
magic(i, ""); //输出 2
解包方法:1.递归模板函数
递归是一种非常经典的方法,也是使用最多的方法。这种方法不断递归地向函数传递模板参数,进而达到递归遍历所有模板参数的目的。如下代码:
#include <iostream>
template<typename T0>
void MyPrintf1(T0 value)
{
cout << value << endl;
}
template<typename T, typename... Ts>
void MyPrintf1(T value, Ts... args)
{
cout << value << endl;
MyPrintf(args...);
}
int main()
{
MyPrintf(1, 2, "Unreal Engine", 1.1);
return 0;
}
/* 控制台输出
1
2
Unreal Engine
1.1
*/
解包方法:2.变参模板展开
上面的方法有点繁琐,C++17 中增加了便参模板展开的支持,于是可以出现下面比较简洁的代码:
template<typename T0, typename... T>
void MyPrintf2(T0 t0, T... t)
{
cout << t0 << endl;
if constexpr (sizeof...(t) > 0) MyPrintf(t...);
}
有时候,我们可能不需要逐个遍历,这个时候可以利用 std::bind(即将弃用) 及完美转发来实现对函数和参数的绑定,从而达到目的。
这里不会深究完美转发,所以就不展示代码了。
解包方法:3.初始化列表展开
递归模板参数是一种标准的做法,但是要求必须有一个终止递归的函数。
书上这里又给出来一种黑魔法(不知道书里为啥这么喜欢这个词),如下代码:
template<typename T, typename... Ts>
auto MyPrintf3(T value, Ts... args)
{
cout << value << endl;
(void)initializer_list<T>
{
([&args] {cout << args << endl; }(), value)...
};
}
MyPrintf3(1, "Unreal Engine", 1.23);
/*控制台输出
1
Unreal Engine
1.23
*/
上面的代码比较玄学,涉及到的 C++ 特性也比较多。初始化列表、Lambda 表达式、逗号表达式、可变参数模板。其实,这里复杂的主要是这一行代码:
([&args] {cout << args << endl; }(), value)...
先看逗号表达式的部分。逗号表达式的第一个参数是一个 Lambda 表达式,第二个参数是 value。第二个参数是逗号表达式最后一个参数,将作为逗号表达式的值。这样可以保证,initializer_list 列表中有参数,并且都是同一类型的值。
再看里面的 Lambda 表达式。[&args] 捕获了 Ts… args 参数包, { } 中是具体的函数实现。由于没有参数传入,省去了 ( ) 中的传参列表。后面的 ( ) 小括号表示这里即使定义,也是调用。
最后看最外层的初始化列表的部分。通过初始化列表的特性(对传参列表进行展开操作),(Lambda 表达式, value)… 将被展开。因为初始化列表并没有被使用(没有初始化对象),为了防止编译器警告,显示的转换为 void 。
折叠表达式
C++17 中引入了折叠表达式,让解包工作在某些特定的情况下变得容易。如下代码:
#include <iostream>
template<typename... T>
auto sum(T... t)
{
ruturn (t + ...);
}
cout << sum(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) << endl; //输出 55
关于更多的折叠表达式的知识,以后我会单独的开一个 C++17 的专栏。
非类型模板参数推导
让不同的字面量成为模板参数,也就是非类型模板参数。如下代码:
template <typename T, int BufSize>
class buffer_t
{
public:
T& alloc();
void free(T& item);
private:
T data[BufSize];
}
buffer_t<int, 100> buf; //100 作为模板参数
C++17 可以让我们通过 auto 关键字,在模板参数中,让编译器完成类型推到工作。如下代码:
template<auto value>
void foo()
{
cout << value << endl;
return;
//书上这行 return 代码属实没理解上去
//这行代码去掉也没有关系,不会报错也不会报警告
}
int main()
{
foo<10>(); //输出 10,value 被推到为 int
return 0;
}
总结
书中多次将模板称作 C++ 的黑魔法,也有一些狂热的模板编程爱好者。模板编程的核心就是尽可能将运行阶段处理的事情,提前到编译阶段,以此提高运行的效率。但是这也会带来一个问题,会延长 C++ 代码的编译时间。
本人作为一个虚幻引擎的使用者,使用虚幻引擎进行游戏开发。虚幻引擎为了提高运行效率,底层源码使用了大量的模板。我目前的能力和工作内容,暂时还没有接触到编写一个模板工具库。我也和我们公司的后端老哥探讨过这个事,基本上和我们 C 端是一样的。也就是说,在工作中,你可能会使用别人写好的模板,很少有机会自己去开发一个模板库。虽然我们很少会编写一个模板,我们也要了解模板的机制和原理,以及调用规则。这就好比你使用枪械打仗,你可能不知道这把枪的具体制作流程。但是,基本的组装枪械,上子弹等等的表层东西,你应该是要掌握的。