美文网首页
Effective Modern C++ 学习笔记1——类型推导

Effective Modern C++ 学习笔记1——类型推导

作者: 拔丝圣代 | 来源:发表于2023-03-18 20:20 被阅读0次

类型推导规则

在大多数情况下,模板与auto的类型推导规则一致,且规则很简单。

情况1. 没有加任何修饰

// 模板函数:
template <typename T>
f(T t) {...}

// auto声明:
auto x = ...;

这种情况下,参数是按值传递,形参t或者变量x都是一个副本,那么就需要去掉引用,且副本本身没必要加cv限定符(const、volatile,下文都不考虑volatile,只说const),也需要去掉。

const int& a = ...;

f(a);       // T 推导为 int
auto x = a; // x 的类型为 int

但需要注意,只能去掉变量本身的const,对于指针类型,所指对象的const修饰不能去掉。

const int* const a = ...;

f(a)        // T 推导为 const int*
auto x = a; // x 的类型为 const int*

其实不需要特别记忆,只要记住一点:在能编译通过的前提下,去掉没必要的const和&符号即可:

const int& a = ...;

// 以下语句能否编译通过?
const int& x = a;       // OK
int& x = a;             // 编译不通过,const int& 类型不可转为int&
const int x = a;        // OK
int x = a;              // OK,且去掉了多余的const和&符号
// 因此,auto x = a; 中,x类型推导为 int

const int* const b = ...;

// 以下语句能否编译通过?
int* const x = b;       // 编译不通过
const int* const x = b; // OK
const int* x = b;       // OK,且去除了多余的const
// 因此,auto x = b; 中,x类型推导为 const int*

情况2. 加了引用

也就是T&的形式:

// 模板函数:
template <typename T>
f(T& t) {...}

// auto声明:
auto& x = ...;

这种情况则需要保持原有的const,举例来看:

const int a = 1;

f(a); // T 推导为 const int,所以形参t类型为 const int&
auto& x = a; // x类型推导为 const int

保留const的原因也很好理解,因为这里是引用传递,丢掉const会导致可以修改原来的值,同样编译不通过:

const int a = 1;
int& x = a; // 编译不通过
const int& x = a; // OK

情况2.2 万能引用

如果加了两个引用符号 &&,则可以根据情况推导为普通的引用,或者右值引用,因此也叫做万能引用:

// 模板函数:
template <typename T>
f(T&& t) {...}   // &&表示万能引用

// auto声明:
auto&& x = ...;  // &&表示万能引用

// 举例:
int a = 1;
auto&& x = a;     // x的类型是 int&
auto&& x = 1;     // x的类型是 int&& 右值引用

其他情况

经过上面的分析,我们得出结论,对于模板类型的推导,其实只要能替换成普通函数,并去掉不必要的const和引用&,就可以得到推导结果。

书中特别提到了数组作为函数实参的特殊情况,但实际上这并不算什么特殊情况。我们先了解下普通函数,数组作为参数的情况。

在普通函数签名中,形参可以写成数组的形式,但实际上类型还是指针。二者最大的区别是:指针并不会包含数组长度信息。这也是为什么函数以数组作为参数时,一般还要再传入数组长度参数。

int getlen(char a[]) { // 等价于 int* a 或 int a[1]
    return sizeof(a);
}

int main() {
    char a[2];
    cout << sizeof(a); // 输出 2 即两个char所占的空间
    cout << getlen(a); // 输出 8 即int*指针所占的空间
}

其实,如果想要保留数组的长度信息,也有办法,那就是使用“数组的引用”作为函数参数:

int getlen(char (&a)[2]) { // a是数组char[2] 的引用类型
    return sizeof(a);  // 返回2
}

注意,形参中需指定长度,且需要与实参的数组长度一致。

而对于模板函数其实是一样的,可以使用数组作为参数,这样实际上的类型是指针;也可以使用“数组的引用”作为参数,保留长度信息。

template<typename T>
int f(T t) {
  return sizeof (t);
}

template<typename T>
int g(T& t) {
    return sizeof(t);
}

