recent
C++ Templates(6):模版实践与泛型库

《C++ Templates》读书笔记(6),个人翻译

2022 Mar 14 15:57
Tags: C&C++
C++ Templates(5):编译时编程

《C++ Templates》读书笔记(5),个人翻译

2022 Mar 14 12:33
Tags: C&C++
C++ Templates(4):移动与引用

《C++ Templates》读书笔记(4),个人翻译

2022 Mar 13 14:27
Tags: C&C++
C++ Templates(3):基本技术

《C++ Templates》读书笔记(3),个人翻译

2022 Mar 12 17:53
Tags: C&C++
C++ Templates(2):非类模版参数

《C++ Templates》读书笔记(2),个人翻译

2022 Mar 12 14:57
Tags: C&C++
μblog

C++ Templates(6):模版实践与泛型库

1. 包含模式

1.1 链接错误

        大部分C/C++程序员通常以这种方式组织非模版代码:

  • 类和其他类型声明在头文件中;
  • 全局变量和非内联函数只在头文件中声明,定义在另一个单独的翻译单元中。

        这种方式让类型定义在整个程序中可用并且避免了链接过程中重复定义的错误。在这种方便方式的前提下,很多初学模版的程序员会对将定义声明在头文件中的方式感到抱怨。如果我们将模版函数的声明和定义分开:

#ifndef MYFIRST_HPP
#define MYFIRST_HPP

template<typename T>
void printTypeof(T const&);

#endif // MYFIRST_HPP

#include <iostream>
#include <typeinfo>
#include "myfirst.hpp"

template<typename T>
void printTypeof(T const& x) {
  std::cout << typeid(x).name() << '\n';
}

#include "myfirst.cpp"

int main() {
  double ice = 3.0;
  printTypeof(ice);
}

        C++编译器很可能会编译这个程序,但是链接器会产生错误,错误为没有 $printTypeof$ 函数的定义。这个错误的原因是函数模版的定义无法被实例化。要实例化一个模版,编译器必须知道应该实例化哪些定义以及哪些模版参数应该被实例化。不幸的是,在上面的例子中,这两个信息都被单独编译了。因此,当我们的编译器想实例化 $printTypeof$ 的调用但是没有看到定义时,它只能假设这个定义在别处被提供了并且创建了一个引用 ( 给链接器解析 )。另一方面,当编译器处理 $myfirst.cpp$ 文件时,并没有任何提示说明它应该实例化出特定类型参数的模版定义,从而链接器无法解析出先前创建的引用。

1.2 头文件中的模版

        之前问题的通用解决方案是采用与宏和内联函数相同的方式:在头文件中定义声明的模版。

#ifndef MYFIRST_HPP
#define MYFIRST_HPP

#include <iostream>
#include <typeinfo>

template<typename T>
void printTypeof(T const&);

template<typename T>
void printTypeof(T const& x) {
  std::cout << typeid(x).name() << '\n';
}

#endif // MYFIRST_HPP

        这种组织模版的方式称为包含模式。通过这种方式,程序现在可以正常编译、链接和执行了。
        这里有几点我们要注意下。最重要的一点是这种方式使得包含头文件的开销变得相当大。在这个例子中,开销主要不是来源于模版定义的代码体积,而是我们在定义模版的过程中使用的其他两个头文件—— $iostream$ 和 $typeinfo$ 。你会发现这两个头文件中有着一大堆代码,因为它们本身也有着很多模版定义。
        在实践中,这个问题很重要,因为它会导致一些重要程序的编译时间增加。因此,我们将通过一些可行的方法来解决这个问题,包括预编译头和显式模版特例化。尽管存在编译时间问题,我们还是推荐使用包含模式来组织你的模版,直到有新的更好的机制推出。模块 ( $modules$ ) 在C++20被引入,这是一种允许程序员以更加逻辑化的方式组织代码的机制,通过它,编译器可以独立编译所有声明并且之后可以在需要的时候选择性地高效地导入处理好的声明。
        另一个要注意的点是包含模式下非内联模版函数和内联函数以及宏是不同的,它们不会在被调用时展开。相反,当它们被实例化时,会创建一个函数副本。因为这是自动进行的,编译器可能会在两个不同的文件中创建相同的副本。理论上,这不应该由我们来关心,这是C++编译系统该关心的。工程上这种方式大部分时候是可以的,我们完全不需要解决这个问题。对于大型项目,当它们创建自己的代码库,这个问题可能偶尔会出现。
        最后,我们还要注意在上面的例子中,适用于普通函数模版的方式,也适用于类模版中的成员函数和静态数据成员,同样也适用于成员函数模版。

2. 模版和内联

        将函数声明为内联是一种在运行时提升程序速度的通用手段。$inline$ 关键字暗示在函数调用时,会优先将函数体内联替代调用,而不是执行函数调用机制。然而,函数实现会忽略这种暗示。因此,$inline$ 能保证的效果只有允许函数定义在一个程序内多次出现 ( 因为定义会在头文件里并且被多次包含 )。
        类似于内联函数,函数模版也可以在多个翻译单元中被定义,通常通过在头文件中定义并被多个源文件包含实现。这不意味着函数模版会是默认内联的,它是完全由编译器决定的。可能会有点出人意料,通常编译器评估一个函数内联后是否能提升性能的能力比程序员更好。因此,不同编译器处理 $inline$ 关键字的方式是不同的,甚至取决于你指定的编译方式。
        尽管如此,通过合适的监测工具,程序员可以比编译器知道更多的信息,因此希望覆盖编译器的决定 ( 例如在针对不同平台调优时 )。有时候只能通过编译器属性实现这个功能,例如 $noinline$ 或者 $always_-inline$ 。
        完全实例化函数模版的行为与普通函数一样,它们的定义只能出现一次,除非被定义为 $inline$ 。

3. 预编译头

        就算没有模版,C++头文件也是很大的,需要编译很久。模版增加了这种趋势,这种让程序员等到抓狂的情况,驱使了供应商实现一种称为预编译头 ( $precompiled$ $headers$ ,$PCH$ ) 的方法。这个方法在标准之外,行为取决于特定的供应商。尽管我们没有提到在多种C++编译系统中怎么创建和使用预编译头文件,但是了解一下它们是怎么工作的是很有帮助的。
        当编译器翻译一个文件,它从文件开始翻译到文件结束。当它处理文件符号时,它会修改内部状态,例如往符号表中添加表项。当做这些工作的时候,编译器也可能在对象文件中生成代码。预编译头方法是基于代码中的许多文件以相同的 $N$ 行开始这个事实的。让我们假设每个文件都是以相同的 $N$ 行开始的,我们可以编译这 $N$ 行然后在预编译头中保存编译器状态。然后,在我们程序的每个文件,我们可以重新装载之前保存的状态,然后从 $N + 1$ 行开始编译。这样我们就可以说装载之前保存的状态是一个比编译前 $N$ 行快几个数量级的操作。然而,在第一次编译时保存状态的开销比直接编译 $N$ 行的开销要大,这个开销提升可能从 $20\% \sim 200\%$ 不等。
        使用预编译头的关键是尽可能保证在文件的开始使用最大数目相同的代码。在工程中,这意味着文件必须从相同的 #$include$ 指示开始,这会占用很大一部分编译时间。因此,关注头文件包含的顺序是很有用的。例如:

#include <iostream>
#include <vector>
#include <list>

#include <list>
#include <vector>

        这两个文件就不能使用预编译头了,因为它们的起始状态不同。
        一些程序员觉得可以通过包含一些不必要的头文件从而让编译器使用预编译头来加速编译,这个想法可以很大程度上简化包含策略的管理。例如,直接创建一个包含所有标准头文件的头文件:

/* std.hpp */
#include <iostream>
#include <string>
#include <vector>
#include <deque>
#include <list>

        这个文件可以被预编译,并且每个使用标准库的程序文件都可以直接简单地包含这个头文件。通常,这个文件需要一段时间来编译,但是如果系统有着足够的内存,预编译头方法就可以让它的处理速度比大部分不使用预编译的单独处理每个标准库的方法快很多。标准头文件适合在这种方法中使用,因为它们几乎不改变,因此我们定义的 $std.hpp$ 预编译头只需要编译一次。否则,预编译头通常会是项目依赖配置的一部分 ( 例如,通过 $make$ 或者IDE来按需更新 )。
        一个管理预编译头的方式是创建预编译头文件 ( $layers$ ),涵盖最常用、最稳定的头文件到大部分时间不会改变的头文件。然而,如果头文件会被频繁修改,那么为它们创建预编译头花费的时间会比使用预编译头节省的时间要多。这种方式的一个重要用法是使用一个更稳定的预编译头文件层提升相对来讲更不稳定的头文件的预编译速度。例如,假设除了 $std.hpp$ ,我们还定义了一个 $core.hpp$ 头文件,包含了我们项目需要的额外的工具,具有一定程度的稳定性。

#include "std.hpp"
#include "core_data.hpp"
#include "core_algos.hpp"

        因为这个文件先包含了 $std.hpp$ ,编译器可以装载相关联的预编译头,就不需要重新编译整个标准头文件了。当这个文件处理完成后,会生成一个新的预处理头文件。应用之后在使用 $core.hpp$ 时候,就可以快速访问其中定义的功能了。

