Xander's Wiki


  • Home

  • Tags

  • Categories

  • Archives

  • Search

16.确保const成员函数线程安全

Posted on 2018-07-03 | In Effective Modern C++

问题实例

 
假设当前存在一个类以表征多项式,且该类存在一个成员函数以完成多项值求根功能。显然此类函数不可能对多项式造成任何修改,因此可将其声明为const:

1
2
3
4
5
6
7
class Polynomial {
public:
using RootsType = std::vector<double>;
…
RootsType roots() const;
…
};

一般而言,求根操作成本高昂,我们理应避免重复计算,因此我们在某次计算完成后将其值作为缓存,每一次求根都从缓存中取值(此思想可见More Effective C++ Item18):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Polynomial {
public:
using RootsType = std::vector<double>;
RootsType roots() const{
if (!rootsAreValid) { // if cache not valid
… // compute roots,store them in rootVals
rootsAreValid = true;
}
return rootVals;
}
private:
mutable bool rootsAreValid{ false };
mutable RootsType rootVals{};
};

roots成员函数不可能修改Polynomial对象,但它可能会修改rootVals和rootsAreValid,此二者被声明为mutable,其本意便在于方便const函数修改。
假设当前存在两个线程同时在Polynomial对象上调用roots:
1
2
3
4
Polynomial p;
…
/*----- Thread 1 ----- */ /*------- Thread 2 ------- */
auto rootsOfP = p.roots(); auto valsGivingZero = p.roots();

在客户端执行上述程序十分合理,因为roots具备const属性,客户可以认为其执行读操作,而多线程在无同步的条件下执行读取操作在概念上是安全的。但实际上我们知道,roots可能会改变对象内部的data member,这意味着可能存在不同的线程在无同步的条件下对同一块内存进行读取与写入操作,这就是data race的标准定义。因此,该程序可能会导致未定义行为。

关键问题在于roots的const属性。C++98中的const成员函数仅仅表示不会修改对象,但C++11中它需要在此基础之上更进一步:保证线程安全性。


解决方案

 
解决此问题最简单的方法(或许也是最常见的方法)是使用互斥锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Polynomial {
public:
using RootsType = std::vector<double>;
RootsType roots() const{
std::lock_guard<std::mutex> g(m); // lock mutex
if (!rootsAreValid) { // if cache not valid
… // compute and store roots
rootsAreValid = true;
}
return rootVals;
} // unlock mutex
private:
mutable std::mutex m;
mutable bool rootsAreValid{ false };
mutable RootsType rootVals{};
};

我们将std::mutex声明为mutable,因为锁定与解锁操作均在const成员函数中进行。此外需要注意的是,mutex是一种move-only type(无法被复制,仅能被移动),因此mutex的加入导致多项式对象也变成了一个move-only object,从此失去了被复制的能力。


std::atomic

 
在某些情况下,使用mutex可能有些小题大做。例如我们当前正关注于一个成员函数被调用的次数,那么使用一个std::atomic计数器(见Item40)可能更加恰当(实际上是否比mutex更加好用取决于硬件与你所使用的mutex的实现)。下述实例展示了如何使用std::atomic计算成员函数被调用的次数:

1
2
3
4
5
6
7
8
9
10
11
class Point {
public:
…
double distanceFromOrigin() const noexcept{
++callCount; // atomic increment
return std::sqrt((x * x) + (y * y));
}
private:
mutable std::atomic<unsigned> callCount{ 0 };
double x, y;
};

类似于mutex,atomic也属于move-only type,因此Point对象也必将是一个move-only object。


atomic的不足

 
由于atomic的使用成本相较于mutex较为低廉,因此我们可能会更倾向于使用atomic。举例而言,如果当前有一个很消耗计算量的int值需要被缓存,我们可能会倾向于使用一对atomic而非mutex来确保线程安全性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Widget {
public:
…
int magicValue() const {
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2; // part 1
cacheValid = true; // part 2
return cachedValue;
}
}
private:
mutable std::atomic<bool> cacheValid{ false };
mutable std::atomic<int> cachedValue;
};

上述程序能够运行,只是运行流程未必如你所想,试考虑如下情形:

  1. 某线程调用magicValue,发现当前cacheValid处于false状态,于是默默计算了一遍。
  2. 就在此时,第二个(或者其他)也调用了magicValue,也发现当前cacheValid处于false状态,于是进行了一次无意义的重复计算。

这种行为与我们设定缓存的目标背道而驰。将颠倒cacheValid与cachedValue的赋值顺序可以解决上述问题,但你会发现我们将得到一个更糟的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Widget {
public:
…
int magicValue() const{
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cacheValid = true; // part 1
return cachedValue = val1 + val2; // part 2
}
}
…
};

假设当前cacheValid为false,此后:

  1. 莫线程调用了Widget::magicValue 并且通过Point对象将caheValid设置为1;
  2. 就在此时,另一个线程发现当前cacheValid为1,于是直接把cachedValue拿去用了(此时第一个线程尚未完成计算)。

由此我们可以发现,对于需要同步的单个变量或内存位置,std::atomic足以应对,但如果需要操作的两个或多个变量或内存位置,则应当使用mutex完成相关任务。因此,magicValue的正确实现应当为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Widget {
public:
…
int magicValue() const{
std::lock_guard<std::mutex> guard(m); // lock m
if (cacheValid) return cachedValue;
else {
auto val1 = expensiveComputation1();
auto val2 = expensiveComputation2();
cachedValue = val1 + val2;
cacheValid = true;
return cachedValue;
}
} // unlock m
…
private:
mutable std::mutex m;
mutable int cachedValue; // no longer atomic
mutable bool cacheValid{ false }; // no longer atomic
};


const成员函数的线程安全性

 
本节内容的前提是可能存在多个线程同时在某对象上执行const成员函数,并且该const函数试图修改data member。如果你确保自己的程序不会应用于多线程环境下,那么const成员函数的线程安全性并不重要。随着多线程的普及度越来越高,这种可能性似乎越来越小。可以肯定的是,const成员函数必将受到并发编程的影响,这就是我们应当确保const成员函数是线程安全的原因所在。


总结

  1. 开发者应当致力于保证const成员函数的线程安全性,除非你的程序永不涉及并发编程。
  2. std::atomic变量可以提供相对于mutex更加优良的性能,但其仅适用于操作单个变量或内存位置。

15.尽量使用constexpr

Posted on 2018-07-02 | In Effective Modern C++

前言

 
constexpr应用于对象时,其类似于const的加强版,但其应用于函数时的意义却与此大相径庭。从概念而言,constexpr不仅仅表示一个常量,也表示该量在编译期即可明确。然而,在constexpr function中,constexpr未必代表着编译期取值,甚至未必能保证const。


constexpr objects

 
constexpr对象具备const属性,其值在编译期已知。(从技术层面来说,其值决定于translation时期,也就是编译期和链接期,但这一点并不重要,除非你需要自己写一个C++的编译器和链接器,不然你总可以认为constexpr对象的值在编译期即已确定)。

编译期即被明确的值具有一定的特权,例如它们可能会被存于只读存储器中,对于嵌入式开发者来说这或许是一个非常重要的特性,但其更广泛适用于C++编译期需要整型常量表达式的环境中,例如数组长度说明、整型模板参数设定、枚举量设定、齐位说明符设定等等。当你需要将一个变量应用于上述环境时,将其声明为constexpr即等效于向编译器保证其可在编译期求值:

1
2
3
4
5
6
int sz; // non-constexpr variable
…
constexpr auto arraySize1 = sz; // error! sz's value not known at compilation
std::array<int, sz> data1; // error! same problem
constexpr auto arraySize2 = 10; // fine, 10 is a compile-time constant
std::array<int, arraySize2> data2; // fine, arraySize2 is constexpr

需要注意的是const并不具备编译期明确其值的能力:
1
2
3
4
int sz; // as before
…
const auto arraySize = sz; // fine, arraySize is const copy of sz
std::array<int, arraySize> data; // error! arraySize's value not known at compilation

简而言之,所有的constexpr对象都是const的,但并不是所有的const对象都是constexpr。仅有constexpr对象具备编译期明确其值的能力。


constexpr function

 
constexpr function的情况与其使用情境相关,如果你以一个compile-time constants调用constexpr function,该函数会产生一个compile-time constants,而如果你以一个runtime values调用它,它则会产生一个runtime value。

