0%

Effective Modern C++ 笔记 Chapter3 现代C++(Item 7-10)

Item7: 创建对象时区分开()和{}

通常来说,C++11中我们能用(){}=来初始化一个变量:

1
2
3
4
int x(0);
int y = 0;
int z{0};
int z = {0}; // available in many cases

但这几种初始化方式之间还有着区别。

=与另外两个不太一样,它代表着拷贝构造函数或赋值函数。

{}是C++11引入的新的初始化方式,它被设计为能用在各种地方,表达各种形式的值,也可以称为“统一初始化式”。

它能表达一组值,来初始化STL容器:

1
std::vector<int> v{1, 3, 5};

它能用来给类的非static成员设定默认值(而()就不行):

1
2
3
4
5
6
7
class Widget {
...
private:
int x{0}; // fine
int y = 0; // also fine
int z(0); // error!
};

它和()都能用于初始化一个uncopyable的对象(而=就不行):

1
2
3
std::atomic<int> ai1{0};   // fine
std::atomic<int> ai2(0); // also fine
std::atomic<int> ai3 = 0; // error!

{}有一个性质:它会阻止基本类型向下转换:

1
2
3
4
5
double x, y, z;
...
int sum1{x + y + z}; // error! double -> int is prohibited
int sum2(x + y + z); // ok
int sum3 = x + y + z; // ditto

另一个性质是:它不会被认为是声明。

C++中规定“所有看起来像声明的语句都会被视为声明”,这导致()在一些场景下会被视为函数声明,而{}则不会:

1
2
3
Widget w1(10);      // call Widget ctor with 10
Widget w2(); // NOTICE: w2 is a function declare!
Widget w3{}; // call Widget ctor with no args

但是{}也不是什么都好,在类有std::initializer_list参数的构造函数时,{}会有麻烦:{}总会被认为是std::initializer_list,即使解析出错。

我们先看一个没有std::initializer_list构造函数的类Widget

1
2
3
4
5
6
7
8
9
10
class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
...
};
Widget w1(10, true); // call first ctor
Widget w2{10, ture}; // also call first ctor
Widget w3(10, 5.0); // call second ctor
Widget w4{10, 5.0}; // also call second ctor

一切都很正常,直到我们给Widget添加了一个新构造函数:

1
2
3
4
5
6
7
8
9
10
11
class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<long double> il); // added
...
};
Widget w1(10, true); // call first ctor
Widget w2{10, ture}; // NOTICE: now call third ctor(10 and true convert to long double)
Widget w3(10, 5.0); // call second ctor
Widget w4{10, 5.0}; // NOTICE: now call third ctor(10 and 5.0 convert to long double)

甚至通常拷贝和移动构造函数该被调用的地方,都会被劫持到std::initializer_list构造函数上:

1
2
3
4
5
6
7
8
9
10
11
12
class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<long double> il);
operator float() const; // added
...
};
Widget w5(w4); // call copy ctor
Widget w6{w4}; // call third ctor! w4 -> float -> long double
Widget w7(std::move(w4)); // call move ctor
Widget w8{std::move(w4)}; // call third ctor! w4 -> float -> long double

甚至在{}中的内容没办法完全匹配std::initializer_list时:

1
2
3
4
5
6
7
8
class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<bool> il);
...
};
Widget w{10, 5.0}; // error! requires narrowing conversions

只有当{}中的所有元素都没办法转换为std::initializer_list需要的类型时,编译器才会去选择其它构造函数。比如上面的bool改为std::string,编译器找不到{10, 5.0}中有能转换为std::string的元素,就会去匹配我们希望的前两个构造函数了。

一个有趣的地方:如果{}中没有元素,那么被调用的是默认构造函数,而不是一个空的std::initializer_list

如果你真的想传入一个空的std::initializer_list,那么这样:

1
2
Widget w4({});
Widget w5{{}};

std::vector<int>就有上面说的问题:

1
2
3
4
5
6
std::vector<int> v1(10, 20);   // use non-std::initializer_list ctor:
// create 10-element std::vector, all
// elements are 20
std::vector<int> v2{10, 20}; // use std::initializer_list ctor:
// create 2-element std::vector whose
// values are 10 and 20

