《Effective Modern C++》读书笔记

Tags:

Note:为避免各种侵权问题,本文并没有复制原书任意文字(代码除外,作者已经声明代码可以被使用)。需要原书完整中文翻译的读者请等待官方译本的发布。

正文

为了让本文更加清晰,依然还是用条款的形式来介绍知识点。(但不能保证我写的条款就是原书的条款)

条款7:考虑用新的变量初始化语法{}代替旧的()吧

优点:

  • 用{}来初始化变量,可以避免程序员不期望的隐式类型转换(更具体地说应该是narrowing conversions,收缩转换);
  • 用{}替代(),可以避免A a()被编译器解析(parsed)成函数声明的问题;

缺点:

  • 和auto结合得不友好,auto遇到{},auto推导成了std::initializer_list,这不是所期望的;
  • 当类的多个构造函数里,有一个是用std::initializer_list时,要注意其他构造函数不能用{}语法;
  • 当类有类型转换函数时,第二个缺点会变得更严重:复制构造函数可能不会被调用;
  • 当存在std::initializer_list构造函数时,即使构造代码不正确,编译器也不会转而使用其他构造函数来构造(即使其他构造函数更加match),而是报错。(一种例外情况是当{...}里的元素不能被转换成std::initializer_list的T时,编译器才会转而使用其他构造函数);

编写类构造函数的最佳实践

当你要给自定义的类加上std::initializer_list构造函数时,要细心考虑这个类被使用时,用{}和()是否一致,是否会有反直觉的结果。也就是说,为了避免上面所说的缺点,为了不坑自己或你的代码的用户,你需要在编写一个class时保持警惕。如无必要,应尽可能不添加td::initializer_list构造函数。

c++11创造了2个阵营

用()构造亦或用{}构造对象。只使用()的话,是传统派;只使用{}的话,是革新派。革新派追求{}的那2个优点,对{}的缺点保持乐观面对的态度;传统派更重视避免std::initializer_list构造函数带来的问题。选择哪一个阵营,看自己喜好了。

对于库的编写者,并不是立场的问题

编写template function,可能会需要构造局部变量,当局部变量的类型未知时,怎么知道要用{}还是()?万一T是一个革新派写的class,而你又用了{}来构造对象,那么你的template function的执行情况,可能和T的构造函数重载情况大有关系。也即是说,你的template function是不稳定的。究竟在template function里用{}还是(),是一个复杂的问题。

条款8:不用考虑了,就用nullptr代替0和NULL

nullptr的好处在哪,请阅读书中的例子。这个条款十分简单:不要再用0和NULL来表示空指针,而是用c++11的nullptr,只有好处,没有坏处。

条款9:用using代替typedef

同条款8,大部分情况下都可以用using代替typedef。

条款10:具有作用域的enum

写法如下:


enum class Color { black, white, red }; // black, white, red
// are scoped to Color
auto white = false; // fine, no othe
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)

我觉得主要好处是避免名空间污染。

c++11还允许给enum指定underlying type:


enum class Status: std::uint32_t; // underlying type for
// Status is std::uint32_t
// (from <cstdint>)

意思是,Status的每一个元素都是std::uint32_t类型。缺省类型是int。

还有就是,c++11 enum支持前置声明(类似class的前置声明)。

条款11:新功能:在成员函数声明后面加 = delete

这样子写:


class A {
public:
    A(const A& ) = delete;
    A& operator=(const A&) = delete;
};

想比c++98的做法(把函数声明为private,并不定义实现):


class A {
private:
    A(const A& );
    A& operator=(const A&);
};

用 = delete会更好,因为被声明 = delete的函数,编译器保证什么代码都不能调用它们(会编译报错),如果是c++98,有可能是链接时才报错。

注意到 = delete声明的函数,是public的,其实是为了让报错内容更准确。想一下,如果 = delete的函数是private,然后这个函数被外部调用,编译器可能只是给出"不能调用private函数"的错误信息。这可能会误导调用者。

= delete的另一个特性是,它并不是只能用在类成员函数,而是任意函数。看这段代码:



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

if (isLucky('a'))  // error! call to deleted function
if (isLucky(true))  // error!
if (isLucky(3.5f))  // error!

把后3个函数重载给delete掉,保证了那3种调用方式不能被编译!也就是说,=delete可以用来阻止隐式转换陷阱。

用= delete还有一个高端的好处:在class内部的函数模板,它的访问类型只能是public、protected、private其中一种,不能又有public又有private的实例化,所以c++98的"delete"方案对函数模板没辙。还好,C++11解决了这个问题,用= delete即可:


class A {
public:
    template<typename T>
    void foo(T* ptr) { }

};

template<>
void A::foo<void>(void*) = delete; // still public, but deleted

再引用下作者的一段话:

the C++98 approach is not as good as the real thing. It doesn’t work outside classes, it doesn’t always work inside classes, and when it does work, it may not work until link-time. So stick to deleted functions.

条款12-1:新功能:引用限定符 reference qualifiers

这样子玩:


class A {
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

A makeA(); // factory function (returns rvalue)
A w; // normal object (an lvalue)

w.doWork(); // calls A::doWork for lvalues
// (i.e., A::doWork &)
makeA().doWork(); // calls A::doWork for rvalues
// (i.e., A::doWork &&)

条款12-2:新功能:覆盖限定符 override qualifiers

这个限定符是用来防止程序员粗心写错重载的。

错误例子:


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;
};

Derived的前3个函数一点没有覆盖掉基类的实现。因为派生类函数和基类对应的函数,函数签名不完全一样。

c++11给出了优雅的解决办法:

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;
};