4. 可调用实体

        许多库包含了用于客户端代码传递调用实体的接口,例如需要在其他线程执行的某个操作、哈希函数、用于集合排序的对象或者提供一些默认参数值的泛型封装。标准库定义了许多可以携带可调用实体的组件。用于这种情况的术语是回调 ( $callback$ ),通常它指代作为参数传递给函数调用的实体。例如,一个排序函数可能包含一个回调参数作为排序标准,当判断哪个元素在前时会调用。
        C++有许多作为回调的有用的类型,因为它们既可以作为函数调用参数,也可以直接被调用。这些类型有:

  • 函数指针;
  • 重载了 $operator(\ )$ 函数的类;
  • 可以转为函数指针或者函数引用的类。

        总的来说,这些类型被称为函数对象类型 ( $function$ $ojbect$ $types$ ),这种类型的值称为函数对象 ( $function$ $object$ )。C++标准库稍微扩展了可调用类型 ( $callable$ $type$ ) 的概念,使得它既可以是一个函数对象类型也可以是一个成员指针。可调用类型值称为可调用对象 ( $callable$ $object$ )。
        通过模版,泛型代码通常可以接收任意类型的可调用对象。

4.1 函数对象支持

        让我们看看标准库 $for_-each$ 算法是怎么实现的:

template<typename Iter, typename Callable>
void foreach(Iter current, Iter end, Callable op) {
  while (current != end) {
    op(*current);
    ++current;
  }
}

        我们可以这样使用:

#include <iostream>
#include <vector>
#include "foreach.hpp"

void func(int i) {
  std::cout << "func() called for: " << i << '\n';
}

class FuncObj {
public:
  void operator()(int i) const {
    std::cout << "FuncObj::op() called for: " << i << '\n';
  }
};

int main() {
  std::vector<int> primes = {2, 3, 5, 7, 11, 13, 17, 19};
  foreach(primes.begin(), primes.end(), func);
  foreach(primes.begin(), primes.end(), &func);
  foreach(primes.begin(), primes.end(), FuncObj());
  foreach(primes.begin(), primes.end(), [](int i) {
    std::cout << "lambda called for: " << i << '\n';
  });
}
  • 当传递函数名作为函数参数时,我们不会真的把函数传递过去,而是传递函数指针或者引用。与数组相同,以值方式传递的函数参数会退化为指针,如果对应的参数是模版参数,那么会推导为函数指针;以引用方式传递的函数参数不会退化。然而,函数类型不能使用 $const$ 限定符。如果我们使用 $const$ 声明 $foreach$ 的最后一个参数,$const$ 会被忽略;
  • 我们第二个调用显式指定了函数指针,这与第一个调用等价,不过可能更容易理解;
  • 传递一个函数结构体即传递一个类对象作为可调用对象,调用这种类型通常相当于调用它的 $operator(\ )$ 函数。注意你应该将 $operator(\ )$ 定义为常量成员函数,否则,当框架或库想要调用不改变传递对象的状态时,就会产生一个错误。一个类类型对象也可以隐式转为一个代理调用函数 ( $surrogate$ $call$ $function$ ),通过这种方式,它们也可以被调用;
  • Lambda表达式生成一个函数结构体 ( 称为闭包,$closure$ ),因此这种方式与上面一样。

4.2 成员函数和额外参数

        一种调用实体并没有在先前的例子中提起:成员函数。因为调用一个非静态成员函数通常会涉及确定一个被调用对象,语法类似于 $object.memfunc(\dots)$ 或者 $ptr->memfun(\dots)$ ,而不是 $function(\dots)$ 。幸运的是,C++17开始,标准库提供了 $std::invoke$ 函数,允许我们统一普通函数调用语法和成员函数调用语法。

#include <utility>
#include <functional>

template<typename Iter, typename Callable, typename... Args>
void foreach(Iter current, Iter end, Callable op, Args const&... args) {
  while (current != end) {
    std::invoke(op, args..., *current);
    ++current;
  }
}

        这里除了可调用对象参数之外,我们还接收一个可选的额外的变长参数。$foreach$ 函数使用给定的可调用对象和额外的参数调用 $std::invoke$ ,后者的调用逻辑为:

  • 如果可调用对象是成员指针,它使用第一个额外参数作为被调用对象,其余额外参数作为调用参数;
  • 否则,所有额外参数都作为调用参数。

        注意我们这里不能使用完美转发,因为第一个调用可能会移动,从而导致之后的调用产生未定义行为。
        通过这种实现方式,我们可以调用成员函数了:

#include <iostream>
#include <vector>
#include <string>
#include "foreachinvoke.hpp"

class MyClass {
public:
  void memfunc(int i) const {
    std::cout << "MyClass::memfunc() called for: " << i << '\n';
  }
};

int main() {
  std::vector<int> primes = {2, 3, 5, 7, 11, 13, 17, 19};
  foreach(primes.begin(), primes.end(),
          [](std::string const& prefix, int i) {
            std::cout << prefix << i << '\n';
          },
          "- value: ");

  MyClass obj;
  foreach(primes.begin(), primes.end(),
          &MyClass::memfunc,
          obj);
}

4.3 包装函数调用

        $std::invoke$ 的一个应用是包装函数调用,这种方式下我们也可以使用完美转发来传递可调用对象和参数。

#include <utility>
#include <functional>

template<typename Callable, typename... Args>
decltype(auto) call(Callable&& op, Args&&... args) {
  return std::invoke(std::forward<Callable>(op),
                     std::forward<Args>(args)...);
}

        另一个有趣的事是调用者如何处理被调用者使用完美转发返回的返回值。为了支持返回引用 ( 比如 $std::ostream$& ),你必须使用 $decltype(auto)$ 。如果你想要存储 $std::invoke$ 的返回值,你也可以使用 $decltype(auto)$ 。但是,使用 $decltype(auto)$ 有一个问题:如果调用对象返回 $void$ ,那么就无法使用 $decltype(auto)$ ,因为 $void$ 是一个不完全类型。你可以这样解决:

  • 在调用前声明一个对象,该对象的析构函数执行你想要对 $std::invoke$ 返回值所进行的操作:
struct cleanup {
  ~cleanup() {
    // ...
  }
} dummy;
return std::invoke(std::forward<Callable>(op),
                   std::forward<Args>(args)...);
  • 分开实现 $void$ 和非 $void$ 情况:
#include <utility>
#include <functional>
#include <type_traits>

template<typename Callable, typename... Args>
decltype(auto) call(Callable&& op, Args&&... args) {
  if constexpr (std::is_same_v<
                  std::invoke_result_t<Callable, Args...>,
                  void>) {
    std::invoke(std::forward<Callable>(op),
                std::forward<Args>(args)...);
  } else {
    decltype(auto) ret{std::invoke(std::forward<Callable>(op),
                                   std::forward<Args>(args)...)};
    return ret;
  }
}

5. 其他泛型工具

5.1 Type Traits

        标准库提供了一系列实用工具,称为 $type$ $traits$ ,允许我们检查和修改类型。这种泛型代码可以适配其实例化所用的类型,并依据不同类型进行不同的操作。例如:

#include <type_traits>

template<typename T>
class C {
  static_assert(!std::is_same_v<std::remove_cv_t<T>, void>,
                "invalid instantiation of class C for void type");

public:
  template<typename V>
  void f(V&& v) {
    if constexpr (std::is_reference_v<T>) {
      // ...
    }
    if constexpr (std::is_convertible_v<std::decay_t<V>, T>) {
      // ...
    }
    if constexpr (std::has_virtual_destructor_v<V>) {
      // ...
    }
  }
};

        就像这个例子展示的,检查不同模版实现之间的不同特定条件。这里我们使用了编译时 $if$ 特性,也可以通过 $std::enable_-if$ 、部分特例化或者SFINAE来启用和禁用帮助类模版实现。
        注意 $type$ $traits$ 可能会产生一些出乎你意料的行为,比如:

std::remove_const_t<int const&>  // int const&

        因为引用不是 $const$ ,所以调用不会产生任何效果,而是返回传入类型。因此,如果你想要移除 $const$ ,你必须先移除引用:

std::remove_const_t<std::remove_reference_t<int const&>>  // int
std::remove_reference_t<std::remove_const_t<int const&>>  // int const

        或者,你也可以直接:

std::decay_t<int const&>  // int

        但是上面这种也会把原始数组和函数转换为指针。
        有些 $type$ $traits$ 对传入类型也有要求,不满足这些要求可能会产生未定义行为,比如:

make_unsigned_t<int>  // unsigned int
make_unsigned_t<int const&>  // 未定义行为

        有时候也会产生出乎意料的结果:

add_rvalue_reference_t<int>  // int&&
add_rvalue_reference_t<int const>  // int const&&
add_rvalue_reference_t<int const&>  // int const&

        我们可能认为通过 $add_-rvalue_-reference_-t$ 可以获得右值,但是因为引用折叠,最终会返回一个左值。另一个例子是:

is_copy_assignable_v<int>  // true
is_assignable_v<int, int>  // false

        $is_-copy_-assignable$ 检查类型是否可以拷贝赋值,而 $is_-assignable_-v$ 则会考虑值类型,因为你无法将一个右值赋给右值,所以会返回 $false$ 。从这个角度来看,第一个表达式其实等价于:

is_assignable_v<int&, int&>  // true

        同样的道理,有:

is_swappable_v<int>  // true
is_swappable_v<int&, int&> // true
is_swappable_with_v<int, int>  // false

5.2 std::addressof

        $std::addressof$ 函数模版生成一个对象或者函数的确切地址。即使对象类型重载了 & 运算符,它也能工作。所以,我们推荐使用 $addressof$ 来获取对象或者一个任意类型的地址:

template<typename T>
void f(T&& x) {
  auto p = &x;  // 如果T重载了&,可能会失败
  auto q = std::addressof(x);
}

5.3 std::declval

        $std::declval$ 函数模版可以作为一个确切类型对象引用的占位符,这个函数没有定义,因此无法被调用 ( 也不会创建一个对象 )。因此,它只能被用于不进行计算的操作 ( 例如 $decltype$ 和 $sizeof$ )。所以,这个模版用于你不想创建一个对象,而是假设你有一个对应类型的对象的时候。例如:

#include <utility>

template<typename T1, typename T2,
         typename RT = std::decay_t<
                        decltype(true ? std::declval<T1>(),
                                        std::declval<T2>())>>
RT max(T1 a, T2 b) { return b < a ? a : b; }

        为了避免调用 $T1$ 和 $T2$ 的默认构造函数,我们使用 $std::declval$ 来假设这里有一个这种类型的对象。不要忘了使用 $std::decay$ 来保证返回类型可以被引用,因为 $std::declval$ 本身产生的是一个右值引用。

6. 完美转发临时变量

        我们可以通过转发引用和 $std::forward$ 来完美转发泛型参数。然而,有时候我们需要在泛型代码中完美地转发并非参数的数据。这种情况下,我们可以使用 $auto$&& 来创建一个可以被转发的变量。例如:

template<typename T>
void foo(T x) { set(get(x)); }

        如果我们要对上面的函数应用完美转发,可以这样做:

template<typename T>
void foo(T x) {
  auto&& val = get(x);
  // ...
  set(std::forward<decltype(val)>(val));
}

        这样就避免了没有必要的中间值拷贝了。

6. 模版引用参数

        虽然不常见,模版类型参数可以成为引用类型。例如:

#include <iostream>

template<typename T>
void tmplParamIsReference(T) {
  std::cout << "T is reference: " << std::is_reference_v<T> << '\n';
}

int main() {
  std::cout << std::boolalpha;
  int i;
  int& r = i;
  tmplParamIsReference(i);  // false
  tmplParamIsReference(r);  // false
  tmplParamIsReference<int&>(i);  // true
  tmplParamIsReference<int&>(r);  // true
}

        即使引用变量被传给了 $tmplParamIsReference$ ,模版参数 $T$ 还是被推导为非引用类型 ( 因为对于一个引用变量 $v$ ,表达式 $v$ 具有引用类型,但是表达式 $v$ 的类型永远都不会是一个引用 )。然而,我们可以通过显式指定的方式来强制使用引用。这样可以改变一个模版的行为,但是最好不要这样做,一个模版可能没有被设计为这样使用,从而导致错误或者未定义行为。例如:

template<typename T, T Z = T{}>
class RefMem {
private:
  T zero;

public:
  RefMem() : zero{Z} {}
};

int null = 0;

int main() {
  RefMem<int> rm1, rm2;
  rm1 = rm2;

  RefMem<int&> rm3;  // 错误
  RefMem<int&, 0> rm4;  // 错误

  extern int null;
  RefMem<int&, null> rm5, rm6;
  rm5 = rm6;  // 错误,引用成员没有赋值运算符
}

        我们使用 $int$ 来实例化类型时,这个类模版可以正常工作。但是当我们使用引用来实例化时,就有点棘手了:

  • 默认实例化机制无法运行;
  • 不能再传入 $0$ 作为初始值;
  • 最出乎意料的是,赋值运算符也不能使用,因为具有非静态引用类型成员的类没有默认的赋值运算符。

        并且,使用引用类型作为非类模版参数是很麻烦且危险的。例如:

#include <vector>
#include <iostream>

template<typename T, int& SZ>
class Arr {
private:
  std::vector<T> elems;

public:
  Arr() : elems(SZ) {}
  void print() const {
    for (int i = 0; i < SZ; i++)
      std::cout << elems[i] << ' ';
  }
};

int size = 10;

int main() {
  Arr<int&, size> y;  // 编译时错误

  Arr<int, size> x;
  x.print();
  size += 10;
  x.print();  // 运行时错误
}

        这里我们尝试使用引用类型实例化,但是因为 $vector$ 不能接收引用类型,所以会产生一个编译错误。比编译错误更糟糕的是运行错误,我们将 $SZ$ 参数变为一个引用,从而导致它可以在容器未察觉的情况下随意修改,因此导致了未定义行为。注意,就算我们将 $SZ$ 的模版参数声明改为 $int$ $const$& 也无济于事,因为通过 $size$ ,引用值还是可以被修改。
        这个例子有些牵强,然而,在更复杂的情况下,我们并不能排除类似问题发生的可能。而且,在C++17中,非类型参数也可以被推导,例如:

template<typename T, decltype(auto) SZ>
class Arr;

        这样会产生引用类型,因此我们会避免这种情况 ( 默认情况下使用 $auto$ )。
        因为这个,C++标准库有时候会进行出乎意料的特例化和约束,例如:

  • 为了在接收引用对象时仍然保持赋值运算符,类模版 $std::pair$ 和 $std::tuple$ 显式实现赋值运算符而非使用默认版本。
namespace std {
  template<typename T1, typename T2>
  struct pair {
    T1 first;
    T2 second;
    pair(pair const&) = default;
    pair(pair&&) = default;
    pair& operator=(pair const& p);
    pair& operator=(pair&& p) noexcept(...);
    // ...
  };
}
  • 因为解决可能副作用的方案的复杂性,C++17标准库类模版 $std::optional$ 和 $std::variant$ 使用引用类型来实例化会产生错误:
template<typename T>
class optional {
  static_assert(!std::is_reference<T>::value,
                "Invalid instantiation of optional<T> for references");
  // ...
};

        引用类型总体上与其他类型有着很大区别,它遵循一个独一无二的语言规则。

7. 推迟计算

        在模版实现中,模版代码是否能处理未完成类型有时会成为一个问题。

template<typename T>
class Cont {
private:
  T* elems;
  // ...
};

        这时,这个类可以使用未完成类型,比如:

struct Node {
  std::string value;
  Cont<Node> next;
};

        然而,如果使用一些 $traits$ ,那么类模版就会失去这种可以使用未完成类型的能力:

template<typename T>
class Cont {
private:
  T* elems;

public:
  typename std::conditional<std::is_move_constructible<T>::value,
                            T&&, T&>::type;
  foo();
};

        这里我们使用 $traits$ 来判断根据 $T$ 是否可以移动构造来决定 $foo$ 的返回值是 $T$&& 还是 $T$& 。这样做的问题是 $is_-move_-constructible$ 要求其模版参数是一个完成类型 ( 并且非 $void$ 和未知长度数组 )。因此,如果我们继续使用 $Cont$ 声明 $Node$ ,就会产生一个错误。
        我们可以使用一个成员模版来代替原本的 $foo$ ,这样就把 $is_-move_-constructible$ 的计算推迟到实例化 $foo$ 的时候了:

template<typename T>
class Cont {
private:
  T* elems;

public:
  template<typename D = T>
  typename std::conditional<std::is_move_constructible<D>::value,
                            T&&, T&>::type
  foo();
};

7. 编写泛型库时的注意事项

  • 在模版中使用转发引用来转发。如果值并不依赖于模版参数,使用 $auto$&& ;
  • 当参数被声明为转发引用时,注意在传递左值时模版参数具有引用类型;
  • 当你需要一个依赖于模版参数的对象的地址的时候,使用 $std::addressof$ ,这样就可以避免类型重载 $operator$& 带来的错误了;
  • 对于成员函数模版,确保它们不会比预定义的拷贝/移动构造函数和运算符有着更优的匹配;
  • 当模版参数可能是字符串常量并且以非值方式传递时,考虑使用 $std::decay$ ;
  • 如果你有一个依赖于模版参数的作为返回值或者同时作为传入值和返回值的参数时,注意模版参数类型可能会推导为 $const$ ;
  • 注意模版引用参数带来的副作用,尤其是,你可能想要让返回值不是一个引用的时候;
  • 注意对未完成类型的支持,例如用于递归数据结构时;
  • 对所有可能的数组类型进行重载,不要只有 $T[SZ]$ 这种情况。

C++ Templates(5):编译时编程

1. 模版元编程

        模版在编译期实例化,这使得一些C++模版特性可以在实例化阶段进行一系列递归的语言编程。所以,我们可以使用模版来作为程序计算。

template<unsigned p, unsigned d>
struct DoIsPrime {
  static constexpr bool value =
    (p % d != 0) && DoIsPrime<p, d - 1>::value;
};

template<unsigned p>
struct DoIsPrime<p, 2>{
  static constexpr bool value = (p % 2 != 0);
};

template<unsigned p>
struct IsPrime {
  static constexpr bool value =
    DoIsPrime<p, p / 2>::value;
};

template<>
struct IsPrime<0> {
  static constexpr bool value = false;
};

template<>
struct IsPrime<1> {
  static constexpr bool value = false;
};

template<>
struct IsPrime<2> {
  static constexpr bool value = true;
};

template<>
struct IsPrime<3> {
  static constexpr bool value = true;
};

        上面的代码计算一个数是否为质数。$DoIsPrime$ 会递归地展开表达式。例如:

IsPrime<9>::value

        会展开为:

DoIsPrime<9, 4>::value

        然后按照以下顺序继续展开:

9 % 4 != 0 && DoIsPrime<9, 3>::value

9 % 4 != 0 && 9 % 3 != 0 && DoIsPrime<9, 2>::value