constexpr function的用途

  1. 用于必需compile-time constants的环境下(例如数组长度)
    如果你传入的参数可在编译期获得,那么constexpr function会在编译期产生结果,反之只要有一个参数不符合compile-time,则代码将无法通过编译。
  2. 减少代码重复
    当一个constexpr函数被一个或多个runtime values调用时,它会按照普通函数的形式运行。这意味着我们不需要在开发时区分compile-time value与runtime value。

问题实例

 
假设我们需要一个数据结构以保存各种实验条件下的运行结果,实验条件条件可能有光照等级、风扇速度、室内温度等等。如果可变的实验条件共有n个,每个实验条件又存在3种状态,那么本实验共有3^n种组合,因此我们需要一个可以容纳3^n个实验结果的数据结构。假定每一个结果均为int类型且n在编译期已知(或可被计算),那么std::array似乎可以适用于本次设计,但我们还需要一种方式以在编译期计算出3^n具体的大小。std::pow可以完成我们想要的计算,但它存在两个问题:1、工作于浮点数;2、不具备constexpr属性。因此我们无法使用它来在编译期计算std::array的大小。

不过我们可以自己完成pow的编写。在定义它之前,我们先看看它应当被如何声明与使用:

1
2
3
4
5
constexpr int pow(int base, int exp) noexcept{
… // impl is below
}
constexpr auto numConds = 5;
std::array<int, pow(3, numConds)> results;

pow前面的constexpr并不表明pow返回一个const值,它表示如果base和exp是编译时常量,那么pow的结果可以用作编译时常量。 如果base或exp不是编译时常量,pow的结果将在运行时计算。这意味着pow不仅用于在编译期计算std::array的大小,还可以在运行时环境中调用它:
1
2
3
auto base = readFromDB("base"); // get these values
auto exp = readFromDB("exponent"); // at runtime
auto baseToExp = pow(base, exp); // call pow function at runtime


constexpr的限制条件

 
当传入参数是一个compile-time value时,constexpr function必然compile-time value,因此它的实现方式存在一些限制,并且在C++11与C++14中这些限制不尽相同。

在C++11中,constexpr函数只允许包含一个可执行语句:return语句,但实际上我们可以用两种方法钻空子。首先,我们可以使用条件”?:”运算符来代替if-else语句;其次,我们可以使用递归来代替循环。因此,pow可被实现为:

1
2
3
constexpr int pow(int base, int exp) noexcept{
return (exp == 0 ? 1 : base * pow(base, exp - 1));
}

想必除了函数式语言开发者之外没人会觉得上述代码比较优雅直观。C++14对于constexpr的限制较为宽泛,因此我们可以自由地写出这样的代码:
1
2
3
4
5
constexpr int pow(int base, int exp) noexcept {
auto result = 1;
for (int i = 0; i < exp; ++i) result *= base;
return result;
}

constexpr函数的参数与返回值均限定为literal type,因为仅有该类型可在编译期确定。 在C++11中,除void之外的所有内置类型均为literal type,但用户自定义类型也可能是literal type,因为其构造函数和其他成员函数可能具备constexpr属性:
1
2
3
4
5
6
7
8
9
10
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,原因在于如果在编译期间传给构造函数的参数已知,那么在编译期Point内部数据成员均已确定,那么这种初始化得到的point具备constexpr属性:
1
2
constexpr Point p1(9.4, 27.7); // fine, "runs" constexpr ctor during compilation
constexpr Point p2(28.8, 5.3); // also fine

同样地,xValue和yValue也可以是constexpr,如果在constexpr Point对象上调用getter,则可以在编译期间知道数据成员x和y的值。如此则可以用这些函数的结果初始化constexpr对象:
1
2
3
4
5
constexpr Point midpoint(const Point& p1, const Point& p2) noexcept{
return { (p1.xValue() + p2.xValue()) / 2,
(p1.yValue() + p2.yValue()) / 2 };
}
constexpr auto mid = midpoint(p1, p2);

这种操作手法十分高妙,尽管mid的初始化操作涉及构造函数、getter函数、非成员函数,但最终我们在只读内存中完成了对它的创建。这种技法意味着编译期与运行期的界限开始趋于模糊,并且以往在运行期才能完成的操作如今可以提前至编译期完成。参与提前的代码越多,运行速度越快(当然编译速度会有所降低)。

在C++11中,两个限制阻止了Point的成员函数setX和setY被声明为constexpr。首先,它们能够修改*this,而在C++11中,constexpr成员函数被隐式的认为具备const属性。 其次,它们具有void返回类型,而void在C++11中不属于literal type。这两个限制都在C++14中得到了解除,所以在C++14中,Point的setter也可被声明为constexpr:

1
2
3
4
5
6
7
class Point {
public:
…
constexpr void setX(double newX) noexcept { x = newX; }
constexpr void setY(double newY) noexcept { y = newY; }
…
};

因此下述函数成为了可能:
1
2
3
4
5
6
7
//return reflection of p with respect to the origin (C++14)
constexpr Point reflection(const Point& p) noexcept{
Point result; // create non-const Point
result.setX(-p.xValue()); // set its x and y values
result.setY(-p.yValue());
return result; // return copy of it
}

客户端程序看起来可能如下所示:
1
2
3
4
constexpr Point p1(9.4, 27.7); // as above
constexpr Point p2(28.8, 5.3);
constexpr auto mid = midpoint(p1, p2);
constexpr auto reflectedMid = reflection(mid); // known during compilation


constexpr的意义

 
constexpr对象和constexpr函数的使用范围均大于non-constexpr对象与函数,通过尽可能使用constexpr,对象与函数的使用范围得到了扩大。

constexpr类似于const、noexcept,是对象和函数接口的一部分,如果一个对象或函数被声明为constexpr,则意味着它可以在任何需要常量表达式的环境中使用。如果你将某个对象或函数声明为constexpr后又后会做出了这个决定,并且将constexpr撤销,那么可能会导致无数的客户端程序发生了雪崩。我们这里所说的尽可能,指的是在你愿意付出精力长期维系它们为constexpr的情况下。


总结

  1. constexpr对象具有const属性,并且其值在编译期即可确定。
  2. constexpr函数在其调用对象为compile-time value时会产生compile-time value。
  3. constexpr对象与函数的使用范围大于non-constexpr。
  4. constexpr是对象或函数接口的一部分。

14.若函数不会发生异常,则将其声明为noexcept

Posted on 2018-07-01 | In Effective Modern C++

前言

 
在C++98中,异常规格是一个很麻烦的事情。你必须时刻关注函数可能抛出的异常,一旦函数的实现发生改变,其异常规格也需要发生变化。轻易更改异常规格可能会破坏客户端代码,因为调用者可能依赖于原始的异常规格。编译器并不能帮助你维持函数实现、异常规格、客户端代码之间的一致性。大多数程序员都认为C++98的异常规格实在是太不友善了。

在长期的开发过程中人们逐渐产生了一项共识:函数的异常规格只需要告诉人们该函数是否可能会抛出一个异常即可。C++11以这种非黑即白式的异常规格取代了C++98中繁琐且易于出错的规格(不过它们现在仍然有效)。在C++11中,我们使用noexcept表征某个函数不会抛出任何异常。


noexcept声明

 
函数是否应当被声明为noexcept是一个接口设计问题。对于使用者来说,函数是否可能会抛出异常是一个关键问题。调用者理当了解被调用函数的异常状态,并且该状态会影响调用程序的异常安全性及效率。从这个角度而言,noexcept声明重要性不亚于const,如果一个不可能抛出异常的函数不声明为noexcept,那这个接口规范真是太失败了。

不过声明noexcept还有额外的目的:促使编译器生成更加恰当的目标码。假设当前存在一个函数f承诺不会抛出任何异常,那么它们在C++98与C++11中分别表示为:

1
2
int f(int x) throw(); // no exceptions from f: C++98 style
int f(int x) noexcept; // no exceptions from f: C++11 style

如果在运行期间f抛出了异常,违背了其异常规格,C++98异常规格会展开调用堆栈给f的调用者,并且在执行完一些与当前无关的操作后终止程序。C++11异常规格则保证堆栈仅可能展开于程序结束运行之前。

展开调用堆栈与可能展开调用堆栈对于代码生成有着极大影响,在noexcept函数中,如果异常传播到别处,那么优化器不需要保持运行期堆栈处于不可展开状态,也不必确保noexcept函数中的对象按照构造的相反顺序销毁。带有throw()或完全不具备异常规格的函数则不具备这种优化弹性:

1
2
3
RetType function(params) noexcept; // most optimizable
RetType function(params) throw(); // less optimizable
RetType function(params); // less optimizable


