回到顶部 暗色模式

EffectiveModernCpp(2):使用现代C++语法

1. 初始化

        从不同的角度来看,C++11的初始化语法丰富却又混乱。

int w(0);  // 小括号初始化
int x = 0;  // =初始化
int y{0};  // 花括号初始化
int z = {0};  // =和花括号初始化

        在通常情况下,后面两种是等价的。对于 $int$ 这种内置类型而言,这些初始化方式通常没有什么太大区别。但是对于用户类型而言,理解它们的区别很重要。

Widget w1;  // 默认构造函数
Widget w2 = w1;  // 拷贝构造函数
w1 = w2;  // 调用operator=

        C++使用统一初始化 ( $uniform$ $initialization$ ) 来整合这些混乱的初始化语法。所谓统一初始化即使用单一初始化语法,是基于花括号的初始化。

std::vector<int> v{1, 3, 5};

std::atomic<int> ai1{0};
std::atomic<int> ai2(0);
std::atomic<int> ai3 = 0;  // 错误

class Widget {
  // ...
private:
  int x{0};
  int y = 0;
  int z(0);  // 错误
};

        可以发现,相比于其他初始化方法,使用花括号的初始化方式在任何地方都成立,这也是为什么它被称为统一初始化。
        花括号初始化还具备其他初始化不具有的特性——不允许内置类型的隐式变窄转换 ( $narrowing$ $conversion$ )。

double x, y, z;
int sum1(x + y + z);
int sum2 = x + y + z;
int sum3{x + y + z};  // 错误

        此外,使用花括号初始化还可以避免C++的一个问题。这个问题的根源是C++规定所有能被认为是一个声明的语句都视为声明。从而会导致以下情况:

Widget w1(10);  // 初始化
Widget w2();  // 函数声明

        本来是想调用默认构造函数的,结果却变成了一个函数声明。但是使用花括号初始化就不会有这个问题:

Widget w3{};  // 初始化

        当然,花括号初始化也存在缺点,主要是与 $std::initializer_-list$ 相关的问题。

class Widget {
public:
  Widget(int i, bool b);
  Widget(int i, double d);
  Widget(std::initializer_list<long double> il);
  operator float() const;
  // ...
};

Widget w1(10, true);  // 调用第一个构造函数
Widget w2(10, 5.0);  // 调用第二个构造函数
Widget w3{10, true}; // 调用第三个构造函数
Widget w4{10, 5.0}; // 调用第三个构造函数

Widget w5(w4);  // 拷贝构造函数
Widget w6{w4};  // 调用第三个构造函数

Widget w7(std::move(w4));  // 移动构造函数
Widget w8{std::move(w4)};  // 调用第三个构造函数

        可以发现,当存在使用 $initializer_-list$ 的构造函数时,花括号初始化总是会优先选择该构造函数。只有当实在无法选择该构造函数时,才会进入正常的构造函数选择中:

class Widget {
public:
  Widget(int i, bool b);
  Widget(int i, double d);
  Widget(std::initializer_list<std::string> il);
  // ...
};

Widget w1{10, true};  // 调用第一个构造函数
Widget w2{10, 5.0};  // 调用第二个构造函数

        这里还有一个边缘情况,那就是空的花括号。

class Widget {
public:
  Widget();
  Widget(std::initializer_list<int> il);
  // ...
};

Widget w1;  // 默认构造函数
Widget w2{};  // 默认构造函数
Widget w3();  // 函数声明

        空的花括号意味没有实参,而不是空的 $initializer_-list$ ,从而会调用默认构造函数。如果想在这种情况下调用初始化列表形式的构造函数,需要创建一个实参:

Widget w4({});  // 调用第二个构造函数
Widget w5{{}};  // 调用第二个构造函数

        如果你是一个模版的作者,那么花括号和小括号初始化方式会存在更麻烦的问题。

template<typename T, typename... Ts>
void doSomeWork(Ts&&... params) {
  // create T object from params
}

std::vector<int> v;
doSomeWork<std::vector<int>>(10, 20);
doSomeWork<std::vector<int>>{10, 20};

        上面两种调用方式的结果是不同的,前者的 $vector$ 大小为 $10$ 而后者为 $2$ 。所以,对于开发者来说,规范一个正确的用法很重要。标准库的 $std::make_-unique$ 和 $std::make_-shared$ 选择的解决方案是统一使用小括号,并记录在文档中作为接口的一部分。

