回到顶部 暗色模式

EffectiveModernCpp(1):类型推导

1. 类型推导

        考虑这样一个模版:

template<typename T>
void f(ParamType param);

f(expr);

        编译器会使用 $expr$ 进行类型推导,一个是针对 $T$ 的,另一个是针对 $ParamType$ 的。这两个类型通常是不同的。例如:

template<typename T>
void f(const T &param);

int x = 0;
f(x);

        这时 $T$ 被推导为 $int$ ,$ParamType$ 被推导为 $const$ $int$ & 。事实上,$T$ 的推导不仅取决于 $expr$ ,还取决于 $ParamType$ 。这里有三种情况:

1.1 情况一

        最简单的情况是 $ParamType$ 是一个指针或引用但不是普通引用,这时类型推导过程为:

  1. 如果 $expr$ 的类型是引用,忽略引用部分;
  2. 根据剩下部分推导 $T$ ,并得出 $ParamType$ 。
template<typename T>
void f(T &param);

int x = 27;
const int cx = x;
const int &rx = cx;

f(x); // T为int, ParamType为int &
f(cx);  // T为const int, ParamType为const int &
f(rx);  // T为const int, ParamType为const int &

        注意第三个例子中,即使 $rx$ 是一个引用,$T$ 也会被推导为非引用。

1.2 情况二

        如果 $ParamType$ 是一个通用引用,那么推导过程如下:

template<typename T>
void f(T &&param);

int x = 27;
const int cx = x;
const int &rx = cx;

f(x);  // T为int &, paramType为int &
f(cx);  // T为const int &, paramType为const int &
f(rx);  // T为const int &, paramType为const int &
f(27);  // T为int, paramType为int &&

1.3 情况三

        当 $ParamType$ 既不是指针也不是引用时,采用传值方式。推导过程如下:

template<typename T>
void f(T param);

int x = 27;
const int cx = x;
const int &rx = x;
const char *const ptr = "pointers";

f(x);  // T和ParamType都是int
f(cx);  // T和ParamType都是int
f(rx);  // T和ParamType都是int
f(ptr);  // T和ParamType都是const char *

        最后一个例子中,由于 $const$ 被忽略,所以类型会被推导为 $const$ $char$ $\star$ 。

1.4 数组

const char name[] = "name";  // 类型为const char[13]

const char *ptrToName = name;

        虽然 $name$ 和 $ptrToName$ 的类型不同,但是C++允许数组退化为一个指针。应用在函数上,体现为:

void myFunc(int param[]);

void myFunc(int *param);

        上面这两个函数是等价的。将这个规则应用于模版:

template<typename T>
void f(T param);

f(name);  // T为const char *

        虽然函数不能接收数组,但是可以接收数组引用。

template<typename T>
void f(T &param);

f(name);  // T为const char[13], paramType为const char (&)[13]

        从而我们可以通过模版函数推导出数组大小:

template<typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept {
  return N;
}

1.5 函数

void foo(int, double);

template<typename T>
void f1(T param);

template<typename T>
void f2(T &param);

f1(foo);  // T和paramType为void (*)(int, double)
f2(foo);  // T和paramType为void (&)(int, double)

        与数组相同,函数也会退化为指针,但是对于引用,它们不会退化。

2. auto

auto x = 27;  // int
const auto cx = x;  // int
const auto &rx = cx; // int

const char name[] = "name";
auto arr1 = name;  // const char *
auto &arr2 = name;  // const char (&)[13]

void foo(int, double);

auto f1 = foo;  // void (*)(int, double)
auto f2 = foo;  // void (&)(int, double)

        $auto$ 类型推导除了一个例外,其他情况都和模版类型推导一样。C++中允许以下的类型声明方式:

auto x1 = 27;  // int
auto x2(27);  // int
auto x3 = {27};  // std::initializer_list
auto x4{27};  // std::initializer_list

        这就是 $auto$ 推导不同于模版推导的地方,使用花括号的变量声明会被推导为 $initializer_-list$ ,后者是一个模版。$initializer_-list$ 在实例化的过程中也要被推导,推导出的类型为 $initalizer_-list$<$int$> 。

3. decltype

const int i = 0;  // decltype(i)为const int

bool f(const Widget &w);  // decltype(f)是bool (const Widget &)

struct Point {
  int x;  // decltype(Point::x)为int
  int y;  // decltype(Point::y)为int
};

std::vector<int> v;  // decltype(v)为std::vector<int>