移动语义、性能优化与noexcept

 

在某些情况下,使用noexcept十分恰当,移动操作就是一个典型的例子。假设你有一个使用vector<Widget>的C++98代码库,widget通过push_back不断加入该vector::

1
2
3
4
5
6
std::vector<Widget> vw;
…
Widget w;
… // work with w
vw.push_back(w); // add w to vw
…

你希望利用C++11中的移动语义来提升程序性能,因此,你通过某种方式,例如自己撰写or自动生成(见Item17)来确保widget拥有移动操作。

当一个新元素被添加到一个std::vector时,std::vector可能没有空间(及size==cap)。当发生这种情况时,std ::vector会分配一个新的,更大的内存块来保存它的元素,并将内存中的元素从旧内存块传送到新的内存块。C++98将每个元素从旧内存复制到新内存,然后销毁旧内存中的对象。这种方法使push_back能够提供strong异常安全保证:如果在复制元素期间抛出异常,则std::vector的状态保持不变。因为在将所有元素成功复制到新内存之前,旧内存中的元素都不会被销毁。

C++11中存在一种针对上述情况的优化方法:以移动替换拷贝。不过这种优化策略破坏了push_back的异常安全保证,例如当前已完成n个元素的移动,但第n+1个元素在移动时抛出异常,此时push_back不得不被迫中止。在此情况下,原本的vector已经发生更改,并且将已经移动的元素再次移动回来并不现实,因为在此移动过程中可能会再次触发异常。因此,除非我们明确地了解移动操作不会抛出异常,否则C++11不会主动以移动代替拷贝。

std::vector::push_back采用的策略是”move if you can,but copy if you must”,并且它不是标准库中唯一采取此策略的函数,std::vector::reverse、std::deque::insert也是一样。当移动操作明确不会抛出异常时,这些函数会以移动操作代替C++98中性能低下的复制操作。那么这些函数如何了解移动操作是否可能会抛出异常呢?答案十分显然:通过检查移动操作是否具备noexcept声明。


swap与noexcept

 
swap是许多STL算法实现的关键组件,并且经常应用于对象的拷贝赋值运算符之中(见Effective C++ Item11)。由于它的广泛使用,因此对其使用noexcept是一件性价比极高的事情。有趣的是,标准库中swap的noexcept有时取决于用户自定义的swap是否noexcept。举例而言,标准库对于数组以及std::pair的swap函数有声明如下:

1
2
3
4
5
6
7
8
9
10
11
template <class T, size_t N>
void swap(T (&a)[N], 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)));
…
};

可以看出,这些函数是否noexcept取决于noexcept子句中的表达式是否为noexcept。例如,假设当前存在两个vector<Widget>,swap这两个数组是否noexpt取决于swap其中单个元素是否这一行为(即swap Widget)是否noexcept。类似地,交换两个含有Widget的std::pair是否为noexcept亦取决于交换Widget是否noexcept。一言以蔽,交换高层数据数据这一行为是否noexcept取决于交换底层数据是否noexcept。

noexcept承诺

在讲优化之前,我们需要明确的是:优化固然重要,但程序的正确性更加重要,在前文中我们曾提及noexcept是接口的一部分,仅在你愿意付出长期维持某函数noexcept的代价时你才应当声明此函数为noexcept。如果你声明了一个函数noexcept然后发现noexcept无法维系,你的选择和下场十分惨烈:或冒着破坏客户端代码的风险从函数的声明中删除noexcept(即更改接口);或更改实现以使异常可以转义,从而保留原始但不正确的异常规格。

从实际情况而言,大部分函数都不会抛出异常,但其调用的子程序则未必尽然。当这种情况发生时,这些函数允许被抛出的异常越过自己直接到达调用链中的异常处理程序。这种不会自己抛出异常的函数不会被声明为noexcept,因为它们会抛出这种“just passing through”的异常。

但也存在一些函数,其本质而言不可能抛出异常,又或者其一旦不抛出异常会带来显著的性能提升,因此将其实现为noexcept性价比很高。如果你可以保证一个函数永不抛出异常,那你应当放心大胆地将其声明为noexcept。值得强调的是,我们这里所说的本质上不可能抛出异常,而非你通过种种手段强行吞下或处理了所有异常。

对于某些函数而言,其默认状态即为noexcept。在C++98中,允许内存释放函数(诸如operator delete、operator delete[])或析构函数抛出异常被视为非常恶劣的编码风格,而在C++11中,这几乎成为了一种语言规则:在默认情况下,所有的内存释放函数与析构函数均被隐式声明为noexcept(也就是说没必要去特意强调它们是noexcept,尽管没有任何坏处)。仅有在类的数据成员明确声明自身析构函数为noexcept(false)时类的析构函数才会不再隐式声明为noexcept。


wide contracts and narrow contracts

 
值得注意的是,某些库接口设计者将函数区分为”wide contracts”与”narrow contracts”。”wide contracts”函数没有任何先决条件,无论当前程序运行状态如何它都能够正常运行,且对传给它的参数没有任何限制,在任何情况下”wide contracts”均不会导致未定义行为。不具备”wide contracts”的函数被称为”narrow contracts”函数,对于此类函数,如果违反了其先决条件,则将触发未定义行为

如果你正在编写一个”wide contracts”函数且明确其不会抛出异常,那么自然可以轻易遵循本节原则将其声明为noexcept。对于”narrow contracts”则相对麻烦,举例而言,假设当前你正在编写一个函数f,其参数为std::string,理论上其不会抛出异常,这意味着f应当被声明为noexcept。现在我们假设f存在一个先决条件:string对象的长度不得超过32个字符,一旦超过则触发未定义行为。f并没有义务检查其先决条件是否满足,因为那是调用者该做的事。即使当前函数存在一个先决条件,将f声明为noexcept似乎也没毛病:

1
void f(const std::string& s) noexcept; // precondition: s.length() <= 32

但假设当前f的实现者决定检查其先决条件是否违背,那么如何在发现先决条件不成立的情况下向测试程序或客户端处理发出报告?一种直接方法是抛出异常,但我们已经声明该函数为noexcept了,抛出异常只会导致程序直接终止。出于这个理由,库设计者通常仅仅保留”wide contracts”函数的noexcept属性。


函数实现与异常规格

 

编译器通常无法识别函数实现与其异常规格之间的不一致。考虑这一段完全合法的代码:

1
2
3
4
5
6
7
void setup(); // functions defined elsewhere
void cleanup();
void doWork() noexcept{
setup(); // set up work to be done
… // do the actual work
cleanup(); // perform cleanup actions
}

显然,noexcept函数doWork调用了non-except setup、cleanup。看起来很矛盾,但这并不意味着程序必然存在问题。例如,setup与cleanup在其文档中表示不会抛出异常,只不过它们没有用noexcept声明。又或者它们是用C语言编写的程序库的一部分(即使是已经移入std命名空间的C标准库中函数也没有声明异常规格,例如,std::strlen未声明为noexcept)。又或者它们是C++98库的一部分,刚刚决定不再使用C++98中的异常规格,并且尚未来得及针对C++11作出修订。

由于上述种种原因,C++允许noexcept函数调用non-noexcept函数,并且编译器不会对此发出任何警告。


总结

  1. noexcept是函数接口的一部分。
  2. noexcept函数比non-noexcept函数具备更高的优化弹性。
  3. noexcept对移动操作,交换,内存释放函数及析构函数性价比极高。
  4. 大部分函数都是excepttion—neutral而非noexcept。

13.以const_iterator代替iterator

Posted on 2018-07-01 | In Effective Modern C++

前言

 
(建议将本节与Effective STL Item26、27结合阅读)
理论上来说,当你不需要改变迭代器所指对象时,你应当尽量使用const_iterator,这条规则对于C++98与C++11都适用。但实际上在C++98中,使用const_iterator会面临处处受限的窘境。


问题实例

 
举例而言,假若你希望在一个vector<int>中搜索1983第一次出现的位置,并在其位置插入一个1998,如果当前vector内不存在1983,则在vector尾端插入。使用C++98中的iterator可以轻松完成上述操作:

1
2
3
4
std::vector<int> values;
…
std::vector<int>::iterator it = std::find(values.begin(),values.end(), 1983);
values.insert(it, 1998);

