C++ 模板笔记

这篇博客记录一下学习 C++ 模板中遇到的一些知识,参考书籍是 C++ Templates: The Complete Guide (2nd Edition),本文所用的编译器版本:

> g++ --version
g++ (Ubuntu 7.3.0-27ubuntu1~18.04) 7.3.0
Copyright (C) 2017 Free Software Foundation, Inc.

typename 的意义

在模板声明中,typenameclass 没有任何区别,但是在一个模板类里,如果要声明一个模板类中的一个类型的变量,则需要加上关键字 typename 来告诉编译器这是一个类型,而不是模板类中的静态成员或者常量:

template<class T>
class A {
    typename T::some_type* variable;
};

如果写成:

template<class T>
class A {
     T::some_type* variable;
};

编译器就会将 T::some_type* variable 当做两个成员相乘。

如果将

template <class T>
void printContainer(const T& container) {
    typename T::const_iterator it, end(container.cend());
    for (it = container.cbegin(); it != end; ++it)
        cout << *it << endl;
}

写成

template <class T>
void printContainer(const T& container) {
    T::const_iterator it, end(container.cend());
    for (it = container.cbegin(); it != end; ++it)
        cout << *it << endl;
}

编译器会报错:

main.cpp: In function ‘void printContainer(const T&)’:
main.cpp:20:11: error: need ‘typename’ before ‘T::const_iterator’ because ‘T’ is a dependent scope
     const T::const_iterator it, end(container.cend());

变长模板参数

#include <iostream>
#include <vector>
using namespace std;

void print() {}

template <class Arg, class... Rest>
void print(Arg first, Rest... rest) {
    cout << first << endl;
    print(rest...);
}

template <class Collection, class... Indicies>
void printElems(const Collection& coll, Indicies... indicies) {
    print(coll[indicies]...);
}

int main(int argc, char const *argv[]) {
    vector<string> strings{"first", "second", "third", "fourth", "fifth"};
    print(1, 2, 3, 4, 5);
    printElems(strings, 3, 1, 0);
    return 0;
}

默认初始化成员值

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

模板模板参数

假如现在有一个类:

template<class ElementType, class Container>
class Stack {
    Container container;
    // ...
};

那么调用的时候就要像这样使用:

Stack<int, std::deque<int>> stack;

如果想这样使用:

Stack<int, std::deque> stack;

就要像这样声明模板:

template<class ElementType, template <class T> class Container = std::deque>
class Stack {
    Container<ElementType> container;
    // ...
};

这么做还不行,因为 STL 中的容器类实际上有两个模板参数,除了元素类型,还有 allocator 类型,在默认匹配的时候,如果只提供一个参数,编译器无法匹配,所以我们要写成下面这种形式:

template<class ElementType, 
template <class T, class Alloc = std::allocator<T>> class Container = std::deque>
class Stack {
    Container<ElementType> container;
    // ...
};

移动语义

在 C++ 11 中新增了移动语义,可以减少不必要的拷贝操作,但是在模板中,如果这样写:

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";
}

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

int main()
{
  X v;              // create variable
  X const c;        // create constant

  f(v);             // f() for variable calls f(X&)  =>  calls g(X&)
  f(c);             // f() for constant calls f(X const&)  =>  calls g(X const&)
  f(X());           // f() for temporary calls f(X&&)  =>  calls g(X&&)
  f(std::move(v));  // f() for move-enabled variable calls f(X&&)  =>  calls g(X&&)
}

得到的输出是:

g() for variable
g() for constant
g() for variable
g() for variable

需要用 std::forward<> 来完美转发 (Perfect Forwarding)潜在的移动语义:

template<typename T>
void f (T&& val) {
  g(std::forward<T>(val));   // call the right g() for any passed argument val
}

输出是:

g() for variable
g() for constant
g() for movable object
g() for movable object

enable_if<> 的应用

为了利用右值引用和移动语义,在声明类和构造函数的时候可以分别为左值和右值写构造函数:

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

class Person {
   private:
    std::string name;

   public:
    // constructor for passed initial name:
    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";
    }
    // copy and move constructor:
    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";
    }
};

int main() {
    std::string s = "sname";
    Person p1(s);      // init with string object => calls copying string-CONSTR
    Person p2("tmp");  // init with string literal => calls moving string-CONSTR
    Person p3(p1);     // copy Person => calls COPY-CONSTR
    Person p4(std::move(p1));  // move Person => calls MOVE-CONST
}

利用上面的转发机制,可以将构造函数写成模板函数:

class Person {
   private:
    std::string name;

   public:
    // generic constructor for passed initial name:
    template <typename STR>
    explicit Person(STR&& n) : name(std::forward<STR>(n)) {
        std::cout << "TMPL-CONSTR for '" << name << "'\n";
    }

