Deducing Types

Item 1: Understand template type deduction

模板函数类型推导

1
2
3
4
template<typename T>
void f(ParamType param)

f(expr);

对于以上的通用模板,其类型推导(即确定模板参数T的类型)同时取决于ParamTypeexpr的类型,遵循以下的规则:

  • ParamType是引用/指针,但不是通用引用(universal reference),例如template<typename T> void f(T& param)

    从字面意思看,我们想要进行引用传值,因此:

    • T的类型不应该包括expr类型中(可能)的引用/指针,因为引用/指针已经包含在ParamType中了。
    • 如果ParamType没有包含cv限定符,T的类型应该包含expr类型中(可能)的cv限定符,否则expr的cv限定符在引用传值之后就丢失了。

    因此,这种情况下类型推导的行为是比较符合直觉的:

    • 如果expr的类型是引用,忽略引用部分。(如int& => int
    • expr的类型与ParamType做模式匹配以确定T

    以下注释中的typeof(X) == YY仅表示X的类型是YY,不代表typeof()的实际行为,下同。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    template<typename T>
    void f(T& param);

    template<typename T>
    void g(const T& param);

    template<typename T>
    void h(const T&& param);

    template<typename T>
    void i(T* param);

    int x = 233;
    const int cx = x;
    const int& rx = x;
    const int* px = &x;

    f(x); // typeof(x) == int, typeof(T) == int, ParamType == int&
    f(cx); // typeof(cx) == const int, typeof(T) == const int, ParamType == const int&
    f(rx); // typeof(rx) == const int&, typeof(T) == const int, ParamType == const int&
    f(233); // typeof(233) == int&&, 左值引用无法绑定右值,编译失败

    g(x); // typeof(x) == int, typeof(T) == int, ParamType == const int &
    g(cx); // typeof(cx) == const int, typeof(T) == int, ParamType == const int&
    g(rx); // typeof(rx) == const int&, typeof(T) == int, ParamType == const int&
    g(233); // typeof(233) == int&&, typeof(T) == int, ParamType == const int&

    h(x); // typeof(x) == int, 右值引用无法绑定左值,编译失败
    h(cx); // typeof(cx) == const int, 右值引用无法绑定左值,编译失败
    h(rx); // typeof(rx) == const int&, 右值引用无法绑定左值,编译失败
    h(233); // typeof(233) == int&&, typeof(T) == int, ParamType == const int&&

    i(&x); // typeof(&x) == int*, typeof(T) == int, ParamType == const int*
    i(px); // typeof(px) == const int*, typeof(T) == const int, ParamType == const int*
  • ParamType是通用引用,例如template<typename T> void f(T&& param)

    通用引用希望既能绑定左值,也能绑定右值。即当expr的类型为左值时,ParamType为左值引用;当expr的类型为右值时,ParamType为右值引用。

    为了实现这一目的,此时类型推导的行为是:

    • 如果expr为左值,T被推导为左值引用。由于引用折叠,ParamType的最终类型也为左值引用。
    • 如果expr为右值,使用正常的推导规则。(即:去掉expr类型的引用部分,再与T&&做模式匹配。最后Texpr的原始类型,ParamType为右值引用)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    template<typename T>
    void f(T&& param);

    int x = 233;
    const int cx = x;
    const int& rx = x;

    f(x); // typeof(x) == int, typeof(T) == int&, ParamType == int&
    f(cx); // typeof(cx) == const int, typeof(T) == const int&, ParamType == const int&
    f(rx); // typeof(rx) == const int&, typeof(T) == const int&, ParamType == const int&
    f(233); // typeof(233) == int&&, typeof(T) == int, ParamType == int&&
  • ParamType非指针也非引用。

    此时的语义应该是进行值传递,因此:

    • param不应该成为一个引用
    • 如果ParamType不含cv限定符,param也不应该继承expr(可能)包含的cv限定符,因为它们是两个不同的变量

    于是此时的类型推导行为是:

    • 如果expr包含引用,忽略引用部分。
    • 如果expr包含cv限定符,忽略cv限定符。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    template<typename T>
    void f(T param);

    template<typename T>
    void g(const T param);

    int x = 233;
    const int cx = x;
    const int& rx = x;

    f(x); // typeof(x) == int, typeof(T) == int, ParamType == int
    f(cx); // typeof(cx) == const int, typeof(T) == int, ParamType == int
    f(rx); // typeof(rx) == const int&, typeof(T) == int, ParamType == int
    f(233); // typeof(233) == int&&, typeof(T) == int, ParamType == int

    g(x); // typeof(x) == int, typeof(T) == int, ParamType == const int
    g(cx); // typeof(cx) == const int, typeof(T) == int, ParamType == const int
    g(rx); // typeof(rx) == const int&, typeof(T) == int, ParamType == const int
    g(233); // typeof(233) == int&&, typeof(T) == int, ParamType == const int

    const char *const ptr = "Hello World";

    f(ptr); // typeof(ptr) == const char* const, typeof(T) == const char*, ParamType == const char*

    注意最后ptr的例子:param只是移除了top-level cv限定符,即指针本身的constness。指针指向的仍然是const char*

数组实参

当数组实参被值传递给函数模板时,TParamType会退化为指针;当数组实参被引用传递给函数模板时,T会被推导为数组类型,形参类型则会是对数组的引用:

1
2
3
4
5
6
7
8
9
10
template<typename T>
void f(T param);

template<typename T>
void g(T& param);

int arr[10];

f(arr); // typeof(arr) == int[10], typeof(T) == int*, ParamType == int*
g(arr); // typeof(arr) == int[10], typeof(T) == int[10], ParamType == int (&)[10]

这一特性使我们可以通过模板参数推导获得数组大小:

1
2
3
4
template<typename T, size_t N>
constexpr size_t arraySize(T (&)[N]) {
return N;
}

函数实参

当函数类型的实参被传递给函数模板时,若ParamType不是引用,param的类型为函数指针;若ParamType为引用,param的类型为函数的引用。

1
2
3
4
5
6
7
8
9
10
template<typename T>
void f(T param);

template<typename T>
void g(T& param);

char* foo(int, double) { return nullptr; }

f(foo); // typeof(T) == char*(*)(int, double), ParamType == char*(*)(int, double)
g(foo); // typeof(T) == char*(int, double), ParamType == (char*)(&)(int, double)

Item 2: Understand auto type deduction

auto类型推导的规则和模板类型推导基本一样,因为:

1
const auto& rx = cx;

就相当于:

1
2
3
4
template<typename T>
void func_for_rx(const T & param);

func_for_rx(cx);

模板参数T的类型就是auto推导出的类型,以此类推。

auto类型推导不同于模板类型推导的唯一例外来自于std::initializer_list,在使用花括号列表初始化auto变量时:

  • 如果使用了等号,auto被推导为std::initializer_list<T>。(如果列表中元素类型不一致,无法推导出T,编译失败)
  • 如果不使用等号,初始化列表中只能有一个元素,并用它推导auto。如果初始化列表中有多个值,编译失败。

这是现行标准的行为,与书中描述的并不一致,见New Rules for auto deduction from braced-init-list. (open-std.org)

1
2
3
4
5
6
7
int i1{1};		// decltype(i1) == int
int i2 = {2}; // decltype(i2) == int
auto i3{3}; // decltype(i3) == int
auto i4 = {4}; // decltype(i4) == std::initializer_list<int>

auto e = {1, 2};// decltype(e) == std::initializer_list<int>
auto f{1, 2}; // error: direct-list-initialization of ‘auto’ requires exactly one element

而模板类型推导不会假设花括号初始化列表是std::initializer_list<T>类型,除非显式指定ParamTypestd::initializer_list<T>

1
2
3
4
5
6
7
8
template<typename T>
void f(T param) {}

template<typename T>
void g(std::initializer_list<T> param) {}

f({1, 2, 3}); // 编译失败,无法推断T的类型
g({1, 2, 3}); // decltype(T) == int

在C++14中,auto允许被用来推导函数返回类型,也可以在lambda函数中推导形参类型。但是在这两种场景下,auto都适用模板类型推导的规则。这就意味着它不会假设花括号初始化列表是std::initializer_list<T>类型。因此以下写法无法编译:

1
2
3
4
5
6
7
8
9
auto foo() {
return {1, 2, 3}; // error: 无法根据{1, 2, 3}推断返回类型
}

auto bar = [](auto &v) {
cout << v << endl;
};

bar({1,2,3}); // error: 无法推导出{1, 2, 3}的类型

Item 3: Understand decltype

decltype获得一个变量名/表达式的类型:

  1. 如果参数是一个变量名,decltype返回其完整类型(包含cv限定符和引用)
  2. 如果参数是一个类型为T的表达式,decltype根据表达式的值类型返回TT的引用类型:
    • 表达式为将亡值(xvalue),返回T&&
    • 表达式为左值(lvalue),返回T&
    • 表达式为纯右值(prvalue),返回T

这意味着decltype(x)decltype((x))的结果可能不同,例如:

1
2
3
4
5
6
7
const int &&x = 1;
if (is_same_v<decltype(x), const int&&>) {
cout << "decltype(x) is const int&&" << endl;
}
if (is_same_v<decltype((x)), const int &>) {
cout << "decltype((x)) is const int &" << endl;
}

x是变量名,decltype(x)返回其完整类型const int&&;而(x)是类型为const int的左值表达式,因此decltype((x))返回其左值引用类型,即const int&

decltype的一个典型用途是声明函数模板的返回类型,这个返回类型依赖于形参的类型,没法直接写出来。例如,由于std::vector<bool>这个特例的存在,std::vector<T>operator []的返回值并不一定是T&

假设我们有一个wrapper,需要返回std::vector某下标处元素的引用:

1
2
3
4
5
6
7
8
9
10
11
12
template <typename T, typename Index>
T& getVector(std::vector<T>& v, Index i) {
return v[i];
}

int main() {
std::vector<int> vi = {1, 2, 3, 4, 5};
std::vector<bool> vb = {true, false, true, false, true};

getVector(vi, 3); // 正确
getVector(vb, 3); // 错误!
}

我们会发现,getVector(vb, 3)的类型并不是bool&,而是std::vector<bool>内部的某个代理类,不能被绑定到bool类型的左值引用上,因此编译无法通过。

我们无法写出getVector的返回类型,因为它和T具体是什么有关。当然,我们可以为T == bool提供一个特化版本,但更优雅的解决方法是使用decltype推导返回类型:

1
2
3
4
template <typename T, typename Index>
auto getVector(std::vector<T>& v, Index i) -> decltype(v[i]) {
return v[i];
}

这一写法下,auto用来占位,真正的返回类型尾置,通过decltype(v[i])推导得出。当T不为bool时,这一推导会得到T&

但这一写法有个小小问题:v[i]return语句和decltype中出现了两次,如果这个表达式很长,代码就会显得很啰嗦。

在C++14中,编译器可以根据返回语句直接推导返回类型,而不需要尾置类型,即:

1
2
3
4
template <typename T, typename Index>
auto getVector(std::vector<T>& v, Index i) {
return v[i];
}

但此时应用的是模板函数类型推导规则(而非decltype的推导规则),因此当T不为bool时,v[i]的类型为T&,真正的返回类型为T。(因为返回类型auto并不是引用/指针类型,发生值传递)

这显然不是我们期望的行为,这会导致getVector(vi, 3) = 4;这样的语句将无法通过编译,因为赋值运算符左边是一个右值。

如何既不使用尾置返回类型,又使用decltype的推导规则呢?C++14允许我们使用decltype(auto)作为返回类型:

1
2
3
4
template <typename T, typename Index>
decltype(auto) getVector(std::vector<T>& v, Index i) {
return v[i];
}

这一写法表明,我们希望编译器根据返回语句推导返回类型,但使用decltype的推导规则,而非模板函数类型的推导规则。这一写法的效果与尾置返回类型是等价的。

Item 4: Know how to view deduced types

我们可以从IDE报告的信息,编译器报错信息,或者typeid(x).name等方式查看类型推导的结果,但它们的保证都很弱,不一定准确,不要轻信它们。例如类型的cv限定符,引用部分等可能会丢失,见书上的例子。

在我的实践中,通过is_same_v<T, U>(C++17之后)或is_same<T, U>::value将待测类型T与已知类型U进行比较,结果还是比较准确的。

auto

Item 5: Prefer auto to explicit type declarations

作者建议能使用auto声明变量的地方,就尽量使用auto,因为:

  1. 有些类型名称非常冗长:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    template<typename It>           //对从b到e的所有元素使用
    void dwim(It b, It e) //dwim(“do what I mean”)算法
    {
    while (b != e) {
    // typename std::iterator_traits<It>::value_type currValue = *b;
    auto currValue = *b;

    }
    }
  2. C++14中,lambda表达式的形参也可以使用auto,起到一定的泛型效果。在下面的例子里,如果不使用auto声明形参,就无法使用两个double变量调用foo()

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    int main() {
    // auto foo = [](const int *a, const int *b) {
    auto foo = [](const auto *a, const auto *b) {
    return *a < *b;
    };

    int a = 1;
    int b = 2;
    double c = 3.0;
    double d = 4.0;

    foo(&a, &b);
    foo(&c, &d);
    }
  3. 避免发生一些隐式类型转换。例如std::vector<int>::size()的返回类型其实是std::vector<int>::size_type,如果习惯性用一个比较小的变量类型去存它,在某些平台上可能发生溢出,使用auto规避了这个问题。

    1
    2
    3
    std::vector<int> v(10000, 0);
    // u_int8_t size = v.size();
    auto size = v.size();

    另一个例子如下:ump中的元素类型其实是std::pair<const std::string, int>,而我们在第一轮循环中错误地遗漏了const。因此,编译器在每轮迭代中都会通过拷贝构造ump中的元素,生成一个类型为std::pair<std::string, int>的临时对象,再将p绑定为这个临时对象的引用。这带来了拷贝开销,并且不是我们期望的行为,我们希望p直接绑定为ump中元素的引用。使用auto就不会有这个问题。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    int main() {
    std::unordered_map<std::string, int> ump;

    ump.insert(std::make_pair("a", 1));
    ump.insert(std::make_pair("b", 2));
    ump.insert(std::make_pair("c", 3));

    for (const std::pair<std::string, int>& p: ump) {
    cout << &p << endl; // 输出临时对象的地址
    }

    for (const auto &p: ump) {
    cout << &p << endl; // 输出ump中元素的地址
    }
    }
  4. 使用auto能略微减少一些重构开销。如果一些变量使用auto声明,并使用某个函数的返回值初始化。一旦这个函数的返回类型发生改变,这些变量的类型也会一起改变。如果不使用auto声明,就要手动修改这些变量的类型。

Item 6: Use the explicitly typed initializer idiom when auto deduces undesired types

auto当然也不是万能的,有时候我们需要显式指定auto变量的类型。

一个例子是std::vector<bool>带来的。在下面的代码中,我们调用features()生成一个std::vector<bool>类型的临时对象,并在其上调用operator [],用其结果初始化一个auto变量,然后将该变量值传递给handle()函数。

1
2
3
4
5
6
7
8
9
10
11
12
std::vector<bool> features() {
return {true, false, true};
}

void handle(bool status) {
cout << "status: " << status << endl;
}

int main() {
auto status = features()[1];
handle(status);
}

这段代码的问题是:

  • std::vector<bool>::operator[]的返回类型并不是bool&,而是一个用于模拟bool&行为的std::vector<bool>::reference类型。
  • 这个类型可以隐式转换为bool,因此如果我们用bool来声明status没有任何问题。
  • 然而,由于我们用auto声明了status,隐式转换并没有发生,status就是一个std::vector<bool>::reference类型的值,它的行为类似一个指向features()返回的std::vector<bool>中某个位置的引用。
  • features()的返回值是一个右值,在第10行语句执行完后,这个引用(status)就是空悬的,因此我们将它传给handle()函数会导致未定义行为。

要解决这个问题,我们可以不使用auto,或者通过一个static_cast显式指出我们需要的变量类型:

1
auto status = static_cast<bool>(features()[1]);

Moving to Modern C++

Item 7: Distinguish between () and {} when creating objects

花括号初始化不允许隐式narrowing conversion

1
2
3
4
int main() {
double a = 1.0, b = 2.0;
int c = {a + b}; // 错误
}

如果有的话,花括号初始化总是优先匹配接收std::initializer_list的构造函数(即使由于narrowing conversion,这样的调用无法成功):

1
2
3
4
5
6
7
8
9
10
class A {
public:
A(int, double) {}
A(std::initializer_list<int>) {}
};

int main() {
A a1(1, 2.0); // 调用A(int, double)
A a2{1, 2.0}; // 试图调用A(std::initializer_list<int>),因为无法变窄转换而失败
}

Item 8: Prefer nullptr to 0 and NULL

C++98之前使用0NULL表示空指针,前者的类型是int,后者一般也是某个整型类型。使用它们可能会被当作整形处理,丢失”空指针“的语义。

C++11之后最好使用nullptr,它的类型是std::nullptr_t。这个类型不是指针类型,但是可以隐式转换成任何指针类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void foo(char* cp) {}

template <typename T>
void bar(T p) {
foo(p);
}

template <typename T>
void takePointer(T* p) {
foo(p);
}

int main() {
bar(0); // invalid conversion: int -> char*
bar(NULL); // invalid conversion: long(或其他某种整型类型) -> char*
bar(nullptr); // 正确

takePointer(nullptr); // 错误,nullptr不是指针类型
}

Item 9: Prefer alias declarations to typedefs

C++11后,声明类型别名时应该优先采用using,而不是C-style的typedef

1
2
3
typedef std::ios_base::fmtflags flags;
// 等价于
using flags = std::ios_base:fmtflags;

定义一个函数指针类型时,使用using的语法比typedef容易理解:

1
2
3
using FuncType = void (*)(int, double);
// 等价于
typedef void (*FuncType)(int, double);

using还可以用来声明类型别名模板(alias template),typedef要想实现同样的目的,得定义一个模板类。同时,using还能避免出现nested dependent type name,可以少写一个typename

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <typename T>
using ptr_t = T*;

template <typename T>
struct ptr {
typedef T* type;
};

template <typename T>
class Widget {
ptr_t<T> p1; // 编译器确信ptr_t<T>是alias template,一定是一个类型名
typename ptr<T>::type p2; // 编译器无法确定ptr<T>::type是类型名,万一有个偏特化把它定义成变量呢?
};

int main() {
ptr_t<int> p1 = new int(5);
ptr<int>::type p2 = new int(5);
}

Item 10: Prefer scoped enums to unscoped enums

C-style的enum被称为unscoped enum,因为枚举名会泄漏出所在的作用域,污染全局名称:

1
2
enum Color { red, green, blue };
int red = 1; // Error: Re-definition of red

在C++11中,使用scoped enum(又称为枚举类enum class)是更好的选择,它不污染全局名称。

在enum class中,枚举名是强类型,不像unscoped enum的枚举名可以隐式转换为整型或浮点类型:

1
2
3
4
5
6
7
enum class Color { red, green, blue };
enum Color2 { red, green, blue };

int main() {
if (red > 1.0) {} // 正确,隐式类型转换
if (Color::red > 1.0) {} // 错误
}

enum class的枚举名类型默认为int,可以显式指定这一类型:

1
2
3
4
5
6
7
enum class Color { red, green, blue };
enum class Color2: char { red, green, blue };

int main() {
cout << sizeof(Color::red) << endl; // 4
cout << sizeof(Color2::red) << endl;// 1
}

enum class允许前置声明,unscoped enum则必须指定了枚举值类型才能前置声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
enum class Color;
void foo(Color color) {}
enum class Color { Red, Green, Blue };

enum Color2; // 错误

enum Color3 : int;
void bar(Color3 color) {}
enum Color3 : int { Red, Green, Blue };

int main() {
foo(Color::Blue);
bar(Color3::Blue);
}

unscoped enum在少数场景下也是有用的,比如我们就是需要枚举名泄漏到自身作用域之外:

1
2
3
4
5
6
7
8
enum UserInfoFields { uiName, uiEmail, uiReputation };
using UserInfo = std::tuple<std::string, std::string, int>;

int main() {
UserInfo uinfo;

auto name = std::get<uiName>(uinfo);
}

这个例子里,我们将std::get<0>(uinfo)变成std::get<uiName>(uinfo),以增加代码可读性。

如果要使用enum class,这个例子就不得不变成:

1
2
3
4
5
6
7
8
9
enum class UserInfoFields { uiName, uiEmail, uiReputation };
using UserInfo = std::tuple<std::string, std::string, int>;

int main() {
UserInfo uinfo;

auto name =
std::get<static_cast<std::size_t>(UserInfoFields::uiName)>(uinfo);
}

不仅需要加上枚举类的名字,还得做一次类型转换。

Item 11: Prefer deleted functions to private undefined ones.

C++11之前,我们防止调用成员函数的方法是将其声明为private。现在可以使用=delete标记这些函数。

=delete还可以用来删除非成员函数,比如删除函数的某些重载以防止非预期的隐式类型转换:

1
2
3
4
5
6
7
8
9
void isLucky(int n) {}
void isLucky(char c) = delete;
void isLucky(double d) = delete;

int main() {
isLucky(42);
isLucky('a'); // error
isLucky(3.14); // error
}

=delete还可以用来标记偏特化的模板函数,禁止其实例化。下面的例子里,foo()只接收非指针类型的实参:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T>
void foo(T t) {}

template <typename T>
void foo(T* t) = delete;

int main() {
foo(1);
foo(nullptr);

int i = 1;
foo(&i); // error
}

Item 12: Declare overriding functions override

override用于显式指出派生类函数覆写了对应的基类函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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() override; //错误:与Base::mf1()的constness不一样,是overload而非override
virtual void mf2(unsigned int x) override; // 错误:与基类同名函数的参数列表不同,不是override
virtual void mf3() && override; // 错误:与基类同名函数的引用限定符不同,不是override
void mf4() const override; //错误:不是虚函数,不能override
};

