第一章 auto与类型推导
# 第一章 auto与类型推导
在本章中,我们将学习C++的类型推导规则,以及如何使用C++11中引入的auto
关键字。
# 问题1:解释auto类型推导!
auto
类型推导通常与模板类型推导相同,但auto
类型推导假定用大括号括起来的初始化器代表std::initializer_list
,而模板类型推导并不持有这样的前提。
以下是几个示例:
int* ip;
auto aip = ip; // aip是一个指向整数的指针
const int* cip;
auto acip = cip; // acip是一个指向常量整数的指针(值不能修改,但它所指向的内存地址可以修改)
const int* const cicp = ip;
auto acicp = cicp; // acicp仍然是一个指向常量整数的指针,指针的常量性被丢弃
auto x = 27; // (x既不是指针也不是引用),x的类型是int
const auto cx = x; // (cx既不是指针也不是引用),cx的类型是const int
const auto& rx = x; // (rx是一个非万能引用),rx的类型是指向const int的引用
auto&& uref1 = x; // x是int且为左值,所以uref1的类型是int&
auto&& uref2 = cx; // cx是const int且为左值,所以uref2的类型是const int&
auto&& uref3 = 27; // 27是int且为右值,所以uref3的类型是int&&
auto x3 = { 27 }; // 类型是std::initializer_list<int>,值是{27}
auto x4{ 27 }; // 类型是std::initializer_list<int>,值是{27}
// 在某些编译器中,类型可能会被推导为int,值为27。更多信息见注释。
auto x5 = { 1, 2.0 }; // 错误!无法为std::initializer_list<T>推导T的类型
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
可以看出,如果使用大括号初始化器,auto
会强制创建一个std::initializer_list
类型的变量。如果它无法推导出T
的类型,代码就会被拒绝。
我们也看到,auto
可以为指针推导出正确的类型,但为了得到引用,我们必须写成auto&
。为了保持一致性,如果我们期望得到一个指针,也可以写成auto*
。
在函数返回类型或lambda参数中使用auto
意味着进行模板类型推导,而非auto
类型推导。
参考资料:
- Scott Meyers所著的《Effective Modern C++》¹
- 《Modernes C++》² ¹https://amzn.to/38gK5bd ²https://www.modernescpp.com/index.php/c-insights-type-deduction
# 问题2:auto在何时会推导出不理想的类型?
“不可见”的代理类型会导致auto
为初始化表达式推导出 “错误” 的类型。std::vector<bool>
就是这样一种类型。如果有一个函数返回这种类型,而你只对其中一位信息感兴趣,想用auto
声明一个变量来保存它,之后又想使用这个bool
类型向量中的某个元素,这会产生未定义行为。
这听起来有点复杂,我们来看代码:
std::vector<bool> foo() {
// ...
}
void bar(bool b) {
// ...
}
auto someBit = foo()[2];
bar(bits[2]); // 未定义行为
2
3
4
5
6
7
8
9
10
std::vector
的[]
运算符返回T&
。std::vector<bool>
是一种专门的向量形式,只包含位(bit),而C++不允许对位的引用。这里面的情况有点复杂(双关语),你可以在《Effective Modern C++》中详细了解。简而言之,如果你请求一个bool
类型,会发生隐式转换。虽然推导出的类型取决于具体实现,但它会是一个指向临时对象的指针。这可不是人们想要的结果。
在这种情况下,最好要么根本不使用auto
,要么使用Meyers所说的显式类型初始化器习惯用法。
auto highPriority = static_cast<bool>(features(w)[5]);
当你想要的类型精度低于函数返回的类型时,也会出现类似问题,比如当你知道可能的输出范围,想节省一些内存时。例如,一个函数返回double
类型,但你想要float
类型。使用auto
不会进行隐式转换,但你可以用上述习惯用法强制转换。
这种习惯用法在处理代理类型时也很有用。
参考资料:
- Scott Meyers所著的《Effective Modern C++》³
- 维基百科⁴ ³https://amzn.to/38gK5bd ⁴https://en.wikipedia.org/wiki/Proxy_pattern
# 问题3:使用auto有哪些优点?
auto
变量必须初始化,因此它们通常不会出现可能导致可移植性或效率问题的类型不匹配情况。auto
还能让重构更轻松,并且通常比显式指定类型输入的内容更少。
auto
变量主要可以提高正确性、性能、可维护性和健壮性。输入起来也更方便,但这是它最不重要的优点。
当你确实想显式指定类型时,可以考虑声明局部变量auto x = type{ expr };
。这样做能表明代码是在明确请求转换,具有自文档化的作用,并且能保证变量被初始化,此外,它还能防止意外的隐式窄化转换。只有当你确实想要显式窄化时,才使用()
而不是{}
。
// 假设features(w)[5]返回的类型精度高于bool,
// 使用static_cast<bool>进行显式转换,防止auto推导错误类型
auto highPriority = static_cast<bool>(features(w)[5]);
2
3
如果你担心可读性,前面提到的技巧会有帮助,否则你不必为此烦恼,现代的集成开发环境(IDE)会在你将鼠标悬停在变量上时显示其确切类型。
对于局部变量,如果你使用auto x = expr;
这种声明方式,有很多优点:
- 能保证你的变量被初始化。如果你忘了初始化,编译器会报错。
- 没有临时对象和隐式转换,因此效率更高。
- 使用
auto
能保证你使用正确的类型。 - 在维护和重构时,无需更新类型。
- 对于内置类型的算术运算,这是一种可移植地表示特定于实现的类型的最简单方法。这些类型可能因平台而异,使用
auto
还能确保不会意外出现有损的窄化转换。 - 你可以省略难以书写的类型,比如lambda表达式、迭代器等。
参考资料:
- Scott Meyers所著的《Effective Modern C++》⁵
- Sutter’s Mill⁶ ⁵https://amzn.to/38gK5bd ⁶https://herbsutter.com/2013/08/12/gotw-94-solution-aaa-style-almost-always-auto/
# 问题4:在以下声明后,myCollection的类型是什么?
auto myCollection = {1,2,3};
其类型是std::initializer_list<int>
。int
部分可能很容易理解,至于std::initializer_list
,你要知道,在auto
类型推导中,如果你使用大括号,有两种情况。
如果你在大括号之间什么都不写,会得到一个编译错误,因为编译器无法从<用大括号括起来的初始化列表>()
推导出std::initializer_list<auto>
。所以你得到的不是一个空容器,而是一个编译错误。
如果你在大括号之间至少有一个元素,类型将是std::initializer_list
。
如果你想知道这个类型是什么,应该了解它是一个轻量级的代理对象,用于访问const T
类型的对象数组。在以下情况会自动构造:
- 当使用大括号初始化列表来列表初始化一个对象,且相应的构造函数接受一个
std::initializer_list
参数时。 - 当大括号初始化列表用于赋值的右侧或作为函数调用参数,且相应的赋值运算符/函数接受一个
std::initializer_list
参数时。 - 当大括号初始化列表绑定到
auto
时,包括在范围for
循环中。
初始化列表可以用一对指针或一个指针和一个长度来实现。复制std::initializer_list
被认为是浅拷贝,因为它不会复制底层对象。
参考资料:
- C++参考:
std::initializer_list
⁷ - C++参考:列表初始化⁸ ⁷https://en.cppreference.com/w/cpp/utility/initializer_list ⁸https://en.cppreference.com/w/cpp/language/list_initialization
# 问题5:什么是尾随返回类型(trailing return types)?
尾随返回类型允许在参数列表之后(在->
之后)声明函数的返回类型:
auto getElement(const std::vector<int>& container, int index) const -> int ;
我们没有将int
作为起始的返回类型,而是在该行开头使用auto
关键字,并在末尾箭头(->
)之后添加int
作为返回类型。
借助尾随返回类型,例如,我们可以省略枚举(enum)的作用域。
假设我们有如下类定义:
class Wine {
public :
enum WineType { WHITE, RED, ROSE, ORANGE };
void setWineType(WineType wine_type);
WineType getWineType() const ;
//...
private :
WineType _wine_type;
};
2
3
4
5
6
7
8
9
不用写成:
Wine::WineType Wine::getWineType() {
return _wine_type;
}
2
3
我们可以这样写:
auto Wine::getWineType() -> WineType {
return _wine_type;
}
2
3
在该行开头,编译器无法知晓作用域,因此我们必须写出Wine::WineType
。而当我们在末尾声明返回类型时,编译器已经知道我们处于Wine
类的作用域内,所以无需重复该信息。
根据作用域的名称不同,这样做可能会节省一些字符,但至少不必重复类名。
使用尾随返回类型更具说服力的一个原因是,当返回类型取决于参数类型时,它可以简化函数模板。下一个问题中会对此进行更多介绍。
# 问题6:解释一下decltype!
decltype
是一个关键字,用于查询变量或表达式的类型。它在C++11中被引入,主要用于泛型编程,在泛型编程中,要表达依赖于模板参数的类型通常很困难,甚至是不可能的。
给定一个名称或表达式,decltype
会告知你该名称或表达式的类型。
让我们用一些变量和函数调用来试试:
const int&& foo();
const int bar();
int i;
decltype(foo()) x1;
decltype(bar()) x2;
decltype(i) x3;
2
3
4
5
6
这并不意外。再试试一些表达式:
struct A {
double x;
};
const A* a;
decltype(a->x) y;
decltype((a->x)) z = y;
2
3
4
5
6
结果仍符合预期。
或许decltype
的主要用途是在声明函数模板时,当函数的返回类型取决于其参数的类型。一个常见的用例是简单的加法运算:两个可能不同类型的值相加,结果可能是多种不同类型,尤其是涉及运算符重载时。
template <class T , class U>
auto add(T const& t, U const& u) -> decltype(t+u) {
return t+u;
}
2
3
4
在C++14中,增加了函数返回类型推导这一特性,所以我们可以直接使用auto
。
decltype
对于与lambda相关的类型也很有用:
auto f = [](int a, int b) -> int {
return a * b;
};
decltype(f) g = f;
2
3
4
# 问题7:何时使用decltype(auto)?
decltype(auto)
这个习惯用法是在C++14中引入的。与auto
类似,它从初始化器中推导类型,但它使用decltype
规则进行类型推导。
正如我们在上一个问题中提到的,它允许在泛型代码中转发返回类型。你甚至可以对返回类型使用auto
类型推导,甚至可以像这样返回一个引用,或者将返回类型声明为const
:
auto const& Example(int const& i) {
return i;
}
2
3
另一方面,在泛型代码中,你必须能够在不知道处理的是引用还是值的情况下,完美转发返回类型。decltype(auto)
赋予了你这种能力:
template<class Fun , class... Args>
decltype(auto) foo(Fun fun, Args&&... args) {
return fun(std::forward(args)...);
}
2
3
4
如果你只想用auto
来实现相同的功能,就必须为上述示例函数声明不同的重载,一个auto
返回类型总是推导为纯值,另一个auto&
返回类型总是推导为引用类型。
当你希望对初始化表达式应用decltype
类型推导规则时,它也可以用于声明局部变量。
Widget w;
const Widget& cw = w;
auto myWidget1 = cw;
decltype(auto) myWidget2 = cw;
2
3
4
# 问题8:将两个bool类型相加会得到什么数据类型?
下面这段代码片段的输出是什么:
#include <iostream>
auto foo(bool n, bool m) {
return n + m;
}
int main() {
bool a = true;
bool b = true;
auto c = a + b;
std::cout << c << ", " << typeid(c).name() << '\n';
std::cout << foo(a, b) << '\n';
}
2
3
4
5
6
7
8
9
10
11
12
答案是c
和foo()
的返回值都等于2。显然,它们的类型不是bool
,而是int
。
为了理解发生了什么,最好的办法是将代码复制粘贴到CppInsights上,它会进行源到源的转换。这有助于你从编译器的角度查看源代码。
你会看到这样的代码:
int c = static_cast<int>(a) + static_cast<int>(b);
foo
函数的情况也非常类似。所以我们的两个bool
类型变量a
和b
都被提升了,在相加之前它们被转换为了整数。