char a[2];
cout << f(a) << endl; // 输出 8, 即指针的长度,t被推导为char*类型
cout << g(a) << endl; // 输出 2, t被推导为 char(&)[2] 类型
auto x = a;   // x类型推导为 char*
auto& y = a;  // y类型推导为 char(&)[2]

仔细对比后发现,其实这和普通函数的规则完全一致,即:数组会退化为指针,数组引用则不会。

有趣的是,利用这一特性,我们可以写一个函数,获得数据长度:

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

char a[2];
// 声明一个与数组a长度相同的array
std::array<char, arraySize(a)> arr{};

arraySize声明中的constexpr表示:返回的N是编译期常量。这样才可以用在array的声明中。

auto与模板唯一的区别

前面的每个例子中,都同时包含了模板与auto的类型推导,它们二者的推导结果都一摸一样。二者唯一的一点不同是:auto支持初始化表达式,而模板不支持:

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

f({1,2,3});          // 错误,无法推导
auto x = {1,2,3};    // OK,x类型推导为std::initializer_list<int>
auto y = {1,2,3.0};  // 错误,包含int和double,无法推导
int[] z = {1,2,3};   // OK

在上例中,模板与auto出现区别的根本原因在于,C++11中为了支持统一初始化,可以用这种语法进行声明;而函数参数则没有这样的语法。

当然,这样推导的结果总是std::initializer_list<T>的类型,往往不是我们想要的结果,所以还是尽量去避免这样使用。

auto使用的优劣

大多数时候,优先使用auto

使用auto的好处包括:

  • 简化一长串复杂的类型
  • 避免忘记初始化(使用auto未初始化会产生编译错误)
  • 类型变更时可以自适应
  • 能够为lambda表达式声明变量,且优于std::function,不需要堆内存
  • 避免由于类型写错导致额外的运行开销

关于最后一点,额外的运行开销,举例说明一下:

std::unordered_map<std::string, int> m;

// 不使用auto进行遍历,代码出现瑕疵:
for (const std::pair<std::string, int>& p : m) {
    //... 对p进行操作
}

// 使用auto:
for (const auto& p : m) {
    // ...
}

在for循环中,我们本意并不希望发生任何拷贝,因此使用了const引用的形式声明p,但很可惜,在代码运行中,仍然会发生拷贝。为什么呢?

原因是,m中元素正确类型是:std::pair<const std::string, int>,const不可以丢掉。由于这和代码中p的类型不匹配,编译器就会将其拷贝一份,并进行类型转换。所以,由于程序员对p类型的判断失误,导致运行期额外的性能开销。

而使用auto时,则永远会推断出正确的类型,可以完全避免这个问题。

auto使用的坑

有一种情况,使用auto无法得到我们想要的类型,这甚至可能会导致十分隐蔽的问题。例如:

std::vector<bool> GetVec() {
  return {true};
}

void HandleBool(bool b) {
  cout << b;
}

int main() {
  auto b = GetVec()[0]; // 这里auto如果换成bool,一切正常
  HandleBool(b);
}

本例中,我们将std::vector<bool>的operator[]操作结果赋值给b,期望变量b是bool类型,并在HandlerBool中进行打印。然而实际运行发现,打印的值往往不符合预期。

这是为什么?原因在于,b的类型并没有推断为bool,而是一个std::vector<bool>::reference类型的复杂对象,其中包含一个指针,指向vector<bool>中的某个位置。

之所以这样做,是因为vector内部针对bool类型做了一些优化。正常情况下,这个对象可以正确转化成bool类型。但是,在上例中,GetVec返回的vector在执行完这一行代码后已经销毁,b对象中的指针则成为野指针,在调用HandlerBool时,无法正确转化成bool类型。

在本例中,std::vector<bool>::reference类型我们称之为代理类,它可以模拟另一种类型。类似的,智能指针shared_ptr、unique_ptr也是代理类,它们可以模拟普通指针。但本例中的代理类更加隐蔽,我们难以察觉,才会出现上述问题。

所以,对于这种隐形代理类,需要根据情况避免使用auto来声明。

相关文章

网友评论

      本文标题:Effective Modern C++ 学习笔记1——类型推导

      本文链接:https://www.haomeiwen.com/subject/aoherdtx.html