回到顶部 暗色模式

C++ Templates(1):函数模版和类模版

        前面都是一些基础概念,快速过一下。

1. 函数模版

        函数模版提供了一种可以被不同类型调用的函数行为。换句话说,函数模版表示一组类似的函数。它的表现类似于普通函数,除了一些元素类型未指定。

template<typename T>
T max(T a, T b) {
  return b < a ? a : b;
}

std::cout << max(1, 2) << std::endl;  // 2

        关键字typename声明了一个类型参数 $T$ ,由于历史原因,你也可以使用class关键字声明。$T$ 表示一个任意类型,会根据不同的调用而改变。在上面的例子中,类型 $T$ 必须支持 $operator$< 函数并且可拷贝。C++17也允许在 $T$ 既不可拷贝也不可移动的情况下传入一个右值。在上面的例子中,$T$ 的类型是 $int$ 。将模版参数用具体类型代替的过程称为实例化 ( $instantiation$ ),实例化会产生一个模版实例。
        注意到 $void$ 也可以作为模版参数:

template<typename T>
T foo(T*) {}

void *vp = nullptr;
foo(vp);

        模版会进行两阶段编译:

  1. 定义阶段 ( $definition$ $time$ ),即没有实例化的阶段,编译器会检查模版代码是否正确,包括语法检查、调用检查、$static_-assert$ 等;
  2. 实例化阶段 ( $instantiation$ $time$ ),模版代码会被再次检查,这次包括了所有依赖于模版参数的部分。

        有些编译器并不会在定义阶段进行完全检查,这意味着有些问题只有在实例化的时候才能发现。
        在类型推导时,如果存在隐式类型转换,那么会应用以下规则:

        函数模版类型推导也不支持默认参数:

template<typename T>
void f(T = "");

f();  // 错误

        正确的使用方式如下:

template<typename T = std::string>
void f(T = "");

f();

        当我们使用两个模版参数时:

template<typename T1, typename T2>
T1 max(T1 a, T2 b) {
  return b < a ? a : b;
}

        看上去一切正常,但是考虑一下返回值,如果我们传入了一个浮点数和一个整型,那么返回值就永远会是浮点数,因为我们先传入了浮点数。C++提供了几种方式解决这个问题。
        第一种解决方案是再加入一个模版参数,作为返回值类型:

template<typename T1, typename T2, typename RT>
RT max(T1, T2);

        但是这样声明,编译器因为不知道返回类型,所以我们必须在调用时显式指定类型:

::max<int, double, double>(4, 7.2);

        也可以通过调换声明顺序从而只显式指定第一个参数类型:

template<typename RT, typenamet T1, typename T2>
RT max(T1, T2);

::max<double>(4, 7.2);

        这种多一个类型参数的方式其实并没有什么优势,因为就算是之前的单类型参数的版本,我们也可以通过类型转换来显式指定返回值。相比之下,这种写法就显得很啰嗦了。
        第二种解决方案是让编译器自动推导。从C++14开始,函数返回值允许使用 $auto$ 关键字。

template<typename T1, typename T2>
auto max(T1, T2);

        函数返回值推导让编译器根据函数的返回语句推导参数的类型。当然,前提是可以推导,如果函数不可用或者具有多条返回不同类型的 $return$ 语句,那么编译器也无能为力。在C++11,我们也可以通过尾置返回类型解决这个问题:

template<typename T1, typename T2>
auto max(T1 a, T2 b) -> decltype(b < a ? a : b);

        这里的比较并不那么必要,我们可以直接使用 $true$ 代替:

template<typename T1, typename T2>
auto max(T1 a, T2 b) -> decltype(true ? a : b);

        这种方案也存在一个缺点:返回类型可能是一个引用类型,因为 $decltype$ 关键字是精确的推导。为了避免这种情况,我们可以使用 $traits$ :

#include <type_traits>

