C++填坑系列——EffectiveModernC++之类型推导

接下来会记录我在学习《Effective Modern C++》的一些总结和思考。
鉴于C++的知识太多了,我难以全面覆盖到学习,所以这里借此来补充和发散自己的学习心得:)

以下内容由学习这个网站Effective Modern C++的中文翻译内容得来
https://cntransgroup.github.io/EffectiveModernCppChinese/Introduction.html

Chapter 类型推导

C++98有一套类型推导的规则:用于函数模板的规则。
C++11修改了其中的一些规则并增加了两套规则,一套用于auto,一套用于decltype
C++14扩展了autodecltype可能使用的范围。

Item 1: Understand template type deduction

一个比较普通的函数模板形式是这样的,其中T代表了基础的类型;ParamType代表了推导出来的param的类型(可能会包含一些修饰符,const&之类的)。

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

f(expr);

如何理解模板推导?我觉得秉持这样一个方法就行:

  1. 先使用expr的类型和ParamType的类型共同来推导出最终的ParamType类型;
  2. 再基于ParamType的类型模式匹配得到T的类型。

下面的代码展示了模板推导的一些具体使用方式,以及推导的结果,总结一下需要注意的点就是:

  1. 数组和函数在推导T时(模板参数非引用),会退化为指针;模板参数为引用时,会推到为原类型;
  2. 模板参数为万能引用时,考虑引用折叠;
  3. int &b = a; c = b;一个引用在操作符=的右边时,该引用会被脱掉,对左边的操作数并无影响;在模板推导中一样;
  4. 推导时需要考虑语义上expr的类型,是否为const,尤其是模板参数为&*时,需要区分顶层const和底层const的影响;
template <typename T>
void func1(T param);

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

template <typename T>
void func3(const T param);

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

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

void add(int a, int b);

template <typename T>
void func6(T* param);

int main() {
  int a = 10;
  int& b = a;
  const int c = 20;
  const int& d = a;
  const int& e = c;
  int* f = &a;
  const int* g = &a;
  const int* const h = &a;
  int* p = &a;
  int arr[1]{1};

  /**
   * void func1(T param);
   * 1.无论传递什么,param都会成为它的一份拷贝(一个完整的新对象),那在模板内部无论怎么修改param,对外部实参都没啥影响(指针除外)。
   * 2.传递引用,会忽略&;传递const,会忽略const;
   * 3.传递指针,会将T推导为指针类型;
   *   但是:如果是底层const会保留const(指针传值会复制指针本身的内容,这样还可以通过该地址更改指向变量的内容);如果是顶层const会忽略;
   * 4.函数和数组会退化为指针进行推导;
   */
  func1(a);    // T=int                 -> T-int, ParamType-int
  func1(b);    // T=int&                -> T-int, ParamType-int
  func1(c);    // T=const int           -> T-int, ParamType-int
  func1(d);    // T=const int&          -> T-int, ParamType-int
  func1(e);    // T=const int&          -> T-int, ParamType-int
  func1(f);    // T=int*                -> T-int*, ParamType-int*
  func1(g);    // T=const int*          -> T-const int*, ParamType-const int*
  func1(h);    // T=const int* const    -> T-const int*, ParamType-const int*
  func1(p);    // T=int*                -> T-int*, ParamType-int*
  func1(arr);  // T=int[1]              -> T-int*, ParamType-int*
  func1(add);  // T=void (*)(int, int)  -> T-void (*)(int, int), ParamType-void (*)(int, int)

  /**
   * void func3(const T param);
   * 这里的const是个顶层const,它修饰的是T这个类型,这个类型应该当做整体来看,
   * 比如说T是个int*指针,那就表示指针是个const,它不能再指向其他了。
   * 推导T的规则和func(Tparam)都是一样的,只不过是会给ParamType添加一个顶层const的修饰符
   */
  func3(a);    // const T=int                 -> T-int, ParamType-const int
  func3(b);    // const T=int&                -> T-int, ParamType-const int
  func3(c);    // const T=const int           -> T-int, ParamType-const int
  func3(d);    // const T=const int&          -> T-int, ParamType-const int
  func3(e);    // const T=const int&          -> T-int, ParamType-const int
  func3(f);    // const T=int*                -> T-int*, ParamType-int* const
  func3(g);    // const T=const int*          -> T-const int*, ParamType-const int* const
  func3(h);    // const T=const int* const    -> T-const int*, ParamType-const int* const
  func3(p);    // const T=int*                -> const T=int* -> T-int*, ParamType-int* const
  func3(arr);  // const T=int[1]              -> const T=int* -> T-int*, ParamType-int* const
  func3(add);  // const T=void (*)(int, int)  -> T-void (*)(int,int), ParamType-void (*const)(int,int)

  /**
   * void func2(T& param);
   * 1. 函数和指针不会退化为指针进行推导;
   */
  func2(a);    // T&=int                -> T-int, ParamType-int&
  func2(b);    // T&=int&               -> T-int, ParamType-int&
  func2(c);    // T&=const int          -> T-const int, ParamType-const int&
  func2(d);    // T&=const int&         -> T-const int, ParamType-const int&
  func2(e);    // T&=const int&         -> T-const int, ParamType-const int&
  func2(f);    // T&=int*               -> T-int*, ParamType-int*&
  func2(g);    // T&=const int*         -> T-const int*, ParamType-const int*&
  func2(h);    // T&=const int* const   -> T-const int*, ParamType-const int* const&
  func2(p);    // T&=int*               -> T-int*, ParamType-int*&
  func2(arr);  // T&=int[1]             -> T-int[1], ParamType-int &param[1]
  func2(add);  // T&=void()(int,int)    -> T-void()(int,int), ParamType-void(&param)(int,int)

  /**
   * void func5(T&& param);
   * 1.如果expr是左值,T和ParamType会被推导为左值引用。这是模板类型推导唯一一种T被推导为引用的情况。
   * 2.如果expr是右值,就使用正常的推导规则。
   */
  func5(10);  // ParamType-int&&, T-int
  int&& t = 10;
  func5(t);    // 这里t依然是个左值(类型是右值引用),ParamType-int&, T-int&
  func5(a);    // T&&=int               -> ParamType-int&, T-int&
  func5(b);    // T&&=int&              -> ParamType-int&, T-int&
  func5(c);    // T&&=const int         -> ParamType-const int&, T-const int&
  func5(d);    // T&&=const int&        -> ParamType-const int&, T-const int&
  func5(e);    // T&&=const int&        -> ParamType-const int&, T-const int&
  func5(f);    // T&&=int*              -> ParamType-int*&, T-int*&
  func5(g);    // T&&=const int*        -> ParamType-const int*&, T-const int*&
  func5(h);    // T&&=const int* const  -> ParamType-const int* const&, T-const int* const&
  func5(p);    // T&&=int*              -> ParamType-int*&, T-int*&
  func5(arr);  // T&&=int[1]            -> ParamType-int(&param)[1], T-int(&param)[1]
  func5(add);  // T&&=void()(int,int)   -> ParamType-void(&)(int,int), T-void(&)(int,int)

  /**
   * void func6(T* param);
   */
  func6(f);    // T*=int*                -> T-int, ParamType-int*
  func6(g);    // T*=const int*          -> T-const int, ParamType-const int*
  func6(h);    // T*=const int* const    -> T-const int, ParamType-const int*
  func6(p);    // T*=int*                -> T-int, ParamType-int*
  func6(arr);  // T*=int[1]              -> T-int, ParamType-int*
  func6(add);  // T*=void (*)(int, int)  -> T-void ()(int, int), ParamType-void (*)(int, int)
}

