回到顶部 暗色模式

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++有许多作为回调的有用的类型,因为它们既可以作为函数调用参数,也可以直接被调用。这些类型有:

        总的来说,这些类型被称为函数对象类型 ( $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';
  });
}

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$ 是一个不完全类型。你可以这样解决:

struct cleanup {
  ~cleanup() {
    // ...
  }
} dummy;
return std::invoke(std::forward<Callable>(op),
                   std::forward<Args>(args)...);
#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$ 来实例化类型时,这个类模版可以正常工作。但是当我们使用引用来实例化时,就有点棘手了:

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

#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++标准库有时候会进行出乎意料的特例化和约束,例如:

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(...);
    // ...
  };
}
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. 编写泛型库时的注意事项

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