template<typename T1, typename T2>
auto max(T1 a, T2 b) -> typename std::decay<decltype(true ? a : b)>::type;

        当然,如果你使用C++14的自动返回类型推导,那么 $traits$ 的使用也是不必要的。
        第三种解决方案是使用 $std::common_-type$ :

#include <type_traits>

template<typename T1, typename T2>
std::common_type_t<T1, T2> max(T1, T2);

        从C++11开始,标准库就提供了一种选择最通用类型的机制 $std::common_-type$ ,它接收两个或多个不同的参数,并且会返回退化之后的类型。
        我们也可以结合默认模版参数来解决这个问题:

#include <type_traits>

template<typename T1, typename T2,
         typename RT = std::decay_t<decltype(true ? T1() : T2())>>
RT max(T1, T2);

        不过这种实现要求我们可以调用 $T1$ 和 $T2$ 的默认构造函数。

#include <type_traits>

template<typename T1, typename T2,
         typename RT = std::common_type_t<T1, T2>>
RT max(T1, T2);

        默认模版参数里也可以使用 $common_-type$ ,这样就可以避开默认构造函数。通过默认模版参数,我们既可以让编译器自动推导,也可以显式指定:

auto a = ::max(4, 7.2);
auto b = ::max<double, int, long double>(7.2, 4);

        考虑模版函数和普通函数同时存在的情况:

int max(int, int);

template<typename T>
T max(T, T);

int main() {
  ::max(7, 42);  // call the nontemplate version
  ::max(7.0, 42.0);  // call max<double>
  ::max<>(7, 42);  // call max<int>
  ::max<double>(7, 42);  // call max<double>
  ::max('a', 42.7);  // call the nontemplate version
}

        你可能会疑惑,为什么我们通常使用传值而不是传引用。通常来讲,传引用更适合一些类类型而不是简单值类型。而且,在很多情况下,传值也是更好的选择:

        此外,对于模版,也需要从一些特定角度考虑:

        通常,函数模版没有必要声明为 $inline$ 。与普通函数不同,我们可以在头文件内定义非内联函数模版。唯一的例外就是如果你要为某个特定类型进行完全特例化,这时特例化的代码就不再是泛型代码了。

2. 类模版

        类似于函数,类也可以被参数化。

#include <vector>
#include <cassert>

template<typename T>
class Stack {
private:
  std::vector<T> elems;

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

template<typename T>
void Stack<T>::push(T const &elem) { elems.push_back(elem); }

template<typename T>
void Stack<T>::pop() {
  assert(!elems.empty());
  elems.pop_back();
}

template<typename T>
T const &Stack<T>::top() const {
  assert(!elems.empty());
  return elems.back();
}

        在一个类内使用不带模版参数类型的类名与带模版参数的类名等价,但是在类外使用的时候必须声明模版参数类型。与普通类型不同,模版类的定义只能在模版类内部或者相同块作用域内。
        一个类模版通常会被用于多种操作,这意味着类必须提供多种重载函数,但是这些函数只有在被调用时才会实例化。如果实例化的类型不支持对应操作,那么就会产生编译错误。这带来了一个问题,我们怎么知道一个实例化之后的模版能进行哪些操作呢?概念 ( $concept$ ) 是C++20引入的,用于表示一组模版库内经常会被使用的约束,通过概念可以约束只有实现了特定操作的类型才能实例化模版。但是,C++11没有概念,我们只能通过 $static_-assert$ 以及 $traits$ 进行一些基本的约束。
        我们可以通过友元函数重载 $Stack$ 的 << 运算符:

template<typename T>
class Stack {
  // ...
  void printOn(std::ostream& strm) const;  

  friend std::ostream& operator<<(std::ostream& strm,
                                  Stack<T> const& s) {
    s.printOn(strm);
    return strm;
  }
};

        上面这种方式声明的 $operator$<< 函数是一个非模版函数,但是会在必要的时候实例化。然而,如果我们不在类内定义这个友元函数,那么我们就要指定模版。有两种方式可以指定函数模版。

template<typename T>
class Stack {
  // ...
  template<typename U>
  friend std::ostream& operator<<(std::ostream&, Stack<U> const&);
};

