08 | 易用性改进 I:自动类型推断和初始化
如果要挑选 C++11 带来的最重大改变的话,自动类型推断肯定排名前三。如果只看易用性或表达能力的改进的话,那它就是“舍我其谁”的第一了。
auto
自动类型推断,顾名思义,就是编译器能够根据表达式 (注意是“表达式”!!接下来会和decltype对比) 的类型,自动决定变量的类型(从 C++14 开始,还有函数的返回类型),不再需要程序员手工声明。但需要说明的是,auto 并没有改变 C++ 是静态类型语言 这一事实——使用 auto 的变量(或函数返回值)的类型仍然是编译时 (编译时多态:可以用模板;运行时多态:可以用虚函数机制)就确定了,只不过编译器能自动帮你填充而已。
c++是静态语言,如果你想 在函数的参数里 用auto那是不可以的,但是你可以用函数模板实现你的目的
不用auto的一个例子
如果我们的遍历函数要求支持 C 数组的话,不用自动类型推断的话,就只能使用两个不同的重载:
template <typename T, std::size_t N>
void foo(const T (&a)[N])
{
typedef const T* ptr_t;
for (ptr_t it = a, end = a + N;
it != end; ++it) {
// 循环体
}
}
template <typename T>
void foo(const T& c)
{
for (typename T::const_iterator
it = c.begin(),
end = c.end();
it != end; ++it) {
// 循环体
}
}
使用auto的一个例子
template <typename T>
void foo(const T& c)
{
using std::begin;
using std::end;
for (auto it = begin(c),
ite = end(c);
it != ite; ++it) {
// 循环体
}
}
从这个例子可见,自动类型推断不仅降低了代码的啰嗦程度,也提高了代码的抽象性,使我们可以用更少的代码写出通用的功能。
总结auto
auto 实际使用的规则类似于函数模板参数的推导规则。当你写了一个含 auto 的表达式时,相当于把 auto 替换为模板参数的结果。举具体的例子:
- auto a = expr; 意味着用 expr 去匹配一个假想的 template
f(T) 函数模板,结果为值类型。 - const auto& a = expr; 意味着用 expr 去匹配一个假想的 template
f(const T&) 函数模板,结果为常左值引用类型。 - auto&& a = expr; 意味着用 expr 去匹配一个假想的 template
f(T&&) 函数模板,根据转发引用和引用坍缩规则,结果是一个跟 expr 值类别相同的引用类型。
decltype
两个基本用法
- decltype(变量名) 可以获得变量的精确类型。
- decltype(表达式) (表达式不是变量名,但包括 decltype((变量名)) 的情况)可以获得表达式的引用类型;除非表达式的结果是个纯右值(prvalue),此时结果仍然是值类型。
如果上述规则看不懂,那么你可以移步到:https://subingwen.cn/cpp/autotype/#2-decltype
如果我们有 int a;,那么:
- decltype(a) 会获得 int(因为 a 是 int)。 即a 是一个变量
- decltype((a)) 会获得 int&(因为 a 是 lvalue)。 即**(a)可以看作是一个表达式
- decltype(a + a) 会获得 int(因为 a + a 是 prvalue)。 虽然 a+a 是表达式但是为纯右值,同第一个例子一样
- decltype(a=a)
下列编译通过
int main() {
int a = 0;
decltype(a) b;
decltype((a)) c=a; //erro decltype((a)) c=10;
decltype(a + a) d = 10; //纯右值同b
decltype(a = a) e = a; //表达式:且不为纯右值
return 0;
}
decltype(auto)
通常情况下,能写 auto 来声明变量肯定是件比较轻松的事。
但这儿有个限制,你需要在写下 auto 时就决定你写下的是个引用类型还是值类型。
根据类型推导规则,auto 是值类型,auto& 是左值引用类型,auto&& 是转发引用(可以是左值引用,也可以是右值引用)。
使用 auto 不能通用地根据表达式类型来决定返回值的类型。不过,decltype(expr) 既可以是值类型,也可以是引用类型。因此,我们可以这么写:
decltype(expr) a = expr;
这种写法明显不能让人满意,特别是表达式很长的情况(而且,任何代码重复都是潜在的问题)。为此,C++14 引入了 decltype(auto) 语法。对于上面的情况,我们只需要像下面这样写就行了。
decltype(auto) a = expr;
这种代码主要用在通用的转发函数模板中:你可能根本不知道你调用的函数是不是会返回一个引用。这时使用这种语法就会方便很多。
函数返回值类型推断
从 C++14 开始,函数的返回值也可以用 auto 或 decltype(auto) 来声明了。同样的,用 auto 可以得到值类型,用 auto& 或 auto&& 可以得到引用类型;而用 decltype(auto) 可以根据返回表达式通用地决定返回的是值类型还是引用类型。
和这个形式相关的有另外一个语法,后置返回值类型声明。严格来说,这不算“类型推断”,不过我们也放在一起讲吧。它的形式是这个样子:
auto foo(参数) -> 返回值类型声明
{
// 函数体
}
通常,在返回类型比较复杂、特别是返回类型跟参数类型有某种推导关系时会使用这种语法。
类模板的模板参数推导
如果你用过 pair 的话,一般都不会使用下面这种形式:
pair<int, int> pr{1, 42};
使用 make_pair 显然更容易一些:
auto pr = make_pair(1, 42);
这是因为函数模板有模板参数推导,使得调用者不必手工指定参数类型;但 C++17 之前的类模板却没有这个功能,也因而催生了像 make_pair 这样的工具函数。
知其然知其所以然
在进入了 C++17 的世界后,这类函数变得不必要了。现在我们可以直接写:
pair pr{1, 42};
在初次见到 array 时,我觉得它的主要缺点就是不能像 C 数组一样自动从初始化列表来推断数组的大小了:
int a1[] = {1, 2, 3};
array<int, 3> a2{1, 2, 3}; // 啰嗦
// array<int> a3{1, 2, 3}; 不行
这个问题在 C++17 里也是基本不存在的。虽然不能只提供一个模板参数,但你可以两个参数全都不写
array a{1, 2, 3};
// 得到 array<int, 3>
这种自动推导机制,可以是编译器根据构造函数来自动生成:
template <typename T>
struct MyObj {
MyObj(T value);
…
};
MyObj obj1{string("hello")};
// 得到 MyObj<string>
MyObj obj2{"hello"};
// 得到 MyObj<const char*>
也可以是手工提供一个推导向导,达到自己需要的效果:
template <typename T>
struct MyObj {
MyObj(T value);
…
};
MyObj(const char*) -> MyObj<string>;
MyObj obj{"hello"};
// 得到 MyObj<string>
结构化绑定
在讲关联容器的时候我们有过这样一个例子:
multimap<string, int>::iterator
lower, upper;
std::tie(lower, upper) =
mmp.equal_range("four");
这个例子里,返回值是个 pair,我们希望用两个变量来接收数值,就不得不声明了两个变量,然后使用 tie 来接收结果。在 C++11/14 里,这里是没法使用 auto 的。好在 C++17 引入了一个新语法,解决了这个问题。目前,我们可以把上面的代码简化为:
auto [lower, upper] =
mmp.equal_range("four");
这个语法使得我们可以用 auto 声明变量来分别获取 pair 或 tuple 返回值里各个子项,可以让代码的可读性更好。
推荐大家阅读:https://mp.weixin.qq.com/s/jS2NjcmzTHJwrPutnLlOXw
列表初始化
在 C++98 里,标准容器比起 C 风格数组至少有一个明显的劣势:不能在代码里方便地初始化容器的内容。比如,对于数组你可以写:
int a[] = {1, 2, 3, 4, 5};
而对于 vector 你却得写:
vector<int> v;
v.push(1);
v.push(2);
v.push(3);
v.push(4);
v.push(5);
这样真是又啰嗦,性能又差,显然无法让人满意。于是,C++ 标准委员会引入了列表初始化,允许以更简单的方式来初始化对象。现在我们初始化容器也可以和初始化数组一样简单了:
vector<int> v{1, 2, 3, 4, 5};
同样重要的是,这不是对标准库容器的特殊魔法,而是一个通用的、可以用于各种类的方法。从技术角度,编译器的魔法只是对 {1, 2, 3} 这样的表达式自动生成一个初始化列表,在这个例子里其类型是 initializer_list
这里要注意的是你的类要包含一个接受接受 initializer_list 的构造函数,而这个容器参数中的元素类型相同!
统一初始化
你可能已经注意到了,我在代码里使用了大括号 {} 来进行对象的初始化。这当然也是 C++11 引入的新语法,能够代替很多小括号 () 在变量初始化时使用。这被称为统一初始化(uniform initialization)。
大括号对于构造一个对象而言,最大的好处是避免了 C++ 里“最令人恼火的语法分析”(the most vexing parse)。
介绍一个坑
假设你有一个类,原型如下:
class utf8_to_wstring {
public:
utf8_to_wstring(const char*);
operator wchar_t*();
};
然后你在 Windows 下想使用这个类来帮助转换文件名,打开文件:
ifstream ifs(
utf8_to_wstring(filename));
你随后就会发现,ifs 的行为无论如何都不正常。最后,要么你自己查到,要么有人告诉你,上面这个写法会被编译器认为是和下面的写法等价的:
ifstream ifs(
utf8_to_wstring filename);
换句话说,编译器认为你是声明了一个叫 ifs 的函数,而不是对象!
如果你把任何一对小括号替换成大括号(或者都替换,如下),则可以避免此类问题:
ifstream ifs{
utf8_to_wstring{filename}};
推而广之,你几乎可以在所有初始化对象的地方使用大括号而不是小括号。它还有一个附带的特点:当一个构造函数没有标成 explicit 时,你可以使用大括号不写类名来进行构造,如果调用上下文要求那类对象的话。如:
Obj getObj()
{
return {1.0};
}
如果 Obj 类可以使用浮点数进行构造的话,上面的写法就是合法的。如果有无参数、多参数的构造函数,也可以使用这个形式。除了形式上的区别,它跟 Obj(1.0) 的主要区别是,后者可以用来调用 Obj(int),而使用大括号时编译器会拒绝“窄”转换,不接受以 {1.0} 或 Obj{1.0} 的形式调用构造函数 Obj(int)。
换句话说用{}来调用构造函数,对参数的要求更加苛刻
举个小例子
#include <utility>
#include <iostream>
#include <initializer_list>
using namespace std;
class A {
private:
int x;
int y;
public:
A(int a,int b) :x(a),y(b){}
void pp() {
cout << x << endl;
cout << y << endl;
}
};
int main() {
//A a{1.0,2.0}; //error
A a( 1.0,2.0 ); //ok
a.pp();
system("pause");
return 0;
}
这个语法主要的限制是,如果一个类既有使用初始化列表的构造函数,又有不使用初始化列表的构造函数,那编译器会千方百计地试图调用使用初始化列表的构造函数,导致各种意外。所以,如果给一个推荐的话,那就是:
- 如果一个类没有使用初始化列表的构造函数时,初始化该类对象可全部使用统一初始化语法。
- 如果一个类有使用初始化列表的构造函数时,则只应用在初始化列表构造的情况。
类数据成员的默认初始化
按照 C++98 的语法,数据成员可以在构造函数里进行初始化。这本身不是问题,但实践中,如果数据成员比较多、构造函数又有多个的话,逐个去初始化是个累赘,并且很容易在增加数据成员时漏掉在某个构造函数中进行初始化。为此,C++11 增加了一个语法,允许在声明数据成员时直接给予一个初始化表达式。这样,当且仅当构造函数的初始化列表中不包含该数据成员时(即如果你忘记了的话),这个数据成员就会自动使用初始化表达式进行初始化。
class Complex {
public:
Complex()
: re_(0) , im_(0) {}
Complex(float re)
: re_(re), im_(0) {}
Complex(float re, float im)
: re_(re) , im_(im) {}
…
private:
float re_;
float im_;
};
使用数据成员的默认初始化的话,我们就可以这么写:
class Complex {
public:
Complex() {}
Complex(float re) : re_(re) {}
Complex(float re, float im)
: re_(re) , im_(im) {}
private:
float re_{0};
float im_{0};
};
第一个构造函数没有任何初始化列表,所以类数据成员的初始化全部由默认初始化完成,re_ 和 im_ 都是 0。第二个构造函数提供了 re_ 的初始化,im_ 仍由默认初始化完成。第三个构造函数则完全不使用默认初始化