由于我们并没有改变iterator指向的元素,理论上我们应当将其改为const_iterator。话虽如此,但在C++98中却并不简单,下面展示了一个似是而非的修正版本:
1
2
3
4
5
6
7
8
typedef std::vector<int>::iterator IterT; 
typedef std::vector<int>::const_iterator ConstIterT;
std::vector<int> values;
…
ConstIterT ci = std::find(static_cast<ConstIterT>(values.begin()),
static_cast<ConstIterT>(values.end()),
1983);
values.insert(static_cast<IterT>(ci), 1998); // may not compile

find函数中使用了强制转换,原因在于:在C++98中,从一个non-const容器中获取const_iterator并非易事。当然了,也不一定非要使用强制转换,你可以定义一个常量引用指向values,然后在源代码中以该常量引用替换values。但不管怎么说,从获取一个指向non-const容器内部元素的const_iterator总是很麻烦。

即使你真的获取到了const_iterator,事情也未必会发展地如你所愿。在C++98中,插入和删除函数参数必须为iterator类型,这也就是上述代码最后千方百计将const_iterator转换回来的原因。不过,在上述代码的注释中标明强制转换可能无法编译(正确的转换方法与为何会无法编译可见Effective STL Item26、27)。


C++11中的const_iterator

 
C++11中一切都发生了改变,通过cbegin与cend成员函数,non-const容器也可以轻松获取const_iterator。此外,以往STL中需要以iterator标明位置的成员函数(例如insert、erase等等)现如今也可以用const_iterator作为参数,现在我们用C++11再次完成刚才所说的功能:

1
2
3
4
std::vector<int> values; // as before
…
auto it = std::find(values.cbegin(),values.cend(), 1983);
values.insert(it, 1998);


C++14中的const_iterator

 
尽管C++11中的const_iterator已经大幅度提高了我们的生产力,但其对于开发通用库程序时还是略显不足。这些程序可能会接收一个容器,返回其数据结构起始和结束的位置。我们认为真正的泛型应当以非成员函数的形式出现,而非成员函数。

假设我们需要泛型之前的findAndInsert程序:

1
2
3
4
5
6
7
template<typename C, typename V>
void findAndInsert(C& container, const V& targetVal,const V& insertVal){
using std::cbegin;
using std::cend;
auto it = std::find(cbegin(container),cend(container),targetVal);
container.insert(it, insertVal);
}

遗憾的是上述程序只能在C++14中生效,原因在于C++11只添加了begin、end这两种非成员函数泛型版本,C++14才完成了cbegin、cend的加入。

但我们也可以稍加变动,在C++11中实现自己的cbegin:

1
2
3
4
template <class C>
auto cbegin(const C& container)->decltype(std::begin(container)){
return std::begin(container); // see explanation below
}

其工作原理为:如果实参为non-const,那么container将会成为一个const C&,并且对container调用begin将得到一个const_iterator。这种实现的一大优点在于对于那些带有begin成员函数却不带有cbegin成员函数的容器,你甚至可以直接在函数内部调用begin转换之。

如果C是一个内置数组,那么这个模板函数也能够正确地运行,因为container被推衍为一个const数组的引用,C++11的非成员函数begin会返回一个指向数组首元素的指针,又由于数组现为const,因此指针类型为一个指向const的指针,这与const_iterator是等价的。


总结

  1. 以const_iterator代替iterator。
  2. begin,end之类函数的非成员函数版本比其成员函数版本更加适合于泛型程序。

12.使用override来修饰需要覆写的虚函数

Posted on 2018-06-30 | In Effective Modern C++

前言

 
OOP的最重要的特性即为多态,其具体表现为派生类中的虚函数实现override了基类的实现。本节将重点讨论使用override确保你真正完成了override操作。


override约束

 
尽管override与overload长得很像,但实际上根本就不是一回事。下面将通过一个实例展示如何通过一个基类接口调用派生类函数:

1
2
3
4
5
6
7
8
9
10
11
12
class Base {
public:
virtual void doWork(); // base class virtual function
…
};
class Derived: public Base {
public:
virtual void doWork(); // overrides Base::doWork virtual" is optional here
…
};
std::unique_ptr<Base> upb = std::make_unique<Derived>();
upb->doWork(); // call doWork through base class ptr derived class function is invoked

发生需要满足以下要求:

  1. 基类函数必须为虚函数。
  2. 基类函数与派生类函数名称必须相同(析构函数除外)。
  3. 基类函数与派生类函数形参类型必须相同。
  4. 基类函数与派生类函数必须具备同样的constness。
  5. 基类函数与派生类函数返回值类型与异常规格必须匹配(至少可以转换?)

在这些基础之上,C++11又增加了一个新的要求:

  1. 基类函数与派生类函数的reference qualifier(引用限定符)必须相同。
    成员函数reference qualifier是C++11不太为人所熟知的特性之一,其功能为将成员函数的使用限定为左值或右值,它们的存在与virtual不具备关联性,以下为实例展示:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class Widget {
    public:
    …
    void doWork() &; // this version of doWork applies only when *this is an lvalue
    void doWork() &&; // this version of doWork applies only when *this is an rvalue
    };
    …
    Widget makeWidget(); // factory function (returns rvalue)
    Widget w; // normal object (an lvalue)
    …
    w.doWork(); // calls Widget::doWork for lvalues (i.e., Widget::doWork &)
    makeWidget().doWork(); // calls Widget::doWork for rvalues (i.e., Widget::doWork &&)
    有关reference qualifier的介绍以后再说,这里需要强调的是,如果派生类需要override基类函数,那么它们需要具备同样的refernce qualifier,否则基类版本将依旧出现在派生类之中并且不会被override。

override声明

 
上述约束表明即使一个很小的失误也可能会导致override无法成功,并且编译器将不会对没有发生override的情况进行通知。例如下个实例中并没有发生override,你能够定位出是哪些地方出了问题吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base {
public:
virtual void mf1() const;
virtual void mf2(int x);
virtual void mf3() &;
void mf4() const;
};
class Derived: public Base {
public:
virtual void mf1();
virtual void mf2(unsigned int x);
virtual void mf3() &&;
void mf4() const;
};

答案分别为:

  1. mf1在基类中为const
  2. mf2在基类中形参为int
  3. mf3在基类中被限定为左值
  4. mf4在基类中没有被声明为virtual

鉴于派生类中声明的virtual函数很容易无法覆写基类中对应的函数,并且编译器并不会对未发生override的行为发出警告,因此C++11提供了一种显式的声明方式表示当前函数需要override基类中对应的函数,其使用方式如下:

1
2
3
4
5
6
7
class Derived: public Base {
public:
virtual void mf1() override;
virtual void mf2(unsigned int x) override;
virtual void mf3() && override;
virtual void mf4() const override;
};

上述代码将无法通过编译,因为编译器会认真地检查其是否如实override了基类版本,这正是我们想要的。真正能够通过编译的代码有形式如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base {
public:
virtual void mf1() const;
virtual void mf2(int x);
virtual void mf3() &;
virtual void mf4() const;
};
class Derived: public Base {
public:
virtual void mf1() const override;
virtual void mf2(int x) override;
virtual void mf3() & override;
void mf4() const override; // adding "virtual" is OK,
};


override优点

 
使用override不仅仅能够让编译器告诉你某函数是否发生了override,还能够在你试图更改基类虚函数签名时观察到对派生类的影响。只需要在更改签名后查看有多少派生类函数编译失败,即可权衡更改签名是否值得。如果没有override声明,那你则不得不需要一个全面的单元测试以判断所有分支。

C++11引入的两个新关键词final与override均为contextual keywords,即只有在特定环境下才有其存在意义。以override来说,它仅出现于成员函数声明式末尾。这也就是说历史遗留代码中的名称override依然合法,不会触发标识符不合法的行为:

1
2
3
4
5
class Warning { // potential legacy class from C++98
public:
…
void override(); // legal in both C++98 and C++11 with the same meaning
};


reference qualifier

 
如果你希望某个函数只接受左值或右值参数,那你完全可以这样声明:

1
2
void doSomething(Widget& w); // accepts only lvalue Widgets
void doSomething(Widget&& w); // accepts only rvalue Widgets

reference qualifier的作用与之类似,其形参可以看作为*this,它通过判断*this的左右属性来进行决断,从某种意义而言,非常类似于const成员函数。

举例而言,我们的Widget类有一个std::vector数据成员,并且我们给用户提供了一个接口以确保用户可以直接访问它:

1
2
3
4
5
6
7
8
class Widget {
public:
using DataType = std::vector<double>; // see in Item9
DataType& data() { return values; }
…
private:
DataType values;
};