    // copy and move constructor:
    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";
    }
};

但是这么做会有问题,在编译的时候,下面的语句会报错:

Person p3(p1);     // copy Person => calls COPY-CONSTR

原因是,对于左值 p1 来说,调用模板函数只需要简单地将类型 STR 替换为 Person&,但是对于拷贝构造函数而言,则需要一次额外的 const 转换,因此解决函数重载的时候,模板函数比下面的拷贝构造函数更优。

一种可能的解决办法是像下面这样定义一个构造函数:

Person(Person& p) 

然而对于 Person 的派生类而言,模板函数依然是更优的匹配,不能完全解决问题。我们需要的解决方案是,对于从 Person 或者其派生类的对象,需要屏蔽掉模板函数。C++ 11 中提供了 std::enable_if<> 来解决这个问题。

enable_if<> 的作用

template<typename T>
typename std::enable_if<condition, OptionalType>::type
foo() {
    // ...
}
  • condition 为真的时候,type 类型就是 OptionalType,如果第二个模板参数没有指定,那么就是 void
  • condition 为假的时候,type 类型未定义,由 SFINAE(Substitution Failure Is Not An Error) 原则,该模板当前的特化被忽略,达到屏蔽的效果

一般也可以写成下面这种形式,更简洁一些:

template<typename T, typename = std::enable_if<condition>::type>
void foo() {
    // ...
}

回到上面 Person 类的问题,我们可以通过这样的方式来在特定时候启用模板构造函数:

#include <type_traits>

template<typename T>
using EnableIfString = std::enable_if<
                         std::is_convertible<T,std::string>::value>::type;
// ...
template<typename STR, typename = EnableIfString<STR>>
    explicit Person(STR&& n)
     : name(std::forward<STR>(n)) {
        std::cout << "TMPL-CONSTR for '" << name << "'\n";
    }
// ...

就可以解决问题了。

连接问题

按照惯常的做法,一般我们会在写大程序的时候,将声明放在头文件中,比如 .h 一类的,然后在另外的 .cpp 文件中定义函数。但是如果按照这种方式组织模板代码,会发生下面的问题:

// myfirst.hpp
#ifndef MYFIRST_HPP
#define MYFIRST_HPP

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

#endif // MYFIRST_HPP

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

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

// myfirstmain.cpp
#include "myfirst.hpp"

// use of the template
int main()
{
    double ice = 3.0;
    printTypeof(ice);   // call function template for type double
}

当我们试着编译这段程序的时候,会报连接错误:

> g++ myfirstmain.cpp myfirst.cpp -o myfirst
/tmp/cc3q27zY.o: In function `main':
myfirstmain.cpp:(.text+0x2c): undefined reference to `void printTypeof<double>(double const&)'
collect2: error: ld returned 1 exit status

原因是编译器在遇到模板函数的时候需要实例化,但是实例化所需要的信息分别在两个编译模块中,因此,当编译器遇到 printTypeof() 调用的时候,并不知道如何具体实例化这个函数,只能假定在别的地方定义了实例函数;而当编译器遇到了 printTypeof() 的模板定义的时候,它并不知道需要用什么模板参数去实例化,也就没有实例化,因此导致了连接错误。

解决办法是将模板函数的定义放在头文件中:

#ifndef MYFIRST_HPP
#define MYFIRST_HPP

#include <iostream>
#include <typeinfo>

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

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

#endif // MYFIRST_HPP

这样可以解决问题,但是会增加编译时间。另一种办法是模板显式实例化:

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

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

template void printTypeof<>(double const&);

但是这样就需要每种可能的模板参数都声明一遍,所以还是内含在头文件中比较方便,尽管会带来额外的编译开销。

类型特性(Type Traits)

从 C++ 11 起,可以通过引入头文件 <type_traits> 实现编译时的类型特性检查和操作,在编写通用库的时候非常有用。

其中值得注意的是 std::remove_const_t<>std::remove_reference_t<> 两者的使用顺序:

std::remove_const_t<int const&> // -> int 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 

特性(Traits)

假设有一个求和函数:

template<typename T>
T accum (T const* beg, T const* end)
{
    T total{};  // assume this actually creates a zero value
    while (beg != end) {
        total += *beg;
        ++beg;
    }
    return total;
}

这段模板代码在如下调用的时候会发生溢出错误:

char name[] = "templates";
int length = sizeof(name)-1;

// (try to) print average character value
std::cout << "the average value of the characters in \""
    << name << "\" is "
    << accum(name, name+length) / length
              << '\n';

原因是,模板中的类型 T 被实例化成为 char,所以发生了溢出错误,当然我们可以通过额外声明求和变量的类型来解决问题:

accum<int>(name, name+length) 

类型特性(Type Traits)

通过类型特性,可以更好地解决这个问题:

template<typename T>
struct AccumulationTraits;

template<>
struct AccumulationTraits<char> {
    using AccT = int;
};

template<>
struct AccumulationTraits<short> {
    using AccT = int;
};

// ...

这里的技巧是通过模板特化来选择我们需要的类型,然后求和函数代码只需要改造成下面这样既可:

template<typename T>
auto accum (T const* beg, T const* end)
{
    // return type is traits of the element type
    using AccT = typename AccumulationTraits<T>::AccT;

    AccT total{};  // assume this actually creates a zero value
    while (beg != end) {
        total += *beg;
        ++beg;
    }
    return total;
}

值特性(Value Traits)

在原本的代码中,AccT 是根据一个 T 推断出来的类型,并且使用了花括号进行默认构造,但是 AccT 很可能没有默认构造函数,或者不能提供一个足够好的默认值,这时候可以用值特性来提供默认值:

template<typename T>
struct AccumulationTraits;

template<>
struct AccumulationTraits<char> {
    using AccT = int;
    static AccT const zero = 0;
};

template<>
struct AccumulationTraits<short> {
    using AccT = int;
    static AccT const zero = 0;
};

模板代码改造成下面这样:

template<typename T>
auto accum (T const* beg, T const* end)
{
  // return type is traits of the element type
  using AccT = typename AccumulationTraits<T>::AccT;

  AccT total = AccumulationTraits<T>::zero;  // init total by trait value
  while (beg != end) {
    total += *beg;
    ++beg;
  }
  return total;
}

但是这样也有限制:C++ 在类中的静态常量只能使用整形或者字面量来初始化。

SFINAE

通过SFINAE,可以实现std::is_default_constructible<>的功能,首先利用值特性创建布尔常量:

template<bool val>
struct BoolConstant {
  using Type = BoolConstant<val>;
  static constexpr bool value = val;
};
using TrueType  = BoolConstant<true>;
using FalseType = BoolConstant<false>;

通过模板特化,实现判断两个类型是否相同的功能:

#include "boolconstant.hpp"

template <typename T1, typename T2>
struct IsSameT : FalseType {};

template <typename T>
struct IsSameT<T, T> : TrueType {};

最后实现模板来检查一个类是否有默认构造函数:

#include "issame.hpp"

template<typename T>
struct IsDefaultConstructibleT {
  private:
    // test() trying substitute call of a default constructor for T passed as U:
    template<typename U, typename = decltype(U())>
      static char test(void*);
    // test() fallback:
    template<typename>
      static long test(...);
  public:
    static constexpr bool value
      = IsSameT<decltype(test<T>(nullptr)), char>::value;
};

这段代码的意思是:尝试用模板类中的 T 替换模板类中的模板函数中的 U,假如没有默认构造函数,那么 decltype(U()) 就会发生错误,从而 char test(void*) 替换失败,这时候会回退到下面定义的函数,由于参数列表是省略号,因此总是最后匹配到,这时候 test() 的返回值类型就变为 long 。利用这个特性,我们只需要判断 test() 的返回值类型就知道有没有默认构造函数了。

注意,如果我们直接用 T 来测试,而不是通过传递模板参数的方法的话,会直接产生编译错误:

template<typename, typename = decltype(T())>
      static char test(void*);

这是因为在模板实例化的时候,所有的成员函数都会被替换进去,如果没有默认构造函数,就会发生编译错误,并且不会触发 SFINAE。通过传递模板参数的方法,则创造了一个局部性的模板替换上下文,从而可以触发 SFINAE

改进版本

当然这么写有点复杂,我们可以直接让函数的返回类型就是布尔常量,同时也符合了一个谓词模板应该返回布尔常量类的惯例:

#include <type_traits>

template<typename T>
struct IsDefaultConstructibleHelper {
  private:
    // test() trying substitute call of a default constructor for T passed as U:
    template<typename U, typename = decltype(U())>
      static std::true_type test(void*);
    // test() fallback:
    template<typename>
      static std::false_type test(...);
  public:
    using Type = decltype(test<T>(nullptr));
};

template<typename T>
struct IsDefaultConstructibleT : IsDefaultConstructibleHelper<T>::Type {
};

利用偏特化改进

#include <type_traits>  // defines true_type and false_type

// helper to ignore any number of template parameters: 
template<typename...> using VoidT = void;

// primary template:
template<typename, typename = VoidT<>>
struct IsDefaultConstructibleT : std::false_type
{
};

// partial specialization (may be SFINAE'd away):
template<typename T>
struct IsDefaultConstructibleT<T, VoidT<decltype(T())>> : std::true_type
{
};