第三章:转向现代C++
《Effective Modern C++》阅读笔记(三)
条款7:区别使用()和{}创建对象
c++初始化的方式比较多样,包括圆括号(()
)初始化、等号(=
)初始化和大括号({}
)初始化:
int x(0); // 使用圆括号初始化
int y = 0; // 使用"="初始化
int z{ 0 }; // 使用花括号初始化
int z = {0}; // 使用"="和花括号
这种多样性可能会导致一些误解,对此C++11引入了统一的初始化形式—基于大括号的初始化形式。大括号初始化的默认类型为std::initializer_list
:
auto var = {1, 2, 3}; // 则decltype(var)为std::initializer_list
使用大括号初始化常规变量:
std::vector<int> v{ 1, 3, 5 }; // v初始内容为1,3,5
使用大括号初始化非静态成员默认值:
class Widget{
private:
int x{ 0 }; // 没问题,x初始值为0
int y = 0; // 也可以
int z(0); // 错误!
}
使用大括号初始化不可复制类的对象:
std::atomic<int> ai1{ 0 }; // 没问题
std::atomic<int> ai2(0); // 没问题
std::atomic<int> ai3 = 0; // 错误!
大括号初始化可以解决小括号初始化存在的种种问题。
问题1:小括号初始化可能会因为C++的解析语法被视为函数声明,例如:
Widget w1(10); // 使用实参10调用Widget的一个构造函数
Widget w2(); // 最令人头疼的解析!声明一个函数w2,返回Widget
使用大括号则没上述问题:
Widget w3{}; // 调用没有参数的构造函数构造对象
问题2:小括号初始化可能会无法实现预期的构造函数,例如想要创建一个包含10和20两个数的vector
容器,如果使用小括号:
std::vector<int> v1(10, 20); // 创建一个包含10个元素的std::vector,所有的元素的值都是20
如果使用大括号:
std::vector<int> v2{10, 20}; // 创建包含两个元素的std::vector,元素的值为10和20
条款8:优先考虑使用nullptr
而不是0和NULL
NULL
和nullptr
最大的区别是NULL
的类型是int
,即NULL=0
;而nullptr
的类型是std::nullptr_t
,本质是一个指针类型。nullptr
可以隐式转换成普通指针(type*
)和只能指针(std::shared_ptr<type>
)类型,而NULL
只能转换成普通指针。
void f(int); // 三个f的重载函数
void f(bool);
void f(void*);
f(0); // 调用f(int)而不是f(void*)
f(NULL); // 可能不会被编译,一般来说调用f(int),
// 绝对不会调用f(void*)
条款9:有限考虑使用using
声明别名而不是typedef
大部分场景下typedef
和using
用法类似:
// 使用typedef声明类别名
typedef
std::unique_ptr<std::unordered_map<std::string, std::string>>
UPtrMapSS;
// 使用using声明类别名
using UPtrMapSS =
std::unique_ptr<std::unordered_map<std::string, std::string>>;
// 使用typedef声明函数别名
typedef void (*FP)(int, const std::string&);
// 使用using声明函数别名
using FP = void (*)(int, const std::string&);
但是如果要声明类中的嵌套类型,using
会比typedef
要方便许多。例如容器类MyAllocList
中定义了分配器的类型type
:
template<typename T> // MyAllocList<T>是
struct MyAllocList { // std::list<T, MyAlloc<T>>
typedef std::list<T, MyAlloc<T>> type; // 的同义词
};
template<typename T>
class Widget { // Widget<T>含有一个
private: // MyAllocLIst<T>对象
typename MyAllocList<T>::type list; // 作为数据成员
… // 这里使用typename主要是因为
// 声明type为类型而不是一个成员变量
};
而如果使用using
则会简化很多:
template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>; // 同之前一样
template<typename T>
class Widget {
private:
MyAllocList<T> list; // 没有“typename”
… // 没有“::type”
};
C++中一些特殊的特征类型:
std::remove_const<T>::type // C++11: const T → T
std::remove_const_t<T> // C++14 等价形式
std::remove_reference<T>::type // C++11: T&/T&& → T
std::remove_reference_t<T> // C++14 等价形式
std::add_lvalue_reference<T>::type // C++11: T → T&
std::add_lvalue_reference_t<T> // C++14 等价形式
条款10:优先考虑使用限定域的枚举而不是非限定域的枚举
非限定域枚举可能存在的问题:
enum Color { black, white, red }; // black, white, red在Color所在的作用域
auto white = false; // 错误! white早已在这个作用中声明
应改为限定域(也称枚举类)来实现:
enum class Color { black, white, red }; // black, white, red限制在Color域内
auto white = false; // 没问题,域内没有其他“white”
Color c = white; // 错误,域中没有枚举名叫white
Color c = Color::white; // 没问题
auto c = Color::white; // 也没问题(也符合条款5的建议)
条款11:优先考虑使用delete
而不是private
未定义函数
如果想让普通函数不接受某些类别的形参进行隐式转换或者模板函数的某种实例化函数被调用,可以采用delete
关键字:
bool isLucky(int number); // 原始版本
bool isLucky(char) = delete; // 拒绝char
bool isLucky(bool) = delete; // 拒绝bool
bool isLucky(double) = delete; // 拒绝float和double
if (isLucky('a')) … // 错误!调用deleted函数
if (isLucky(true)) … // 错误!
if (isLucky(3.5f)) … // 错误!
template<typename T>
void processPointer(T* ptr);
template<>
void processPointer<void>(void*) = delete; // 拒绝void*
template<>
void processPointer<char>(char*) = delete; // 拒绝char*
template<>
void processPointer<const void>(const void*) = delete; // 拒绝const void*
template<>
void processPointer<const char>(const char*) = delete; // 拒绝const char*
条款12:使用override
声明重载函数
派生类重写(override)基类函数需要满足以下条件:
- 基类函数必须是
virtual
(派生类则不一定需要是虚函数); - 基类和派生类函数名必须完全一样(除非是析构函数);
- 基类和派生类函数形参类型必须完全一样;
- 基类和派生类函数常量性
const
必须完全一样; - 基类和派生类函数的返回值和异常说明必须兼容。
class Base {
public:
virtual void doWork(); // 基类虚函数
…
};
class Derived: public Base {
public:
virtual void doWork(); // 重写Base::doWork
… // (这里“virtual”是可以省略的)
};
std::unique_ptr<Base> upb = // 创建基类指针指向派生类对象
std::make_unique<Derived>();
upb->doWork(); // 通过基类指针调用doWork,
// 实际上是派生类的doWork函数被调用
引用限定符是C++11引入的特性,其可以在成员参数列表之后来指定this
对象的左值与右值属性:
- 若引用限定符为
&
,则表明this
对象指向着左值对象; - 若引用限定符为
&&
,则表明this
对象指向着右值对象。
举个例子:
struct X {
void foo() &;
void foo() &&;
};
X x;
x.foo(); // 调用 foo1()
X().foo(); // 调用 foo2()
struct X {
void foo() && {}
};
X x;
x.foo(); // 报错
struct X {
void foo() & {}
};
X().foo(); // 报错
引用限定符的用法于const
限定类似,const
放在成员函数参数列表之后来指定this
对象的const
属性。
条款13:优先考虑const_iterator
而非iterator
优先使用const类型的iterator:
std::vector<int> values;
auto it = // 使用cbegin
std::find(values.cbegin(), values.cend(), 1983); // 和cend
values.insert(it, 1998);
条款14:只要函数不抛出异常请使用noexcept
C++异常处理流程
c++程序在执行过程中,出现异常后具体的处理流程如下:
- **异常或错误发生:**例如除零错误、内存访问错误或其他错误,程序会抛出一个异常;
- **调用栈解开(stack unwinding):**程序会逐层返回到调用栈中的函数,不能处理异常的函数会直接退出;
- **返回到调用函数:**当程序找到可以处理异常的函数
func
时,程序会将控制权返回到该函数,并执行func
的异常处理代码; - **程序继续执行:**如果异常被成功处理,程序会继续执行
func
的后续逻辑。如果异常没有被处理,程序会调用std::terminate()
中止程序。
为了能正确处理异常,程序在任何时间都要维护完整的调用栈,这会占用一定的空间同时也会屏蔽部分优化。
为什么要加上noexcept
对于一些不会产生异常的函数来说,加上noexcept
主要有两方面的原因:
让编译器生成更好的目标代码
首先对比C++98和C++11在声明函数不会抛出异常的写法:
int f(int x) throw(); // C++98风格,没有来自f的异常 int f(int x) noexcept; // C++11风格,没有来自f的异常
如果在运行时
f
出现异常,在C++98中调用栈会解开至f
的调用方,然后执行一些与本条款无关的操作后中止,可能会导致未定义行为;而C++11中并没有要求此时f
的调用栈是可解开的,也不要求在抛出异常后调用栈中函数内部变量析构时的顺序,因此可以提供了更多的优化机会,同时标准库会调用std::terminate()
来中止程序不会出现程序未定义的情况。RetType function(params) noexcept; // 极尽所能优化 RetType function(params) throw(); // 较少优化 RetType function(params); // 较少优化
这里举个例子:
std::vector<Widget> vw; … Widget w; … // 用w做点事 vw.push_back(w); // 把w添加进vw
对于
push_back
的操作一种显然的优化思路是将复制操作改为移动操作,但是如果执行push_back
的过程中抛出异常(例如由于Widget
类移动构造函数造成的),则使用移动操作可能会出现问题:部分元素已被移出,而剩下的还在原处,进而导致安全问题。对此如果使用
throw()
标记会避免进行移动操作,但是这样对于移动操作不会产生异常的类来说性能上还有很大的提升空间。由此使用noexcept
便能很好地解决这一问题:只需要检查该类的移动操作是否标记为noexcept
即可。作为一元操作符提供更好的灵活性
swap
函数是noexcept
的另一个绝佳用地。swap
是STL算法实现的一个关键组件,它也常用于拷贝运算符重载中,标准库的swap
是否noexcept
有时依赖于用户定义的swap
是否noexcept
。比如,数组和std::pair
的swap
声明如下:template <class T, size_t N> void swap(T (&a)[N], // 表示a为一个拥有N个T类型数组的引用 T (&b)[N]) noexcept(noexcept(swap(*a, *b))); //见下文 template <class T1, class T2> struct pair { … void swap(pair& p) noexcept(noexcept(swap(first, p.first)) && noexcept(swap(second, p.second))); … };
此时
pair
中的swap
是否被标记为noexcept
取决于T1
和T2
中的swap
函数是否被标记为except
。
noexcept
相关注意事项
- 大多数函数都是异常中立(exception-netural)的,这类函数自身不抛出异常,但是它们调用的函数可能会抛出异常,因此不适合被标记为
noexception
。 - 默认的内存释放函数(即
delete
和delete[]
)和析构函数都会被隐式地标记为noexcept
,如果需要声明为可能抛出异常,需要加上noexcept(false)
。 - 标记为
noexcept
的函数调用没有标记为noexcept
的函数时可以通过编译,但是不推荐这么做。
条款15:尽可能地使用constexpr
constexpr
修饰变量
constexpr
修饰的变量一定是编译时已知的,所有的constexpr
变量都是const
变量,但是const
变量不一定是constexpr
变量:
int sz; // non-constexpr变量
…
constexpr auto arraySize1 = sz; // 错误!sz的值在
// 编译期不可知
std::array<int, sz> data1; // 错误!一样的问题
constexpr auto arraySize2 = 10; // 没问题,10是编译期可知常量
std::array<int, arraySize2> data2; // 没问题, arraySize2是constexpr
int sz; // 和之前一样
…
const auto arraySize = sz; // 没问题,arraySize是sz的const复制
std::array<int, arraySize> data; // 错误,arraySize值在编译期不可知
constexpr
修饰函数
使用constexpr
修饰函数表示如果传入该函数的实参值都是编译期已知的(即字面值(literal type)),则其执行结果也是编译器已知的;否则如果存在一个或者多个实参是编译器未知的,则该函数退化为普通函数。举个例子:
constexpr // pow是绝不抛异常的
int pow(int base, int exp) noexcept // constexpr函数
{
… // 实现在下面
}
constexpr auto numConds = 5; // (上面例子中)条件的个数
std::array<int, pow(3, numConds)> results; // 结果有3^numConds个元素
从c++14开始,constexpr
函数内也可以创建局部变量以及使用if/while/for
语句等。
constexpr
修饰自定义类对象
自定义类的对象也可以是constexpr
类别,只要其构造函数和其他成员函数都是constexpr
修饰的。举个例子:
class Point {
public:
constexpr Point(double xVal = 0, double yVal = 0) noexcept
: x(xVal), y(yVal)
{}
constexpr double xValue() const noexcept { return x; }
constexpr double yValue() const noexcept { return y; }
void setX(double newX) noexcept { x = newX; }
void setY(double newY) noexcept { y = newY; }
private:
double x, y;
};
Point
的构造函数被声明为constexpr
函数,因此其构造出来的对象在编译时也是已知的。可以进一步实现一个接收constexpr
的Point
对象,并返回constexpr
类型结果的编译时函数,如下所示:
constexpr
Point midpoint(const Point& p1, const Point& p2) noexcept
{
return { (p1.xValue() + p2.xValue()) / 2, // 调用constexpr成员函数
(p1.yValue() + p2.yValue()) / 2 };
}
constexpr auto mid = midpoint(p1, p2); // 使用constexpr函数的结果初始化constexpr对象
C++14以后甚至可以修改constexpr
的对象成员变量,例如:
// 返回p相对于原点的镜像
constexpr Point reflection(const Point& p) noexcept
{
Point result; // 创建non-const Point
result.setX(-p.xValue()); // 设定它的x和y值
result.setY(-p.yValue());
return result; // 返回它的副本
}
constexpr Point p1(9.4, 27.7); // 和之前一样
constexpr Point p2(28.8, 5.3);
constexpr auto mid = midpoint(p1, p2);
constexpr auto reflectedMid = // reflectedMid的值
reflection(mid); // (-19.1, -16.5)在编译期可知
条款16:保证const
成员地线程安全
略。。。
条款17:理解特种函数的生成机制
特种函数
C++中的特种函数主要包括:
- 默认构造函数;
- 析构函数;
- 复制构造函数;
- 复制赋值函数;
- 移动构造函数;
- 移动赋值函数。
在特定情境下如果用户没有显示声明特种函数,编译器会自动生成,生成的特种函数都是具有public访问层级且是inline、非虚的。
class Widget {
public:
…
Widget(Widget&& rhs); // 移动构造函数
Widget& operator=(Widget&& rhs); // 移动赋值运算符
…
};
自动生成的复制/移动构造函数会调用rhs
中各个非静态成员的复制/移动构造函数。当然对于移动构造函数来说,如果非静态成员类别为不可移动类别(例如C++98的遗留类型),则会调用其复制构造函数。
生成机制
默认构造函数
仅当类中不包含用户声明的任何构造函数时才会生成。
析构函数
当类中不包含用户声明的析构函数时生成。自动生成的析构函数是noexcept
类型的,并且如果继承的基类函数的析构函数为虚函数,则生成的析构函数也是虚函数。
复制构造函数
仅在类中不包含用户声明的复制构造函数时生成。如果该类声明了移动操作函数,则自动生成的复制构造函数将被会标记为delete
,也就是说如果类中仅声明了移动操作函数而未声明复制操作函数,则编译器不允许调用该类的复制构造/赋值函数,见下例。
class A {
public:
A() = default;
A(A&&) noexcept = default; // 显式声明移动构造函数
A& operator=(A&&) noexcept = default; // 显式声明移动赋值运算符
};
int main() {
A obj1;
A obj2(obj1); // 编译器报错:
return 0; // error: use of deleted function 'constexpr A::A(const A&)'
}
复制赋值函数
仅在类中不包括用户声明的复制赋值函数时才会生成,同样如果该类中声明了移动构造函数,则不允许调用该类的复制赋值函数。
复制构造函数和复制赋值函数时彼此独立的,用户声明了其中一个不会影响自动生成另一个。
移动构造函数和移动赋值函数
仅在类中不包括用户声明的任何(复制/移动)赋值操作、移动操作和析构函数时才会生成。如果该类中声明了复制构造函数,则不会生成移动操作函数(移动操作可以调用但是实际上转化为赋值操作)。
class A {
public:
A() = default;
A(const A& a) { // 复制构造函数
std::cout << "copy is called" << std::endl;
}
A& operator=(const A&) = default;
// A(A&&) noexcept = default; // 显式声明移动构造函数
};
int main() {
A obj1;
A obj3(std::move(obj1)); // 调用移动构造函数
return 0;
}
// 输出:copy is called
// 如果取消移动构造函数注释,则输出:(空)
移动构造函数和移动赋值函数相互不独立,声明了其中一个就会阻止编译器生成另一个,如下例所示:
class A {
public:
A() = default;
A(A&&) noexcept = default; // 显式声明移动构造函数
// A& operator=(A&&) noexcept = default; // 显式声明移动赋值运算符
};
int main() {
A obj1;
A obj2(std::move(obj1)); // 没问题
A obj3;
obj3 = std::move(obj1); // 编译器报错
return 0; // error: use of deleted function 'constexpr A& A::operator=(const A&)'
}
大三率(Rule of Three)原则
如果用户声明了复制构造函数、复制赋值函数和析构函数中的任意一个,则应该同时声明所有的这三个。
基于此原则C++规定:只要用户声明了析构函数,就不会生成移动操作,例如下例所示:
// 移动操作会调用自动生成的移动构造函数
class StringTable {
public:
StringTable() {}
… // 插入、删除、查找等函数,但是没有拷贝/移动/析构功能
private:
std::map<int, std::string> values;
};
// 移动操作会调用赋值构造函数
class StringTable {
public:
StringTable()
{ makeLogEntry("Creating StringTable object"); } // 增加的
~StringTable() // 也是增加的
{ makeLogEntry("Destroying StringTable object"); }
… // 其他函数同之前一样
private:
std::map<int, std::string> values; // 同之前一样
};