引用限定符指定成员函数只能被左值/右值对象调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Widget {
public:
using DataType = std::vector<double>;
DataType data() & { return values; }
DataType data() && { return std::move(values); }

private:
DataType values;
};

int main() {
Widget w;
auto vals1 = w.data(); // vals1 从左值生成
auto vals2 = Widget().data(); // vals2 从右值生成
return 0;
}

Item 13: Prefer const_iterators to iterators

对不需要修改容器元素的场景,都应该使用const_iterators,它是常量迭代器,不能修改所指向的元素。

STL容器(如std::vector)提供类似于cbegin() cend() crbegin() crend()的成员函数,用于返回const_iterators

遍历裸数组也可以使用迭代器,C++11提供了非成员函数的std::begin()std::end()

1
2
3
4
5
int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9};

for (auto it = begin(arr); it != end(arr); ++it) {
*it = 0;
}

但是C++14才有非成员版本的cbegin() cend() rbegin() crbegin()等函数,如果编译器只支持到C++11,可以这么实现:

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

如果container是STL容器,std::begin将调用continer.begin(),返回const_iterator;如果container是裸数组,std::begin返回指向数组元素类型的常量指针,它们都相当于常量迭代器。

Item 14: Declare functions noexcept if they won’t emit exceptions

C++11后,应该使用noexcept表示函数不抛出异常。原有的throw(exception list)语法已经在C++17后被移除,但是throw()仍可以使用,(在C++17以后)等价于noexcept