9 % 4 != 0 && 9 % 3 != 0 && 9 % 2 != 0

        从而得到 $false$ ,于是 $9$ 不是质数。
        注意所有的这些运算都会在编译期完成。虽然模版语法很丑,但是它在一些库中被证实是有效的。

2. constexpr

        $constexpr$ 是C++11引入的新特性,极大地简化了多种形式的编译期运算。特别的,如果给予了合适的输入,$constexpr$ 函数可以在编译期就完成计算。在C++11中,$constexpr$ 函数有着严格的限制。在C++14,大部分限制都被移除了。当然,一个有效的 $constexpr$ 函数还是要求所有计算阶段在编译期都是可行且有效的。现阶段,堆分配或者抛出异常等行为还是不允许的。

constexpr bool doIsPrime(unsigned p, unsigned d) {
  return d != 2 ? (p % d != 0) && doIsPrime(p, d - 1)
                : (p % 2 != 0);
}

constexpr bool isPrime(unsigned p) {
  return p < 4 ? !(p < 2)
               : doIsPrime(p, p / 2);
}

        上面是我们使用 $constexpr$ 函数实现的求质数。因为在C++11中,$constexpr$ 有着只能有一条语句的限制,所以我们只能使用三目运算符来选择分支,通过递归来遍历元素。但是因为它使用的是普通C++函数的代码,所以第一印象要比模版编程好。
        C++14中,$constexpr$ 函数可以使用更多的控制结构,所以与其使用笨重的模版代码或者神秘的一行代码,不如直接使用 $for$ 循环:

constexpr bool isPrime(unsigned int p) {
  for (unsigned int d = 2; d <= p / 2; d++) {
    if (p % d == 0) return false;
  }
  return p > 1;
}

        不管是C++11版本的还是C++14版本的,我们都能在入参是编译时常量的前提下,在编译期就完成计算。但是编译器不一定会这样做。如果上下文需要一个编译期常量 ( 比如数组长度或非类模版参数 ),编译器就会尝试让 $constexpr$ 函数在编译期计算,并且会在无法计算时报错。在其他情况下,如果不需要一个编译期常量,编译期可能会也可能不会在编译期计算,这时如果编译期计算失败,不会产生编译错误,而是变为一个运行时调用。例如:

constexpr bool b1 = isPrime(9);  // 编译期计算
const bool b2 = isPrime(9);  // 如果在命名空间内就在编译期计算

bool isfiftySevenIsPrime() {
  return isPrime(7);  // 编译期计算或者运行时计算
}

int x;
std::cout << isPrime(x);  // 运行时计算

3. 部分特例化的执行分支选择

        像 $isPrime$ 这种函数在编译期测试的一个有趣应用就是使用部分特例化来实现在编译时选择不同的实现版本。例如,我们可以根据模版参数是否是质数来选择:

template<int SZ, bool = isPrime(SZ)>
struct Helper;

template<int SZ>
struct Helper<SZ, false> {
  // ...
};

template<int SZ>
struct Helper<SZ, true> {
  // ...
};

template<typename T, std::size_t SZ>
long foo(std::array<T, SZ> const& coll) {
  Helper<SZ> h;
  // ...
}

        这里我们根据 $array$ 大小是否是质数来选择不同的 $Helper$ 特例化。这种使用部分特例化在一些根据参数属性来决定调用的方式在函数模版中被广泛使用。上面的代码中,我们列出了两种特例化情况。实际上,我们只需要列出一种就行了:

template<int SZ, bool = isPrime(SZ)>
struct Helper {
  // ...
};

template<int SZ>
struct Helper<SZ, true> {
  // ...
};

        由于函数模版不支持部分特例化,你必须使用其他机制来在特定条件下改变函数实现:

  • 使用类内静态函数成员;
  • 使用 $std::enable_-if$ ;
  • 使用SFINAE
  • 使用编译时 $if$ 。

4. SFINAE

        对多种参数类型进行函数重载在C++中很常见。当编译期看到对重载函数的调用时,它必须考虑每个候选版本,检查参数然后选择最匹配的版本。如果这些候选版本内存在函数模版,编译期首先要去判断应该使用什么模版参数,然后使用这个参数代替函数参数和返回值类型,最后再将它像普通函数那样检查是否匹配。然而,这个替代的过程可能会产生问题,导致无意义的构造。为了避免这种无意义的构造产生的错误,C++语言规定了这种替代问题产生的错误会被忽略。这就是我们所说的SFINAE ( $substitution$ $failure$ $is$ $not$ $an$ $error$ ) 原则。
        注意这里所说替代过程与按照需求实例化是不同的,替代可能会在实例化的结果是不需要的结果时发生,从而编译器可以根据替代结果判断是需要还是不需要。

template<typename T, unsigned N>
std::size_t len(T (&)[N]) { return N; }

template<typename T>
typename T::size_type len(T const& t) {
  return t.size();
}

        这里我们定义了两个模版函数,它们都是 $len$ 的重载,第一个版本接收一个数组,第二个版本没有约束,但是要求类型具有 $size_-type$ 成员。

int a[10];
std::cout << len(a);  // 匹配数组
std::cout << len("tmp");  // 匹配数组

std::vector<int> v;
std::cout << len(v);  // 匹配size_type

int* p;
std::cout << len(p);  // 错误

std::allocator<int> x;
std::cout << len(x);  // 错误

        当我们传入数组时,虽然数组也能匹配 $T$ ,但无法匹配 $size_-type$ ,如果实例化会产生错误。对于原始指针,因为哪个模版都无法匹配,所以会产生一个错误。$std::allocator$ 虽然也具有 $size_-type$ 成员,但是因为没有 $size$ 函数,所以也会产生错误。
        忽略一个候选版本可能会导致编译期选择另一个更差匹配的版本,例如:

template<typename T, unsigned N>
std::size_t len(T (&)[N]) { return N; }

template<typename T>
typename T::size_type len(T const& t) {
  return t.size();
}

std::size_t len(...) { return 0; }

        我们提供了一个通用版本,有着最差的匹配,但是总是可以匹配,理论上这种后备函数应该抛出异常或者声明一个静态断言,输出有效的错误信息。这时,对于原始数组和 $vector$ ,有着更优的匹配。但是对于指针,只能匹配到通用版本。$allocator$ 会匹配第二版本和第三个版本,但是第二个版本是更优匹配,所以它还是会匹配第二个版本,虽然它会产生一个错误。
        随着时间的推移,SFINAE在模版设计中变得越来越重要和流行,并且这个缩写也变成了动词。如果我们想要通过SFINAE机制来确保一个函数模版会在特定条件下产生错误代码从而被忽略,我们会说"我们SFINAE了这个函数“。并且无论何时当你在C++标准库中读到某个函数模版注释着”最好不要作为重载函数解析除非…“时,意味着这个函数模版在特定条件下被SFINAE了。
        例如,$std::thread$ 声明了一个构造函数:

namespace std {
  class thread {
  public:
    template<typename F, typename... Args>
    explicit thread(F&& f, Args&&... args);
  // ...
  };
}

        这段代码有着以下的注释:

注释:如果 $decay_-t$<$F$> 的类型与 $std::thread$ 相同,这个构造函数最好不要作为重载函数解析。

        这意味着如果将 $std::thread$ 作为第一个模版参数传入,这个模版构造函数会被忽略。因为有时候这种类似的成员函数比起预定义的拷贝或者移动构造函数有着更优的匹配。通过SFINAE $thread$ 的模版构造函数,我们可以确保在接收另一个 $thread$ 作为参数的情况下预定义的拷贝或者移动构造函数总是会被调用。
        在这种就事论事的情况下使用这项技术可能显得有些笨重。幸运的是,标准库提供了更方便的禁用模版的工具。这些工具中,我们最熟悉的就是 $std::enable_-if$ 了。因此,$std::thread$ 构造函数的一个典型声明为:

namespace std {
  class thread {
  public:
    template<typename F, typename... Args,
             typename = enable_if_t<!is_same_v<decay_t<F>,
                                    thread>>>
    explicit thread(F&& f, Args&&... args);
    // ...
  };
}

4.1 使用decltypeSFINAE表达式

        编写一条在特定条件下正确SFINAE函数模版的表达式是不简单的。例如,我们想要确保函数模版 $len$ 在模版参数具有 $size_-type$ 成员但是没有 $size$ 函数成员时忽略,从而避免类似于 $allocator$ 的错误。有一个通用模版可以处理这种情况:

  • 使用尾置返回语句确定返回类型;
  • 使用 $decltype$ 和逗号运算符定义返回值类型;
  • 所有必须为有效值的表达式放在逗号运算符之前;
  • 表示真实返回类型的对象放在逗号运算符之后。

        例如:

template<typename T>
auto len(T const& t)
-> decltype((void)(t.size()), T::size_type()) {
  return t.size();
}

        $decltype$ 使用逗号分离的表达式列表,最后一个表达式 $T::size_-type(\ )$ 表示真正的返回类型,而在最后一个逗号之前的表达式必须是有效的。我们将第一个表达式的运算结果转为 $void$ ,用于避免用户重载逗号运算符带来的问题。
        注意 $decltype$ 参数是一个没有计算的操作,这意味着我们可以创建一个不调用构造函数的虚拟对象。

5. 编译时if

        部分特例化、SFINAE和 $std::enable_-if$ 一起使用,可以让我们启用或禁用模版。C++17额外引入了编译时 $if$ 语句,允许根据编译时条件来启用或者禁用某些特定语句。使用 $if$ $constexpr$ ,编译器可以在编译期通过表达式确定进入 $then$ 分支还是 $else$ 分支。