2. nullptr

        一般来讲,C++会优先将 $0$ 看作 $int$ 而不是指针。对于 $NULL$ ,它没有一个确切的类型,它的类型取决于实现。在C++98中,对指针类型和整数进行重载可能会导致异常情况:

void f(int);
void f(bool);
void f(void *);

f(0);  // f(int)
f(NULL);  // 可能会编译失败,一般来说会调用f(int)

        这段代码的行为取决于 $NULL$ 实现,如果 $NULL$ 为 $0L$ ,那么就存在二义性,因为它可以转为 $int$ 、$bool$ 和 $void$ $\star$ 中的任意一个。这种行为的存在使得C++98程序员都会尽量避免同时重载整型和指针。
        C++11新增了 $nullptr$ 。$nullptr$ 的优点是它不是整型,严格来讲它也不是指针,真正的类型是 $std::nullptr_-t$ ,并且经过循环定义,它又被定义为 $nullptr$ 。$nullptr$ 可以转换为任何指向内置类型的指针。而且可以避开使用 $NULL$ 时带来的问题:

f(nullptr);  // f(void *)

        $nullptr$ 应用于模版时就更有用了。

template<typename FuncType, typename MuxType,
         typename PtrType>
auto lockAndCall(FuncType func, MuxType &mutex,
    PtrType ptr) -> decltype(func(ptr)) {
  MuxGuard g(mutex);
  return func(ptr);
}

int f1(std::shared_ptr<Widget> spw);
double f2(std::unique_ptr<Widget> upw);
bool f3(Widget *pw);

std::mutex f1m, f2m, f3m;  // 用于不同函数的互斥量
using MuxGuard = std::lock_guard<std::mutex>;

auto result1 = lockAndCall(f1, f1m, 0);  // 错误
auto result2 = lockAndCall(f2, f2m, NULL);  // 错误
auto result3 = lockAndCall(f3, f3m, nullptr);

        前两个调用不能通过编译。在第一个调用中,$0$ 会被推导为 $int$ ,而 $f1$ 并不接受 $int$ 类型。同理,第二个调用中 $f2$ 也不接收整型。

3. using

        通过别名声明,我们可以简化一些声明:

typedef std::unique_ptr<std::unordered_map<std::string, std::string>>
  UPtrMapSS;
using UPtrMapSS =
  std::unique_ptr<std::unordered_map<std::string, std::string>>;

        这里 $typedef$ 和 $using$ 的作用相同。在大部分情况下,这两个关键字都可以互相替换。但是碰到模版时就不行了,因为 $using$ 可以被模版化。

template<typename T>
using MyAllocaList = std::list<T, MyAlloc<T>>;

MyAllocList<Widget> lw;

        而 $typedef$ 只能这样使用:

template<typename T>
struct MyAllocList {
  typedef std::list<T, MyAlloc<T>> type;
};

MyAllocList<Widget>::type lw;

        对于一些使用 $typedef$ 的老代码,我们也可以使用 $using$ 修改:

template<typename T>
using MyAllocList_t = MyAllocList<T>::type;

4. 限域枚举

        未限域枚举 ( $unscoped$ $enum$ ) 的枚举名作用于与 $enum$ 域一致。C++11中引入了限域枚举 ( $scoped$ $enum$ ),它不会导致枚举名泄露:

enum Color1 { black, white, red };  // 未限域枚举
auto white = false;  // 错误

enum class Color2 { black, white, red };  // 限域枚举
Color2 c = Color2::white;

        限域枚举通过 $enum$ $class$ 声明,所以也被称为枚举类 ( $enum$ $class$ )。使用限域枚举减少命名空间污染已经是一个足够吸引人使用它的理由了,但它还有第二个优点:在它的作用于中,枚举名是强类型。

std::vector<std::size_t> primeFactors(std::size_t x);

enum class Color { black, white, red };
Color c = Color::red;

if (c < 14.5) {  // 错误
  auto factors = primeFactors(c);
}

        如果真的需要将枚举类型像上面这样使用,需要进行类型转换:

if (static_cast<double>(c) < 14.5) {
  auto factors = primeFactors(static_cast<std::size_t>(c));
}

        除了限域枚举之外,C++11还允许枚举的前置声明。