显然这个东西根本不能体现出一丝一毫的封装性,但我们先不管他,看看用户可能进行的操作:
1
2
3
Widget w;
…
auto vals1 = w.data(); // copy w.values into vals1

由于w.data()返回一个左值引用,因此vals的类型被推衍为vector,利用values初始化之。
假设我们当前有一个工厂函数:
1
Widget makeWidget();

并且我们希望利用工厂函数生成的无名对象(临时对象)来初始化一个vector:
1
auto vals2 = makeWidget().data(); // copy values inside the Widget into vals2

和上一次不同的是,本次设计不应当执行copy操作,因为从临时对象中获取数据使用move更好,但由于data返回一个左值引用,所以拷贝依然会发生。显然这里存在可以优化之处,但试图让编译器给你优化是不切实际的。因此我们需要使用reference qualifier来限定当widget为右值时data()返回值也应当是一个右值:
1
2
3
4
5
6
7
8
9
10
class Widget {
public:
using DataType = std::vector<double>;
…
DataType& data() & { return values; } // for lvalue Widgets,return lvalue
DataType data() && { return std::move(values); } // for rvalue Widgets,return rvalue
…
private:
DataType values;
};

一切大功告成。


总结

 

  1. 将需要覆写的函数声明为override。
  2. reference qualifier可以针对对象的左右值属性实现重载。

11.以deleted函数取代private undefined函数

Posted on 2018-06-29 | In Effective Modern C++

前言

 
在C++98中我们倾向于使用privtae undefined来禁止用户使用某个函数(例如copy constructor或者operator =),C++11引入了新的解决方案。


问题实例

 
在C++98中,为了保证istream与ostream无法被拷贝,basic_ios有定义如下:

1
2
3
4
5
6
7
8
template <class charT, class traits = char_traits<charT> >
class basic_ios : public ios_base {
public:
…
private:
basic_ios(const basic_ios& ); // not defined
basic_ios& operator=(const basic_ios&); // not defined
};

这么做的原因可见Effective C++ Item6。

在C++11中,我们可以使用=delete来更好地完成这一任务:

1
2
3
4
5
6
7
8
template <class charT, class traits = char_traits<charT> >
class basic_ios : public ios_base {
public:
…
basic_ios(const basic_ios& ) = delete;
basic_ios& operator=(const basic_ios&) = delete;
…
};

C++11中的deleted function将问题发现的时间提前到了编译期,即任何试图调用函数函数的操作都将引发编译期错误,但private undefined function则可能由于友元调用等行为在连接期才会发现函数未定义从而报错(解决方法是使用mixin class uncopyable将报错时机提前至编译期)。


delete function的访问类型

 
我们倾向于将delete function设置为public,因为发生函数调用时编译首先会检查其访问类型,然后再判断其delete status。如果用户试图调用一个deleted private function,某些编译器可能会本末倒置地提示该函数不可访问,而非该函数不可使用。在修改历史遗留代码时应当考虑这一点,这样才能保证编译器的错误提示正确与直观。


delete function的优势

任何函数均可delete

delete function的一大优点在于任何函数均可设为delete function,而只有成员函数才能被设为private。举例而言,假若我们有一个非成员函数,其接受一个int且返回一个bool:

1
bool isLucky(int number);

C++继承自C的特性使得可能某些对象可能会被隐性地转为int从而触发该函数:
1
2
3
if (isLucky('a')) … // is 'a' a lucky number?
if (isLucky(true)) … // is "true"?
if (isLucky(3.5)) … // should we truncate to 3 before checking for luckiness?

如果我们需要isLucky仅仅针对int有效,那我们可以利用重载与delete function将其余函数定义为不可使用:

1
2
3
4
bool isLucky(int number); // original function
bool isLucky(char) = delete; // reject chars
bool isLucky(bool) = delete; // reject bools
bool isLucky(double) = delete; // reject doubles and floats

需要注意的是最后一个重载可以同时阻止double与float发生隐式转换,因为相较于int,C++编译器更倾向于将float隐式转换为double。


禁止模板实例化

delete function相较于private undefined function较优的另一点在于其可以禁止模板实例化。举例而言,假设当前有一个形参为内置指针的模板函数(尽管本书第4章建议使用智能指针):

1
2
template<typename T>
void processPointer(T* ptr);

众所周知,指针类型有两种特殊情况:void* 与 char*。前者无法被解引用,也无法执行算数操作。后者则通常作为字符串的象征。我们认为processPointer函数模板需要对这两种指针进行特殊处理,即不针对这两种类型实例化函数,通过delete我们可以轻松实现所述功能:
1
2
3
4
template<>
void processPointer<void>(void*) = delete;
template<>
void processPointer<char>(char*) = delete;

现在并没有万事大吉,因为如果void*与char*无法实例化模板,那么const void*与const char*理当也无法实例化模板才对,因此我们不辞劳苦地再写一次:
1
2
3
4
template<>
void processPointer<const void>(const void*) = delete;
template<>
void processPointer<const char>(const char*) = delete;

如果想要斩草除根的话,别忘了把const volatile void*、const volatile char*、std::wchar_t, std::char16_t,and std::char32_t之类的东西都定义为无效。

在C++98当中,如果某个类内含一个函数模板,那我们无法采用private undefined的形式对其进行禁止实例化。原因很简单,不可能针对某个特例化改变整个函数模板的访问级别,如下行为将无法通过编译:

1
2
3
4
5
6
7
8
9
10
class Widget {
public:
…
template<typename T>
void processPointer(T* ptr)
{ … }
private:
template<> // error!
void processPointer<void>(void*);
};

模板特例化理当出现在某个命名空间内,而非处于类范围内。使用delete function则无此烦恼,因为它们不需要不同的访问级别,并且可以处于类外被deleted:
1
2
3
4
5
6
7
8
9
10
class Widget {
public:
…
template<typename T>
void processPointer(T* ptr)
{ … }
…
};
template<> // still
void Widget::processPointer<void>(void*) = delete; // public,but deleted

我们可以认为private undefined function是deleted function尚未出现时C++98的权宜之计,我们应当尽可能使用deleted function将其替换掉。


总结

  1. 尽可能以deleted function取代private undefined function。
  2. 任何function均可被delete,其中包括非成员函数与模板实例化。

10.以scoped enums代替unscoped enums

Posted on 2018-06-28 | In Effective Modern C++

unscoped enum与命名空间污染

 
通常,在大括号内声明某名称会导致该名称可见性仅限于大括号范围之内,但对于C++98中的枚举类型来说并非如此。这些枚举变量名的作用域等价于枚举所存在的作用域,因此直接导致别的变量再也无法使用这些名字:

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

这种污染命名空间的行为被定义为:unscoped。C++11对此提出了解决方案scoped enums:
1
2
3
4
5
enum class Color { black, white, red }; // black, white, red are scoped to Color
auto white = false; // fine, no other
Color c = white; // error! no enumerator named "white" is in this scope
Color c = Color::white; // fine
auto c = Color::white; // also fine (and in accord with Item 5's advice)

这种技法亦被称为enum classes。


scoped的强类型保证

 
scoped enum除了不会污染命名空间之外还具备一大优势:其成员均为强类型枚举量。unsoped enum的成员总是可以被隐式转换为int型(并可以藉由此转为浮点型),例如下文的转换是完全有效的:

1
2
3
4
5
6
7
8
enum Color { black, white, red }; // unscoped enum
std::vector<std::size_t> primeFactors(std::size_t x); // returning prime factors of x
Color c = red;
…
if (c < 14.5) { // compare Color to double (!)
auto factors = primeFactors(c);//compute prime factors of a color
…
}

如果将其转变为scoped enum,上述问题都将不复存在:
1
2
3
4
5
6
7
enum class Color { black, white, red }; // enum is now scoped
Color c = Color::red; // as before, but with scope qualifier
if (c < 14.5) { // error! can't compare Color and double
auto factors = // error! can't pass Color to
primeFactors(c); // function expecting std::size_t
…
}

如果你非要进行转换也不是不可以,只要你使用static_cast:
1
2
3
4
if (static_cast<double>(c) < 14.5) { // odd code, but it's valid
auto factors = primeFactors(static_cast<std::size_t>(c)); // // suspect, but it compiles
…
}


forward—declartion

 
scoped enum似乎还有一大优点在于可以完成forward—declartion,即可以在不具备定义的情况下直接声明枚举类型:

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

但事实并非如此。在C++11中,unscoped enum亦可完成forward—declartion,但是需要一点点额外的工作。由于在C++语言中每一个枚举量都具备一个由编译器决定的整形底层类型,对于下述这个枚举量:
1
enum Color { black, white, red };