template<typename T, typename... Types>
void print(T const& firstArg, Types const&... args) {
  std::cout << firstArg << '\n';
  if constexpr(sizeof...(args) > 0) {
    print(args...);
  }
}

        这里如果 $print$ 只接收了一个参数,那么 $if$ 表达式将为 $false$ ,从而不会继续递归,并且代码也不会实例化。
        代码不会实例化意味着只会进行第一个翻译阶段 ( 定义阶段 ),这时检查不依赖于模版参数的代码正确性。

template<typename T>
void foo(T t) {
  if constexpr(Std::is_integral_v<T>) {
    if (t > 0) foo(t - 1);
  } else {
    undeclared(t);  // 如果没有声明并且进入else分支,错误
    undeclared();  // 如果没有声明,即使未进入else分支也会错误
    static_assert(false, "no integral");  // 总是会判断,即使未进入else分支
    static_assert(!std::is_integral_v<T>, "no integral");
  }
}

        注意 $if$ $constexpr$ 语句可以在任何函数中使用,不局限于模版。

int main() {
  if constexpr(std::numeric_limits<char>::is_signed) {
    foo(42);
  } else {
    undeclared(42);  // 如果没有声明,错误
    static_assert(false, "unsigned");  // 即使未进入else分支也会判断
    static_assert(!std::numeric_limits<char>::is_signed,
                  "char is unsigned");
  }
}

C++ Templates(4):移动与引用

1. 移动语义

        C++11的一个最突出的特性的就是移动语义,它对模版设计有着显著的影响。

1.1 完美转发

        假设你想要编写泛型代码,转发参数的基本属性:

  • 可变对象必须被转发,这样它们就能被继续修改;
  • 常量对象应该被转发为只读对象;
  • 可移动对象应该继续被转发为可移动对象。

        为了在不使用模版的前提下实现这个功能,我们需要编写三种情况的代码。

#include <utility>
#include <iostream>

class X {};

void g(X&) { std::cout << "g() for variable\n"; }

void g(X const&) { std::cout << "g() for constant\n"; }

void g(X&&) { std::cout << "g() for movable object\n"; }

void f(X& val) { g(val); }

void f(X const& val) { g(val); }

void f(X&& val) { g(std::move(val)); }

        注意可移动对象的代码需要使用 $std::move$ ,因为当它们作为表达式使用时会被视为左值。
        如果我们想要使用泛型代码,就会出现这种问题:

template<typename T>
void f(T& val) { g(val); }

        上面这种形式的代码只会匹配前两种情况,不会匹配可移动对象。出于这个原因,C++11引入了完美转发参数。

