|
| 1 | +\section{函数模版与\texttt{constexpr}} |
| 2 | +\subsection*{什么是泛型?} |
| 3 | +在尚未接触泛型的时候,我们为了完成某个函数/类,必须指明一种欲处理的数据类型。比如要写一个 \lstinline@max@ 函数,就要写一个 \lstinline@double@ 版本的;或者要写一个 \lstinline@valarray@ 数组,就要写一个 \lstinline@int@ 版本的。但是,作为一套通用的操作,它应该能处理不同类型的数据。比如说 \lstinline@max@ 函数,它应当可以处理各种基本数据类型的值才对。为此我们能做的就是重载。\par |
| 4 | +而类是不允许重载的。以 \lstinline@valarray@ 为例,无论 \lstinline@int@ 数据,还是 \lstinline@double@ 数据,还是 \lstinline@long long@ 数据,它们都是按照相同方式来存储的。如果我们希望有一个能处理 \lstinline@double@ 数据的版本,那么很不幸,我们必须把 \lstinline@valarri@ 的代码抄一遍,把其中的许多 \lstinline@int@ 改成 \lstinline@double@\footnote{未必全部 \lstinline@int@ 都需要改成 \lstinline@double@,因为有些 \lstinline@int@ 值可能只是用来作循环结构变量的。虽然我们统一使用了 \lstinline@std::size_t@ 类型,但是难免会因为习惯的缘故而不小心写出个别的 \lstinline@int@ 来。},然后再改一个类名,比如用 \lstinline@valarrd@。\par |
| 5 | +但无论从哪个角度上讲,这样不停地和类型打交道也太麻烦了点。泛型编程能让我们从繁琐的类型问题中解放出来,我们可以更多地关注这个函数/类具体做了什么,而不必在于类型。我们可以用一个抽象的类型名称 \lstinline@T@(或者你自定义的别的名称)来代表任何一种类型。 |
| 6 | +\begin{lstlisting} |
| 7 | +\template<typename T> |
| 8 | +T max(T a, T b) { |
| 9 | + return a > b ? a : b; |
| 10 | +} |
| 11 | +\end{lstlisting} |
| 12 | +这样一来,当我们在代码当中调用 \lstinline@max(int,int)@ 时,编译器就会察觉到这个需求,并为我们生成一个 \lstinline@int max(int,int)@ 函数来。当我们在代码当中调用 \lstinline@max(double,doule)@ 时,编译器也会察觉到这个需求,并为我们生成一个 \lstinline@double max(double,double)@ 函数来。\par |
| 13 | +定义一个函数模版的基本语法是 |
| 14 | +\begin{lstlisting} |
| 15 | +template<[模版参数列表]> |
| 16 | +[返回类型] [函数名]([函数参数列表]) { |
| 17 | + [函数体] |
| 18 | +} |
| 19 | +\end{lstlisting} |
| 20 | +在 \lstinline@template@ 块内的部分称为模版参数。模版参数可以是 \lstinline@typename@/\lstinline@class@ 关键字引出的类名\footnote{\lstinline@typename@ 和 \lstinline@class@ 关键字在这方面的作用相同,没有任何区别。但从习惯上讲,我们还是倾向于使用 \lstinline@typename@。},也可以是任何一个类的数据。 |
| 21 | +至于其它的部分,都和一般的函数没什么差别。只不过我们可以使用模版参数来指代一个``待定''的类名或者数据。 |
| 22 | +\begin{lstlisting} |
| 23 | +template<typename T> //T是模版参数,作为待定的类名 |
| 24 | +void swap(T &a, T &b) { //交换a和b的值 |
| 25 | + T tmp {a}; |
| 26 | + a = b; |
| 27 | + b = tmp; |
| 28 | +} |
| 29 | +\end{lstlisting} |
| 30 | +在这里,\lstinline@T@ 就是一个待定的类名。编译器会根据我们在代码中的实际需要,生成\lstinline@T=int@ 的版本、\lstinline@T=double@ 的版本、\lstinline@T=char@ 的版本等等。我们也可以为模版参数设定默认值,不过对于大多数函数模版来说,默认值是不怎么需要的东西。\par |
| 31 | +我们当然也可以交换两个指针的值。不过请读者注意,这时我们交换的就是指针的指向(也即,指针存储的地址值)而非内容了。比如,这样调用: |
| 32 | +\begin{lstlisting} |
| 33 | + int *p1 {new int [10]}, *p2 {new int[5]}; |
| 34 | + swap(p1, p2); //调用自定义的swap(T&,T&) |
| 35 | +\end{lstlisting} |
| 36 | +这样做会让 \lstinline@p1@ 与 \lstinline@p2@ 调换指向。\par |
| 37 | +\subsection*{数据模版参数} |
| 38 | +不过如果我们想要调换两个数组,问题就出现了: |
| 39 | +\begin{lstlisting} |
| 40 | +void swap(T &a, T &b){ |
| 41 | + T tmp {a}; |
| 42 | +//error: invalid conversion from 'int*' to 'int' [-fpermissive] |
| 43 | + a = b; |
| 44 | +//error: invalid array assignment |
| 45 | + b = tmp; |
| 46 | +//error: invalid array assignment |
| 47 | +} |
| 48 | +int main() { |
| 49 | + int a[3] {1,2,3}, b[3] {4,5,6}; |
| 50 | + swap(a, b); |
| 51 | + std::cout << a[0]; |
| 52 | +} |
| 53 | +\end{lstlisting} |
| 54 | +我来解释一下出现这个错误的原因:\par |
| 55 | +首先我们来看 \lstinline@main@ 函数。在这里,我们调用了 \lstinline@swap(a,b)@。因为 \lstinline@a@ 和 \lstinline@b@ 都是 \lstinline@int[3]@ 类型的,所以编译器会创建一个 \lstinline@T=int[3]@ 版本的 \lstinline@swap@ 函数,你可以理解成这样: |
| 56 | +\begin{lstlisting} |
| 57 | +void swap(int (&a)[3], int (&b)[3]) { //int(&)[3]意味着“对int[3]”数组的引用 |
| 58 | + int tmp[3] {a}; |
| 59 | + a = b; |
| 60 | + b = tmp; |
| 61 | +} |
| 62 | +\end{lstlisting} |
| 63 | +第一则报错信息的含义是:``\lstinline@int*@ 到 \lstinline@int@ 的类型转换是禁止的。''这是因为编译器把 \lstinline@int tmp[3] {a}@ 当作了数组的列表初始化来处理。在这个过程中 \lstinline@a@ 发生了数组到指针的隐式类型转换。然而,列表初始化所期待的是 \lstinline@int@ 类型,但 \lstinline@int*@ 类型却不能直接转换为 \lstinline@int@ 类型,所以编译器会有此报错。\par |
| 64 | +第二、三则报错信息说的是一回事。简单来说就是 \lstinline@int[3]@ 这样的数组不能直接赋值——其实这很好理解,因为我们在写 \lstinline@valarri@ 的时候也不是直接用数组赋值的方式来改写赋值运算符的啊;都是通过循环结构,一个个元素赋值的。\par |
| 65 | +总之问题的根源就出在数组类型上,编译器自动把 \lstinline@T@ 识别成了 \lstinline@int[3]@ 并生成了这个版本的函数,但它没有类似于拷贝构造的机制,也不允许使用赋值运算符来直接赋值,所以我们才遇到了这样的困难。\par |
| 66 | +解决方法就是写一个函数重载,专门处理数组类型,像这样: |
| 67 | +\begin{lstlisting} |
| 68 | +template<typename T> |
| 69 | +void swap(T& a, T& b) { |
| 70 | + T tmp {a}; |
| 71 | + a = b; |
| 72 | + b = tmp; |
| 73 | +} |
| 74 | +void swap(int (&a)[3], int (&b)[3]) { |
| 75 | + int tmp[3]; |
| 76 | + for (int i = 0; i < 3; i++) { |
| 77 | + tmp[i] = a[i]; |
| 78 | + a[i] = b[i]; |
| 79 | + b[i] = tmp[i]; |
| 80 | + } |
| 81 | +} |
| 82 | +int main() { |
| 83 | + int a[3] {1,2,3}, b[3] {4,5,6}; |
| 84 | + swap(a, b); |
| 85 | + std::cout << a[0]; |
| 86 | +} |
| 87 | +\end{lstlisting} |
| 88 | +这个 \lstinline@swap(int(&)[3],int(&)[3])@ 函数没有 \lstinline@template@ 关键字,这意味着它不是模版函数——或者说,它是非模版函数(Non-template function)。\par |
| 89 | +不过我们发现这个非模版函数的适用范围还是太窄了。它只能接收 \lstinline@int@ 数组的,而且还只能是 \lstinline@3@ 长度的数组。如果我们需要处理一个 \lstinline@double@ 数组呢?必须重载另一个非模版函数 \lstinline@void swap(double(&)[3],double(&)[3])@——那么我们又陷入到类型的麻烦之中了。\par |
| 90 | +所以我们还是需要使用模版来把类型抽象起来,写成这样: |
| 91 | +\begin{lstlisting} |
| 92 | +template<typename T> |
| 93 | +void swap(T (&a)[3], T (&b)[3]) { //把类型抽象起来,变成T[3] |
| 94 | + T tmp[3]; |
| 95 | + for (int i = 0; i < 3; i++) { |
| 96 | + tmp[i] = a[i]; |
| 97 | + a[i] = b[i]; |
| 98 | + b[i] = tmp[i]; |
| 99 | + } |
| 100 | +} |
| 101 | +\end{lstlisting} |
| 102 | +但是这样还不够。我们只是有了 \lstinline@T[3]@ 的版本,那么 \lstinline@T[4]@ 怎么办,\lstinline@T[5]@ 乃至其它的怎么办?所以我们还需要把数组长度这则信息抽象起来,变成一个模版参数 \lstinline@N@,其类型为 \lstinline@std::size_t@。 |
| 103 | +\begin{lstlisting} |
| 104 | +template<typename T, std::size_t N> //模版参数N表示这个数组的长度 |
| 105 | +void swap(T (&a)[N], T (&b)[N]) { //参数是两个T(&)[N] |
| 106 | + T tmp[N]; |
| 107 | + for (int i = 0; i < N; i++) { |
| 108 | + tmp[i] = a[i]; |
| 109 | + a[i] = b[i]; |
| 110 | + b[i] = tmp[i]; |
| 111 | + } |
| 112 | +} |
| 113 | +\end{lstlisting} |
| 114 | +这样我们就完成了一个针对一般数据类型的 \lstinline@swap@ 函数模版和一个针对一维数组的 \lstinline@swap@ 函数模版。\par |
| 115 | +\subsection*{函数模版中的代码重用} |
| 116 | +不知读者有没有想过,二维数组乃至更高维数组要怎么办? |
| 117 | +\begin{lstlisting} |
| 118 | + int a[2][3] {}, b[2][3] {{1,2,3},{4,5,6}}; |
| 119 | + swap(a, b); //相同的问题再次出现 |
| 120 | +\end{lstlisting}\par |
| 121 | +我们想,\lstinline@a@ 和 \lstinline@b@ 都是 \lstinline@int[2][3]@ 类型的。在调用 \lstinline@swap@ 时,编译器会选中 \lstinline@swap(T(&)[N],T(&)[N])@\footnote{对于函数重载/模版来说,C++规定了一套很复杂的函数选取过程(重载决议)。总之结果是选中了它。}来调用,此时应有 \lstinline@T=int[3]@, \lstinline@N=2@。问题来了:\lstinline@int[3]@ 类型的 \lstinline@a[i]@ 和 \lstinline@b[i]@ 仍然不支持直接赋值,那么我们岂不是还要再写一个二维数组的版本咯?\par |
| 122 | +其实不必,因为我们有更好的实现方法: |
| 123 | +\lstinputlisting[caption=\texttt{swap.h}]{code_in_book/11.1/swap.h} |
| 124 | +这样就够了吗?没错,这样就够了。在实现数组交换的时候,我们对每个数组对应位置的元素调用 \lstinline@user::swap@ 函数来进行交换。试想,当我们传入两个 \lstinline@int[3]@ 参数的时候,编译器会根据我们的需要,生成一个 \lstinline@T=int@, \lstinline@N=3@ 版本的 \lstinline@user::swap@ 函数;这个函数又需要调用一个 \lstinline@T=int@ 版本的 \lstinline@user::swap@ 函数(属于另一个函数模版),所以编译器也会生成之。\par |
| 125 | +而当传入两个 \lstinline@int[2][3]@ 参数的时候,会发生什么呢?首先,编译器生成一个 \lstinline@T=int[3]@, \lstinline@N=2@ 版本的函数,而这个函数需要调用一个 \lstinline@T=int@, \lstinline@N=3@ 版本的函数,所以编译器也会生成之。同上,这个函数又会调用 \lstinline@T=int@ 版本的函数,编译器也会生成之。\par |
| 126 | +总而言之,通过这样的写法,我们就保证了:只要能交换一维数组,就能交换二维数组;只要能交换二维数组,就能交换三维数组……以至任意高维的数组。有点像数学归纳法。这也是代码重用的魅力。\par |
| 127 | +\subsection*{\texttt{constexpr}的使用} |
| 128 | +通过上述内容,读者应该发现,函数模版不是一个函数,而是一套方案。编译器会根据这套方案,按照我们在代码中调用函数的情况——也就是实际需求,来生成相应的函数。这个过程自始至终完成在编译期,所以它们全都是编译时行为。\par |
| 129 | +编译时行为就意味着,我们不能把变量等运行时才能确定的信息作为模版参数。 |
| 130 | +\begin{lstlisting} |
| 131 | + int n; |
| 132 | + std::cin >> n; |
| 133 | + int a[n] {}, b[n] {}; //部分编译器支持这种语法,但它是不标准的 |
| 134 | + user::swap<int,n>(a, b); //不可以用变量作为模版参数 |
| 135 | +//error: the value of 'n' is not usable in a constant expression |
| 136 | +\end{lstlisting} |
| 137 | +对于模版参数来说,它们只能接收类型名和常量表达式作为模版参数,所以我们要么传入字面量,要么传入常量表达式,总之不能传入在运行时才能确定的量。\par |
| 138 | +对于一般情况来说,把字面量作为模版参数就是最好的选择了;如果出于统一的目的,可以用 \lstinline@constexpr@ 数据。\par |
| 139 | +不过有些时候我们难免要用到一点复杂的常量表达式,如果还要写成字面量的话就不太方便了——比如说,\lstinline@48@ 和 \lstinline@32@ 的最小公倍数。我们必须要人工把这个结果算出来,然后再把它写成一个 \lstinline@constexpr@ 数据。更可怕的是,如果我们需要很多组这样的最小公倍数,我们需要进行大量的人工计算——其实我们可以自己写一个函数,然而这些函数只能在运行时进行计算,但我们在编译时就需要这个值,那就不行了。\par |
| 140 | +从C++11起,我们可以用 \lstinline@constexpr@ 说明符来允许函数在编译期进行求值。简单点说,如果它的所有实参都是常量表达式,那么它将在编译期进行计算\footnote{实际的条件非常复杂,但是不会本书不打算在这里深究。另外,这个编译期计算的计算量也是有限制的,如果计算量过大,编译器将报错。};否则,它将在运行期进行计算,一切如常。以下是几个例子: |
| 141 | +\begin{lstlisting} |
| 142 | +constexpr unsigned long long factorial(unsigned long long n) { //阶乘 |
| 143 | +//如果提供的实参是常量表达式,这个函数的值将在编译期求得 |
| 144 | + return n ? n * factorial(n - 1) : 1; |
| 145 | +} |
| 146 | +constexpr unsigned long long fibonacci(unsigned long long N) { //斐波那契数 |
| 147 | + if (N == 0) |
| 148 | + return 0; |
| 149 | + if (N == 1) |
| 150 | + return 1; |
| 151 | + return fibonacci(N - 1) + fibonacci(N - 2); |
| 152 | +} |
| 153 | +constexpr unsigned gcd(unsigned a, unsigned b) { //最大公因数 |
| 154 | + return b ? gcd(b, a % b) : a; //欧几里得算法,细节就先不追究了 |
| 155 | +} |
| 156 | +constexpr unsigned lcm(unsigned a, unsigned b) { //最小公倍数 |
| 157 | + return a / gcd(a, b) * b; //只要a,b和gcd都是常量表达式,lcm也能在编译期求得 |
| 158 | +} |
| 159 | +\end{lstlisting} |
| 160 | +如果我们提供了合适的常量表达式作为实参,那么这些结果都可以在编译期求得。 |
| 161 | +\begin{lstlisting} |
| 162 | + constexpr unsigned long long F = factorial(10); //factorial(10)在编译期调用 |
| 163 | + std::cout << F << '\n'; |
| 164 | + int n {5}; |
| 165 | + std::cout << factorial(n); //实参n不是常量表达式,这个函数将在运行期调用 |
| 166 | +\end{lstlisting}\par |
| 167 | +这样一来,我们可以直接把 \lstinline@constexpr@ 函数的返回值当作模版参数了(前提是这个函数能在编译期计算)。\par |
0 commit comments