Item 2: Understand auto type deduction

auto类型推导记住这两点即可:
1.auto类型推导和模板类型推导相同,例外是std::initializer_list
2.auto在c++14中允许出现在函数返回值或者lambda函数形参中,但是它的工作机制是模板类型推导那一套方案,而不是auto类型推导。

auto类型推导除了一个例外之外,其他推导规则和模板类型推导一样。

只记录下这个例外:std::initializer_list(文章最后记录下这是个啥)

对于auto来说,使用大括号来初始化一个变量时,该变量的类型会被推到为std::initializer_list

  auto a{27};     // int
  auto b = {27};  // std::initializer_list<int>

以上是c++11中的规则。在c++14中,auto允许用于函数返回值并会被推导,而且C++14的lambda函数也允许在形参声明中使用auto。但是在这些情况下auto使用的是模板类型推导的一套规则在工作,而不是auto的类型推导规则:

//// 
  auto func2 = [](auto& param) {};
  func2({1, 2, 3});  // 编译器会报错,虽然lambda形参有auto修饰,但是无法推导出来类型
////
auto func() {
  return {1, 2, 3};  // 编译器会报错:Cannot deduce return type from initializer list
}

Item 3: Understand decltype

之前有写了一篇文章,记录decltype的定义是一些用法,见:https://mp.weixin.qq.com/s/SYewMlUo-eLIk0c2U5mlhA

在C++11中,decltype最主要的用途就是用于声明函数模板,而这个函数返回类型依赖于形参类型

比如说,一个模板的参数是一个容器和一个索引,该模板的功能是返回这个容器索引位置的元素,但是这个元素我们并不知道具体的类型,所以需要推导。

c++11,函数authAndAccess1前面的auto不会做任何的类型推导。只是表明使用了C++11的尾置返回类型语法,即在函数形参列表后面使用一个->符号指出函数的返回类型,尾置返回类型的好处是可以在函数返回类型中使用函数形参相关的信息。

