现代C++学习:易用性改进 I:自动类型推断和初始化 引言在之前的几讲里我们已经多多少少接触到了一些 C11 以来增加的新特性。下面的两讲我会重点讲一下现代 CC11/14/17带来的易用性改进。就像我们之前说的我们主要是介绍 C 里好用的特性而非让你死记规则。因此这里讲到的内容有时是一种简化的说法。对于日常使用本讲介绍的应该能满足大部分的需求。对于复杂用法和边角情况你可能还是需要查阅参考资料里的明细规则。自动类型推断如果要挑选 C11 带来的最重大改变的话自动类型推断肯定排名前三。如果只看易用性或表达能力的改进的话那它就是“舍我其谁”的第一了。auto自动类型推断顾名思义就是编译器能够根据表达式的类型自动决定变量的类型从 C14 开始还有函数的返回类型不再需要程序员手工声明[1]。但需要说明的是auto 并没有改变 C 是静态类型语言这一事实——使用 auto 的变量或函数返回值的类型仍然是编译时就确定了只不过编译器能自动帮你填充而已。自动类型推断使得像下面这样累赘的表达式成为历史// vectorint v; for (vectorint::iterator it v.begin(), end v.end(); it ! end; it) { // 循环体 }现在我们可以直接写当然是不使用基于范围的 for 循环的情况for (auto it v.begin(), end v.end(); it ! end; it) { // 循环体 }不使用自动类型推断时如果容器类型未知的话我们还需要加上 typename注意此处 const 引用还要求我们写 const_iterator 作为迭代器的类型template typename T void foo(const T container) { for (typename T::const_iterator it v.begin(), … }如果 begin 返回的类型不是该类型的 const_iterator 嵌套类型的话那实际上不用自动类型推断就没法表达了。这还真不是假设。比如如果我们的遍历函数要求支持 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 实际使用的规则类似于函数模板参数的推导规则[3]。当你写了一个含 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 值类别相同的引用类型。decltypedecltype 的用途是获得一个表达式的类型结果可以跟类型一样使用。它有两个基本用法decltype(变量名) 可以获得变量的精确类型。decltype(表达式) 表达式不是变量名但包括 decltype((变量名)) 的情况可以获得表达式的引用类型除非表达式的结果是个纯右值prvalue此时结果仍然是值类型。如果我们有 int a;那么decltype(a) 会获得 int因为 a 是 int。decltype((a)) 会获得 int因为 a 是 lvalue。decltype(a a) 会获得 int因为 a a 是 prvalue。decltype(auto)通常情况下能写 auto 来声明变量肯定是件比较轻松的事。但这儿有个限制你需要在写下 auto 时就决定你写下的是个引用类型还是值类型。根据类型推导规则auto 是值类型auto 是左值引用类型auto 是转发引用可以是左值引用也可以是右值引用。使用 auto 不能通用地根据表达式类型来决定返回值的类型。不过decltype(expr) 既可以是值类型也可以是引用类型。因此我们可以这么写decltype(expr) a expr;这种写法明显不能让人满意特别是表达式很长的情况而且任何代码重复都是潜在的问题。为此C14 引入了 decltype(auto) 语法。对于上面的情况我们只需要像下面这样写就行了。decltype(auto) a expr;decltype(auto) a expr;这种代码主要用在通用的转发函数模板中你可能根本不知道你调用的函数是不是会返回一个引用。这时使用这种语法就会方便很多。函数返回值类型推断从 C14 开始函数的返回值也可以用 auto 或 decltype(auto) 来声明了。同样的用 auto 可以得到值类型用 auto 或 auto 可以得到引用类型而用 decltype(auto) 可以根据返回表达式通用地决定返回的是值类型还是引用类型。和这个形式相关的有另外一个语法后置返回值类型声明。严格来说这不算“类型推断”不过我们也放在一起讲吧。它的形式是这个样子auto foo(参数) - 返回值类型声明 { // 函数体 }通常在返回类型比较复杂、特别是返回类型跟参数类型有某种推导关系时会使用这种语法。以后我们会讲到一些实例。今天暂时不多讲了。类模板的模板参数推导如果你用过 pair 的话一般都不会使用下面这种形式pairint, int pr{1, 42};使用 make_pair 显然更容易一些auto pr make_pair(1, 42);这是因为函数模板有模板参数推导使得调用者不必手工指定参数类型但 C17 之前的类模板却没有这个功能也因而催生了像 make_pair 这样的工具函数。在进入了 C17 的世界后这类函数变得不必要了。现在我们可以直接写pair pr{1, 42};生活一下子变得简单多了在初次见到 array 时我觉得它的主要缺点就是不能像 C 数组一样自动从初始化列表来推断数组的大小了int a1[] {1, 2, 3}; arrayint, 3 a2{1, 2, 3}; // 啰嗦 // arrayint a3{1, 2, 3}; 不行这个问题在 C17 里也是基本不存在的。虽然不能只提供一个模板参数但你可以两个参数全都不写 array a{1, 2, 3}; // 得到 arrayint, 3这种自动推导机制可以是编译器根据构造函数来自动生成template typename T struct MyObj { MyObj(T value); … }; MyObj obj1{string(hello)}; // 得到 MyObjstring MyObj obj2{hello}; // 得到 MyObjconst char*也可以是手工提供一个推导向导达到自己需要的效果template typename T struct MyObj { MyObj(T value); … }; MyObj(const char*) - MyObjstring; MyObj obj{hello}; // 得到 MyObjstring结构化绑定在讲关联容器的时候我们有过这样一个例子multimapstring, int::iterator lower, upper; std::tie(lower, upper) mmp.equal_range(four);这个例子里返回值是个 pair我们希望用两个变量来接收数值就不得不声明了两个变量然后使用 tie 来接收结果。在 C11/14 里这里是没法使用 auto 的。好在 C17 引入了一个新语法解决了这个问题。目前我们可以把上面的代码简化为auto [lower, upper] mmp.equal_range(four);这个语法使得我们可以用 auto 声明变量来分别获取 pair 或 tuple 返回值里各个子项可以让代码的可读性更好。列表初始化在 C98 里标准容器比起 C 风格数组至少有一个明显的劣势不能在代码里方便地初始化容器的内容。比如对于数组你可以写int a[] {1, 2, 3, 4, 5};而对于 vector 你却得写vectorint v; v.push(1); v.push(2); v.push(3); v.push(4); v.push(5);这样真是又啰嗦性能又差显然无法让人满意。于是C 标准委员会引入了列表初始化允许以更简单的方式来初始化对象。现在我们初始化容器也可以和初始化数组一样简单了vectorint v{1, 2, 3, 4, 5};同样重要的是这不是对标准库容器的特殊魔法而是一个通用的、可以用于各种类的方法。从技术角度编译器的魔法只是对 {1, 2, 3} 这样的表达式自动生成一个初始化列表在这个例子里其类型是 initializer_list。程序员只需要声明一个接受 initializer_list 的构造函数即可使用。从效率的角度至少在动态对象的情况下容器和数组也并无二致都是通过拷贝构造进行初始化。统一初始化你可能已经注意到了我在代码里使用了大括号 {} 来进行对象的初始化。这当然也是 C11 引入的新语法能够代替很多小括号 () 在变量初始化时使用。这被称为统一初始化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);推而广之你几乎可以在所有初始化对象的地方使用大括号而不是小括号。它还有一个附带的特点当一个构造函数没有标成 explicit 时你可以使用大括号不写类名来进行构造如果调用上下文要求那类对象的话。如Obj getObj() { return {1.0}; }如果 Obj 类可以使用浮点数进行构造的话上面的写法就是合法的。如果有无参数、多参数的构造函数也可以使用这个形式。除了形式上的区别它跟 Obj(1.0) 的主要区别是后者可以用来调用 Obj(int)而使用大括号时编译器会拒绝“窄”转换不接受以 {1.0} 或 Obj{1.0} 的形式调用构造函数 Obj(int)。这个语法主要的限制是如果一个类既有使用初始化列表的构造函数又有不使用初始化列表的构造函数那编译器会千方百计地试图调用使用初始化列表的构造函数导致各种意外。所以如果给一个推荐的话那就是如果一个类没有使用初始化列表的构造函数时初始化该类对象可全部使用统一初始化语法。如果一个类有使用初始化列表的构造函数时则只应用在初始化列表构造的情况。关于这个语法的更多详细用法讨论请参见参考资料类数据成员的默认初始化按照 C98 的语法数据成员可以在构造函数里进行初始化。这本身不是问题但实践中如果数据成员比较多、构造函数又有多个的话逐个去初始化是个累赘并且很容易在增加数据成员时漏掉在某个构造函数中进行初始化。为此C11 增加了一个语法允许在声明数据成员时直接给予一个初始化表达式。这样当且仅当构造函数的初始化列表中不包含该数据成员时这个数据成员就会自动使用初始化表达式进行初始化。这个句子有点长。我们看个例子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_ 仍由默认初始化完成。第三个构造函数则完全不使用默认初始化。内容小结在本讲中我们介绍了现代 C 引入的几个易用性改进自动类型推断初始化列表及类数据成员的默认初始化。使用这些特性非常简单可以立即简化你的 C 代码而不会引入额外的开销。唯一的要求只是你不要再使用那些上古时代的老掉牙编译器了……