v[0] = 0;  // decltype(v[0])为int &

        $decltype$ 会返回精确的结果,主要用途是作为模版函数的返回类型,根据不同的形参返回不同的类型。

template<typename Container, typename Index>
auto authAndAccess(Container &c, Index i) -> decltype(c[i]) {
  authenticateUser();
  return c[i];
}

        函数名称前的 $auto$ 不会做任何推导工作,只是暗示使用尾置返回类型。C++11允许自动推导单一语句的lambda表达式返回类型,C++14扩展到允许自动推导所有lambda表达式和函数,甚至包含多条语句。

template<typename Container, typename Index>  // C++14
auto authAndAccess(Container &c, Index i) {
  authenticateUser();
  return c[i];
}

        由于 $auto$ 应用于函数时使用的是模版推导的方式,所以根据我们在情况一中所说的,如果表达式是一个引用,那么引用会被忽略,从而以下代码不合法:

std::deque<int> d;
authAndAccess(d, 5) = 10; // 编译失败

        为了避免这种情况,我们应该使用 $decltype$ ,因为 $decltype$ 会返回精确类型,包括引用。

template<typename Container, typename Index>
decltype(auto) authAndAccess(Container &c, Index, i) {
  authenticateUser();
  return c[i];
}

        $decltype(auto)$ 是C++14引入的,允许我们通过 $decltype$ 方式推导返回值,而不是使用模版推导方式。在使用 $decltype(auto)$ 之后,函数会返回引用类型。
        $decltype(auto) $ 也不局限于函数返回类型:

Widget w;
const Widget &cw = w;

auto myWidget1 = cw;  // Widget
decltype(auto) myWidget2 = cw;  // const Widget &

        向 $authAndAccess$ 传递一个右值是不合法的,因为右值不能绑定到左值引用上。如果想要它支持左值和右值,需要修改为通用引用:

template<typename Container, typename Index>
decltype(auto) authAndAccess(Container &&c, Index i) {
  authenticateUser();
  return std::forward<Container>(c)[i];
}

        $decltype$ 也存在一些特殊情况。

int x = 0;  // decltype(x)为int
            // decltype((x))为int&

        在上述例子中,$decltype(int)$ 为 $int$ ,这没有问题,但是 $decltype((int))$ 却变成了 $int$ & 。对于 $x$ 来说,它是一个左值,而C++11定义了 $(x)$ 也是一个左值,并且对后者的 $decltype$ 调用会返回引用类型。将这个特性应用在函数上,结果如下:

decltype(auto) f1() {
  int x = 0;
  return x;  // int
}

decltype(auto) f2() {
  int x = 0;
  return (x);  // int &
}

4. 类型诊断

        为了获取真实的推导结果,我们需要采取一些诊断方式。

4.1 编译器诊断

        编译出错时,编译器会输出报错信息,这些信息中会包含推导结果。

// 声明一个模版类但不定义
template<typename T>
class TD;

        我们声明了一个模版类但不定义,从而在尝试实例化这个类的时候就会报错。

int x = 1;
const int *y = &x;

TD<decltype(x)> xType;
TD<decltype(y)> yType;

        上面的代码产生类似于下面的错误:

error: aggregate 'TD<int> xtype' has incomplete type and
    cannot be defined
error: aggregate 'TD<const int *> yType' has incomplete type and
    cannot be defined

4.2 运行时输出

        标准I/O提供了一种格式化输出的方法:

std::cout << typeid(x).name() << std::endl;
std::cout << typeid(y).name() << std::endl;

        这种方法会产生一个 $std::type_-info$ 对象,并调用该对象的 $name$ 函数。要注意的是,这个函数不保证返回有意义的东西,比如GNUClang可能会返回 $i$ ( 表示 $int$ ),和 $PKi$ ( 表示 $pointer$ $to$ $\require{cancel}\bcancel{konst}$ $const$ $int$ )。
        考虑一个更复杂的例子:

template<typename T>
void f(const T &param) {
  std::cout << "T=" << typeid(T).name() << "\n"
            << "param=" << typeid(param).name() << std::endl;
}

std::vector<Widget> createVec();

const auto vw = createVec();
// ...
if (!vw.empty()) {
  f(&vw[0]);
}

        GNUClang会输出类似如下的结果:

T=PK6Widget
param=PK6Widget

        数字 $6$ 是类名称的字符串长度。看起来这种输出方式好像可以理解,但是推导一下却发现不是这样的,因为 $T$ 和 $param$ 的类型输出一致,很明显这是错的。所以 $std::type_-info::name$ 的结果并不总是可信。相比于标准库,Boost库是更好的选择:

#include <boost/type_index.hpp>

template<typename T>
void f(const T &param) {
  using std::cout;
  using boost::type_index::type_id_with_cvr;

  cout << "T=" << type_id_with_cvr<T>().pretty_name() << "\n"
       << "param=" << type_id_with_cvr(decltype(param)>().pretty_name()
       << std::endl;
}

        在GNUClang环境下,会输出:

T=Widget const *
param=Widget const * const&

5. 再谈auto

        $auto$ 的概念很简单,但是如果使用不小心,会产生一些错误。以下列举了一些 $auto$ 的使用法则。

5.1 优先使用auto

        C++的变量声明是不会将变量清空的,这意味着当你使用:

int x;  // 声明但不初始化

        的时候,$x$ 的值是完全不确定的。使用 $auto$ 则可以避免这个问题,因为你不初始化便无法推导出结果。
        此外,当有些变量名过于冗长时:

template<typename T>
void dwim(It b, It e) {
  while (b != e) {
    typename std::iterator_traits<It>::value_type
    currValue = *b;
  }
}

        我们也可以使用 $auto$ 简化。因为 $auto$ 使用类型推导技术,所以它还可表达一些只有编译器才能知道的类型:

auto derefUPLess = 
  [](const std::unique_ptr<Widget> &p1,
     const std::unique_ptr<Widget> &p2) {
    return *p1 < *p2;
  };

        如果使用C++14,上述代码还可以继续简化:

auto derefUPLess =
  [](const auto &p1, const auto &p2) {
    return *p1 < *p2;
  };

        $auto$ 还可以避免一个问题,称之为类型快捷方式 ( $type$ $shortcuts$ ) 问题。

std::vector<int> v;
unsigned sz = v.size();

        $std::vector::size$ 的返回类型为 $std::vector$<$int$>$::size_-type$ ,后者实际上也是无符号整型。但是这会造成移植性问题,比如在 $64$ 位Windows系统中,前者为 $64$ 位,后者为 $32$ 位。通过使用 $auto$ ,我们可以避免这个问题。

std::unordered_map<std::string, int> m;
for (const std::pair<std::string, int> &p : m) {
  // ...
}

        上述代码好像没有问题,而且也能正常运行,但是还是有问题。$std::unordered_-map$ 的 $key$ 是一个常量,但是 $for$ 循环中声明的不是常量。从而编译器需要在每个遍历过程中不断创建临时对象,将该临时对象引用绑定到 $p$ ,并在每次迭代结束后销毁这个临时对象。同样,使用 $auto$ 也可以避免这个问题。

5.2 通过显式类型避免错误推导

std::vector<bool> features(const Widget &w);

auto highPriority = features(w)[5];  // 应该使用bool显式声明
processWidget(w, highPriority);  // 未定义行为

        $processWidget$ 是一个未定义行为,因为 $std::vector$<$bool$>$::operator[\ ]$ 不会返回容器元素的引用,而是返回一个 $std::vecotr$<$bool$>$::reference$ 对象,从而 $auto$ 不会推导为 $bool$ 。虽然如此,但是 $reference$ 是可以隐式转换为 $bool$ 的,为什么这里还是未定义行为呢?因为 $reference$ 的行为依赖于具体实现,举例来讲,其中一种实现是包含一个指向结果的指针。这时,我们调用 $features$ ,后者返回一个临时 $vector$ 对象,$reference$ 的成员指针指向这个临时对象中的某个元素。之后临时对象销毁,从而导致 $reference$ 的成员指针变为了悬垂指针。
        $std::vector$<$bool$>$::reference$ 是代理类的一个应用,一些代理类被设计为对客户可见,比如 $std::shared_-ptr$ 和 $std::unique_-ptr$ ,其他代理类则与之相反。作为一个通则,不可见的代理类不应该使用 $auto$ 。因为这样类型的对象的生命周期通常被设计为不超过一条语句,使用 $auto$ 违反了它们的设计理念。
        但是实际上,开发者并不知道哪些函数返回的是代理类,往往都是在跟踪一些问题时才能发现代理类。$auto$ 本身没问题,问题是 $auto$ 不会推导出你想要的类型。解决方案是使用另一种类型推导形式,称为显式类型初始化惯用法 ( $the$ $explicitly$ $typed$ $initialized$ $idiom$ )。

auto highPriority = static_cast<bool>(features(w)[5]);

EffectiveModernCpp(1):类型推导