简介

优质
小牛编辑
128浏览
2023-12-01

如果你是一个像我一样有经验的C++程序猿,当初次体验C++11时,“啊,就是他,我明白了,这就是C++”。但是自从你学习了更多的内容,你会惊讶于他的变化。auto类型声明,基于区间的for循环,lambda表达式和右值引用改变了C++的样貌,还有新的并发API。除此之外,还包括一些合服语言习惯的改动。0和typedef都已经过时,nullptr和别名声明(alias declarations)强势登场。enum需要被作用域限制。现在更加建议使用在内部实现的智能指针。移动对象要比拷贝一个对象代价更小。

关于C++11我们要学习很多,还没有提C++14呢。

更重要的是,要想有效的利用好这些特性需要学习很多的东西。如果你拥有了关于“现代”C++特性,知识储备的基础,但是希望得到一个关于如何正确驾驭这些特性来创造运行正确,高效,可维护和可移植的软件的向导,搜寻的过程是非常具有挑战性的。这就是这本书的目的。他不是来介绍C++11和C++14的新特新的,而是来介绍如何使用他们的高效做法。

这本书的内容被封装成一系列叫做“条款”(item)的东东。想了解更多的关于类型推导的形式吗?或者想知道什么时候用(或者不用)auto声明吗?你对为什么const成员函数必须保证线程安全感兴趣吗?想知道怎么利用std::unique_ptr实现Pimpl Idiom,为什么不建议你在lambda表达式里面使用默认的捕捉模式,或者std::atomicvolatile有什么区别?答案都在这本书里面。更多的是平台独立,标准兼容的答案。这本书讲的是可移植的C++。

书中的条款是指导性建议,并不是法则,因为这些是有意外情况的。重要的不是每条条款带来的建议,而是这些建议背后的道理。一旦你理解了他们,你就可以在你的项目中扮演一个决策者的地位来判断他们是不是违背某个条款的指导性。这本书的目的不是告诉你什么要做,什么不要做,而是要你对C++11和C++14的基础之上进行深层理解。

术语和约定

为了让我们之间互相理解,在一开始我们预定C++的一些术语是很重要的。到目前为止有四份关于C++的官方版本,每一份依据对应ISO标准草案定制的年份来命名:C++98,C++03,C++11和C++14。C++98和C++03只是在技术细节上略有区别,在这本书里面我把它们都称之为C++98。当我提到C++11的时候,指的是C++11和C++14,因为C++14就是一个C++11的超集。当我写到C++14的时候,是特指C++14。当我简单的提到C++的时候,应该指的是所有的语言版本。

我的表述我所指的语言版本
C++所有的版本
C++98C++98和C++03
C++11C++11和C++14
C++14C++14

一般来说,我可能绝大时候说C++对运行效率比较重视(对所有的版本都是对的),但是C++98缺乏对并发的支持(这个对于C++03和C++98是对的),但是C++11支持lambda表达式(对C++11和C++14是对的),C++14提供了通用的函数返回值类型推导(对C++14是对的)。

C++11的最普遍的特性是移动语义(move semantics),移动语义的基石是从那些左值中区分出右值。这是因为右值标志着对象是可以在移动操作中使用的而左值通常不是。在概念上来说(在实际中不一定),右值代表着你可以引用的临时对象,不管是通过变量名还是通过一个指针或者左值引用。

一个有用的,有启发意义的判断一个表达式是左值的方法是取它的地址。如果可以取地址,它基本上就是一个左值。如果不行,通常来说是一个右值。这个启发式的特性可以很好的帮助我们记住一个表达式的类型,不管他是一个左值还是一个右值。也就是说,给定一个类型T,你可以得到类型T的左值同时也可以得到它的右值。当处理一个有右值引用的参数时需要铭记于心,因为参数本身是个左值:

class Widget {
public:
    Widget(Widget&& rhs);       // rhs是一个左值,尽管他
    …                           // 有一个右值引用类型
};

这里,在Widget的移动构造函数里面完全可以取得rds的地址,所以rds是一个左值尽管他的类型是个右值引用。(因为类似的原因,所有的参数都是左值。)

这段代码片段阐述了我一般要遵守的几条原则:

  • 类名是Widget。我通常会使用Widget来代指一个任意的用户自定义类型。我使用Widget是不会声明他的,除非我要展示类的特殊细节。

  • 我使用的参数名字叫做rhs(“right-hand side”)。他是我在移动操作(移动构造函数和移动赋值运算符)中和拷贝操作(拷贝构造函数和复制赋值运算符)喜欢使用的名字。我还把他用在二元运算符的右边的参数:

    Matrix operator+(const Matrix& lhs, const Matrix& rhs);
    

    不要惊讶,我希望lhs代表“left-hand side”。

  • 我在代码和注释中使用这种格式向你表示你要注意这些东西。在Widget的移动构造函数中,我高亮了rhs和部分注释来表明rhs是一个左值。(很抱歉,译者使用的Markdown语法暂时无法控制代码里面的高亮——译者注。)高亮代码从根本上说不好也不坏。他只是一段你需要加以注意的特殊的代码。

  • 我使用“…”来表示“在此处有其他的代码”。这种比较窄的省略号和用在C++11源代码里面的的变长模板的宽省略号(“...”)是不一样的。这听起来比较困惑。举个例子:

    template<typename... Ts>               // 这里是C++
    void processVals(const Ts&... params)  // 源代码里面的
    {                                      // 省略号
    
      …                                   // 此处意味着
    }                                      // “有些代码着这里省略了”
    

    processVals展示了我在模板中使用typename关键字,但是这只是一个个人习惯;关键字class也可以工作的正常(这里是不严谨的,nested dependent type name使用的时候,typename是不能替换成class的——译者注)。当我要使用C++标准展示代码,我会使用class来做参数类型类型声明,因为标准就是这样做的。