template<typename T>
void f(T&& val) { g(std::forward<T>(val)); }

        注意这里使用了 $std::forward$ ,它是一个模版。不要认为模版函数中的 $T$&& 行为与非模版的 $X$&& 相同,从语法上来看,它们分别代表:

  • $X$&& 是一个类型为 $X$ 的右值引用参数,只能绑定到可移动对象上,总是可变和可移动的;
  • $T$&& 是一个声明为转发引用 ( $forwarding$ $reference$ ) 的模版参数 ( 也被称为通用引用 ( $universal$ $reference$ ) 。它可以绑定到可变对象、不可变对象或者可移动对象。在函数定义内,参数可以是可变的、不可变的或者可移动的。

        $T$ 必须是一个模版参数,仅仅在模版内声明是不够的,比如 $typename$ $T::iterator$&& 就仅仅是一个右值引用,而不是转发引用。

1.2 特殊成员函数模版

        成员函数模版也能作为特殊成员函数。

#include <utility>
#include <string>
#include <iostream>

class Person {
private:
  std::string name;

public:
  explicit Person(std::string const& n) : name(n) {
    std::cout << "copying string-CONSTR for '" << name << "'\n";
  }
  explicit Person(std::string&& n) : name(std::move(n)) {
    std::cout << "moving string-CONSTR for '" << name << "'\n";
  }
  Person(Person const& p) : name(p.name) {
    std::cout << "COPY-CONSTR Person '" << name << "'\n";
  }
  Person(Person&& p) : name(std::move(p.name)) {
    std::cout << "MOVE-CONSTR Person '" << name << "'\n";
  }
};

        在上面的代码中,我们提供了拷贝构造函数和移动构造函数。现在,让我们改为泛型版本:

#include <utility>
#include <string>
#include <iostream>

class Person {
private:
  std::string name;

public:
  template<typename STR>
  explicit Person(STR&& n) : name(std::forward<STR>(n)) {
    std::cout << "TMPL-CONSTR for '" << name << "'\n";
  }
  Person(Person const& p) : name(p.name) {
    std::cout << "COPY-CONSTR Person '" << name << "'\n";
  }
  Person(Person&& p) : name(std::move(p.name)) {
    std::cout << "MOVE-CONST Person '" << name << "'\n";
  }
};

        但是上面的版本存在一些问题,当我们这样使用时:

std::string s = "sname";
Person p1(s);  // TMPL-CONSTR
Person p2(p1);  // 错误

Person const p2c("ctmp");  // TMPL-CONSTR
Person p3c(p2c);  // COPY-CONSTR

        问题在于,根据C++的重载函数解析规则,因为模版构造函数参数没有 $const$ ,所以它对于非常量左值有着更优的匹配。而只有我们显式指定了常量左值,才能调用拷贝构造函数。你可能觉得我们只要提供一个非常量左值的拷贝构造函数就行了,但是这只能解决部分问题。假设我们的类是一个基类,那么当子类进行拷贝时,还是会匹配模版函数版本。如果我们真的想解决这个问题,需要使用 $std::enable_-if$ 。

2. enable_if

2.1 模版禁用

        从C++11开始,标准库提供了一个帮助模版 $std::enable_-if$ 来在编译期忽略模版函数。例如,你可以这样使用:

template<typename T>
typename std::enable_if<(sizeof(T) > 4>::type
foo() {}

        当 $sizeof(T)$ 的值不大于 $4$ 时,$foo$ 的声明会被忽略。
        $enable_-if$ 是一个 $type$ $traits$ 模版类,在编译期计算其第一个模版参数定义的表达式,根据结果,会有:

  • 如果表达式值为 $true$ ,它的成员 $type$ 会表示一个类型:
    • 如果未指定第二个模版参数,那么类型为 $void$ ;
    • 否则,类型为第二个模版参数指定的类型;
  • 如果表达式值为 $false$ ,成员 $type$ 不会被定义,这是因为模版具有SFINAE ( $substitution$ $failure$ $is$ $not$ $an$ $error$ ) 特性,从而使得使用 $enable_-if$ 声明的函数模版会被忽略。

        C++14提供了一个对应的别名模版 $std::enable_-if_-t$ ,这样我们就可以不用声明 $typename$ 和 $::type$ 了。
        注意到使用 $enable_-if$ 表达式作为返回值有点丑陋,所以,我们可以在模版参数中使用:

template<typename T,
         typename = std::enable_if_t<(sizeof(T) > 4)>>
void foo() {}

        如果还是觉得丑,并且你想让约束更加明显,你可以使用 $using$ 定义一个别名模版:

template<typename T>
using EnableIfSizeGreater4 = std::enable_if_t<(sizeof(T) > 4)>;

template<typename T,
         typename = EnableIfSizeGreater4<T>>
void foo() {}

2.2 使用enable_if

        在上一节中我们演示了使用完美转发构造函数带来的问题,我们可以使用 $enable_-if$ 解决这个问题。这里要用到另一个标准 $type$ $traits$ ,$std::is_-convertible$ ,C++17中可以这样写:

template<typename STR,
         typename = std::enable_if<
                      std::is_convertible_v<STR, std::string>>>
Person(STR&& n);

        如果 $STR$ 不能转为 $std::string$ ,这个函数就会被忽略。
        我们也可以使用别名模版:

template<typename T>
using EnableIsString = std::enable_if_t<
                        std::is_convrtible_v<T, std::string>>;

template<typename STR, typename EnableIfString<STR>>
Person(STR&& n);

        在C++11,我们需要这样定义:

template<typename T>
using EnableIfString =
  typename std::enable_if<
    std::is_convertible<T, std::string>::value>::type;

        注意 $is_-convertible$ 表示的是类型可以被隐式转换。通过 $is_-constructible$ ,我们可以检查类型是否可以显式转换,但是顺序要反过来。

template<typename T>
using EnableIfString =
  std::enable_if_t<
    std::is_constructible_v<std::string, T>>;

        我们不能使用 $enable_-if$ 来禁用预定义的拷贝或移动构造函数以及相应的赋值运算符,因为成员函数模版不会被视为特殊成员函数,并且会在需要拷贝构造时被忽略。例如:

class C {
public:
  template<typename T>
  C(T const&) {
    std::cout << "tmpl copy constructor\n";
  }
};

C x;
C y{x};  // 仍然使用预定义的拷贝构造函数

        删除预定义的拷贝构造函数也不行,这会导致对 $C$ 的拷贝产生错误。这个问题有一个麻烦的解决方案:我们可以声明一个 $const$ $volatile$ 参数的拷贝构造函数,并且删除它。这样可以阻止编译器生成默认拷贝构造函数,并且会让我们的模版拷贝构造函数被优先匹配。

class C {
public:
  C(C const volatile&) = delete;
  template<typename T>
  C(T const&) {
    std::cout << "tmpl copy constructor\n";
  }
};

        在这样的模版构造函数中我们可以使用 $enable_-if$ 进行额外约束,例如如果模版参数是整型,那么无法进行拷贝。

template<typename T>
class C {
public:
  C(C const volatile&) = delete;
  template<typename U,
           typename = std::enable_if_t<
                        !std::is_integral<U>::value>>
  C(C<U> const&) {
    // ...
  }
};

2.3 使用概念

        即使通过别名模版,$enable_-if$ 表达式还是有些丑陋,因为它需要额外添加一个模版参数,这会让代码难以阅读和理解。
        原则上,我们需要一个允许我们指定或者约束函数的语言特性,并且在限制未满足的时候可以忽略函数。这个特性就是概念 ( $concepts$ ),它允许我们定制模版需求。概念在C++20被引入。

template<typename STR>
requires std::is_convertible_v<STR, std::string>
Person(STR&& n) : name(std::forward<STR>(n)) {
  // ...
}

        我们也可以自定义一个概念:

template<typename T>
concept ConvertibleToString = std::is_convertible_v<T, std::string>;

template<typename STR>
requires ConvertibleToSTring<STR>
Person(STR&& n) : name(std::forward<STR>(n)) {
  // ...
}

        也可以这样使用:

template<ConvertibleToString T>
Person(STR&& n) : name(std::forward<STR>(n)) {
  // ...
}

3. 值传递

        当使用值传递时,每个参数都会被拷贝。对于类对象,它们一般通过拷贝构造函数构造。调用一个拷贝构造函数的开销可能很大,然而,有几种方式可以在传值时避免拷贝。事实上,编译器可能也会优化掉拷贝操作并且也可以对复杂对象采用移动操作。例如:

template<typename T>
void printV(T arg);

        当通过整型调用时,实例化的代码为:

void printV(int arg);

        这时会通过拷贝传递参数,无论它是一个对象、字面量或者函数返回值。
        如果我们指定 $std::string$ ,函数会被实例化为:

void printV(std::string arg);

        这次参数也会通过拷贝构造函数构造,但是可能开销很大,因为原则上需要进行深拷贝。然而,拷贝构造函数不一定会被调用,考虑以下例子:

std::string returnString();
std::string s = "hi";
printV(s);  // 拷贝构造函数
printV(std::string("hi"));  // 拷贝通常会被优化为移动
printV(returnString());  // 拷贝通常会被优化为移动
printV(std::move(s));  // 移动构造函数

        第二个和第三个调用的共同点是它们都是临时对象,编译器通常会优化这种传参。从C++17开始,这种优化是强制的。在这之前,不会优化拷贝的编译器至少会尝试使用移动。因此,值传递的函数只会在我们以左值传递参数时才可能会出现较大开销。不幸的是,这种情况很常见。
        值传递还有另一种属性,当我们值传递参数时,类型会退化。这意味着原始数组会转换为指针并且它们的标识符比如 $const$ 和 $volatile$ 会被移除。这种行为是从C继承而来的,它能在大部分时候简化对字符串常量的处理,但缺点是我们无法区分传递的是指针还是数组。

4. 引用传递

4.1 常量引用

        为了避免不必要的拷贝,当传递非临时对象时,我们可以使用常量引用,例如:

template<typename T>
void printR(T const& arg);

        这样的话我们传递一个对象的时候就永远不会拷贝,即使是基础类型也是一样,虽然对基础类型使用引用可能会适得其反。在底层实现中,传递引用其实就是传递变量的地址。地址会被紧凑地编码,从而使得传递变得高效。然而,传递地址可能会在编译调用者代码时产生不确定行为,因为无法确定被调用者会对该地址进行怎样的操作。理论上,被调用者可以在可用范围内对引用值进行任何修改,从而,编译器必须假设所有在缓存中的引用值在调用结束后变为不可用。之后再次使用这些值时需要重新读入,这个开销可能会很大。你可能会觉得我们传递的是常量引用,编译器难道不能推导出常量引用不会被修改吗?不幸的是,事实并非如此,因为调用者持有的非常量引用可能会修改自己引用的对象。
        这个问题可以通过内联缓解,如果编译器可以内联函数,那么它就可以推理出调用者和被调用者,并且在大部分情况下,能够发现地址是否除了传递引用值之外没有被使用。函数模版通常很短,因此很可能成为内联函数。然而,如果一个模版封装了一个更复杂的算法,它就几乎不可能被内联。
        当使用引用传递参数时,它们的类型不会退化。这意味着原始数组不会转为指针,标识符也不会被移除。然而,因为我们上面的调用指定了 $const$ ,所以推导出来的 $T$ 是不带 $const$ 的。例如:

template<typename T>
void printR(T const& arg);

std::string const c = "hi";
printR(c);  // T被推导为std::string,参数类型是std::string const&
printR("hi");  // T被推导为char[3], 参数类型是char const(&)[3]
int arr[4];
printR(arr);  // T 被推导为int[4],参数类型是int const(&)[4]

4.2 非常量引用

        当你想要通过参数获取返回值时,你必须使用非常量引用 ( 除非你更喜欢指针 )。同样的,这种方式也不会调用拷贝构造函数。

template<typename T>
void outR(T& arg);

        注意这种方式下无法传递右值:

std::string returnString();
std::string s = "hi";
outR(s);
outR(std::string("hi"));  // 错误
outR(returnString());  // 错误
outR(std::move(s));  // 错误

        你也可以传递数组,它们同样不会退化:

int arr[4];
outR(arr);  // T被推导为int[4], 参数类型为int (&)[4]

        因此,你可以修改元素,例如,处理数组大小:

template<typename T>
void outR(T& arg) {
  if (std::is_array<T>::value) {
    std::cout << "got array of " << std::extent<T>::value << " elems\n";
  }
};

        然而,模版在这里有点麻烦。如果你传递一个常量,参数类型也会被推导为常量,这意味着传递右值变得可行:

const std::string returnString();
std::string const c = "hi";
outR(c);
outR(returnString());
outR(std::move(c));
outR("hi");

        当然,任何对这些常量的修改都是不允许的。如果你想要禁止向非常量引用传递常量对象,你可以这样做:

  • 使用静态断言来产生一个编译期错误:
template<typename T>
void outR(T& arg) {
  static_assert(!std::is_const<T>::value,
                "out parameter of foo<T>(T&) is const");
}
  • 通过 $enable_-if$ 禁用:
template<typename T,
         typename = std::enable_if_t<!std::is_const<T>::value>>
void outR(T& arg);
  • 通过概念约束:
template<typename T>
requires !std::is_const_v<T>
void outR(T& arg);

4.3 完美转发

        使用引用传递的一个理由就是完美转发。

template<typename T>
void passR(T&& arg);

        这样你就可以传递任何类型,并且不会调用拷贝构造函数。

std::string const c = "hi";
passR(c);  // T被推导为std::string const&
passR("hi");  // T被推导为char const(&)[3]
int arr[4];
passR(arr);  // T被推导为int (&)[4]

        在上面的情况中,$passR$ 函数可以知道传递过来的参数是一个左值还是一个右值,这也是唯一一种我们能够辨别传递类型的传递办法。
        这可能会让你觉得转发引用是最完美的传递方式,但是它也是存在问题的。例如:

template<typename T>
void passR(T&& arg) {
  T x;
  // ...
}

passR(42);  // T被推导为int
int i;
passR(i);  // 错误,T被推导为int&,从而x的声明错误

        这种情况下转发引用就会导致一个错误。

4.3 std::refstd::cref

        从C++11开始,你可以让调用者决定一个函数模版参数是传值还是传引用。当一个模版以值方式声明时,调用者可以通过 $std::ref$ 和 $std::cref$ 来传递引用。

#include <functional>

template<typename T>
void printT(T arg);

std::string s = "hello";
printT(s);  // 值传递
printT(std::cref(s));  // 类似于引用传递

        要注意 $std::cref$ 不会改变模版函数中对参数的处理方式。相反,它用一个类包装了参数 $s$ ,这个类的行为类似于引用。事实上,它会创建一个 $std::reference_-wrapper$ 对象,这个对象引用原始参数并且会被以值方式传递。这个包装器类仅支持一种操作:隐式地将类型转换为原始类型,返回原始对象。所以,无论何时,只要你传递的对象存在一个有效操作,你就可以使用引用包装器。例如:

#include <functional>
#include <string>
#include <iostream>

void printString(std::string const& s) {
  std::cout << s << '\n';
}

template<typename T>
void printT(T arg) { printString(arg); }

int main() {
  std::string s = "hello";
  printT(s);
  printT(std::cref(s));
}

        注意到编译器必须要知道先隐式转换为原始类型是必要的。出于这个原因,$std::ref$ 和 $std::cref$ 通常只有在你通过泛型代码传递对象给非泛型函数时才能正常使用。例如,直接输出泛型类型 $T$ 的对象会失败,因为 $std::reference_-wrapper$ 没有定义输出运算符。

template<typename T>
void printV(T arg) {
  std::cout << arg << '\n';
}

std::string s = "hello";
printV(s);
rpintV(std::cref(s));  // 错误

        因此,$std::reference_-wrapper$ 类的作用是允许你使用引用作为第一级对象,这样你可以拷贝,从而可以以值方式传递给函数模版。你也可以在类中使用它,例如,在容器内持有对象引用。但是你最后你总是需要把它转回底层类型使用。

5. 字符串常量和原始数组

        到目前为止,我们已经了解了模版参数在使用字符串常量和原始数组时产生的不同效果:

  • 值传递会将它们退化为指向元素类型的指针;
  • 任何形式的引用传递都不会发生类型退化;

        两者都有好处和坏处。当数组退化为指针,你就没有办法知道它是一个数组还是一个指针。另一方面,当参数可能是一个字符串常量时,不退化会导致不同长度的字符串代表不同的类型。

template<typename T>
void foo(T const& arg1, T const& arg2);

foo("hi", "guy");  // 错误

        传值方式可以解决上面的问题:

template<typename T>
void foo(T arg1, T arg2);

foo("hi", "guy");

        但是那会带来更糟糕的运行时问题:

template<typename T>
void foo(T arg1, T arg2) {
  if (arg1 == arg2) {  // 错误
    // ...
  }
}

foo("hi", "guy");

        就像上面的例子展示的,你知道我们传入的是字符串,但是模版函数不知道,因为它也要处理其他方式传入的已退化的字符串常量,比如从另一个函数调用返回的字符串常量。
        无论如何,在许多情况下,退化很有用,尤其是在检查两个对象是否具有或者可以转化为相同类型时,一个典型的应用就是完美转发。但是如果你想要使用完美转发,你必须声明为转发引用,那样,你可能需要显式退化。
        注意其他 $type$ $traits$ 也会隐式退化,比如 $std::common_-type$ 。

5.1 字符串常量和原始数组的特殊实现

        你可能会在你的实现中区分指针或者数组,当然,这需要数组传递的时候不被退化。有两种方式可以检测数组:

  • 声明数组模版参数:
template<typename T, std::size_t L1, std::size_t L2>
void foo(T (&arg1)[L1], T (&arg2)[L2]) {
  T* pa = arg1;
  T* pb = arg2;
  if (compareArrays(pa, L1, pb, L2)) {
     // ...
  }
}
  • 使用 $type$ $traits$ 来检测数组:
template<typename T,
         typename = std::enable_if_t<std::is_array_v<T>>>
void foo(T&& arg1, T&& arg2);

        通常最好的办法是使用不同的函数名,当然,更好的办法是不使用数组,而是使用 $std::vector$ 或者 $std::array$ 。但是只要字符串常量存在,我们就应该要考虑它们。

6. 返回值

        对于返回值,你也可以设置值返回或者引用返回。然而,返回引用可能会带来一系列潜在的问题,因为可能会引用一些你无法控制的东西。以下几种情况你应该返回一个引用:

  • 返回容器或数组元素;
  • 允许对类成员的写入;
  • 链式调用的返回对象。

        此外,读成员时返回常量引用也是很常见的。
        要注意这些情况都可能因为错误使用而导致问题,例如:

std::string* s = new std::string("whatever");
auto& c = (*s)[0];
delete s;
std::cout << c;  // 运行时错误

        这里我们引用了字符串的元素,但是之后底层字符串不再存在,从而产生了未定义行为。这个例子虽然是故意的,但是有些情况下你很难发现这种错误,例如:

auto s = std::make_shared<std::string>("whatever");
auto& c = (*s)[0];
s.reset();
std::cout << c;  // 运行时错误

        因此我们应该确保函数模版以值方式返回。然而,使用模版参数 $T$ 并不能保证它不是引用,因为 $T$ 也可以被推导为引用。甚至当 $T$ 是从一个值方式调用中推导的出来的时候,它也可以被显式指定为引用:

template<typename T>
T retV(T p);

int x;
retV<int&>(x);  // T被推导为int&

        为了安全,你有两种方法:

  • 使用 $std::remove_-reference$ 或者 $std::decay$ :
template<typename T>
typename std::remove_reference<T>::type
retV(T p);
  • 使用 $auto$ 自动类型推导:
template<typename T>
auto retV(T p);

7. 推荐的模版参数声明

        在模版函数中,我们有许多不同的参数声明方式:

  • 值传递:这种方式很简单,它会退化字符串常量和原始数组,但是对于大对象性能较差。调用者也可以通过 $std::cref$ 和 $std::ref$ 传递引用,但是要在可以这么做的前提下进行;
  • 引用传递:这种方式通常有着更好的性能,尤其是传递:
    • 已存在对象到左值引用;
    • 临时对象或可移动对象到右值引用;
    • 或者上面的对象到转发引用;
  • 引用传递也不会退化,除了在传递字符串常量和原始数组时要特殊处理。对于转发引用,你也要意识到模版参数可能被隐式推导为引用。

        对于函数模版,我们推荐:

  1. 默认情况下使用值传递。它简单且有效,就算对于字符串常量也是如此,在传递小参数、临时对象或者可移动对象时性能也很好。调用者也可以使用 $std::ref$ 和 $std::cref$ 来传递已存在的大对象,从而避免拷贝;
  2. 对于以下情况:
  • 如果你需要参数作为返回值或者同时作为传入值和返回值,就需要将参数作为非常量引用传入。然而,你可能需要禁用会导致意外的常量对象;
  • 如果一个模版负责转发参数,使用完美转发。这时可能需要 $std::decay$ 或者 $std::common_-type$ 来处理不同类型的字符串常量和原始数组;
  • 如果性能很重要并且拷贝开销很大,使用常量引用;
  1. 如果你了解得够多,那就不需要遵循这些建议。然而要记住,不要根据直觉来判断性能,这样即使专家也会有判断失误的时候。

        在实践中,函数模版通常不是为了任意类型的参数,而是为了应用某些限制。例如你可能知道只有某些类型的 $vector$ 才会被传递,这时,最好不要把函数声明得太泛型,因为这可能带来意想不到的副作用。例如:

template<typename T>
void printVector(std::vector<T> const& v);

        这种声明可以确保 $T$ 不是引用,因为 $vector$ 不接受引用类型。以值方式传递一个 $vector$ 大部分情况下开销都很大,因为拷贝函数需要拷贝所有元素。出于这个原因,我们不建议使用值方式传递 $vector$ 。如果我们只是单纯地使用 $T$ 声明参数,那么是传值还是传引用就很难判断了。
        $std::make_-pair$ 是一个展示判断参数传递机制的陷阱的好例子。它是一个用于创建 $std::pair$ 的函数模版,通过类型推导确定类型。在不同的C++标准中,它的声明也在变化。

  • 在第一个C++标准C++98中,$make_-pair$ 使用引用传递来避免拷贝:
template<typename T1, typename T2>
pair<T1, T2> make_pair(T1 const& a, T2 const& b) {
  return pair<T1, T2>(a, b);
}

        这种方式会在传入字符串常量和原始数组时产生很大的问题。

  • 因此,C++03中改为值传递:
template<typename T1, typename T2>
pair<T1, T2> make_pair(T1 a, T2 b) {
  return pair<T1, T2>(a, b);
}

        你可以在问题解法的理由中读到:“比起其他两个建议,这看起来是标准的一个很小的改变,并且解决方案的优势足以抵消任何效率问题。”

  • 然而,C++11中,$make_-pair$ 支持移动语义,从而参数变为了转发引用:
template<typename T1, typename T2>
constexpr pair<typename decay<T1>::type, typename decay<T2>::type>
make_pair(T1&& a, T2&& b) {
  return pair<typename decay<T1>::type,
              typename decay<T2>::type>(forward<T1>(a),
                                        forward<T2>(b));
}

        这个实现很复杂,因为要支持 $std::ref$ 和 $std::cref$ ,所以函数通过 $std::decay$ 将 $std::reference_-wrapper$ 转为真实引用。
        C++标准库现在在许多地方使用类似的方式来完美地转发,通常也会结合 $std::decay$ 使用。

C++ Templates(3):基本技术

1. typename

        关键字 $typename$ 用于标识模版内部的某个成员是一个类型。

template<typename T>
class MyClass {
public:
  void foo() {
    typename T::SubType* ptr;
  }
};

        这里的第二个 $typename$ 就是用于标识 $SubType$ 是一个类型。如果没有 $typename$ ,$SubType$ 就必须是一个非类型成员,从而表达式会变成乘法运算,这会导致一个错误。
        $typename$ 的一个应用是在泛型类中声明迭代器:

#include <iostream>

template<typename T>
void printcoll(T const& coll) {
  typename T::const_iterator pos;
  typename T::const_iterator end(coll.end());
  for (pos = coll.begin(); pos != end; ++pos) {
    std::cout << *pos << ' ';
  }
  std::cout << '\n';
}

2. 零初始化

        对于基本类型,例如 $int$ 、$double$ 或指针类型,它们没有默认构造函数,意味着初始化时必须被赋予一个初始值,否则它们的值就是不确定的。假设你有一个模版变量,并且想要让它拥有初始值,但是内置类型并不会初始化。出于这个原因,你可以显式初始化,这会让它们的值变为 $0$ :

template<typename T>
void foo { T x{}; }

        这种初始化称为值初始化,表示要么调用一个已提供的构造函数,要么进行零初始化。这种方式甚至对 $explicit$ 构造函数也生效。
        C++11之前,使用零初始化的方式是小括号:

T x = T();

        在C++17之前,只有拷贝初始化的构造函数不是 $explicit$ 时才能使用这个机制,C++17强制性的拷贝省略避开了这种限制。如果类没有默认构造函数,那么也可以通过花括号调用初始化列表构造函数。

3. 原始数组和字符串常量模版

        当给模版参数传递原始数组和字符串常量时,有些问题要注意。首先,如果模版参数声明为引用,那么推导出来的参数是未退化的。只有当模版参数是值传递时,才会发生类型退化,这时原始数组和字符串常量都会退化为指针。

template<typename T, int N, int M>
bool less(T (&a)[N], T (&b)[M]) {
  for (int i = 0; i < N && i < M; i++) {
    if (a[i] < b[i]) return true;
    if (b[i] < a[i]) return false;
  }
  return N < M;
}

std::cout << less("ab", "abc") << std::endl;

        在上面的例子中,$T$ 是 $char$ $const$ ,$N$ 和 $M$ 分别是 $3$ 和 $4$ 。
        如果你只是想要为字符串常量提供一个模版,那么你可以这样:

template<int N, int M>
bool less(char const (&a)[N], char const (&b)[M]) {
  for (int i = 0; i < N && i < M; i++) {
    if (a[i] < b[i]) return true;
    if (b[i] < a[i]) return false;
  }
  return N < M;
}

        我们还可以对模版进行部分特例化。

#include <iostream>

template<typename T>
struct MyClass;

template<typename T, std::size_t SZ>
struct MyClass<T[SZ]> {
  static void print() {
    std::cout << "print() for T[" << SZ << "]\n";
  }
};

template<typename T, std::size_t SZ>
struct MyClass<T(&)[SZ]> {
  static void print() {
    std::cout << "print() for T(&)[" << SZ << "]\n";
  }
};

template<typename T>
struct MyClass<T[]> {
  static void print() {
    std::cout << "print() for T[]\n";
  }
};

template<typename T>
struct MyClass<T(&)[]> {
  static void print() {
    std::cout << "print() for T(&)[]\n";
  }
};

template<typename T>
struct MyClass<T*> {
  static void print() {
    std::cout << "print() for T*\n";
  }
};

template<typename T1, typename T2, typename T3>
void foo(int a1[7], int a2[],  // 指针
         int (&a3)[42],  // 已知长度数组引用
         int (&x0)[],  // 未知长度数组引用
         T1 x1,  // 退化后的类型
         T2& x2, T3&& x3) {  // 引用
  MyClass<decltype(a1)>::print();  // MyClass<T*>
  MyClass<decltype(a2)>::print();  // MyClass<T*>
  MyClass<decltype(a3)>::print();  // MyClass<T(&)[SZ]>
  MyClass<decltype(x0)>::print();  // MyClass<T(&)[]>
  MyClass<decltype(x1)>::print();  // MyClass<T*>
  MyClass<decltype(x2)>::print();  // MyClass<T(&)[]>
  MyClass<decltype(x3)>::print();  // MyClass<T(&)[]>
}

int main() {
  int a[42];
  MyClass<decltype(a)>::print();  // MyClass<T[SZ]>

  extern int x[];
  MyClass<decltype(x)>::print();  // MyClass<T[]>

  foo(a, a, a, x, x, x, x);
}

int x[] = {0, 8, 15};

4. 成员模版

        类成员也能成为模版,可以在内部类和成员函数中声明。

template<typename T>
class Stack {
public:
  template<typename T2>
  Stack& operator=(Stack<T2> const&);
// ...
};

template<typename T>
template<typename T2>
Stack<T>& Stack<T>::operator=(Stack<T2> const& op2) {
  Stack<T2> tmp(op2);
  elems.clear();
  while(!tmp.empty()) {
    elems.push_front(tmp.top());
    tmp.pop();
  }
  return *this;
}

Stack<int> intStack;
Stack<float> floatStack;
floatStack = intStack;

        你也可以通过友元简化上面的代码:

template<typename T>
class Stack {
public:
  template<typename T2>
  Stack& operator=(Stack<T2> const&);
  template<typename> friend class Stack;
  // ...
};

template<typename T>
template<typename T2>
Stack<T>& Stack<T>::operator=(Stack<T2> const& op2) {
  elems.clear();
  elmes.insert(elems.begin(),
               op2.elems.begin(),
               op2.elems.end());
  return *this;
}

        成员函数模版也可以被部分特例化或完全特例化。

class BoolString {
private:
  std::string value;

public:
  BoolString(std::string const& s) : value(s) {}
  template<typename T = std::string>
  T get() const { return value; }
};

template<>
inline bool BoolString::get<bool>() const {
  return value == "true" || value == "1" || value == "on";
}

        注意你不需要也不能声明特例化函数,你只能定义它们。因为它是一个完全特例化并且在头文件里,你需要声明为 $inline$ 来避免当定义被不同的翻译单元包含时产生的错误。
        模版成员函数可以在任何允许拷贝或者移动的对象中使用。类似于上面定义的赋值运算符,它们也能作用于构造函数。然而,注意模版构造函数或者模版赋值运算符不会代替预定义的构造函数或者赋值运算符。成员模版不会被视为特殊成员函数,这意味着对于相同的类型的赋值,还是会调用默认的拷贝赋值运算符。这既是一件好事也是一件坏事:

  • 当模版构造函数或者赋值运算符比起预定义的构造函数或者赋值运算符是更优匹配时,就算这个模版函数是为了其他某个类型而存在,它们也会被调用;
  • 完全模版化拷贝或者移动构造函数是很困难的。

        有时候,为了调用指定模版参数版本的成员函数,我们可以使用 $template$ 关键字,它可以确保 < 是作为模版参数列表的开始而使用的。

template<unsigned long N>
void printBitset(std::bitset<N> const& bs) {
  std::cout << bs.template to_string<char, std::char_traits<char>,
                                     std::allocator<char>>();
}

        在上面的例子中,如果没有 $template$ 关键字,编译器会认为 < 是一个小于号。注意这个问题只有在 $.template$ 之前的类型构造依赖于模版参数时才会出现。在我们的例子中,$bs$ 依赖于模版参数 $N$ 。$.template$ ( 类似的还有 ->$template$ 和 $::template$ ) 应该只在模版中且它们之后某些调用依赖于模版参数时使用。
        C++14中的lambda表达式也可以使用泛型。

[](auto x, auto y) { return x + y; }

        它等价于以下类:

class SomeCompilerSpecificName {
public:
  SomeCompilerSpecificName();
  template<typename T1, typename T2>
  auto operator()(T1 x, T2 y) const {
    return x + y;
  }
};

5. 变量模版

        从C++14开始,变量也能被模版化,称为变量模版。

template<typename T>
constexpr T pi{3.141592653589793285};

std::cout << pi<double> << '\n';
std::cout << pi<float> << '\n';

        你也可以在不同的翻译单元声明:

/* header.hpp */
template<typename T> T val{};  // 零初始化

/* translation unit 1 */
#include "header.hpp"

int main() {
  val<long> = 42;
  print();
}

/* translation unit 2 */
#include "header.hpp"

void print() {
  std::cout << val<long> << '\n';  // print 42
}

        还可以指定一个默认模版参数:

template<typename T = long double>
constexpr T pi = T{3.1415926535897932385};

std::cout << pi<> << '\n';

        要注意你必须使用尖括号,否则会报错:

std::cout << pi << '\n';  // 错误

        变量模版也可以使用非类模版参数:

#include <iostream>
#include <array>

template<int N>
std::array<int, N> arr{};

template<auto N>
constexpr decltype(N) dval = N;

int main() {
  std::cout << dval<'c'> << '\n';
  arr<10>[0] = 42;
  for (std::size_t i = 0; i < arr<10>.size(); i++) {
    std::cout << arr<10>[i] << '\n';
  }
}

        变量模版的一个有用的应用就是定义代表类成员的变量。

template<typename T>
class MyClass {
public:
  static constexpr int max = 1000;
};

template<typename T>
int myMax = MyClass<T>::max;

auto i = myMax<std::string>;

        从C++17开始,标准库使用变量模版来定义一些产生布尔值的 $type$ $traits$ 模版的缩写,比如:

namespace std {
  template<typename T>
  constexpr bool is_const_v = is_const<T>::value;
}

6. 模版模版参数

        一个类模版可以接收一个模版作为参数。

template<typename T,
         template<typename Elem> class Cont = std::deque>
class Stack {
private:
  Cont<T> elems;

public:
  void push(T const&);
  void pop();
  T const& top() const;
  bool empty() const { return elems.empty(); }
};

        这样 $deque$ 的实例化类型就由第一个模版参数 $T$ 决定了。像这样使用第一个参数来实例化第二个参数还是挺独特的。一般来讲,你可以使用任何参数来实例化模版模版参数。
        通常,除了使用 $typename$ 关键字外,你也可以使用 $class$ 关键字。在C++11之前,$Cont$ 只能使用 $class$ 关键字定义:

template<typename T,
         template<class Elem> class Cont = std::deque>
class Stack;

        从C++11开始,我们也可以使用别名模版代替 $Cont$ ,但是直到C++17,我们才能使用 $typename$ 关键字来声明模版模版参数:

template<typename T,
         template<typename Elem> typename Cont = std::deque>
class Stack;

        因为模版模版参数的模版参数名没有被用到,所以我们也可以省略:

template<typename T,
         template<typename> class Cont = std::deque>
class Stack;

        如果你尝试使用新版本的 $Stack$ ,你可能会发现编译出错了,出错信息是默认的 $std::deque$ 与模版模版参数 $Cont$ 不匹配。这个问题是因为在C++17之前,模版模版参数的模版参数必须精确匹配代替它的模版类型的模版参数。这种情况下,默认的模版参数是不会被考虑的。所以,我们要修改程序:

template<typename T,
         template<typename Elem,
                  typename Alloc = std::allocator<Elem>>
         class Cont = std::deque>
class Stack;

        这样声明的问题就是并非所有标准库容器都可以使用这个模版类了,比如 $std::array$ ,因为它需要提供的是一个数组长度值而不是一个分配器。