第六章 Modern C++的一些最佳实践
# 第六章 Modern C++的一些最佳实践
现在,让我们转换话题,讨论现代C++为我们带来的一些各式各样的实践方法。
# 问题42:什么是聚合初始化?
聚合初始化(aggregate initialization)或大括号初始化(brace-initialization,有时也写作{}
初始化)是在C++11中加入到该语言的特性。
那么,什么是聚合体(aggregate)呢?
它可以是数组类型,或者是满足某些限制条件的类类型,你可以在此处 (opens new window)查看相关内容 。
其最简单的形式,看起来和使用括号的普通构造函数调用完全一样,但这次使用的是大括号。
std::string text{"abcefg"};
Point a{5,4,3}; // Point是一个接受3个整数作为参数的类
2
你也可以在auto
类型推导中使用它:
auto text = std::string{"abcefg"};
auto a = Point{5,4,3}; // Point是一个接受3个整数作为参数的类
2
聚合初始化也可用于初始化容器:
std::vector<int> numbers{1, 2, 3, 4, 5};
auto otherNumbers = std::vector<int>{6, 7, 8, 9, 10};
2
这比之前的初始化方式要好得多。在以前,创建容器后,我们必须逐个添加元素。使用聚合初始化,如果我们知道容器不再需要更改,就可以轻松创建常量容器(const containers)。这在C++11之前是无法实现的。
// C++11之前
std::vector<int> myNums;
myNums.push_back(3);
myNums.push_back(42);
myNums.push_back(51);
// 从C++11开始
const std::vector<int> myConstNums{3, 42, 51};
2
3
4
5
6
7
8
但这还不是全部。{}
初始化有两个优点。
# 不易出现最令人头疼的解析问题
根据C++标准,任何能被解释为声明的内容,都会被解释为声明。
因此,MyClass o();
并不是创建一个名为o
的MyClass
实例并调用其默认构造函数的指令。相反,它是一个名为o
的函数声明,该函数不接受参数,但返回MyClass
类型。
如果使用大括号,在99%的情况下,它不会被解释为声明,而是像开发者预期的那样,被解释为变量声明。
# 不允许窄化转换
窄化(narrowing),更准确地说是窄化转换(narrowing conversion),是一种算术值的隐式转换,会导致精度损失。根据具体情况,这可能极其危险。
以下示例展示了经典数值类型初始化方式存在的问题。无论是使用直接初始化还是赋值操作,都会有这个问题。
#include <iostream>
int main() {
int i1(3.14);
int i2=3.14;
std::cout << "i1: " << i1 << std::endl;
std::cout << "i2: " << i2 << std::endl;
int i3{3.14}
unsigned int i4{-41};
}
2
3
4
5
6
7
8
9
正如我们所见,使用大括号初始化时,编译器会给出一个明确的错误,告知我们发生了窄化转换。
所以,{}
初始化的三个优点是:
- 直接初始化容器
- 不再出现最令人头疼的解析问题
- 检测隐式窄化转换
# 问题43:什么是显式构造函数(explicit constructors),它们有什么优点?
explicit
说明符指定构造函数不能用于隐式转换。
如果不使用explicit
,编译器可以进行一次隐式转换,以便将参数解析为函数所需的类型。编译器可以使用接受单个参数的构造函数,将一种类型转换为另一种类型,从而获得函数参数所需的正确类型。
来看这个例子:
class WrappedInt {
public:
// 单参数构造函数,可以用作隐式转换
WrappedInt(int number) : m_number (number) {}
// 使用explicit,不允许隐式转换
// explicit WrappedInt(int number) : m_number (number) {}
int GetNumber() { return m_number; }
private:
int m_number;
};
void doSomething(WrappedInt aWrappedInt) {
int i = aWrappedInt.GetNumber();
}
int main() {
doSomething(42);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
参数并非WrappedInt
对象,只是一个普通的int
类型。然而,WrappedInt
存在一个接受int
类型参数的构造函数,因此这个构造函数可以用于将参数转换为正确的类型。
编译器允许对每个参数进行一次这样的转换。
在构造函数前加上explicit
关键字,可以防止编译器使用该构造函数进行隐式转换。在上述类中添加explicit
关键字后,在调用doSomething(42)
时会产生编译错误。此时,必须显式调用转换,即doSomething(WrappedInt(42))
。
你可能想要这么做的原因是,避免意外构造对象,这种情况可能会隐藏程序中的错误。以字符串为例,假设有一个MySpecialString
类,它有一个接受int
类型参数的构造函数,该构造函数会创建一个长度为传入int
值的字符串。还有一个函数print(const MySpecialString&)
,如果调用print(3)
(而实际上你想调用的是print("3")
),你期望它打印出3
,但实际上它打印的是一个长度为3的空字符串。
再举一个关于字符串的例子,假设这是MySpecialString
类:
class MySpecialString {
public:
MySpecialString(int n); // 为MySpecialString对象分配n个字节
MySpecialString(const char *p); // 用char *p初始化对象
};
2
3
4
5
对于这个构造函数调用会发生什么呢?
MySpecialString mystring = 'x';
字符x
会被隐式转换为int
,然后调用MySpecialString(int)
构造函数。但这可能并非用户本意。所以,为了避免这种情况,我们应该将构造函数定义为explicit
:
class MySpecialString {
public:
explicit MySpecialString (int n); // 分配n个字节
MySpecialString(const char *p); // 用字符串p初始化对象
};
2
3
4
5
为了避免这类不易察觉的错误,一开始(差不多自动地)应该总是考虑将单参数构造函数定义为explicit
,只有在设计上需要隐式转换时,才去掉explicit
关键字。
# 问题44:什么是用户定义字面量(user-defined literals)?
用户定义字面量允许通过定义用户定义的后缀,让整数、浮点数、字符和字符串字面量生成用户定义类型的对象。
用户定义字面量可用于整数、浮点数、字符和字符串类型。
它们可用于转换,例如,你可以编写一个将角度转换为弧度的转换器:
constexpr long double operator"" _deg ( long double deg ) {
return deg * 3.14159265358979323846264L / 180;
}
//...
std::cout << "50 degrees as radians: " << 50.0_deg << std::endl;
2
3
4
5
但更有趣的可能是,当使用强类型(strong types)时,它如何有助于提高代码的可读性和安全性。
让我们来看前面课程中的一个例子:
auto myCar{Car{Horsepower{98u}}, DoorsNumber{4u},
Transmission::Automatic, Fuel::Gasoline};
2
把车门数量和发动机功率弄混的可能性或许不高,但可能存在其他衡量性能的系统,或者Car
构造函数接受的其他数值,那就容易混淆了。
一种可读性强且安全的方式,是通过用户定义字面量,用硬编码值初始化Car
对象(比如在单元测试中):
//...
Horsepower operator"" _hp(int performance) {
return Horsepower{performance};
}
DoorsNumber operator"" _doors(int numberOfDoors) {
return DoorsNumber{numberOfDoors};
}
//...
auto myCar{Car{98_hp, 4_doors,
Transmission::Automatic, Fuel::Gasoline};
2
3
4
5
6
7
8
9
10
11
当然,使用用户定义字面量的方式仅受限于你的想象力,但数值转换和辅助强类型无疑是其中最有趣的两种应用。
# 问题45:为什么我们应该使用 nullptr 而不是 NULL 或 0?
字面值0
是一个整数(int),不是指针。如果编译器在只能使用指针的地方看到0
,它会将0
解释为空指针,但这只是一种隐式转换,如果你愿意,这只是第二选择。NULL
也是如此,尽管在实现中允许给NULL
赋予除int
之外的整数类型,比如long
,不过这种情况并不常见。
这会产生以下影响。如果你有一个函数存在三个重载版本,参数包括整数类型和指针类型,可能会出现一些意外情况:
#include <iostream>
void foo(int) {
std::cout << "foo(int) is called" << std::endl;
}
void foo(bool) {
std::cout << "foo(bool) is called" << std::endl;
}
void foo(void*) {
std::cout << "foo(void*) is called" << std::endl;
}
int main() {
foo(0); // 调用foo(int),而不是foo(void*)
foo(NULL); // 可能无法编译,但通常会调用foo(int),绝不会调用foo(void*)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
因此,出现了避免对指针和整数类型进行重载的指导原则。
另一方面,nullptr
没有整数类型。它的类型是std::nullptr_t
。这是一种独特的类型,它本身既不是指针类型,也不是指向成员类型的指针。同时,它可以隐式转换为所有原始指针类型,这使得nullptr
表现得好像它是所有类型的指针。
因此,使用nullptr
可以调用指针重载版本:
foo(nullptr); // 调用foo(void*)重载版本
要了解nullptr
的更多优势,我们需要对模板元编程进行一些实验。想要了解更多详细信息,强烈建议你查看《Effective Modern C++》的第8条内容。
# 问题46:类型别名(alias)相较于类型定义(typedef)有哪些优势?
首先,让我们了解一下什么是typedef
。如果你不想每次都书写复杂的类型名称,可以引入一个“快捷方式”,即typedef
:
typedef std::unique_ptr<std::unordered_map<std::string, std::string>> MyStringMap;
自C++11起,你可以使用别名声明(alias declarations)来替代:
using MyStringMap = std::unique_ptr<std::unordered_map<std::string, std::string>>
它们的功能完全相同,但别名声明有一些优势。
在函数指针的情况下,别名声明的可读性更强:
typedef void (*MyFunctionPointer)(int , int);
using MyFunctionPointerAlias = void(*)(int , int);
2
另一个优势是,typedef
不支持模板化,而别名声明支持。
template<typename T> using MyAllocList = std::list<T, MyAlloc<T>>;
使用typedef
会复杂得多,在某些情况下(查看此处),你甚至必须使用::type
后缀,并且在模板中,引用typedef
时通常需要使用typename
前缀。
C++14甚至为所有C++11类型特性转换提供了别名模板,比如std::remove_const_t<T>
、std::remove_reference_t<T>
或std::add_lvalue_reference_t<T>
。
# 问题47:作用域枚举(scoped enums)相较于无作用域枚举(unscoped enums)有哪些优势?
C++98风格的枚举现在被称为无作用域枚举。你可以这样声明它们:
enum Transmission { manual, automatic };
// 编译错误,因为manual已经作为不同类型的实体被声明
auto manual = false;
2
3
现代的作用域枚举使用class
关键字,枚举成员(enumerators)仅在枚举内部可见。它们只有通过强制类型转换才能转换为其他类型。它们通常被称为作用域枚举或枚举类(enum classes)。
enum class Transmission { manual, automatic };
// 现在可以编译,manual尚未被声明
auto manual = false;
// 错误,没有名为manual的枚举成员,这里的manual只是一个bool类型变量
Transmission t = manual;
Transmission t = Transmission::manual; // 正确
auto t = Transmission::manual; // 正确
2
3
4
5
6
7
8
9
作用域枚举和无作用域枚举都支持指定底层类型。作用域枚举的默认底层类型是int
。无作用域枚举没有默认的底层类型。
指定底层类型的语法是相同的:
enum Status : std::uint32_t { /* . .*/ };
enum class Status : std::uint32_t { /* . . .*/ };
2
无作用域枚举只有在声明中指定了底层类型时才能进行前向声明,否则枚举的大小是未知的。另一方面,由于作用域枚举默认有底层类型,所以它们总是可以进行前向声明。
# 问题48:应该显式删除未使用/不支持的特殊函数,还是将它们声明为私有成员?
首先要回答的问题是,为什么要选择这两种方式中的任何一种呢?你可能不希望某个类被复制或移动,所以希望让调用者无法访问相关的特殊函数。一种选择是将它们声明为私有(private)或受保护(protected)成员,另一种选择是显式删除它们。
class NonCopyable {
public:
NonCopyable() {/* . . .*/}
// . . .
private:
NonCopyable(const NonCopyable&);
NonCopyable& operator=(const NonCopyable&);
};
2
3
4
5
6
7
8
9
在C++11之前,除了将不需要的特殊函数声明为私有成员且不实现它们之外,没有其他选择。这样可以禁止对象复制(当时还没有移动语义)。不实现这些函数有助于防止在成员函数、友元函数中意外使用,或者在你忽略访问说明符时的误用。在链接时你会遇到问题。
自C++11起,你可以通过将特殊函数声明为= delete;
来简单地标记它们为已删除:
class NonCopyable {
public:
NonCopyable() {/* . . .*/}
NonCopyable(const NonCopyable&) = delete ;
NonCopyable& operator=(const NonCopyable&) = delete ;
// . . .
private:
// . . .
};
2
3
4
5
6
7
8
9
10
11
C++11的方式更好,原因如下:
- 它比将函数放在私有部分更明确,因为将函数放在私有部分可能只是个错误。
- 如果你尝试进行复制,在编译时就会报错。
已删除的函数应该声明为公有成员,而不是私有成员。这不是编译器的强制要求,但有些编译器可能只会抱怨你调用了私有函数,而不会指出该函数已被删除。
# 问题49:如何在C++中使用= delete
说明符?
这个问题也可以表述为:如何禁止函数调用时的隐式类型转换?
假设有一个接收整数的函数。这里指的是整数,比如该函数的参数表示一辆汽车能坐多少人。可能是2人,有些车比较特殊,是三座的,一些豪华车能坐4人,大多数车能坐5人。不会是4.9人,也不是5.1人,甚至不是5个半人,就是5人。我们不涉及人体部分(这种不合理的数值)。
那么如何确保函数只接收整数作为参数呢?
显然,你会使用一个整型参数。它可以是int
,甚至unsigned
,或者仅仅是short
,有很多选择。你可能还会在文档中注明numberOfSeats
参数应该是一个整数。
很好!
那么,如果客户端调用时仍然传入一个浮点数会怎样呢?
#include <iostream>
void foo(int numberOfSeats) {
std::cout << "Number of seats: " << numberOfSeats << '\n';
// . . .
}
int main() {
foo(5.6f);
}
2
3
4
5
6
7
8
9
10
Number of seats: 5
浮点型参数被接受并转换为整数。甚至不能说它是四舍五入,它是被隐式转换为整数的。
你可能会说,在某些情况下这没问题。但在其他情况下,这种行为是完全不可接受的。
在这种情况下,如何避免这个问题呢?显然,你可以在调用方进行处理,但:
- 如果
foo
函数经常被使用,每次都进行检查会很繁琐; - 如果
foo
是外部使用的应用程序编程接口(API)的一部分,你无法控制调用方。
从C++11开始,我们可以使用delete
说明符来限制某些类型的拷贝、移动,实际上甚至可以限制其多态使用。
但是= delete
的用途不止于此。它可以应用于任何函数,无论是成员函数还是自由函数。
如果你不想允许从浮点数进行隐式转换,可以简单地删除接受float
类型参数的foo
重载版本:
#include <iostream>
void foo(int numberOfSeats) {
std::cout << "Number of seats: " << numberOfSeats << '\n';
// . . .
}
void foo(double) = delete ;
int main() {
foo(5.6f);
}
2
3
4
5
6
7
8
9
10
11
12
main.cpp: In function 'int main()':
main.cpp:11:6: error: use of deleted function 'void foo(d\
ouble)'
11 | foo(5.6f);
| ~~~^~~~~~
main.cpp:8:6: note: declared here
8 | void foo(double) = delete;
| ^~~
2
3
4
5
6
7
8
就是这样。通过删除函数的某些重载版本,你可以禁止从某些类型隐式转换为你期望的类型。现在,你完全可以控制用户可以向你的API传递什么样的参数了。
# 问题50:C++中的平凡类(trivial class)是什么?
在C++中,如果一个类或结构体(struct)只有编译器提供的或显式默认的特殊成员函数,那么它就是一个平凡类型。它占据连续的内存区域。它可以有不同访问说明符的成员。在C++中,在这种情况下编译器可以自由选择成员的排列顺序。因此,你可以使用memcpy
函数处理这样的对象,但不能在C程序中可靠地使用它们。一个平凡类型T
可以被复制到char
或unsigned char
数组中,然后再安全地复制回T
类型的变量。请注意,由于对齐要求,类型成员之间可能存在填充字节。
平凡类型有平凡的默认构造函数、平凡的拷贝和移动构造函数、平凡的拷贝和移动赋值运算符,以及平凡的析构函数。在每种情况下,“平凡”意味着构造函数/运算符/析构函数不是用户提供的,并且属于一个满足以下条件的类:
- 没有虚函数(virtual functions)或虚基类(virtual base classes);
- 没有具有相应非平凡构造函数/运算符/析构函数的基类;
- 没有具有相应非平凡构造函数/运算符/析构函数的类类型数据成员。
一个类是否为平凡类,可以通过std::is_trivial
特性类(trait class)来验证。它会检查该类是否是平凡可拷贝的(std::is_trivially_copyable
)以及是否是平凡可默认构造的(std::is_trivially_default_constructible
)。
一些示例:
#include <iostream>
#include <type_traits>
class A {
public:
int m;
};
class B {
public:
B() {}
};
class C {
public:
C() = default ;
};
class D : C {};
class E { virtual void foo() {} };
int main() {
std::cout << std::boolalpha;
std::cout << std::is_trivial<A>::value << '\n';
std::cout << std::is_trivial<B>::value << '\n';
std::cout << std::is_trivial<C>::value << '\n';
std::cout << std::is_trivial<D>::value << '\n';
std::cout << std::is_trivial<E>::value << '\n';
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
程序输出结果:
true
false
true
false
2
3
4