当一个对象使用另外一个类型相同的对象来初始化的时候,新的对象称作一份初始化对象的拷贝,甚至这个拷贝是基于移动构造函数实现的也叫做对象的拷贝。遗憾的是,在C++中没有一个术语是用来区分拷贝构造个移动构造的拷贝。

void someFunc(Widget w);        // someFunc的参数w是以值传送

Widget wid;                     // wid是个Widget的对象

someFunc(wid);                  // 在这个someFunc调用里面,w是通过
                                // 拷贝构造函数生成wid的一个拷贝

someFunc(std::move(wid));       // 在这个someFunc调用里面,w是通过
                                // 移动构造函数生成wid的一个拷贝

在一个函数调用里面,在函数的调用方的表达式是函数的实参。这些表达式被用来初始化函数的形参。在上面的代码中的第一次调用someFunc,实参是wid。在第二次调用的地方,实参是 std::move(wid)。两次调用的形参都是w。实参和形参的区别是很重要的,因为形参只能是左值,但是给他们初始化的实参即有可能是右值也有可能是左值。这和完美转发的过程是密切相关的,在完美转发中一个传递给一个函数的实参再传递给第二个函数,以此来保证原始的参数的右值特性或者左值特性被保留。(完美转发的细节在条款30中)。

良好设计的函数是异常安全的,也就意味着他们至少接受基本的异常保证(弱保证)。这样的函数确保调用者触发异常,程序任然保持正常(没有数据结构被损坏)没有资源泄露。函数保证强壮的异常安全(强保证)会确保程序发生异常的时候,程序的运行状态和之前调用这个函数的状态是一样的。

当我提到函数对象(仿函数也属于其中一种——译者注)的时候,我通常意味着这个类型支持operator()操作。也就是说,这个对象的行为像一个函数。有时候我会在一些更加通用的地方来使用这种说法(“functionName(arguments)”)。更加广义的定义不仅仅包含那些支持operator()的对象,也包括函数和C风格的函数指针。(狭义的定义来自于C++98,广义的定义来自于C++11)。添加成员函数指针被称之为可调用对象(callable objects)。通常你可以忽略他们的区别,仅仅认识到在C++中函数对象和可调用对象可以被用在一些函数调用的语法结构里面。

通过lambda表达式创造的函数对象通常称之为闭包(closure)。通常很少区分lambda表达式和它产生的闭包,我通常用lambdas来代指它们。类似的,我很少区分函数模板(生成函数的模板)和模板函数(利用函数模板生成的函数)。对于类模板和模板类也是如此。

在C++许多东西可以声明和定义。声明把类型和名字带入我们的视野但是细节啥都不给,例如是在哪儿放置的存储空间,问题是怎么实现的之类的:

extern int x;                   // 对象声明

class Widgets;                  // class声明

bool func(const Widget& w);     // 函数声明

enum class Color;               // 被作用域包裹的enum声明(参考条款10)

定义提供存储地址或者实现的细节:

int x;                          // 对象定义

class Widget {
  …                             // class定义
};

bool func(const Widget& w)
{ return w.size() < 10; }       // 函数定义

enum class Color
{ Yellow, Red, Blue };          // 被作用域包裹的enum定义

一个定义当然是需要对应一个声明,除非定义对某个东西非常重要,我通常指的是声明。

我指一个函数的签名是由函数的参数和返回值确定的。函数和参数的名字并不是函数签名的一部分。在上述代码中,func的签名是bool(const Widget&)。函数声明的组成部分除了他的参数和返回值(比如如果有noexcept或者constexpr)都被排除在外。(noexceptconstexpr在条款14和条款15中被讨论)。正式的“签名”的定义和我的略有出入。但对于这本书来说,我的定义会非常有用。(正式的定义会排除返回值类型)。

新的C++标准通常兼容于老的代码,但是有的时候标准化委员会会废弃一些特性。这些特性很有可能在未来的标准化进程中被移除。编译器可能对这些即将废弃的特性没有任何警告,但是你最好要避免使用它们。不仅仅是因为他们会给将来的代码带来头痛,而且他们通常是有好的实现来代替它们。举个例子,std::auto_ptr被C++11所废弃,因为有更好的相同功能的std::unique_ptr,而且能做的更好。(std::auto_ptr本来是设计用来防止内存泄露的智能指针,但是为了使用它你必须要注意一堆坑,一般旧的C++书籍也会说明不建议使用——译者注)。

有些时候标准说某个操作会导致未定义行为,这意味着运行时的行为无法预测,不用说,你是需要避开这种不确定性的。一个未确定性的例子是使用方括号(“[]”)去索引超出std::vector的长度,从一个未初始化的迭代器取值,或者是有趣的数据竞争(两个或者更多的线程,至少有一个是生产者,同时访问同一块内存区域)。

我把直接从new返回的原始指针叫做内建指针。一个原始指针的反义词就是智能指针。智能指针通常重载了指针取值运算符(operator->operator*),在条款20里面会解释std::weak_ptr是个特殊情况。

在源码注释里面,我通常把“构造函数”简称为ctor,“析构函数”简称为dtor。

报告Bug和建议优化

我尽我的努力去让这本书能够带来清楚,准确,有用的信息,但是总是可以再度改善完美的。如果你发现书中的任何错误(技术的,解释的,语法的,印刷的,等等)或者你有一些关于让这本书更好的建议,可以给我发邮件 emc++@aristeia.com。关于修订Effective Modern C++可以交付于书新版,但是我不能确定出我不知道的问题。

查看这本书已经发现的问题,审核本书的勘误。http://www.aristeia.com/BookErrata/emc++-errata.html