编译器可能会选择char作为其底层类型,因为只有三个值可以表示。但也有可能会出现枚举值范围很大的情况:
1
enum Status { good = 0,failed = 1,incomplete = 100,corrupt = 200,indeterminate = 0xFFFFFFFF};

此时编译器将会选择一个大于char的类型作为底层类型。

为了保证高效使用内存,编译器通常会选择最合适(最小)的数据类型作为底层类型。但在某些情况下,编译器会倾向于在保证速度的前提下使用较小内存。为了保证这种实现的可行性,C++98仅支持枚举定义而不支持枚举声明,这一特性使得编译器可以在使用枚举之前为每个枚举选择底层类型。

无法forward—declartion存在一定的缺陷,其中最显著的缺陷即在于编译依存性被显著提升。仍以Status enum为例:

1
enum Status { good = 0,failed = 1,incomplete = 100,corrupt = 200,indeterminate = 0xFFFFFFFF};

如果该枚举被定义于某个头文件中并且被#include多次,那么如果我们想要向其中增加一个新的状态,整个系统都需要被重新编译。C++11修复了这个问题,例如以下则是一个枚举声明以及将枚举类型作为参数的函数:
1
2
enum class Status; // forward declaration
void continueProcessing(Status s); // use of fwd-declared enum

如果Status的定义发生了改变,那么包含这个声明的头文件无需重新编译。此外,只要continueProcessing保持行为一致,continueProcessing的实现亦不需要重新编译。


手动指定enum底层类型

 
如果编译器需要在使用enum前了解其大小,C++11是如何做到完成forward—declartion的?答案十分简单:scoped enum的底层类型总是已知的,而unscoped enum则可以手动指定。

在默认情况下,scoped enum的底层类型是int:

1
enum class Status; // underlying type is int

当然你也可以手动地指定其类型:
1
enum class Status: std::uint32_t; // underlying type for is std::uint32_t(from <cstdint>)

无论如何,编译器总是在声明时即明确了scoped enum的底层类型。

unscoped enum也可以这样操作:

1
enum Color: std::uint8_t; // fwd decl for unscoped enum;

甚至我们还可以在定义时指定其底层类型:
1
2
3
4
5
6
7
enum class Status: std::uint32_t { good = 0,
failed = 1,
incomplete = 100,
corrupt = 200,
audited = 500,
indeterminate = 0xFFFFFFFF
};


unscoped enum的可取之处

 
scoped enum可以避免污染命名空间,并且不易受到无意义隐式类型转换的影响,但它并非毫无可取之处:在C++11的std::tuple中起到了一定的作用。举例而言,我们现有一个tuple,它持有社交网站用户的姓名、电子邮件地址与信誉值:

1
using UserInfo = std::tuple<std::string,std::string,std::size_t>;// type alias;

作为一名开发者,我们需要关注很多东西,但这其中可能并不包括记住tuple的字段1对应着用户email。使用unscoped enum可以将名称与字段号关联,从此不必费心记忆:
1
2
3
4
enum UserInfoFields { uiName, uiEmail, uiReputation };
UserInfo uInfo; // as before
…
auto val = std::get<uiEmail>(uInfo); // ah, get value of email field

通过隐式类型转换,我们将enum变为了std::get所需要的类型。如果你坚持使用scoped enum,那么效果可能会非常惨烈:
1
2
3
4
enum class UserInfoFields { uiName, uiEmail, uiReputation };
UserInfo uInfo; // as before
…
auto val = std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>(uInfo);

当然,我们也可以编写一个形参为eunm且返回值为std::size_t的函数来降低语句复杂程度,但这稍微有点麻烦。std::get是一个模板,因此你所需要提供的值是一个模板参数(必须在编译期确定),那也就是说,该函数必须是一个constexpr函数。
实际上光是constexpr函数还不够,这个转换必须得是一个constexpr函数模板,因为它必须能够支持所有类型的enum,同样地,我们也需要对返回值进行泛型化处理。在泛型设计中我们将不再返回std::size_t,而是返回enum的底层类型(该操作通过std::underlying_type实现)。最后,我们将其声明为noexcept,因为它永远不会抛出异常,最终转换函数实现如下所示:
1
2
3
4
5
template<typename E>
constexpr typename std::underlying_type<E>::type
toUType(E enumerator) noexcept{
return static_cast<typename std::underlying_type<E>::type>(enumerator);
}

在C++14中,toUType可以简化为:
1
2
3
4
5
template<typename E> // C++14
constexpr std::underlying_type_t<E>
toUType(E enumerator) noexcept{
return static_cast<std::underlying_type_t<E>>(enumerator);
}

当然我们也可以通过使用auto来更进一步地简化它:
1
2
3
4
template<typename E> // C++14
constexpr auto toUType(E enumerator) noexcept{
return static_cast<std::underlying_type_t<E>>(enumerator);
}

在具备了toUType后,tuple的使用如下所示:
1
auto val = std::get<toUType(UserInfoFields::uiEmail)>(uInfo);

看起来还是比使用unscoped enum麻烦多了,但至少它避免了命名空间污染与无意义的转换,这二者的破坏力都十分巨大,因此最好还是心甘情愿地多写几个字符吧。


总结

 

  1. C++98中的enum被称为unscoped enum。
  2. scoped enum不会污染命名空间,仅在cast下才能完成转换。
  3. scoped enum 与 unscoped enum都支持底层类型指定,scoped enum默认为int类型。
  4. scoped enum支持forward declaration,unscoped enum需要指定其底层类型方可。

9.使用alias declarations而非typedefs

Posted on 2018-06-27 | In Effective Modern C++

前言

 
想必没有人愿意反复写下诸如std::unique_ptr<std::unordered_map<std::string, std::string> >的类型名,本节将介绍如何在C++11中避免这种繁琐无聊的工作。


typedef与alias declarations

 
C++98中的typedef可以有效帮助我们解决问题:

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

C++11提供的alias declarations也能发挥同样的作用:
1
2
using UPtrMapSS =
std::unique_ptr<std::unordered_map<std::string, std::string> >;

相对于前者,后者有一个压倒性的技术优势。


alias declarations优势

 
在讲述真正的优势之前,首先提一下很多人认为在涉及函数指针类型时alias declarations更容易接受:

1
2
3
4
// FP is a synonym for a pointer to a function 
// taking an int and a const std::string& and returning nothing
typedef void (*FP)(int, const std::string&); // typedef
using FP = void (*)(int, const std::string&); // alias declaration

但事实上,在函数指针类型方面二者并无明显优劣,而且很少有人花费很多时间处理函数指针类型,因此这并不是一个令人信服的理由来认为alias declarations强于typedef。

模板化

alias declarations真正强于typedef的一点在于,前者可以被模板化(在这种情况下我们把它称为alias template),因此使用alias declarations则不必像传统的C++98那样将typedef嵌入模板中。举例而言,假若我们需要为一个具备自定义allocator的list定义别名,使用alias declaration可以写为:

1
2
3
template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>; // is synonym for std::list<T,MyAlloc<T> >
MyAllocList<Widget> lw;

为了达到同样的效果,typedef则必须写为:
1
2
3
4
5
template<typename T>
struct MyAllocList {
typedef std::list<T, MyAlloc<T>> type;
};
MyAllocList<Widget>::type lw;

如果需要在模板中使用类型别名,那么typedef定义的别名还需要使用typename进行修饰:
1
2
3
4
5
6
template<typename T>
class Widget {
private:
typename MyAllocList<T>::type list; // as a data member
…
};

如果我们将MyAllocList定义为一个alias template,则不必需要各种修饰(诸如typename以及::type):
1
2
3
4
5
6
7
8
template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>;
template<typename T>
class Widget {
private:
MyAllocList<T> list; // no "typename",
… // no "::type"
};

当编译器在处理Widget模板时遇到MyAllocList<T>,它会直接明确当前是一个类型,因为MyAllocList<T>是一个alias template。但当编译器在Widget模板中看到MyAllocList<T>::type(即使用嵌套的typedef)时,编译器无法确定它是否意味着一个类型(因为可能存在MyAllocList的特化),因此需要加以typename修饰。这并非编译器无理取闹,因为真的可能会有一些人将type作为class的data member:
1
2
3
4
5
6
7
8
class Wine { … };
template<>
class MyAllocList<Wine> {// MyAllocList specialization for when T is Wine
private:
enum class WineType { White, Red, Rose };// see Item10 for "enum class"
WineType type;// type is a data member
…
};