从这个问题中,我们能学到什么?

首先,向一个已有的类添加std::initialzier_list构造函数要非常谨慎,这可能会导致用户的调用被劫持。

其次,作为用户的我们,要小心选择用()还是{}{}的好处上面已经说了。()的好处是:与C++98风格的连续性,还能避免陷入std::initializer_list的问题。

当你写模板代码时,如果需要创建一个对象,你会用哪种语法?

1
2
T localObject(std::forward<Ts>(params)...);
T localObject{std::forward<Ts>(params)...};

如果Tstd::vector<int>,参数是{10, 20},哪种是对的?只有用户才知道。

std::make_uniquestd::make_shared遇到了这个问题,它们选择了(),并在文档中说明了这个选择。

有一种方法允许用户来指定用()还是{}Intuitive interface

(我觉得只在{}明确有好处的地方用{},其它地方还是用()比较好)

Item8: 优先选用nullptr来替代0和NULL

首先是结论:

  1. NULL是0,0是int,不是指针;
  2. nullptr不是指针,但可以安全地用在需要用指针的场合;
  3. 因此在需要空指针的地方,用nullptr
  4. nullptr还能提高代码的可读性。

NULL和0不是指针会带来什么问题?函数重载。当一个函数同时有int参数和指针参数的两个重载版本时,传入NULL或0(下面只说NULL吧,反正它们两个是完全一样的),你猜编译器会匹配哪个版本?

1
2
3
4
5
6
void f(int);
void f(bool);
void f(void*);

f(0); // calls f(int), not f(void*)
f(NULL); // might not complie. typically calls f(int). Never calls f(void*)

f(NULL)能否编译过取决于NULL被定义成了什么:如果定义为0,就能编译过,调用f(int);如果定义为0L,那么NULL的类型就是long,编译器会发现f没有直接匹配long的重载版本,但有两个相同优先级的转换后匹配f(int)f(void*),于是就报错。

C++98中我们只能尽量避免同时存在整数参数和指针参数的重载版本,但C++11中我们可以用nullptr

nullptr不是指针,它的类型是std::nullptr_t,但C++11保证它能转换为任意的裸指针类型。

上面的例子中,f(nullptr)就会乖乖地调用f(void*)

nullptr还能让代码更清晰,尤其是和auto配合使用时。假设你看到这么一段代码:

1
2
3
4
auto result = findRecord(/* args */);
if (result == 0) {
...
}

如果你不看findRecord的签名,就没办法区分result是整数还是指针。但如果用nullptr就很明显的表达了result是指针:

1
2
3
4
auto result = findRecord(/* args */);
if (result == nullptr) {
...
}

在有模板出现的地方,nullptr更加有优势。看下面的例子,我们有三个函数,都需要持有相应的锁时才能调用:

1
2
3
int    f1(std::shared_ptr<Widget> spw);
double f2(std::unique_ptr<Widget> upw);
bool f3(Widget* pw);

刚开始代码是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
std::mutex f1m, f2m, f3m; // mutexes for f1, f2, and f3.
using MuxGuard = std::lock_guard<std::mutex>;
...
{
MuxGuard g(f1m);
auto result = f1(0); // pass 0 as null ptr to f1
}
...
{
MuxGuard g(f2m);
auto result = f2(NULL); // pass NULL as null ptr to f2
}
...
{
MuxGuard g(f3m);
auto result = f3(nullptr); // pass nullptr as null ptr to f3
}

上面这段代码能工作,但是丑了点:三段代码的模式都是一样的,应该封装起来。鉴于f1、f2、f3的参数和返回类型都不一样,我们需要用模板来实现:

1
2
3
4
5
6
template <typename FuncT, typename MuxT, typename PtrT>
auto lockAndCall(FuncT func, MuxT& mutex, PtrT ptr) -> decltype(func(ptr)) {
using MuxGuard = std::lock_guard<MuxT>;
MuxGuard g(mutex);
return func(ptr);
}

C++14中我们还可以用decltype(auto)作为返回值类型,但不影响结论。

现在我们来试一下新代码:

1
2
3
auto r1 = lockAndCall(f1, f1m, 0);         // error! PtrT is deduced to int
auto r2 = lockAndCall(f2, f2m, NULL); // error! PtrT is deduced to int
auto r3 = lockAndCall(f3, f3m, nullptr); // OK, PtrT is nullptr_t

前两个调用是错的!模板只能看到int,而shared_ptrunique_ptr没有int参数的构造函数。第三个调用则没有任何问题,PtrT被推断为std::nullptr_t,在调用f3时隐式转换为Widget*

我们再回头看一下本节的结论,在下个需要传入空指针的地方,你还会用0NULL吗?

Item9: 优先选用using来替代typedef

大家在用STL时,经常会搞出来std::unique_ptr<std::unordered_map<std::string, std::string>>这么长的类型出来。为了避免写好几次这么长的类型,我们会用typedef将它定义为一个新类型:

1
typedef std::unique_ptr<std::unordered_map<std::string, std::string>> UPtrMapSS;

而在C++11中,我们多了一种选择:可以用using做类似的事:

1
using UPtrMapSS = std::unique_ptr<std::unordered_map<std::string, std::string>>;

那么为什么在有typedef的情况下C++还要再增加一个using呢?

首先,using在表达类型时更清晰:

1
2
typedef void (*FP)(int, const std::string&);
using FP = void (*)(int, const std::string&);

typedef来自于C,它的设计初衷是想用声明变量的方式来声明一个类型,但这导致名字和类型搅合在了一起,严重的影响了可读性。而using则将名字和类型分开了,我们能更容易的看出我们声明的类型到底是什么。

其次,using能模板化,称为”别名模板”,而typedef不能:

1
2
3
4
5
template <typename T>
using MyAllocList = std::list<T, MyAlloc<T>>; // OK

template <typename T>
typedef std::list<T, MyAlloc<T>> MyAllockList; // error!

C++98中我们可以通过struct来绕过这个问题:

1
2
3
4
5
6
template <typename T>
struct MyAllocList {
typedef std::list<T, MyAlloc<T>> type;
};

MyAllocList<Widget>::type lw; // client code

但在模板里我们不能直接使用这个类型,要在前面加上typename

1
2
3
4
5
template <typename T>
class Widget {
private:
typename MyAllocList<T>::type list;
};

原因是编译器在第一次处理模板时,不知道T是什么,也没办法知道MyAllocList<T>::type是什么,它只能假设typeMyAllocList<T>的一个成员。只有前面加上typename编译器才能放心的将type按类型处理。

using则明确表明它就是声明了一个类型,因此不需要加typename

做过一些模板元编程(TMP)的人应该都有体会,在处理类型时,我们经常要用到下面的trait类:

1
2
3
std::remove_const<T>::type          // const T -> T
std::remove_reference<T>::type // T& and T&& -> T
std::add_lvalue_reference<T>::type // T -> T&

但每次使用都要在前面加上typename,就是因为这些trait类都是通过typedef定义出来的。

C++14中这些类都有了一个using版本,直接是一个类型,使用时不需要加typename

1
2
3
std::remove_const<T>::type         -> std::remove_const_t<T>
std::remove_reference<T>::type -> std::remove_reference_t<T>
std::add_lvalue_reference<T>::type -> std::add_lvalue_reference_t<T>

如果你还在用C++11,但很羡慕这些新类型,你也可以自己动手实现:

1
2
template <typename T>
using remove_const_t = typename remove_const<T>::type;

Item10: 优先选用有界枚举来替代无界枚举

C++的通用规则是name会从属于它所在的scope,只在这个scope内可见。但C++98的enum没有遵循这个规则,enum内定义的name,不只在这个enum内可见,而是在enum所在的整个scope内可见!

1
2
enum Color {black, white, red};
auto white = false; // error! white already declared in this scope

这种enum我们称为“无界枚举”,源自C。C++11增加了一种有界的枚举,来解决name泄漏到整个scope的问题。

1
2
3
4
5
enum class Color {black, white, red};
auto white = false; // fine, no other "white" in this scope
Color c = white; // error!
Color c = Color::white; // fine
auto c = Color::white; // also find, c is 'Color'