Derived的mf1、mf2、mf3这3个覆盖函数无法编译通过,因为编译器在Base中找不到对应的函数。

条款13: 尽可能使用const_iterators

c++11/c++14可以这么写:


std::vector<int> values;
auto it = // it是const_iterators
std::find(values.cbegin(),values.cend(), 1983);
values.insert(it, 1998);

注意,cbegin和cend是成员函数。在c++11中,有非成员函数的begin和end,但没有非成员函数的cbegin和cend(c++14才有)。

所以要写泛型函数时,c++14这么写:


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++11则不能,但可以先定义2个函数,使得可以和c++14保持一致:


template <class C>
auto cbegin(const C& container)->decltype(std::begin(container))
{
return std::begin(container);
}

template <class C>
auto cend(const C& container)->decltype(std::begin(container))
{
return std::end(container);
}

条款14: 如果函数保证不会抛出异常,请给它加上 noexcept声明

(这个标题怎么有点像《三体》里的安全声明:如果你的文明确定没有攻击性,请给你的星球加个光速黑洞=。=)

在c++98中,允许声明一个函数会抛出什么样的异常,客户端可以根据异常声明去安排自己的代码。然并卵,因为这产生了耦合性:如果一个函数的异常声明被改动了,客户端代码也得跟着改了。所以最终大家都不用这个特性。

在modern c++中,把这个东西废掉了,并加入noexcept关键字。从而只需要声明一个函数是否抛出异常即可。

noexcept的威力在于,它告诉编译器的优化器可以多大程度地优化函数代码生成。当noexcept被声明时,运行时栈不需要保存一个可解状态(可解状态是指,这函数里的局部变量可以按照构造顺序的反序去析构)。即干掉这个函数的不必要信息,让它更轻量。

noexcept在标准库里部有很重要的应用,具体请阅读原书。

noexcept还是支持表达式计算的。可以这样子玩:


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)));
};

解释下第二个swap:一个pair对象和另一个pair对象进行swap,当前仅当swap(first, p.first)和swap(second, p.second)都是noexcept时,pair::swap才是noexcept。

虽然noexcept很强大,但是乱用是不好的。譬如如果你声明一个函数是noexcept,但有一天你反悔了,你可能想去掉noexcept声明,但这对客户端代码的影响不小,或者你不管noexcept声明了,硬是在函数里抛出异常!Oh,那当这个异常抛出来的时候,程序就强制终止了。

解决上述问题的唯一办法就是谨慎,只对关键的、底层的、频繁调用的函数考虑加上noexcept声明。一来,函数是底层函数,函数内部很可能没有调用其他函数,或者调用的函数也都是noexcept的,于是这个函数可以妥妥地加上noexcept。

在modern c++中,用户定义的析构函数亦或是编译器生成的析构函数,都隐式声明了noexcept。

条款15: 尽可能地使用constexpr

  • 所有的constexpr对象都是常量,但不是所有的const对象都是constexpr。如果确定需要一个编译时期的常量,那么得用constexpr。
  • 当你着手的代码需要用到编译时期常量时,可以使用constexpr函数,如果你给constexpr函数传递一些编译时期可知的参数(这些参数来源于上下文),那么这个constexpr函数很可能会在编译时期被执行。然而,如果其中一个参数是编译时期不可知的,这个函数肯定不会在编译时期就执行。这个自动处理是自动的、隐式的,也就是说不要求程序员写2个函数,一个runtime用,一个compiling用。
  • 从第二点可以反推,如果一个函数不是constexpr,那么即使你传递给它的参数都是编译时期已知的,这个函数也不一定就会在编译时期执行。

具体怎么玩?看下面的代码:


constexpr int times(int a, int b) noexcept // C++11
{
    return (b == 0 ? 1 : a + times(a, b - 1));
}

constexpr int times(int a, int b) noexcept // C++14
{
    auto result = 0;
    for (int i = 0; i < b; ++i) 
        result += a;
    return result;
}

int main() {
    constexpr int a = 1, b = 1000000;
    constexpr int result = times(a, b);
    printf("%d\n", result);
    return 0;
}

c++11的递归版本很容易爆栈,而c++14对constexpr做了改进,允许写成迭代的形式。

注释掉c++11的版本,然后试试c++14的版本吧,执行指令:

time gcc test.cpp -o test.out -std=gnu++14 -lstdc++

指令执行耗时:

real 0m1.190s

user 0m1.108s

sys 0m0.052s

哇,用了1秒多,这1秒多花在哪了呢?其实就是gcc编译器在编译时期就把constexpr int result = times(a, b);计算了。(然而有些诡异的是,times仅仅循环100W,就花了一秒。)

不过这也值得高兴了,这个特性可以让程序性能有了进一步提高,譬如可以把复杂的静态class常量变成constexpr,使得在编译器就把变量生成了,而不是等到运行期(即使你说运行期只创建一次)。

总的来说就是,constexpr使得本来在运行期执行的工作,可以提前到编译期,只要你加上constexpr声明即可。

条款16: 关于编译器自动生成的成员函数

移动构造函数(move constructor)和移动赋值操作符(move assignment operator),是modern c++新补充的generated member function。

对于这2个函数的任意一个,当前仅当满足下面的条件时,编译器才会自动生成它们:

  • 有地方需要用到它
  • 用户没有自定义复制操作函数(copy operations),即复制构造函数(copy constructor)和赋值操作符(copy assignment operator)
  • 用户没有自定义移动操作函数(move operations)
  • 用户没有自定义析构函数
(未经授权禁止转载)
Written on November 8, 2015

写作不易,您的支持是我写作的动力!