        第一种方式使用了一个新的模版参数 $U$ 。要注意,在这里是不能再次使用 $T$ 的。

template<typename T>
class Stack;

template<typename T>
std::ostream& operator<<(std::ostream&, Stack<T> const&);

template<typename T>
class Stack {
  // ...
  friend std::ostream& operator<< <T>(std::ostream&, Stack<T> const&);
};

        第二种方式先将 $operator$<< 声明为一个模版函数,并在模版类内指定以 $T$ 为模版参数实例化的 $operator$<< 函数作为友元函数。
        模版类允许特例化,类似于函数模版重载。要注意,如果你需要特例化一个模版类,那么你也必须特例化类内的所有函数。虽然你也可以只特例化模版类的某个函数,但是一旦你这么做了,你就不能再使用这个类型特例化模版类了。

#include "stack1.hpp"
#include <deque>
#include <string>
#include <cassert>

template<>
class Stack<std::string> {
private:
  std::deque<std::string> elems;

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

void Stack<std::string>::push(std::string const& elem) {
  elems.push_back(elem);
}

void Stack<std::string>::pop() {
  assert(!elems.empty());
  elems.pop_back();
}

std::string const& Stack<std::string>::top() const {
  assert(!elems.empty());
  return elems.back();
}

        除了像上面这样特例化之外,模版类还允许部分特例化,即提供特定条件下的实现。

#include "stack1.hpp"

template<typename T>
class Stack<T*> {
private:
  std::vector<T*> elems;

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

template<typename T>
void Stack<T*>::push(T* elem) { elems.push_back(elem); }

template<typename T>
T* Stack<T*>::pop() {
  assert(!elems.empty());
  T* p = elems.back();
  elems.pop_back();
  return p;
}

template<typename T>
T* Stack<T*>::top() const {
  assert(!elems.empty());
  return elems.back();
}

        与模版函数一样,模版类也可以指定一个默认模版参数:

#include <vector>
#include <cassert>

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

// ...
};

        从C++17开始,如果编译器可以推导出类型的话,你可以跳过模版类类型显式声明语句

Stack<int> intStack1;
Stack intStack2 = intStack1;  // Stack<int>

        也可以结合构造函数使用:

template<typename T>
class Stack {
public:
  Stack() = default;
  Stack(T const& elem) : elems({elem}) {}
  // ...
};

Stack intStack = 0;

        理论上,你也可以这样使用:

Stack stringStack = "bottom";  // Stack<char const[7]>

        但是,这会带来一大堆问题。因为这种方式推导出来的类型是未退化的类型,这意味着我们无法再添加其他长度的字符串。出于这个原因,在一些时候我们建议使用传值方式的构造函数。

template<typename T>
class Stack {
public:
  Stack(T elem) : elems({std::move(elem)}) {}
  // ...
};

Stack stringStack = "bottom";  // Stack<char const*>

        有时候,与其让编译器自己推导,不如我们自己指定一种类型。

Stack(char const*) -> Stack<std::string>;

Stack stringStack{"bottom"};  // Stack<std::string>

        这种技巧称为推导指引,在C++17被引入。它定义了在当前作用域内,所有以字符串常量作为参数构造的 $Stack$ ,其模版参数类型都为 $std::string$ 。
        聚合类 ( 没有显式或者继承的构造器,没有私有或受保护的非静态成员,没有虚函数,非 $private$ 或者 $protected$ 继承的类 ) 也可以使用模版。

template<typename T>
struct ValueWithComment {
  T value;
  std::string comment;
};

ValueWithComment<int> vc;
vc.value = 42;
vc.comment = "initial value";

        从C++17开始,你也可以为聚合类定义推导指引。

ValueWithComment(char const*, char const*)
  -> ValueWithComment<std::string>;

        标准库容器 $std::array$ 就是一个聚合类。

C++ Templates(1):函数模版和类模版