那下面的代码中authAndAccess2authAndAccess3有啥区别呢?

对于一般的容器来说,operator[]返回的应该是是一个T&的类型,是可以修改容器内元素的;authAndAccess3使用auto来推导返回值(编译器使用模板类型推导规则),模板类型推导会去掉引用的部分,因此返回类型是T,这样就无法修改容器内元素;authAndAccess2中使用decltype(auto)会保留c[i]的类型。

//// c++11写法
template <typename Container, typename Index>
auto authAndAccess1(Container& c, Index i) -> decltype(c[i]) {
  return c[i];
}
//// c++14写法
template <typename Container, typename Index>
decltype(auto) authAndAccess2(Container& c, Index i) {
  return c[i];
}
template <typename Container, typename Index>
auto authAndAccess3(Container& c, Index i) {
  return c[i];
}

Item 附录

1. 数组指针和引用数组

char(a)[2]表示a是一个数组,是一个包含两个元素的char数组;
char(*a)[2]表示的是a是一个指针,它指向一个包含两个元素的char数组;
char(&a)[2]表示a是一个数组引用,引用一个包含两个元素的char数组;

下面展示代码,其中&bbb*ccc的地址一致,可以看到通过ccc获取到数组元素需要两次寻址(需要两次解引用)。

char bbb[2]{'1', '2'};
char(*ccc)[2] = &bbb;
char(&ddd)[2] = bbb;
printf("%p\n", &bbb);         // 0x7ff7bcc3d33e
printf("%p\n", &ccc);         // 0x7ff7bcc3d330
printf("%p\n", *ccc);         // ccc指向的地址和bbb是一样的,0x7ff7bcc3d33e
printf("%c\n", *bbb);         // 1
printf("%c\n", *(bbb + 1));   // 2
printf("%c\n", *(*ccc));      // 1;要获取数组元素,需要先解一次引用(需要两次寻址)
printf("%c\n", *(*ccc + 1));  // 2;要获取数组元素,需要先解一次引用(需要两次寻址)

2. std::initializer_list

std::initializer_list是 C++11 引入的一个轻量级的模板类,提供了一种访问初始化列表(由大括号括起来的值列表)的方法。这个类特别适用于构造函数和函数,允许它们接收任意数量的同类型参数,而不需要预先定义参数的数量。

构建时机:

  1. 大括号初始化被用来列表初始化一个对象时,该对象的构造函数可以接收一个std::initializer_list的参数;
  2. 大括号初始化作为赋值操作符的右操作数或者作为函数调用的参数时,该赋值操作符和函数调用可以接收一个std::initializer_list的参数;
  3. 大括号初始化用于auto中,包括应用于基于范围的for循环。
//// 1.
std::vector<int> vec1{1, 2, 3, 4};
// 在cppinsights中看到编译器执行的代码其实是
std::vector<int, std::allocator<int> > vec1 = std::vector<int, std::allocator<int> >{std::initializer_list<int>{1, 2, 3, 4}, std::allocator<int>()};

//// 2.
vec1.insert(vec1.end(), {5, 6, 7});
// 在cppinsights中看到编译器执行的代码其实是
vec1.insert(__gnu_cxx::__normal_iterator<const int *, std::vector<int, std::allocator<int> > >(vec1.end()), std::initializer_list<int>{5, 6, 7});

//// 3.
auto x = {1,23};
// 在cppinsights中看到编译器执行的代码其实是
std::initializer_list<int> x = std::initializer_list<int>{1, 23};

//// 3.
for (auto v : {3, 4, 5, 6}) {}
// 在cppinsights中看到编译器执行的代码其实是
  {
    std::initializer_list<int> && __range1 = std::initializer_list<int>{3, 4, 5, 6};
    const int * __begin1 = __range1.begin();
    const int * __end1 = __range1.end();
    for(; __begin1 != __end1; ++__begin1) {
      int v = *__begin1;
    }
  }

特点:

  • 类型是安全的:保证列表中的所有元素必须都是相同的类型,否则推导模板中的T会失败;
  • 自动推导类型:在使用std::initializer_list时,不需要指定元素的具体类型,编译器会自动推导出列表中元素的类型;
  • 只读:std::initializer_list中的元素是只读的,不能修改列表中的元素;
  • 轻量级:std::initializer_list本身并不拥有所包含的元素。只是持有指向临时数组的指针和数组的大小;
  • 支持范围迭代:std::initializer_list支持基于范围的for循环;
  • 标准库兼容性:很多标准库容器都有接受std::initializer_list作为参数的构造函数,初始化容器变得非常方便。
posted @ 2024-03-02 14:48  战斗天使zzy  阅读(230)  评论(0)    收藏  举报