enum class定义的枚举又被称为“枚举类”。而且它还有一个特性:无法隐式转换为整数类型(也因此无法隐式转换为浮点类型):

1
2
3
4
5
6
7
8
9
enum Color {black, white, red};
std::vector<std::size_t> primeFactors(std::size_t x);

Color c = red;
...
if (c < 14.5) { // compare Color to double!
auto factors = primeFactors(c); // compute prime factors of a Color!
...
}

上面的转换显然不是我们预期的,而用enum class就可以避免这种转换:

1
2
3
4
5
6
7
8
9
enum class Color {black, white, red};
std::vector<std::size_t> primeFactors(std::size_t x);

Color c = Color::red;
...
if (c < 14.5) { // error! can't compare Color and double!
auto factors = primeFactors(c); // error! can't pass Color to primeFactors
...
}

如果实在需要做这种转换,我们可以用static_cast来做,比如static_cast<std::size_t>(c)

enum class还有一个特性:可以前向声明。C++98中的无界enum则不能前向声明。C++11中的无界enum可以在指定底层类型后前向声明。

1
2
enum Color;          // error!
enum class Color; // fine

为什么C++98只支持enum的定义,而不支持声明?因为C++中每个enum类都对应着一个底层整数类型,但C++98中我们没办法指定这个类型,也没办法确定这个类型。编译器会在看到enum的定义时确定它底层用什么整数类型来存储。而前向声明的一个基本要求是:知道对应类型的大小。如果我们没办法确定enum是用什么类型存储的,我们也就没办法知道enum的大小,也就没办法前向声明。

1
2
3
4
5
6
7
enum Status {
good = 0,
failed = 1,
incomplete = 100,
corrupt = 200,
indeterminate = 0xFFFFFFFF
};

编译器在看到上面的Status定义时,发现它的所有的值范围都在[-1, 200]之间,最合适的类型就是char。如果我们增加一项audited = 500,值范围就变成了[-1, 500],最合适的类型变成了short

但C++98这种过于死板的规定也导致了:每当我们向Status中增加一项,所有引用了它的.cpp文件都需要被重新编译一次。

C++11允许我们指定enum的底层类型,尤其是enum class在不指定时默认使用int。这就保证了我们能安全的前向声明enum和enum class。

1
2
3
4
enum class Status;                 // underlying type is int
enum class Status: std::uint32_t; // underlying type is std::uint32_t
enum Status; // error!
enum Status: std::uint32_t; // underlying type is std::uint32_t

注意:如果要指定底层类型,需要在enum和enum class的声明和定义处都指定相同的底层类型。

那么enum class有什么地方不如enum呢?还真有。

C++11中我们使用std::tuple时需要用到整数常量作为下标:

1
auto val = std::get<1>(uInfo);

如果用enum来代替硬编码的下标,对可读性有好处:

1
2
enum UserInfoFields {uiName, uiEmail, uiReputation};
auto val = std::get<uiEmail>(uInfo);

但换成enum class上面的代码就不行了:enum class没办法隐式转换为整数类型。但我们可以用static_cast

1
2
enum class UserInfoFields {uiName, uiEmail, uiReputation};
auto val = std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>(uInfo);

这么写太长了,我们可能需要一个辅助函数。注意:std::get是模板,它的参数需要是编译期常量,因此这个辅助函数必须是一个constexpr函数。我们来通过std::underlying_type实现一个将enum class的值转换为它的底层类型值的constexpr函数:

1
2
3
4
template <typename E>
constexpr typename std::underlying_type<E>::type toUType(E e) noexcept {
return static_cast<typename std::underlying_type<E>::type>(e);
}

C++14中我们可以用std::underlying_type_t

1
2
3
4
template <typename E>
constexpr std::underlying_type_t<E>::type toUType(E e) noexcept {
return static_cast<std::underlying_type_t<E>>(e);
}

这样我们在使用std::tuple时就可以:

1
auto val = std::get<toUType(UserInfoFields::uiEmail)>(uInfo);

还是有点长,但已经好多了,尤其是考虑到enum class相比于enum的其它好处:不污染namespace;不会莫名的做隐式转换。

目录