TMP中的alias declarations

如果你使用过TMP(模板元编程)的话,你一定遇到过需要根据模板类型参数来来创建新类型的需求,例如,你可能想要将const std::string&转换为std::string,又或者将Widget转换为const Widget或Widget&(Item23与27讲给出一些TMP实例)。
C++11以type traits的形式提供了执行这些类型转换的必要工具,这一系列模板位于头文件<type_traits>中。该头文件内存在10多个type traits,并且并非所有都提供转换操作(具备转换功能的都提供了一个预见性接口)。如果你需要转换类型,只需要如下操作:

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

这里并非是传授如何在C++中使用type traits完成类型转换,我们应当把目光焦点放到上述代码最后的type之上。由于它们的性质原因,你在实际开发过程中在使用它们之前总需要冠上typename。C++11在设计type trits时将其设计为嵌套式typedef,这并非是因为嵌套式typedef比alias declarations更好用,而是由于历史遗留原因。C++14引入了新的别名模板,形如std::transformation_t,实际使用效果如下:

1
2
3
4
5
6
std::remove_const<T>::type // C++11: const T → T
std::remove_const_t<T> // C++14 equivalent
std::remove_reference<T>::type // C++11: T&/T&& → T
std::remove_reference_t<T> // C++14 equivalent
std::add_lvalue_reference<T>::type // C++11: T → T&
std::add_lvalue_reference_t<T> // C++14 equivalent

即使你的编译器不支持C++14,那使用alias declaration对type traits进行封装也并非难事,例如:
1
2
3
4
5
6
7
template <class T>
using remove_const_t = typename remove_const<T>::type;
template <class T>
using remove_reference_t = typename remove_reference<T>::type;
template <class T>
using add_lvalue_reference_t =
typename add_lvalue_reference<T>::type;


总结

  1. typedef并不支持模板化,但alias declarations支持。
  2. alias declarations不需要type、typename之类的后缀与修饰。
  3. C++14引入了alias templates以取代C++11中的type traits转换。

8.使用nullptr而非0或NULL

Posted on 2018-06-27 | In Effective Modern C++

前言

 
首先需要明确一点:0是一个int,而非指针。C++在一个应该出现指针但只出现了0的环境下会把这个0看作为一个空指针,但这并不代表0与空指针等价。NULL也是一样,虽然它可能是int或者long。总之,0或NULL都不是指针。


0与NULL可能导致的二义性

 
在C++98中,对整数型与指针类型进行重载可能会发生意外(即重载区别仅在一个函数的形参为类似int型,一个形参为pointer)。将0或NULL传递给这样的重载函数永远不会调用其指针重载版本:

1
2
3
4
5
void f(int); // three overloads of f
void f(bool);
void f(void*);
f(0); // calls f(int), not f(void*)
f(NULL); // might not compile, but typically calls f(int). Never calls f(void*)

显然,如果将NULL定义为0L,那么本次函数调用具有二义性,原因在于从long到int、从long到bool以及从0L到void*具备同样的转换优先级。关于该调用,有趣之处在于,你以为你在执行f(the null pointer),实际上它意味着f(some kinds of intergral)。

这种违反直觉的行为直接导致C++98规定开发者应当避免以指针和整型类型作为重载参数。这项原则在C++11中仍然适用,因为尽管目前提倡以nullptr代替0与NULL,但仍有许多开发者孜孜不倦地使用后者。


Nullptr的优点

避免调用二义性

Nullptr的优势在于它不是一个整形,实际上它也不是一个指针类型,不过你可以认为它是一个可以指向任何类型的指针类型。nullptr的实际类型是std::nullptr_t,并且std::nullptr_t被定义为nullptr的类型(一个奇怪的循环)。std::nullptr_t类型可以隐式转换为所有原始指针类型,因此nullptr看起来像是一个可以指向所有类型的指针。
调用f(nullptr)时将直接触发指针重载版本,原因在于nullptr不可能转为任何整形:

1
f(nullptr); // calls f(void*) overload

提高代码清晰度

在涉及到auto声明的变量时,使用nullptr可以提高代码清晰度,有实例如下:

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

如果我们不清楚findRecord的返回类型,我们可能在看代码时可能会困惑result究竟是一个指针还是一个整形,但下述代码则十分明晰:
1
2
3
4
auto result = findRecord( /* arguments */ );
if (result == nullptr) {// result must be a pointer
…
}


template中的nullptr

nullptr在template中能够发挥更大的作用。假设当前有一些函数,它们只有在某些互斥量被锁定时才会被调用,每一个函数的形参都是一个指针类型:

1
2
3
int f1(std::shared_ptr<Widget> spw);    // call these only when
double f2(std::unique_ptr<Widget> upw); // the appropriate
bool f3(Widget* pw); // mutex is locked

现将以空指针作为实参完成函数调用:
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>; // C++11 typedef; see Item 9
…
{
MuxGuard g(f1m); // lock mutex for f1
auto result = f1(0); // pass 0 as null ptr to f1
} // unlock mutex
…
{
MuxGuard g(f2m); // lock mutex for f2
auto result = f2(NULL); // pass NULL as null ptr to f2
}
…
{
MuxGuard g(f3m); // lock mutex for f3
auto result = f3(nullptr); // pass nullptr as null ptr to f3
}

这明显造成了代码重复,因此我们决定对此过程执行模板化操作:
1
2
3
4
5
6
7
8
9
10
11
template<typename FuncType,typename MuxType,typename PtrType>
auto lockAndCall(FuncType func,MuxType& mutex,PtrType ptr) -> decltype(func(ptr)){// C++11
MuxGuard g(mutex);
return func(ptr);
}
or
template<typename FuncType,typename MuxType,typename PtrType>
decltype(auto) lockAndCall(FuncType func, MuxType& mutex,PtrType ptr){// C++14
MuxGuard g(mutex);
return func(ptr);
}

你可能会这样调用模板函数:
1
2
3
auto result1 = lockAndCall(f1, f1m, 0); // error!
auto result2 = lockAndCall(f2, f2m, NULL); // error!
auto result3 = lockAndCall(f3, f3m, nullptr); // fine

当0传入模板函数时,PtrType被推衍为int类型,此时func会发现int无法转为一个指针类型,进而编译失败。使用NULL也是类似的道理,PtrType被推衍为一个整数类型,无法转为func的参数类型。

总之,template类型推衍无法识别出0或NULL所具备的空指针身份,因此我们应当积极使用nullptr代替它们。


总结

  1. 应当尽可能以nullptr取代0与NULL。
  2. 应当避免对整数型与指针类型之间的重载。

7.在创建对象时区分()与{}

Posted on 2018-06-27 | In Effective Modern C++

前言

 
C++11中提供了许多初始化方法,以至于多的有点混乱。一般来说,初始化可以由小括号、等号、大括号完成:

1
2
3
int x(0);
int y = 0;
int z{0};

也可以等号与大括号并用:
1
int z = {0};

在本节接下来的内容中我们将不叙述这种并用的初始化方法,因为C++往往习惯于将其与仅使用大括号的情形等价。


Braced initialization

 

使用等号进行初始化经常会给C++新手带来困扰,因为这看起来像是一个赋值操作,但实际情况并非如此。对于内置类型而言可能没啥关系,但对于用户自定义类型而言,区分初始化与赋值是十分必要的,因为二者调用了完全不同的函数:

1
2
3
Widget w1; // call default constructor
Widget w2 = w1; // not an assignment; calls copy ctor
w1 = w2; // an assignment; calls copy operator=

C++ 98中尽管初始化语法不少,但仍然存在一些无法初始化的情况,例如没法在初始化时指定容器内部的元素值等等。C++11为了解决上述问题,引入了”uniform initialization”的概念:一个初始化表达式应该可以用于任何地方并且可以表达一切。由于该语法基于大括号(brace),作者也将其称为”Braced initialization”,其使用实例如下:
1
std::vector<int> v{ 1, 3, 5 }; // v's initial content is 1, 3, 5

大括号亦可用于指定非静态数据成员的默认初始值,C++11引入的这项新功能与operator=初始化语法共存,但小括号则不行:
1
2
3
4
5
6
7
class Widget {
…
private:
int x{ 0 }; // fine, x's default value is 0
int y = 0; // also fine
int z(0); // error!
};

另一方面,无法被拷贝的对象只能够使用大括号或小括号初始化,无法通过operator=初始化:
1
2
3
std::atomic<int> ai1{0};
std::atomic<int> ai2(0); // fine
std::atomic<int> ai3 = 0; // error!

