Chapter 3 part 1, Moving to Modern C++
Item 7: Distinguish between () and {} when creating objects
几个常见的初始化方式:
int x(0);
int y = 0;
int z{0};
int u = {0};
4种都是有效的声明方式,z
和u
的声明方式相同,以后基本不使用u
的这种声明方式。
对于像int
这样的内置类型,我们不需要太过于深入追究不同,这是一些理论上的区别; 但是对于一些用户自定义的类型,区别这些初始化就十分重要,因为涉及到不同的函数调用:
class Widget {};
Widget w1; // 调用默认的构造函数
Widget w2 = w1; // 不是赋值操作,调用复制构造函数
w1 = w2; // 一个赋值操作,调用拷贝重载 operator=
即使是有这样的几种初始化语法,C++98还是有一些不能表达的初始化方式。比如,我们不能直接创建一个含有特殊数据元素的STL
容器,比如该容器中含有特殊元素{1, 3, 5, …}。
C++11介绍了统一初始化:一个初始化语法,至少在概念上可以在任何地方使用并且表达任何想要的语义。该方式建立在大括号{}的基础上。统一初始化是思想,大括号{}初始化是语法组成部分。
使用大括号{}初始化可以表示一些特殊要求的初始化方式,比如:
std::vector<int>v{1, 3, 5}; // v的初始化内容是1 3 5
大括号{}初始化方式可以默认初始化非静态数据成员,C++11中,=
可以兼容这种初始化语法,但是()
不兼容。
class Widget {
// some propeties here
private:
int x{0};
int y = 0;
// int z(0); 这是错误的操作
};
不可复制的对象(比如 std::atomics)可以使用大括号{}
和小括号()
的初始化方式,但是不可以使用=
初始化
std::atomic<int> ai1{ 0 };
std::atomic<int> ai2(0);
// std::atomic<int> ai3 = 0; 错误的操作
综上所述,大括号{}
初始化方式是一个通用的方式。
大括号初始化方式有一个特征,不能类型窄化:
double x = 1.0, y = 1.0, z = 1.0;
int sum1{x + y + z}; // 错误,类型被缩短了
int sum2(x + y + z); // 正确
int sum3 = x + y + z; // 正确
在gcc-7.2
的情况下,sum1
编译通过了,暂时没找到原因。
most vexing parse的说明:
most verxing parse主要发生在我们想要调用对象的默认构造函数的时候,但是我们会经常不经意间把它写成函数调用的形式。比如:
class Widget {
public:
Widget(int n);
Widget();
// 其他的属性
};
Widget w1(10); // 调用Widget带有参数的构造函数
Widget w2(); // most vexing parse ! 这里实际上是声明了一个函数,并且返回Widget对象
Widget w3{}; // 调用Widget默认的构造函数
大括号{}
初始化方式有时会有一些令人意想不到的方式来实现,这也是它的一些缺点。这些方式需要使用std::initializer_list
和重载构造函数来实现复杂的初始化关系。它们之间的交互会导致我们对代码产生误解,进而影响了代码实际的功能。
在构造函数中,只要std::initializer_list
参数不被调用,{}
和()
的初始化方式是等效的。但是如果同时出现{}
和std::initializer_list
,就会在调用的时候出现一些隐患:
class Widget {
public:
Widget(int i, bool b);
Widget(int i, double d);
Widget(std::initializer_list<long double> il);
};
Widget w1(10, true); // 使用第一个构造函数
Widget w2(10, 5.9); // 使用第二个构造函数
Widget w2{10, true}; // 使用了{}构造方式,但是现在调用std::initializer_list构造函数
// 10和true会转换成long double
也就是说,只要{}
和std::initializer_list
共存,编译器会把{}
的调用部分重载到std::initializer_list
的构造函数,即使调用std::initializer_list
会发生编译错误。
更一般的说,有复制或者移动操作的构造操作都会被std::initializer_list
构造所代替,比如:
class Widget {
public:
Widget(int i, int b) {}
Widget(int i, double d) {}
Widget(std::initializer_list<long double> il) {}
operator float()const; // 自定义类型转换函数,参照补充内容
};
Widget w1(1, 2); // 小括号,使用第一行构造函数
Widget w2(w1); // 调用复制构造函数
Widget w3{w1}; // 大括号,使用std::initializer_list构造,
// w4先转换成float,float再转换成long double
Widget w4(std::move(w1));// 小括号,调用std::move的构造函数
Widget w5{std::move(w1)};// 大括号,调用std::initializer_list,类比w2
如果{}
存在类型缩短转换的情况,会出现编译报错。
class Widget {
public:
Widget(int i, int b) {}
Widget(int i, double d) {}
Widget(std::initializer_list<bool> il) {}
};
Widget w{1, 2}; // 类型缩短,编译出错
在上面的例子中,编译器会忽略掉前两个构造函数,直接使用初始化列表进行处理。
class Widget {};
Widget w1({}); // 调用空的初始化ia列表
Widget w2{{}}; // 同上
std::vector
的一个实例:
std::vector<int> v1(10, 20); // v1包含10个值为20的元素
std::vector<int> v2{10, 20}; // v2包含两个元素,值分别是10和20
总结:
- 大括号初始化方式是最广泛使用的初始化语法,它可以防止类型缩短转化,并且防止most vexing parse
- 在构造函数重载的解决方案中,大括号
{}
总是匹配std::initializer_list
,忽略其他所有存在的方案 std::vector<int>
的例子中说明了如何在小括号()
和大括号{}
之间选择。- 对于类模板的设计来说,需要仔细处理大括号与小括号的区别。
Item 8: Prefer nullptr to 0 and NULL
在C++中,0
是int
类型,如果使用指针的值0
,即使说明它是一个空指针,该过程也是在回调过程中确认的。NULL
是一个集成的类型,它可以int
、long
等。也就是说,0
和NULL
都不是真正意义上的指针类型。
使用传统的0
和NULL
在重载上的弊端:
void f(int) {}
void f(bool) {}
void f(void*) {}
void f(0); // 只能调用f(int)
void f(NULL); // 编译错误,有二义性
void f(nullptr);// 调用f(void*)
上述例子说明,0
和NULL
在某些情况下有二义性,只有nullptr
才是真正意义上的指针类型。
假设现在有一些必须在某些线程被锁定的情况下才能被调用的函数,每个函数使用不同类型的指针:
class Widget {};
int f1(std::shared_ptr<Widget> spw) {
/* do some operations */
return 0;
}
double f2(std::unique_ptr<Widget> upw) {
/*do some operations*/
return 0.0;
}
bool f3(Widget* pw) {
/*do some operations*/
return true;
}
std::mutex f1m, f2m, f3m;
using MuxGuard = std::lock_guard<std::mutex>;
......
{
MuxGuard g(f1m); // 锁住f1线程
auto result = f1(0); // 0当作空指针传递给f1,解锁线程
}
......
{
MuxGuard g(f2m); // 同上
auto result = f2(NULL);
}
......
{
MuxGuard g(f3m); // 同上
auto result = f3(nullptr);
}
......
上述代码中,f1
和f2
的调用是十分不好的例子,虽然代码可以正常工作,但是由于类型的不同,可能会导致程序的不正常。
我们应该尽量使用如下的设计模板:
template<typename FuncType,
typename MuxType,
typename PtrType>
auto lockAndCall(FuncType func, //C++11
MuxType mutex,
PtrType ptr)->decltype(func(ptr)) {
MuxGuard g(mutex);
return func(ptr);
}
// decltype(auto) C++14的声明方式
可能的调用方式:
auto result1 = lockAndCall(f1, f1m, 0); // 错误的
auto result2 = lockAndCall(f2, f2m, NULL); // 错误的
auto result3 = lockAndCall(f3, f3m, nullptr);// 正确的
可以理解为,当0
和NULL
传入模板的时候,都与指针类型不兼容,因此不会通过编译。只有使用nullptr
的时候才可以正常编译。
总结:
- 使用
nullptr
而不是0
和NULL
。 - 避免重载集成和指针类型。
Item 9:Prefer alias declarations to typedefs
// 一种等效变量别名的声明方式
typedef std::unique_ptr<std::unordered_map<std::string, std::string>> UPtrMapSS;
using UPtrMapSS = std::unique_ptr<std::unordered_map<std::string, std::string>>;
// 等效的函数指针的声明方式
typedef void(*FP)(int, const std::string&);
using FP = void(*)(int, const std::string&);
在C++11及以后的版本中,alias
可以被模板化,但是typedef
不能。
例如,我们定义一个链表的适配器MyAlloc
,使用alias模板,代码应该是:
class Widget{};
template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>;
MyAllocList<Widget> lw;
如果使用typedef
:
class Widget{};
template<typename T>
struct MyAllocList {
typedef std::list<T, MyAlloc<T>> type;
};
MyAllocList::type lw;
但是,如果我们要在某个结构的内部定义上述类型的泛型结构,就会有一些要格外注意的差别。
使用typedef
:
template<typename T>
struct MyAllocList {
typedef std::list<T, MyAlloc<T>> type;
};
template<typename T>
class Widget {
private:
typename MyAllocList<T>::type my_list; //注意这里的typename的使用
};
使用alias
:
template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>;
template<typename T>
class Widget {
private:
MyAllocList<T> my_list;
};
//××××××××××××××××××××××××××××这里还需要补充模板元编程的知识×××××××××××××××××××××××××××××\\
总结:
typedef
不支持模板化,alias
支持alias
模板可以避免::type
的后缀,但是typedef
需要用typename
显式地声明一下- 对于C++11的类型转化
traits
,C++14都提供相应的转换模板。
##Item 10: Prefer scoped enums to unscoped enums.
C++11中的枚举类型enum
作用域是在{}
范围之内,而且enum
之内的枚举变量名在作用域之内,不能当作其他变量名使用。
C++98中:
int main() {
enum Color {black, white, red};
auto white = false; // 错误的,有和枚举重名的
return 0;
}
int main() {
{
//........
enum Color {black, white, red};
//........
}
auto white = false; // 正确的,上面的{}限制了Color的作用域
return 0;
}
C++11中的scoped enums :
#include <iostream>
int main() {
enum class Color {black, white, red}; // 注意声明方式
auto white = false;
Color c1 = Color::white;
auto c2 = Color::black;
// Color c3 = white; 这是错误的,必须要声明枚举类
return 0;
}
同时,scoped enum是一种更强的类型。unscoped enum类型总是隐式地转换到集成类型。
enum Color {black, white, red};
std::vector<std::size_t>primeFactors(std::size_t x); // 返回x的素因子!!!
Color c = red;
if(c < 14.5) { // Color与double类型比较 !!!
auto factor = primeFactors(c); // 计算了Color的素因子 !!!
}
关于std::size_t
的一些解释。 上述的enum
类型发生了一些隐式转换,这是我们不想看到的。因此,如果使用scoped enum
类型,应该这样处理:
enum class Color {black, white, red};
std::vector<std::size_t>primeFactors(std::size_t x);
Color c = Color::red;
if(c < 14.5) { // 编译错误,不能把Color与double进行比较!!!
auto factor = primeFactors(c); // 编译错误,传值的方式是错误的。
}
但是,如果真需要进行转换比较的话,应该使用static_cast<>
进行显式强制类型转换!
enum class Color {black, white, red};
Color c = Color::red;
if(static_cast<double>(c) < 14.5) {
auto primeFactors(static_cast<std::size_t>(c));
}
C++编译器会为枚举类型选择潜在的数据类型。对于unscoped enum
来说,比如:
enum Color { black, white, red };
编译器可能会选择char
类型来代替上述3个枚举的值,因为这里只有三个元素。然而,有些enum
的值的范围可能更大,比如:
enum Status { good = 0,
failed = 1,
incomplete = 100,
corrupt = 200,
indeterminate = 0xFFFFFFFF
};
在这里,enum
的范围从0
到0xFFFFFFFF
。 因此编译器会选择一个比char
长的集成类型来代表Status
的类型。为了更有效地使用内存,编译器会选择最小的可以代表enum
的空间来表示枚举。在一些情况下,编译优化是为了速度而不是空间,即使在这样的情况下,编译器也会选择最小的可能允许的空间来表示枚举类型。因此,C++98只支持enum
的定义,而不是声明。
但是,C++ 98不允许提前声明enum
是有缺陷的,最明显的是在编译依赖性的增长上。如果一个Status
贯穿整个个系统,那么该系统的每一个包含Status
头文件的部分都依赖于这个Status
。如果此时Status
增加一个新的部分,比如说:
enum Status { good = 0,
failed = 1,
incomplete = 100,
corrupt = 200,
audited = 500, // 新增加的部分
indeterminate = 0xFFFFFFFF
};
那么,看起来整个系统都要被重新编译,即使使用Status
新增加部分的可能只是子系统的一个函数!
在允许使用提前声明的情况下,比如:
enum class Status; // 前置声明
void continueProcessing(Status s);// 继续使用之前的枚举,没有使用新增加的枚举
那么,如果Status
发生改变,只有使用Status
新增加特性的部分需要重新编译。
因此,只要enum
是静态的,那么就可以进行提前声明。C++11的scopedenum
是默认int
类型的,我们也可以自定义需要的类型:
enum class Status1; // 默认int,无元素
enum class Status2: std::uint32_t; // 显式声明为std::uint32_t,无元素
enum Status3 : std::uint32_t { good = 0,
failed = 1,
incomplete = 100,
corrupt = 200,
audited = 500,
indeterminate = 0xFFFFFFFF
};
unscoped enum有一个合适的应用:std::tuple
假定有如下std::tuple
:
using UserInfo = // alias 类型
std::tuple<std::string,// name
std::string, // email
std::size_t>; // reputation
尽管注释表明了每个作用域的意义,但是如果在不同的文件中调用,还是会给分辨带来麻烦。
UserInfo uInfo;
auto val = std::get<1>(uInfo);
比如,上述代码中,我们就需要时刻牢记1位置表示name。如果std::tuple
的元素过多,会给跟踪带来很多麻烦。但是,如果使用unscoped enum的话,可以直接枚举:
enum UserInfoFields { uiName, uiEmail, uiReputation };
UserInfo uInfo;
auto val = std::get<uiEmail>(uInfo); // 获取email的信息
在上述std::get
中,发生了一个从UserInfoFields
到std::size_t
隐式的类型转换。如果使用scoped enum的话,应该写成:
enum class UserInfoFields { uiName, uiEmail, uiReputation };
UserInfo uInfo;
auto val = std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>
(uInfo); // 获取email的信息
第3行必须要进行一次强制显式的类型转换说明。第三行还可以写成函数的形式,暂时知识水平所限,留做以后补充。
//===这里内容补充\\
总结:
- C++98
enum
是unscoped enums - scoped enum的枚举内容只能在
enum
内部可见。如果要转换成其他类型,必须使用cast - scoped enum和unscoped enum都支持潜在数据类型,scoped enum的潜在类型是
int
,unscoped enum没有默认的潜在类型 - scoped enum一般要前置声明。unscoped enum只有在显式地确定潜在类型后,才可以提前声明。
##补充内容,C++的用户自定义类型转换:
用户自定义类型的转换目的在于:处理用户自定义类到自定义类或者标准类型之间的转换。实现转换的方式有两种:转换构造函数和自定义转换函数
转换构造函数:
构造转换函数用于把用户自定义类型或者标准类型转换成用户自定义类型。比如:
#include <iostream>
class Money {
public:
Money(): amount(0.0) {};
Money(double _amount): amount(_amount) {};
double amount;
};
void display_balance(const Money& balance) {
std::cout << "The balance is: " << balance.amount << std::endl;
}
int main() {
Money payable(79.89);
int x = 10;
display_balance(payable); // 正常调用构造函数
display_balance(49.85); // 调用转换构造函数
display_balance(9.99f); // 进行一次类型转换后,在调用构造函数
display_balance(x); // 同19行
return 0;
}
第19行的函数中,display_balance
不能直接使用数值; 但是,display_balance
的double
的参数可以转换并匹配Money
的构造函数的参数(在这里两个都是double
),因此display_balance
函数会根据display_balance
的参数构造一个临时的Money
类型的变量,并用这个临时的变量完成display_balance
函数的调用。
20和21行中,虽然传入的参数不是double
类型,但是C++的标准转换可以处理内置类型的转换,因此同样适用。
声明转换构造函数的规则:
- 需要转换的目标类型是可以构造的用户自定义类型。
- 转换构造函数一般只有一个资源参数。但是,它也可以添加一个额外的有默认值的参数。资源参数必须在参数表的第一个位置。
- 转换构造函数不能有返回值!!!
- 转换构造函数可以被*explicit*
explicit限定的转换构造函数:
#include <iostream>
class Money {
public:
Money(): amount(0.0) {};
explicit Money(double _amount): amount(_amount) {};
double amount;
};
void display_balance(const Money& balance) {
std::cout << "The balance is: " << balance.amount << std::endl;
}
int main() {
Money payable(79.89);
int x = 10;
display_balance(payable);
// display_balance(49.85); Error:no suitable conversion exists to convert from double to Money
display_balance(Money(9.99f));
display_balance(Money(x));
return 0;
}
显式地转换必须要进行显式的声明。
自定义转换函数:
#include <iostream>
class Money {
public:
Money(): amount(0.0) {};
Money(double _amount): amount(_amount) {};
operator double() const { // 自定义转换函数
return amount;
}
private:
double amount; // 注意这里是私有类型
};
void display_balance(const Money& balance) {
std::cout << "The balance is: " << balance << std::endl;
}
int main() {
Money payable(79.89);
display_balance(payable);
display_balance(49.95);
display_balance(9.99f);
return 0;
}
基本思路与之前的转换构造函数一样,但是由于输出私有数据类型,在这里需要用到第8行的函数,结合第16行的函数,输出结果。
自定义转换函数的声明方式:
- 转换的目标类型必须在转换函数之前提前声明,类、结构体、枚举类型和
typedef
不能提前声明。 - 自定义转换函数不能有任何参数
- 转换函数可以是
virtual
或者explicit
explicit
限定的自定义转换函数:
#include <iostream>
class Money {
public:
Money(): amount(0.0) {};
Money(double _amount): amount(_amount) {};
explicit operator double() const { // 自定义转换函数
return amount;
}
private:
double amount; // 注意这里是私有类型
};
void display_balance(const Money& balance) { // 注意这里显式类型说明
std::cout << "The balance is: " << double(balance) << std::endl;
}
int main() {
Money payable(79.89);
display_balance(payable);
display_balance(49.95);
display_balance(9.99f);
return 0;
}
显式说明类似上述的。