enum class Status;
void continueProcessing(Status s);

        你可以在不给出一个枚举定义的前提下使用该枚举类型。在C++11以前,枚举的前置声明并不允许,因为编译器需要根据枚举值确定枚举实际使用的类型,比如 $char$ 、$int$ 等。为了高效使用内存,编译器通常在确保能包含所有枚举值的前提下为枚举选择一个最小的基础类型。而在C++11中,如果使用前置声明,那么枚举默认会是 $int$ 。如果想要指定类型,可以通过如下方式:

enum class Status: std::uint32_t;
enum Color: std::uint8_t;

        也可以结合定义一起声明:

enum class Status: std::uint32_t {
  good = 0,
  failed = 1
};

        虽然大部分情况下限域枚举更好,但是也存在着非限域枚举更好的情况:

using UserInfo = std::tuple<std::string,  // 名字
                            std::string,  // email
                            std::size_t>;  // 声望

        上面的 $tuple$ 类型每个字段都有不同的含义。字段较少的时候还好,但是一旦变多,记忆哪些字段的含义是什么就很难了。我们可以使用枚举简化:

enum UserInfoFields = { uiName, uiEmail, uiReputation };
UserInfo uInfo;
// ...
auto val = std::get<uiEmail>(uInfo);

        $uiEmail$ 被隐式转换为 $std::size_-t$ ,从而获取了邮箱。而如果使用限域枚举,我们就不得不加入类型转换。

5. 函数delete

        C++编译器会在类缺少某些函数时自动声明,比如默认构造函数、拷贝构造函数等。但是有时候,我们不想让客户调用这些函数。在C++98中,常见的办法是将它们声明为私有成员函数。例如,C++标准库 $iostream$ 的继承链顶部是模版类 $basic_-ios$ ,所有的 $istream$ 和 $ostream$ 都继承自此类,拷贝它们是不合适的。C++98是这样处理的:

template<class charT, class traits = char_traits<charT> >
class basic_ios : public ios_base {
public:
  // ...
private:
  basic_ios(const basic_ios &);
  basic_ios &operator=(const basic_ios &);
};

        声明为私有成员且不定义,可以防止客户调用它们。C++11提供了一种更好的方式:

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

        表面上看与声明为私有成员函数一样,但是实际上函数 $delete$ 还有一些其他意义:声明为 $delete$ 的函数在被以任何方式调用时,都无法通过编译。注意,声明为 $delete$ 的函数应该是 $public$ 成员,因为如果是私有成员,当调用它们时,编译器可能会报该函数是 $private$ 而不是类似于函数被 $delete$ 的错误。
        任何函数都可以被 $delete$ 。

bool isLucky(int number);
bool isLucky(char) = delete;
bool isLucky(bool) = delete;
bool isLucky(double) = delete;

        $char$ 、$bool$ 和 $double$ 都可以被隐式转换为 $int$ ,为了让 $isLucky$ 只接收整型,我们可以将这些对应类型的函数 $delete$ 。
        $delete$ 还可以应用于模版实例化的过程。

template<typename T>
void processPointer<T *ptr);

template<>
void processPointer<void>(void *) = delete;
template<>
void processPointer<char>(char *) = delete;

        指针有两种特殊情况。一种是 $void$ $\star$ ,因为没法对他们进行解引用。另一种是 $char$ $\star$ ,因为它们通常代表C风格字符串,而不是单个字符的指针。我们可以通过 $delete$ 删除它们。按理来说,$const$ 修饰的类型也要删除:

template<>
void processPointer<const void>(const void *) = delete;
template<>
void processPointer<const char>(const char *) = delete;

        更彻底一点,我们还要删除 $const$ $volatile$ $void$ $\star$ 和 $const$ $volatile$ $char$ $\star$ ,还有其他标准字符类型的重载版本,比如 $std::wchar_-t$ 和 $std::char16_-t$ 等。
        模版类型是无法使用C++98的方式禁止实例化的,因为不能给特化的模版指定一个于函数模版的访问级别:

class Widget {
public:
  template<typename T>
  void processPointer(T *ptr);

private:
  template<>
  void processPointer<void>(void *);  // 错误
};

        $delete$ 不会出现这种问题,因为它们不需要不同的访问级别,而且可以在类外删除。

class Widget {
public:
  template<typename T>
  void processPointer(T *ptr);
};

template<>
void Widget::processPointer<void>(void *) = delete;

6. override

        在C++里,要重写一个函数,需要:

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

        上面的 $4$ 个函数都没有正确地重写,而且编译器并不会报错。为了避免这种错误,我们可以使用 $override$ :

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

        这样上面的代码就会在编译时报错。