将”Braced initialization”称为”uniform initialization”是十分自然的,因为只有brace可以在任何C++初始化表达式中正常使用。


Braced initialization特性

禁止narrow conversion

Braced initialization的特性之一在于其禁止在初始化过程中对内置类型作隐式narrow conversion,如果变量不能保证准确表达初始化器内部值,那么该代码将无法完成编译:

1
2
3
double x, y, z;
…
int sum1{ x + y + z }; // error! sum of doubles may not be expressible as int

使用圆括号和operator=进行初始化则不会检查是否触发narrow conversion,因为这可能会对历史遗留代码造成破坏:
1
2
int sum2(x + y + z); // okay (value of expression truncated to an int)
int sum3 = x + y + z; // ditto

避免vexing parse

Braced initialization还有一大特性在于其对C++的vexing parse免疫。C++ vexing parse可以解释为:当你试图使用默认构造函数来构造对象时,却发现自己只是声明了一个函数:

1
Widget w2(); //vexing parse!declares a function named w2 that returns a Widget!

但如果你使用大括号进行初始化操作,则不会被认为是一个函数声明:
1
Widget w3{}; // calls Widget ctor with no args


Braced initialization缺陷

 
Braced initialization并非完美无缺,它的存在也可能会导致一些你意料之外的错误。例如在Item2中我们曾经提及,auto会将经由Braced initialization初始化的变量推断为一个std::initializer_list.如此说来,可能你越喜欢auto,你对Braced initialization的兴趣就越少。

构造函数匹配

在调用构造函数时,只要不涉及std::initializer_list,小括号和大括号几乎没有区别:

1
2
3
4
5
6
7
8
9
10
class Widget {
public:
Widget(int i, bool b); // ctors not declaring
Widget(int i, double d); // std::initializer_list params
…
};
Widget w1(10, true); // calls first ctor
Widget w2{10, true}; // also calls first ctor
Widget w3(10, 5.0); // calls second ctor
Widget w4{10, 5.0}; // also calls second ctor

但如果一个或多个构造函数声明其形参为std::initializer_list类型,则使用Braced initialization初始化对象时会几乎必然会调用形参类型为std::initializer_list的构造函数:
1
2
3
4
5
6
7
8
9
10
11
class Widget {
public:
Widget(int i, bool b); // as before
Widget(int i, double d); // as before
Widget(std::initializer_list<long double> il); // added
…
};
Widget w1(10, true); // uses parens and, as before,calls first ctor
Widget w2{10, true}; // now calls std::initializer_list ctor(10 and true convert to long double)
Widget w3(10, 5.0); // uses parens and, as before,calls second ctor
Widget w4{10, 5.0}; // now calls std::initializer_list ctor(10 and 5.0 convert to long double)

显然,这里发生了严重的匹配错误,不仅如此,甚至拷贝和移动构造函数也可能会被std::initializer_list带偏:
1
2
3
4
5
6
7
8
9
10
11
12
13
class Widget {
public:
Widget(int i, bool b); // as before
Widget(int i, double d); // as before
Widget(std::initializer_list<long double> il); // as before
operator float() const; // convert to float
…
};
Widget w5(w4); // uses parens, calls copy ctor
Widget w6{w4}; // call std::initializer_list ctor
// (w4 converts to float, and float converts to long double)
Widget w7(std::move(w4)); // uses parens, calls move ctor
Widget w8{std::move(w4)}; // calls std::initializer_list ctor (for same reason as w6)

braced initializers与形参为std::initializer_list的ctor具备最高匹配度,当ctor无法被调用时,编译器甚至不会去理会别的构造函数(即使在类型上十分符合):
1
2
3
4
5
6
7
8
class Widget {
public:
Widget(int i, bool b); // as before
Widget(int i, double d); // as before
Widget(std::initializer_list<bool> il); // element type is now bool
…
};
Widget w{10, 5.0}; // error!narrowing conversions are prohibited inside braced initializers

只有在无法将braced initializer内的实参转换为std::initializer_list中的参数时编译器才会去调用其他构造函数(我认为本例是无法转换,而上一个例子说的是大括号内含有禁止narrow conversion语义,二者并不冲突),例如:
1
2
3
4
5
6
7
8
9
10
11
class Widget {
public:
Widget(int i, bool b); // as before
Widget(int i, double d); // as before
Widget(std::initializer_list<std::string> il);
…
};
Widget w1(10, true); // uses parens, still calls first ctor
Widget w2{10, true}; // uses braces, now calls first ctor
Widget w3(10, 5.0); // uses parens, still calls second ctor
Widget w4{10, 5.0}; // uses braces, now calls second ctor

最后,我们讨论一种极端情况:Widget同时具备无参构造函数与带有std::initializer_list的构造函数,当我们写下Widget w{}时,调用的是哪一个呢?答案是调用无参构造函数,原因在于空大括号意味着没有参数,而非一个空的std::initializer_list对象:

1
2
3
4
5
6
7
8
9
class Widget {
public:
Widget(); // default ctor
Widget(std::initializer_list<int> il); // std::initializer_list ctor
…
};
Widget w1; // calls default ctor
Widget w2{}; // also calls default ctor
Widget w3(); // most vexing parse! declares a function!

如果我们需要调用形参类型为std::initializer_list的构造函数,我们应当明确地指出参数为一个空std::initializer_list:

1
2
Widget w4({}); // calls std::initializer_list ctor with empty list
Widget w5{{}}; // ditto


实际问题

作为类的开发者,我们应当明确:如果在类中存在形参为std::initializer_list的构造函数,那么客户在使用Braced initialization时只会看到initializer_list版本,因此,我们应当合理设计构造函数,以便客户不至于产生误解。

如果你曾经的设计中不存在以std::initializer_list为参数的构造函数,当你添加了它之后,原本运行良好的客户端应用程序可能会匹配至新的构造函数然后发生雪崩。虽然别的重载函数更新后也会发生这种情况,但带有std::initializer_list的构造函数破坏性大得多,因为他并非与其他构造函数竞争,而是以一种近乎overshadow的方式抹杀了其余构造函数的存在(当然,如果不能发生转换它还是会乖乖交出构造权)。总之,在类中加入此类重载构造函数需要审慎。

作为类的使用者,我们应当在创建对象时谨慎选择初始化方式。大多数人会从()、{}中选出一种作为惯用方式,并且只有在情非得已时才会去使用另外一种。这两种初始化方式并无优劣之分,因此我们应当选择一种作为自己开发的惯用方式,并在日常使用中不断加深对它的理解。

如果你是一个模板的作者,可能你会发现自己并不确定在创建对象时应当使用哪一种初始化方式。例如,假设我们想从任意数量的参数中创建任意类型的对象:

1
2
3
4
5
template<typename T,typename... Ts>// type of object to create,types of arguments to use
void doSomeWork(Ts&&... params){
create local T object from params...
…
}

有两种方法可将上述伪代码实现(std::forward可见Item25):
1
2
T localObject(std::forward<Ts>(params)...); // using parens
T localObject{std::forward<Ts>(params)...}; // using braces

考虑以下调用程序:
1
2
3
std::vector<int> v;
…
doSomeWork<std::vector<int> >(10, 20);

如果doSomeWork在创建localObject时使用小括号,那么将创建一个包含10个元素的std::vector。如果doSomeWork使用大括号,则结果是一个包含2个元素的std::vector。只有调用者而非开发者才知道哪一个是他想要的(正确的)结果。

上述讨论正是标准库函数std :: make_unique和std :: make_shared所面临的问题(见Item21),这两个函数最终决定在template内部使用小括号,并在接口文档中加以注明。


总结

  1. Braced initialization是使用最为广泛的初始化语法,它可以防止narrow conversion,并且不受vexing parse的影响。
  2. 在构造函数重载解析期间,即使其他构造函数具备更高的匹配度, Braced initialization也不会放过任何一个与std :: initializer_list发生匹配的可能(除非真的无法转换)。
  3. 即使是同样的参数,使用大括号和小括号进行初始化可能结果会天差地别(例如std::vector)。
  4. 在template中创建对象时使用大括号还是小括号会很令人纠结。
<i class="fa fa-angle-left"></i>1…8910…27<i class="fa fa-angle-right"></i>

xander.liu

266 posts
11 categories
36 tags
RSS
GitHub E-Mail
© 2024 xander.liu
Powered by Hexo
|
Theme — NexT.Pisces v5.1.4