在C++17以前,noexcept的性能可能比throw()好一点,因为:

noexcept is an improved version of throw(), which is deprecated in C++11. Unlike pre-C++17 throw(), **noexcept** will not call std::unexpected, may or may not unwind the stack, and will call std::terminate, which potentially allows the compiler to implement noexcept without the runtime overhead of throw(). As of C++17, throw() is redefined to be an exact equivalent of noexcept(true).

标记noexcept可能可以提升性能:

  1. 编译器不需要考虑为异常处理路径生成代码。
  2. 库函数可能视调用的函数是否为noexcept,表现出不同的行为。例如,一个接收右值的vector::push_back不一定能用移动语义代替拷贝语义,因为如果容器发生了扩容,旧元素已经被移动走了,而在新位置移动构造新元素时抛了异常,容器的exception safe就被破坏了。如果移动构造函数被标记为noexceptpush_back就可以放心使用移动语义。(举个例子,不代表真实实现)

默认构造函数,析构函数,拷贝构造/拷贝赋值,移动构造/移动赋值,和内存释放函数(如operator delete)都被隐式声明成noexcept,除非显式通过noexcept(false)指定它们可能抛出异常。

Item 15: Use constexpr whenever possible

constexpr声明的变量是编译期常量,其值在编译期可知。因此constexpr变量必须被常量表达式初始化:

1
2
3
4
5
int i = 1;
cosnt int ci = 1;

constexpr int j = i; //错误:i不是常量表达式
constexpr int k = ci; //正确

constexpr对象一定是const对象,反之不然。

constexpr可以声明函数,该函数的所有实参如果都在编译期可知,并且只调用了constexpr函数,就可以在编译期求值;否则,该函数会在运行时进行计算,就像普通函数一样:

1
2
3
4
5
6
7
8
9
10
11
constexpr int f(int x) {
return x * 2;
}

int main() {
int i = 3;
constexpr int ci = 3;

std::array<int, f(i)> a; //错误:f(i)不是const expression
std::array<int, f(ci)> b; //正确
}

以下的所有计算都可以在编译期完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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; }

constexpr void setX(double newX) noexcept { x = newX; }
constexpr void setY(double newY) noexcept { y = newY; }

private:
double x, y;
};

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

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

int main() {
constexpr auto mid = midpoint({3.0, 4.0}, {5.0, 6.0});
constexpr auto reflectedMid = reflection(mid);
}

Item 16: Make const member functions thread safe

本节是想说明,在使用mutable实现logical constness的成员函数时,尽管这个成员函数被标记为const,但却不是只读的,需要修改mutable成员,因此是并发不安全的。如果需要多线程调用,需要上锁或者用std::atomic保证并发安全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Polynomial {
public:
using RootsType = std::vector<double>;

RootsType roots() const // 需要修改`mutable`成员
{
std::lock_guard<std::mutex> g(m);

if (!rootsAreValid) {
rootVals = calculateRoots();
rootsAreValid = true;
}

return rootVals;
}

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

Item 17: Understand special member function generation

如果不声明任何特殊函数(默认构造,析构,拷贝构造,拷贝赋值,移动构造,移动赋值),编译器会生成它们的默认实现,但是:

  • 如果声明了拷贝构造函数或者拷贝赋值运算符,编译器将不会生成默认的移动构造函数/移动赋值运算符,通过右值调用构造函数/赋值运算符将会匹配到拷贝构造/拷贝赋值的版本。

    声明拷贝行为往往意味着trivial的拷贝行为不合适,此时编译器认为trivial的移动行为也不合适,因此不生成默认实现。

    照理来说,声明了拷贝构造函数/拷贝赋值,意味着拷贝赋值/拷贝构造函数的trivial行为也不合适,这是Rule of Three的体现。但是C++98时代的编译器没有践行这个原则,还是会生成另一个拷贝操作的默认实现。

    C++11标准认为这是不合理的,写道

    The generation of the implicitly-defined copy constructor is deprecated if T has a user-defined destructor or user-defined copy assignment operator.

    但实现上,编译器往往还是会出于兼容性考虑,保持和C++98一样的行为,继续生成另一个拷贝操作的默认实现。

  • 如果声明了移动构造函数,拷贝构造/拷贝赋值/移动赋值都不会自动生成;如果声明了移动赋值,拷贝构造/拷贝赋值/移动构造都不会自动生成。

    如果声明了移动行为,说明拷贝的trivial行为也不合适,因此拷贝构造/拷贝赋值不会自动生成。

    如果声明了移动构造/移动赋值,说明移动的trivial行为不合适,编译器也不会生成另一个移动操作(即移动赋值/移动构造)的默认实现。(这里不同于拷贝操作,因为移动操作没有历史包袱)

  • 如果声明了析构函数,编译器不会生成默认的移动构造函数/移动赋值运算符。

    Rule of Three要求用户同时声明析构函数,拷贝构造和拷贝赋值,这是因为声明析构函数往往意味着类需要管理其他资源,以至于需要在析构函数中释放,这暗示了拷贝构造/拷贝赋值也不能使用默认行为。但C++98时代并没有充分重视这个推论,因此尽管声明了析构函数,编译器还是会生成默认的拷贝构造/拷贝赋值运算符;但对于移动语义就没必要前向兼容了,因此编译器不会自动生成移动构造/移动赋值运算符。

    现在应该遵循的原则是Rule of Five:同时声明析构函数,拷贝构造/赋值,移动构造/赋值。

Smart Pointers

Item 18: Use std::unique_ptr for exclusive-ownership resource management

std::unique_ptr的大小等同于原始指针,而且对于包括解引用在内的大多数操作,他们执行的指令完全相同。

std::unique_ptr只能移动,不能拷贝。

std::unique_ptr可以被指定不同的deleter用于替代默认的对象销毁行为,deleter必须是一个接受unique_ptr<T, Deleter>::pointer类型参数的callable object

deleterstd::unique_ptr类型的一部分,因此使用不同类型的deleter将会影响std::unique_ptr对象的大小。下面的例子使用了4种不同的callable object作为deleter,并打印出std::unique_ptr在我的平台下的大小:

  • 使用lambda表达式和函数对象基本不会增加std::unique_ptr的大小,因为此时deleter是一个空类,编译器可以做空基类优化(EBO),通过private继承的方式把deleter嵌入std::unique_ptr类型中。参考知乎用户LeeCarry的文章
  • 使用函数指针让std::unique_ptr增加了一个指针大小,这是直观的结果,因为多存了一个指向deleter的指针。
  • std::function太重了,直接导致std::unique_ptr的大小变成了40字节。

因此使用lambda表达式作为deleter是一个兼顾易用性和内存占用的选择。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class A {};

auto DeleterLambda = [](A* a) {
cout << "deleter" << endl;
delete a;
};

void DeleterFunction(A* a) {
cout << "deleter2" << endl;
delete a;
};

struct DeleterStruct {
void operator()(A* a) {
cout << "deleter3" << endl;
delete a;
}
};

std::function<void(A*)> DeleterFunctionObject = [](A* a) {
cout << "deleter4" << endl;
delete a;
};

int main() {
std::unique_ptr<A, decltype(DeleterLambda)> p1(new A, DeleterLambda);
std::unique_ptr<A, decltype(DeleterFunction)*> p2(new A, DeleterFunction);
std::unique_ptr<A, DeleterStruct> p3(new A, DeleterStruct());
std::unique_ptr<A, std::function<void(A*)>> p4(new A, DeleterFunctionObject);

cout << sizeof(p1) << endl; // 8
cout << sizeof(p2) << endl; // 16
cout << sizeof(p3) << endl; // 8
cout << sizeof(p4) << endl; // 40
}

std::unique_ptr还有一个用于数组的特化形式std::unique_ptr<T[]>,只有这个形式才有operator []。同时,只有用于非数组的一般形式才有operator *operator ->

1
2
3
4
5
6
7
8
9
int main() {
std::unique_ptr<int> p1(new int);
std::unique_ptr<int[]> p2(new int[4]);

*p1; //正确
p1[0]; //错误
*p2; //错误
p2[0]; //正确
}

不过,使用std::array等容器是比std::unique_ptr<T[]>更好的选择。

Item 19: Use std::shared_ptr for shared-ownership resource management

std::shared_ptr确保对象在引用计数为0时被析构,相较于std::unique_ptr,这是要付出性能代价的:

  • std::shared_ptr需要包含一个指向引用计数(其实是控制块)的指针,因此大小往往是裸指针的两倍。
  • 这个引用计数独立于对象存在,需要额外分配内存来存它。
  • 对引用计数的修改必须是原子的,因为多个std::shared_ptr可能并发修改它。

std::shared_ptr也可以接收deleter用于自定义对象销毁行为。与std::unique_ptr不同的是,std::shared_ptrdeleter不是类型的一部分,而是通过构造函数传入。

1
2
3
4
5
6
auto deleter = [](int* p) {
cout << "deleter" << endl;
delete p;
};

std::shared_ptr<int> p(new int(1), deleter);

为了存储引用计数和(可能的)deleter等元数据,std::shared_ptr会维护一个指向控制块(control block)的指针。每个被std::shared_ptr管理的对象都应该有且仅有一个控制块。为了确保控制块的唯一性,std::shared_ptr按照以下规则创建控制块:

  • std::make_shared总是创建控制块。
  • std::unique_ptr或裸指针构建std::shared_ptr时,总是创建控制块。
  • std::shared_ptrstd::weak_ptr构建std::shared_ptr时,不会创建控制块。

因此,使用同一个裸指针初始化两个std::shared_ptr是一种糟糕的行为,这会导致一个对象拥有两个控制块,很可能会被析构两次。

1
2
3
4
5
6
7
8
9
int main() {
{
int *p = new int(1);

std::shared_ptr<int> sp1(p);
std::shared_ptr<int> sp2(p);
}
// double free!
}

我们一般不会写出这样的代码,因为使用裸指针接收new的返回值并不合适,违背了RAII原则。但是,在使用类的this指针时,我们可能会意外犯下这个错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Widget;

std::vector<std::shared_ptr<Widget>> processedWidgets;

class Widget {
public:
void process() {
processedWidgets.emplace_back(this);
}
};

int main() {
std::shared_ptr<Widget> spw(new Widget);
spw->process();
return 0;
}

Widget::process()中,我们使用裸指针this创建了一个指向Widget对象的std::shared_ptr,然而在它之前已经有一个名为spwstd::shared_ptr指向它了,因此这一用法是错误的。

问题的本质是我们想在成员函数中使用一个当前对象的std::shared_ptr。针对这一目的,标准库中提供了工具类enable_shared_from_thisWidget需要public继承std::enable_shared_from_this<Widget>,并使用该类中的shared_from_this()成员函数替代this指针。

1
2
3
4
5
6
class Widget: public std::enable_shared_from_this<Widget>{
public:
void process() {
processedWidgets.emplace_back(shared_from_this());
}
};

这里的继承方式是Effective C++ Item 49中提及的CRTP(Curiously Recurring Template Pattern)

std::enable_shared_from_this的实现细节:

  • std::enable_shared_from_this<T>包含一个std::weak_ptr<T>类型的成员变量。
  • 如果T继承了std::enable_shared_from_this<T>,在构造std::shared_ptr<T>T类型对象时,这个std::weak_ptr会被赋值并指向当前对象。这样一来,当前对象就可以通过这个std::weak_ptr拿到对应的控制块。
  • 使用shared_from_this()时,通过这个std::weak_ptr初始化一个std::shared_ptr并返回。

更多细节可以参考这篇文章及评论。

在C++17之前,不同于std::unique_ptrstd::shared_ptr并没有一个std::shared_ptr<T[]>的特化版本,它也没有operator[]。尽管可以让T被推导为数组类型,并提供一个使用delete []的自定义deleter,但还是不建议用std::shared_ptr管理裸数组,而应该使用stl容器。

C++17之后,std::shared_ptr拥有operator [],也能在指向数组时默认使用delete []作为deleter,可以管理裸数组:

1
std::shared_ptr<int[]> p(new int[10]);

Item 20: Use std::weak_ptr for std::shared_ptr-like pointers that can dangle

本节介绍了std::weak_ptr的应用场景:

  • Observer设计模式,一个subject对象有若干个observer对象,subject的状态改变时需要通知observer。由于subject不需要管理也不关心observer的生命周期,只是需要一个指针来通知observer,这个指针可以是std::weak_ptr,保证observer生命周期结束后,subject不会错误地访问它。
  • 打破std::shared_ptr的循环引用。

std::weak_ptr虽然不影响指向对象的(强)引用计数,但它需要维护弱引用计数,因此也需要控制块。std::weak_ptrstd::shared_ptr使用相同的控制块,因此它们的性能开销也基本相同。

Q:为什么需要弱引用计数?

A:弱引用计数是控制块的引用计数。对象失去最后一个弱引用计数意味着没有std::weak_ptr指向它了,控制块才可以被销毁。

Item 21: Prefer std::make_unique and std::make_shared to direct use of new

更推荐通过std::make_sharedstd::make_unique创建智能指针,而不是在智能指针的构造函数中使用new

1
2
auto p1 = std::shared_ptr<int>(new int(10));
auto p2 = std::make_shared<int>(10); // recommended

这样做的好处有:

  1. 不需要把类型名写两遍

  2. 异常安全。在std::shared_ptr<Widget>(new Widget)这种写法中,new和创建智能指针两个操作不是原子的,中间一旦被异常打断就会发生内存泄漏。使用std::make_shared不存在这个问题。

    1
    2
    // computePriority()如果抛出异常,可能导致内存泄漏
    processWidget(std::shared_ptr<Widget>(new Widget), computePriority());

    C++17后已经不存在这样的问题,见这里

  3. std::make_shared会在一次内存分配中同时分配存储对象的空间,和存储std::shared_ptr控制块的空间,有性能优势。

但是make系列函数也有局限之处:

  1. 不支持自定义删除器,如果有这种需求必须使用构造函数+new
  2. 由于需要分配控制块,std::make_shared会分配超过对象大小的内存空间,因此它使用::new,而不是对象自定义的operator new。这一行为与构造函数+new不同。
  3. 由于std::make_shared分配的对象和它的控制块来自于同一次内存分配,只有当对象失去最后一个std::weak_ptr引用(弱引用)后(此时控制块可以被回收),这块内存才会被回收。如果对象很大,可能导致析构和内存回收之间出现较大延迟;如果使用构造函数+new创建指向对象的std::shared_ptr,控制块和对象本身位于不同内存中,因此当对象失去最后一个std::shared_ptr引用(强引用)后,对象内存就可以被回收。

Item 22: When using the Pimpl Idiom, define special member functions in the implementation file

我们希望用std::unique_ptr作为裸指针的平替实现pimpl idiom,并写出这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// widget.h
#include <memory>

class Widget {
public:
Widget();
private:
struct Impl;
std::unique_ptr<Impl> pImpl;
};

// widget.cpp
#include "widget.h"
#include <string>
#include <vector>

struct Widget::Impl {
std::string name;
std::vector<double> data;
};

Widget::Widget()
: pImpl(std::make_unique<Impl>())
{}

// main.cpp
#include "widget.h"

int main() {
Widget w;
return 0;
}

这段代码无法通过编译。问题在于编译器会为Widget生成默认析构函数(在widget.h中,class Widget的定义处),该析构函数需要析构pImpl变量,即使用delete销毁内置于std::unique_ptr的裸指针。但是在std::unique_ptr的实现中,销毁裸指针之前会通过static_assert确保它指向的不是一个incomplete type,而Widget::Impl就是一个incomplete type,导致错误。

使用std::shared_ptr代替std::unique_ptr则不会有这个问题,因为std::shared_ptr的删除器不是指针类型的一部分,允许指向一个不完整类型,而std::unique_ptr只能指向完整类型。

1
2
3
4
5
6
7
8
class A;

int main() {
auto up = std::unique_ptr<A>(); // 错误
auto sp = std::shared_ptr<A>(); // 正确

return 0;
}

当然,实现pimpl idiom用std::shared_ptr太重了,因此我们不考虑用std::shared_ptr实现pimpl idiom的方案。

为了解决这个问题,我们在class Widget的定义中声明析构函数Widget::~Widget(),但在Widget::Impl的定义出现后才定义它(其实只是使用了默认定义)。这样一来,std::unique_ptr的析构函数就不会使用不完整的类型Widget::Impl了。

当然,由于我们声明了析构函数,编译器不会自动生成其他特殊函数,需要我们手动声明。它们同样要求Widget::Impl是完整类型,因此我们按照同样的方式处理,在class Widget中声明这些函数,但在Widget::Impl被定义后才定义。

  • 移动构造/移动赋值可以使用默认行为。
  • 拷贝构造/拷贝赋值不能使用默认行为(因为std::unique_ptr不可拷贝),我们需要手动实现,让它们拷贝pImpl指针指向的Widget::Impl对象。

因此最后的代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// widget.h
#include <memory>

class Widget {
public:
Widget();
~Widget();

Widget(Widget&& rhs);
Widget& operator=(Widget&& rhs);

Widget(const Widget&);
Widget& operator=(const Widget&);
private:
struct Impl;
std::unique_ptr<Impl> pImpl;
};

// widget.cpp
#include "widget.h"
#include <string>
#include <vector>

struct Widget::Impl {
std::string name;
std::vector<double> data;
};

Widget::Widget()
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget() = default;

Widget::Widget(Widget&& rhs) = default;
Widget& Widget::operator=(Widget&& rhs) = default;

Widget::Widget(const Widget& rhs): pImpl(std::make_unique<Impl>(*rhs.pImpl)) {}
Widget& Widget::operator=(const Widget& rhs) {
*pImpl = *rhs.pImpl;
return *this;
}