7. const_iterator

        在C++98中,标准库对 $const_-iterator$ 的支持不是很完整。如果你要使用 $const_-iterator$ ,需要这样:

typedef std::vector<int>::iterator IterT;
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);  // 可能无法编译

        之所以会有这种用法,是因为 $vector$ 在C++98中是非常量容器,无法从中取得 $const_-iterator$ 。而且,C++98的插入操作位置只能由 $iterator$ 指定,$const_-iterator$ 是不合法的。但是由于没有一个可移植的将 $const_-iterator$ 转为 $iterator$ 的方法,所以上面的代码可能也无法编译。
        这种情况在C++11中得到了改变,通过 $cbegin$ 和 $cend$ ,我们可以很容易就获取到 $const_-iterator$ ,甚至对非常量容器也是如此。

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

        但是C++11对非成员函数的版本支持仍存在不足:

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中不行,因为C++11没有非函数版本的 $cbegin$ 和 $cend$ 。我们也可以自己实现一个支持C++11的版本:

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

8. noexcept

        在C++98中,异常说明 ( $exception$ $specifications$ ) 很麻烦,你不得不写出函数可能抛出的异常类型,而且这些类型还可能随着代码更改而更改。C++从根本上改变了C++98的异常说明,只包含两种情况,可能抛出异常或绝不抛出异常。$noexcept$ 则用于保证函数不会抛出异常。
        就本身而言,函数是否为 $noexcept$ 和是否为 $const$ 一样重要。调用者可以查看函数是否声明 $noexcept$ ,这也会影响到调用代码的异常安全性和效率。给不抛异常的函数加上 $noexcept$ 可以让编译器生成更好的目标代码。

int f(int x) throw();  // C++98
int f(int x) noexcept;  // C++11

        如果在运行时 $f$ 出现异常,那么就跟异常说明相冲。在C++98中,调用栈会展开到 $f$ 的调用者,一些不期望的行为比如程序中止也会发生。而在C++11中,调用栈只是可能在程序终止之前展开。在一个 $noexcept$ 函数中,当异常传播到函数之外时,优化器不需要保证调用栈的可展开状态,也不需要保证 $noexcept$ 函数中的对象按照相反顺序析构。
        $swap$ 函数是 $noexcept$ 的绝佳用地。标准库的 $swap$ 是否 $noexcept$ 要依赖于用于自定义的 $swap$ :

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)
    noexpcet(noexcept(swap(first, p.first)) &&
    noexcept(swap(second, p.second)));
};

        这些函数是否 $noexcept$ 依赖于其表达式是否 $noexcept$ 。
        对于大多数函数,它们都不应该指定为 $noexcept$ 。但是对于一些函数,比如移动构造函数和 $swap$ 函数等,应该只要可能就将它们声明为 $noexcept$ 。

9. constexpr

        从概念上来说,$constexpr$ 表示一个值不仅仅是常量,还是编译期可知的。但是对于函数来说,$constexpr$ 函数的返回值并不需要是 $const$ ,也不需要是编译器可知的。编译器可知的值可以被存放到只读存储空间中。更广泛的应用是编译器可知的常量整数会出现在需要整型常量表达式的情况中,包括数组大小、整数模版参数、枚举值和对齐修饰符等。如果想要在这些情况中使用变量,就需要声明为 $constexpr$ :

constexpr auto sz = 10;
std::array<int, sz> data1;

        由于 $constexpr$ 变量需要在编译器可知,所以它必须被初始化。而对于 $const$ 变量来说,它们可以不初始化。
        如果 $constexpr$ 作用于函数,并且实参是编译期常量,它们将产出编译期值;如果形参是运行时值,它们就产出运行时值。如果 $constexpr$ 函数应用于需要编译器常量的上下文,要求入参必须为编译器常量,否则编译会失败。在C++11中,$constexpr$ 函数的代码只能由一个 $return$ 语句组成。

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

constexpr auto numConds = 5;
std::array<int, pow(3, numConds)> results;

        在C++14中,这个限制变得非常宽松了,改为只能获取和返回字面量类型:

constexpr int pow(int base, int exp) noexcept {
  auto result = 1;
  for (int i = 0; i < exp; i++) result *= base;
  return result;
}

        在C++11中,构造函数和其他成员函数也可以是 $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;
};

constexpr Point p1(9.4, 27.7);
constexpr Point p2(28.8, 5.3);

constexpr Point midpoint(const Point &p1, const Point &p2) {
  return {(p1.xValue() + p2.xValue()) / 2,
          (p1.yValue() + p2.yValue()) / 2};
}
constexpr auto mid = midpoint(p1, p2);

        $setX$ 和 $setY$ 无法声明为 $constexpr$ ,因为它们会修改对象状态,但是 $constexpr$ 成员函数是隐式 $const$ 的,而且 $constexpr$ 函数也不能返回 $void$ 类型。但是这个限制在C++14中被放宽了,从而 $setX$ 和 $setY$ 也能声明为 $constexpr$ 。

class Point {
public:
  // ...
  constexpr void setX(double newX) noexcept { x = newX; }
  constexpr void setY(double newY) noexcept { y = newY; }
};

constexpr Point reflection(const Point &p) noexcept {
  Point result;
  result.setX(-p.xValue());
  result.setY(-p.yValue());
  return result;
}

10. 线程安全的const函数

class Polynomial {
public:
  using RootsType = std::vector<double>;

  RootsType roots() const {
    if (!rootsAreValid) {
      rootsAreValid = true;
    }
    return rootVals;
  }

private:
  mutable bool rootsAreValid{false};
  mutable RootsType rootVals{};
};

        从概念上将,$roots$ 函数并不会改变对象状态,但是也许会改变 $rootsAreValid$ 和 $rootVals$ ,这也是为什么它们被声明为 $mutable$ 。假设程序运行在多线程环境,有超过一个线程同时调用该函数。因为 $roots$ 是 $const$ ,那么就代表它是一个读操作。读操作不应该是线程不安全的,但可惜的是,上面的代码是线程不安全的。我们需要把它改为线程安全的:

class Polynomial {
public:
  using RootsType = std::vector<double>;

  RootsType roots() const {
    std::lock_guard<std::mutex> g(m);
    if (!rootsAreValid) {
      rootsAreValid = true;
    }
    return rootVals;
  }

private:
  mutable std::mutex m;
  mutable bool rootsAreValid{false};
  mutalbe RootsType rootVals{};
};

        $m$ 是 $mutable$ 的,因为上锁和解锁都是非 $const$ 函数。

10. 特殊函数生成

        特殊函数指的是C++自己生成的函数,C++98中它们是:默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符。这些函数仅在需要时才生成,而且是隐式 $public$ 和 $inline$ 的。C++11中添加了两个特殊函数:移动构造函数和移动赋值运算符。
        拷贝操作是独立的,如果你声明了拷贝构造函数但没有声明拷贝赋值运算符,并且代码中使用了拷贝赋值,那么编译器就会帮你生成拷贝赋值运算符,反之同理。但是对于移动函数不是这样的。如果你声明了移动构造函数或者移动赋值运算符,编译器便不会再为你生成另一个。这里面隐含的意义是:如果你显式声明了一个移动函数,说明这个类型的移动操作不再是单纯地逐一移动成员变量,那么编译器便不会为你生成另一个语义是逐一移动成员变量的移动函数。
        再进一步,如果一个类型显式声明了拷贝构造函数或者拷贝赋值运算符,编译器就不会生成移动操作。这种限制的原因是如果显式声明了拷贝函数,那么意味着这个类型的拷贝操作也可能不是逐一拷贝成员变量,从而移动操作也可能不适用。反之也成立,如果一个类型显式声明了移动函数,便不会生成拷贝函数。
        或许你听过 $Rule$ $of$ $Three$ ,即用户只要声明了拷贝构造函数、拷贝赋值运算符或者析构函数中的一个,那么也需要声明另外两个。这个法则也可以应用于C++11中的移动函数,并且编译器当且仅当满足以下条件时才会生成移动函数:

        如果我们在声明了其中一种函数之后,还想要编译器生成其他类型的函数,那么我们可以使用 $default$ 关键字:

class Widget {
public:
  // ...
  ~Widget();
  Widget(const Widget &) = default;
  Widget &operator=(const Widget &) = default;
};

        这种方法通常用于多态基类,因为它们通常有一个虚析构函数。事实上,即使编译器会自动生成,我们也最好手动声明并使用 $default$ 关键字。
        最后,还要注意,成员模版函数是不会阻止编译器生成特殊函数的。

class Widget {
public:
  // ...
  template <typename T>
  Widget(const T &rhs);
  template <typename T>
  Widget &operator=(const T &rhs);
};

        在上述代码中,编译器依然可以生成移动函数和拷贝函数。

EffectiveModernCpp(2):使用现代C++语法