Mastering C++ Standard Library Features

快速掌握 Modern C++ 标准库特性,基于 Vittorio Romeo 发布的教程编撰。

C++ 后面跟着的数字越来越大,才发现漏学了亿个特性,赶紧捡起来重新看一遍。

1. Value Categories and Move Semantics

1.1 L-values and R-values

区分左值与右值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int a;

// 顾名思义,左值指出现在赋值号左边
a = 0;
// 左值可被取址
int* a_ptr = &a;
// 左值可被引用
int& a_ref = a;

// 下面都是左值:
// (*) 变量名
a;
// (*) 对象成员
struct { int _b; } f;
f._b;
// (*) 函数返回可引用值
int& bar(); // = {static int i = 1; return i;}
&bar();
bar() = 5;
1
2
3
4
5
6
7
8
9
10
11
int&& rv_ref0 = 5;
int&& rv_ref1 = bar(); // {return 1;}

//下面都是右值:
// (*) 数字
5;
10.33f;
// (*) 内建表达式
5 + 10 * 3;
// (*) 函数返回可不引用值
bar();

函数的左、右值参数重载

1
2
3
4
5
6
7
8
9
10
void foo(int &) { std::cout<<"non-const lvalue ref\n"; }
void foo(int &&) { std::cout<<"non-const rvalue ref\n"; }
void bar(const int&) { std::cout<<"const lvalue ref\n"; }
int main() {
int a = 5;
foo(a); // non-const lvalue ref
foo(5); // non-const rvalue ref
bar(a); // const lvalue ref
bar(5); // const lvalue ref
}
  • C++ 表达式分为两类:左值与右值。
  • 左值可以出现在内建赋值号左侧,右值不能。
  • 左值引用只能与左值绑定
  • 右值引用只能与右值绑定

1.2 R-value refences and std::move

右值的意义:表示一个没有标识的临时对象,可以认为一个右值能将自己的资源所有权转移给他人。

std::move

  • 顾名思义,std::move将一个左值(或其他)映射为右值引用
  • 实际上并没有发生内存变化
  • 一般来说使用一个被move后的值是不安全的
  • moving when returning is unnecessary and sometimes detrimental
1
2
3
4
5
6
7
8
9
10
11
// C++03
std::vector<data> v0;
std::vector<data> v1 = v0; // copy internal buffer

// C++11 +
std::vector<data> v0;
std::vector<data> v1 = std::move(v0); // transfer ownership
// 此处使用了 vector(vector && other) 构造函数

// std::move 本质只是一个静态映射
auto v2 = static_cast<std::vector<data>&&>(v0);

使用move后的值可能会导致未定义行为(undefined behavior)错误。

1
2
3
std::vector<data> v0;
std::move(v0);// No-op. 映射为一个右值却没有用于赋值或构造,即啥事都没发生
v0.size() // Perfectly safe.

RVO(Return value optimization),返回值优化,一种编译器技术:https://en.wikipedia.org/wiki/Copy_elision

return时使用std::move会导致编译器不启用 RVO,从而降低效率。

1
2
3
4
5
6
7
8
9
std::vector<data return_example() {
std::vector<data> v0;

// Wrong:
// return std::move(v0);

// Correct:
return v0;// lvalue
}

当然如果需要刻意地将成员所有权移出对象,择机而用。

1
2
3
4
5
6
struct foo {
std::vector<data> v;

std::vector<data> get_v() { return v; } // lvalue, equal to "this->v"
std::vector<data> move_v() { return std::move(v); } // rvalue
}

1.3 Practical Uses of std::move

Move Semantics in the Standard Library

  • Many classes in the standard library are “move aware”
  • Some classes represents “unique ownership” and are move-only
1
2
3
4
5
6
7
std::pair<foo, int> p;
auto p_copy = p;
auto p_move = std::move(p);

std::tuple<foo, int, char> t;
auto t_copy = t;
auto t_move = std::move(t);

std::vector以及其他 container 的索引特性也会随着传入值变化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
std::vector<foo> v;
auto v_copy = v;
auto v_move = std::move(v);

// 将一个临时值添加进容器
v.push_back(foo{});

foo f;

// copy
v.push_back(f);

// move
v.push_back(std::move(f));
// http://en.cppreference.com/w/cpp/container/vector/push_back

有些类型具有 Unique Ownership 特性,运行时只能由一个标识持有所有权,只能使用std::move来赋值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::thread t{[]{ std::cout<<"thread!"; }};
// auto t_copy = t; // calls `thread(const thread&) = delete;`
auto t_move = std::move(t); // calls `thread(thread &&) noexcept;`

// 再者还有 unique_lock 与 unique_ptr
{
std::mutex m;
std::unique_lock<std::mutex> ul{m};
// auto ul.copy = ul;
auto ul_move = std::move(ul);
}
{
std::unique_ptr<int> up = std::make_unique<int>(1);
// auto up_copy = up;
auto up_move = std::move(up);
}

避免std::move导致的无谓消耗

  • 将对象移入容器

    设想一种情况:将文件以std::string的形式读入内存并放入std::vector

    1
    2
    3
    4
    5
    6
    std::vector<std::string> files;
    for (int i = 0; i < 10; ++i) {
    std::string s = read_large_file(i);
    // ...do some processing on `s`...
    files.push_back(s);
    }

    那么问题来了,上面的代码有无缺陷?该如何优化?

    1. line 1后插入files.reserve(10);能预先将容器大小设置为 10 以避免容器扩展时的内存复制行为。
    2. line 5修改为files.push_back(std::move(s));,当然就是我们本章所述的优化重点。
  • move取代复制容器

    1
    2
    3
    4
    5
    6
    7
    8
    void consume_multiples(std::map<int, std::vector<int>);

    std::map<int, std::vector<int>> multiples_of;
    for (int i = 0; i < 100; ++i) {
    auto i_multiples = get_multiples(i);
    multiples_of[i] = i_multiples;
    }
    consume_multiples(multiples_of);

    改进:

    1. line 6i_multiples添加std::move,或者可以直接将line 5-6合并为multiples_of[i] = get_multiples(i);
    2. line 8也可以套一层std::move,但需要注意,consume_multiples形参为左值形式,所以依然会复制内存。

    关于 Sink Argument:https://dzone.com/articles/c-sink-parameter-passing

    在为函数配置参数的时候,需要同时考虑左值与右值的情况。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    struct person {
    std::string _name;
    // before c++ 11
    person(const std::string& name) : _name{name} { }
    void set_name(const std::string& name) { _name = name; }

    // after c++ 11, added below overloads
    person(std::string&& name) : _name{std::move(name)} { }
    void set_name(std::string&& name) { _name = std::move(name); }
    }

    如上写法在传入左值时只执行一次复制,传入右值时只执行一次移动,确实难以挑刺。可是当参数个数为 N 时,我们需要重载 2^N 个函数以保证每个参数都能匹配左右值。

    所以有了一个基本范式:

    1
    2
    3
    4
    5
    struct person {
    std::string _name;
    person(std::string name) : _name{std::move(name)} { }
    void set_name(std::string name) { _name = std::move(name); }
    }
    • 传入左值时,将值复制std::string name中,接着移动给成员(1 次复制 + 1 次移动)。
    • 传入右值时,将值移动std::string name中,接着移动给成员(1 次移动 + 1 次移动)。

    这样一来能够牺牲极为微小的性能(一次移动),来节省指数级个数的重载函数。

1.4 Value Categories: The Full Picture

只把值分为左值、右值远远不能满足 c++ 移动语法的要求。事实上我们需要使用一个树来定义各个值的详细类型。

官方定义的值分类:https://en.cppreference.com/w/cpp/language/value_category

叶子节点(子分类)由三个类别组成:

  • PR-value (“Pure R-value”) 纯右值

  • L-value 左值

  • X-value (“eXpiring r-value”) 将亡值

分类的用例:https://www.cnblogs.com/happenlee/p/9337776.html

X-value 与 L-value 统称为 GL-value,泛左值,Generalized L-value。

X-value 与 PR-value 统称为 R-value,右值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct foo { };

foo prvalue();
foo& lvalue();
foo&& xvalue();

int main() {
// 此处为*纯右值*
prvalue();
// 此处为*左值*,它为一个左值的引用,且不能被移动
lvalue();
// 此处为*将亡值*,`foo&&`是一个右值的引用
xvalue();

foo f;
f; // 左值
std::move(f); // 将亡值,相较纯右值有了左值的特性
}

1.5 Perfect Forwarding

当没有使用参数推导(argument deduction)时,&意为左值引用,&&意为右值引用。

1
2
void take_lvalue(int&);
void tale_rvalue(int&&);

而仅在使用模板参数推导时,&&意为转发引用(forwarding reference)。哪里有 type deduction,哪里才有 forwarding reference。

转发引用研究:https://isocpp.org/blog/2012/11/universal-references-in-c11-scott-meyers
Chinese Ver.:https://www.cnblogs.com/ConfuciusPei/p/12420082.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template <typename T>
void take_anything(T&&); // 不再是右值引用

void example() {
take_anything(0);
// `0` 是纯右值,即右值
// * `T` 推导为 `int`
// * `T&&` 推导为 `int&&`
int x = 0;
take_anything(x);
// `x` 是左值
// * `T` 推导为 `int&`
// * `T&&` 推导为 `int&`
}

template <typename T>
struct foo {
// !important 这个`T`未被推导,故`T&&`仅仅是类型`T`的右值引用
void not_a_forwarding_reference(T&&);
}

注意下面这个例子,将解释为什么需要转发引用以及完美转发的实现方式:

1
2
3
4
5
6
7
8
9
10
11
12
class dictionary {
private:
std::vector<std::string> _words;
public:
template <typename T>
void add(T&& s) {
_words.push_back(std::forward<T>(s));
// 如果`s`是左值,则`std::forward`不做任何操作
}
// 这样一来我们既不需要之前的范式新增多余的move操作
// 也不必重复写相同的代码覆盖所有值分类
}

std::forward是怎样工作的?Cppref 中显式地定义为static_cast<T&&>(t)

1
2
3
4
5
6
7
void sink(int&);
void sink(int&&);

template <typename T>
void pipe(T&& x) {
sink(std::forward<T>(x));
}

可以这样认为:std::forward是一种带有条件的std::move,它仅在传入值为右值时才执行移动操作。

C++11 引入了如下 2 条规则:

  1. 模板函数对右值引用参数的推导

    向一个模板函数传递一个左值实参,同时该模板函数的对应形参是右值引用,编译器会把该实参推导为左值引用。

  2. 引用折叠

    由于存在引用的引用,而 C++ 不允许显式地定义它们,故编译器会将这种 double reference 折叠成 single reference。

    原类别 折叠类别
    T& &&,T&& &,T& & T&
    T&& && T&&

Used std::exchange to elegantly implement move operations for your classes

1
2
3
4
5
6
7
template<class T, class U = T>
constexpr // since C++20
T exchange(T& obj, U&& new_value) {
T old_value = std::move(obj); // 将`obj`转移至`old_value`上
obj = std::forward<U>(new_value); // 为`obj`完美转发`new_value`的值
return old_value;
}

2. Smart Pointer

2.1 What problem do they solve?

  • Problems with manual dynamic memory allocation
  • Why new/delete are considered harmful

传统使用newdelete时,如不规范会造成:

  • delete造成的内存溢出
  • 多次delete,即 Double-free Error
  • 访问已被delete的指针
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int memory_leak() {
int* x = new int{42};
return *x;
}

int double_free() {
int* x = new int{42};
delete x;
delete x; // Undefined behavior, VERY DANGER!!!
}

int use_after_free() {
int* x = new int{42};
delete x;
return *x; // Undefined behavior
}

函数中若传入一个原生指针,我们无法分辨它是否需要在函数内delete。选择销毁,外部若再销毁一次就会出现错误;选择不销毁,外部若不销毁则造成内存溢出。真是两难。

1
2
3
4
5
void bar(int* x) {
// Who owns `x`?
// Am I supposed to `delete` it?
// Can I assume that it is non-null?
}

混乱的所有权需要写好多文档来声明:

1
2
3
4
5
6
struct abc {
std::vector<foo*> _foos;
// 谁负责创建与销毁`_foos`中指针指向的各个元素?
// 还是说`abc`仅仅是`foo`实例的引用?
// `abc`的复制构造函数、析构函数应该怎样设计?
}

这些问题在智能指针面前不攻自破。

2.2 Unique ownership: std::unique_ptr

需要明确以下概念:

  • 所有资源都需要被申请释放
  • 动态内存、文件、线程都是资源的一种
  • 唯一所有权(unique ownership)指有且只有一个所有者(owner)负责申请、释放资源
  • 只能通过其所有者来访问资源

std::unique_ptr是一个 move-only 的类型,以表示对资源的唯一所有权。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 欲使用智能指针,需包含 <memory> 头文件
#include <memory>

void not_so_smart() {
foo* f = new foo;
// ...use `f`...
delete f;
}

void smart() {
std::unique_ptr<foo> f{new foo};
// ...use `f`...
// automaticly delete `f`
}

std::unique_ptr对象包装一个原始指针,并负责其生命周期。当该对象被销毁时,会在其析构函数中删除关联的原始指针。上例两个函数的操作与性能开销完全相同,不过后者能大幅减少写出 bug 的可能。

1
2
3
std::unique_ptr<foo> f{new foo};
auto f1 = f; // ERROR: called a deleted function
auto f2 = std::move(f); // kanzen ojbk

f将所有权交给f2后,f的销毁不会导致任何副作用。在函数中也是如此:

1
2
3
4
5
6
7
8
9
10
11
12
std::unique_ptr<foo> gen_ptr() {
std::unique_ptr<foo> f{new foo};
return f;
}
void take_ptr(std::unique_ptr<foo> f) {
std::cout << "took ownership of `f`\n";
// then `~f()` called
}
int main() {
auto f = gen_ptr();
take_ptr(std::move(f));
}

然而!混合使用new与智能指针,也仍会有一些运行时的潜在问题发生。阅读如下代码片段:

1
2
3
4
5
void foo(std::unique_ptr<int>, int);
int err() { throw std::runtime_error{"whoops!"}; }
int main() {
foo(std::unique_ptr<int>{new int{5}}, err());
}

foo被调用前有三种可能的执行顺序:

#0 #1 #2
申请资源new int[5]
构造unique_ptr
执行err()抛出异常
执行err()抛出异常
申请资源new int[5]
构造unique_ptr
申请资源new int[5]
执行err()抛出异常
构造unique_ptr

上述三种情况中,#0 与 #1 相对来说是内存安全的,而 #2 会导致内存溢出!unique_ptr没能接管申请的new int[5],而是被异常中断了,意味着产生了被申请却未被释放的资源。

这时候就需要std::make_unique的帮助了:

1
foo(std::make_unique<int>(5), err());

How it works?

我们来尝试自己实现一个unique_ptr吧!

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
class UniquePtr {
private:
T* _ptr{nullptr};

public:
UniquePtr() = default;
UniquePtr(T* ptr) noexcept
: _ptr{ptr} {}
~UniquePtr() noexcept { delete _ptr; }

// prevent copies
UniquePtr(const UniquePtr&) = delete;
UniquePtr& operator=(const UniquePtr&) = delete;

// ownership transfer
UniquePtr(UniquePtr&& rhs) noexcept
: _ptr{ptr} {
rhs._ptr = nullptr;
}
UniquePtr& operator=(UniquePtr&& rhs) noexcept {
_ptr = rhs._ptr;
rhs._ptr = nullptr;
return *this;
}
};

Run-Time Overhead:https://godbolt.org/g/EdBxSu

吐槽一下,最新的 GCC 能把未使用的变量直接优化消失……(-O2 人类大脑发光.jpg)

2.3 Shared ownership: std::shared_ptr and std::weak_ptr

相较于std::unique_ptrstd::shared_ptr多了一个“共享”的属性。std::shared_ptr能够被复制,同时使用”引用计数“来确认还有多少个智能指针可以访问被申请的资源。直到”引用计数“归零,即没有任何std::shared_ptr能够访问该资源时,才将该资源析构释放。

来看下面这个例子理解何时释放资源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void sharing() {
std::shared_ptr<foo> keep_alive;
{
std::shared_ptr<foo> f0{new foo}; // reference count = 1
auto f1 = f0; // reference count = 2
auto f2 = f0; // reference count = 3
auto f3 = f0; // reference count = 4
keep_alive = f2; // reference count = 5
} // f0, f1, f2, f3 destroyed, reference count -= 4
assert(keep_alive); // OK // reference count = 1
}
void transferring() {
std::shared_ptr<foo> f0{new foo};
auto f1 = std::move(f0); // reference count = 1
}

同样的,共享指针也有一个配套函数std::make_shared

1
2
template< class T, class... Args >
shared_ptr<T> make_shared`( Args&&... args );

Run-Time Overhead: https://godbolt.org/g/hkjiyi

std::weak_ptr是一种不控制对象生命周期的智能指针,它指向一个std::shared_ptr来对其进行访问与管理。其中一些重要的成员函数:

  • .expired() -> bool:检测所管理的对象是否被释放。
  • .lock() -> std::shared_ptr<T>:若对象未被释放,则返回一个强引用,否则返回一个空的std::shared_ptr
  • .use_count() -> long:返回所管理对象的引用计数值。

其中operator*operator->没有重载,并且它在拷贝或赋值时不会增加引用计数。

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
void checking_existence() {
std::weak_ptr<int> wp;

assert(wp.use_count() == 0);
assert(wp.expired());

{
auto sp = std::make_shared<int>(42);
wp = sp;

assert(wp.use_count() == 1);
assert(!wp.expired());

// 第二个引用
auto sp2 = sp;

assert(wp.use_count() == 2);
assert(!wp.expired());
}

assert(wp.use_count() == 0);
assert(wp.expired());
}
void accessing_objects() {
std::weakp_ptr<int> wp;
assert(wp.lock() == nullptr);

auto sp = std::make_shared<int>(42);
wp = sp;
assert(*wp.lock() == 42);
}

std::weak_ptr还能够避免std::shared_ptr的循环引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void cycle() {
struct b;
struct a {
std::shared_ptr<b> _b;
~a() { std::cout<<"~a()\n"; }
}
struct b {
std::shared_ptr<a> _a;
~b() { std::cout<<"~b()\n"; }
}
auto sa = std::make_shared<a>();
auto sb = std::make_shared<b>();
sb->_a = sa;
sa->_b = sb;
}

执行完上例代码后,sasb依然没有被释放。将line 8修改为std::weak_ptr<a> _a;,便能成功执行两个析构函数。

2.4 Guidelines

No Allocation Is Better then Allocation

  • Makeing use of the stack and value-semantic types should be preferred to dynamic allocation
  • Objects on the stacl are easier to reason about and more “predictable”
  • Dynamic memory usage can hava a significant cost: allocations are not free, they can reduce locality, and the compiler is often not able to aggressively optimize
  • Sometimes allocations are necessary - allocate only when you need to
  • Always use smart pointers - do not use new/delete

std::unique_ptr as Your First Choice

  • If dynamic allocation is necessary, std::unique_ptr should be your first choise
  • It is a zero-cost abstraction over new/delete (except for some rare cases)
  • It models unique ownership, which is simple and easy to reason about

std::shared_ptr Should Be Used Sparingly

  • It is not a zero-cost abstraction over new/delete - it can have significant overhead due to atomic reference-counting operations
  • Shared ownership can be harder to reason about

Always Use std::make_xxx Functions to Create Smart Pointers

  • They prevent potential memory leaks
  • They make the code terser and more readable
  • They do not require new to be explicitly called
  • They can greatly improve performance for std::shared_ptr

Role of Raw Pointers in Mordern C++

  • In Mordern C++, raw pointers are “optional reference” to ab object
  • A raw pointer should never imply ownership
  • As long as raw pointers are not managing memory, they’re fine to use

最佳实践

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
template <typename T>
struct linked_list {
T _value;
std::unique_ptr<linked_list<T>> _next;
}

template <typename T>
struct binary_tree {
T _value;
std::unique_ptr<binary_tree<T>> _left;
std::unique_ptr<binary_tree<T>> _right;
binary_tree<T>* _parent;
}

struct texture;
struct mesh;
struct game_object {
std::shared_ptr<texture> _texture;
std::shared_ptr<mesh> _mesh;
}

template <typename T>
struct cahce {
std::unordered_map<std::string, std::weak_ptr<T>> _items;
}

3. Creating Movable Classes

3.1 Standard Library Support for Movable Types

  • Implemeted move operations for your types allows your code to be faster, safer, and more expressive.
  • Learned that the standard library provides a huge number of move-aware containers and utilities, which make use of move operations where posiible.
  • Studied that the mordern C++ libraries follow the steps of the standard library and will strive to provide more-awareness.
  • Understood that always think about move semantics and move-awareness when creating your own types.

回顾一下类型构造时采取的不同参数,先来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct foo {
foo() { std::cout << "foo()\n"; }
~foo() { std::cout << "~foo()\n"; }
foo(const foo&) { std::cout << "foo(const foo&)\n"; }
foo(foo&&) { std::cout << "foo(foo&&)\n"; }
};
void vector_move_awareness {
std::vector<foo> v; v.reserve(10);
foo f0;
// "foo()"
v.push_back(f0);
// "foo(const foo&)"
v.push_back(std::move(f0));
// "foo(foo&&)"
v.push_back(foo{});
// "foo()"
// "foo(foo&&)"
}
// "~foo()" ** 5

可以看见,在一些对象被构造时出现了很多无必要的移动语义。这种情况可以通过使用一些容器(Container)来避免、甚至完全回避移动语义。std::vector::emplace_back就是一个很好的例子:

1
2
3
4
5
6
7
8
9
10
11
struct bar {
int _x;
bar(int x) : _x{x} { }
};
void vector_emplacement() {
std::vector<bar> v; v.reserve(10);
// Moves `bar` temporary
v.push_back(bar{42});
// Constructs `bar` instance "in place"
v.emplace_back(42);
}

emplace_back能将参数完美转发到对应的容器内并进行构造。而对于一些嵌套的构造来说,就不可避免地发生移动语义:

1
2
3
4
5
6
7
8
9
struct bar_wrapper {
bar _b;
bar_wrapper(bar b) : _b{std::move(b)} { }
};
void vector_emplacement_and_move() {
std::vector<bar_wrapper> v;
// 即使`bar_wrapper`能够完美转发,但是参数`bar{42}`仍然需要在此处被构建,并被移动
v.emplace_back(bar{42});
}

除了std::vector,还有很多标准库中的容器支持emplace,譬如std::mapstd::setstd::list

也有很多通用函数与类支持移动语义,例如:

  • 通用功能:std::swapstd::exchange
  • 封装类:std::pairstd::tuple
  • 计数器:std::move_iterator

Utility Functions

std::swap来说,在 C++11 之前,它通常如下定义:

1
2
3
4
5
6
template <typename T>
void old_swap(T& x, T& y) {
T tmp{x}; // copy #0
x = y; // copy #1
y = tmp; // copy #2
}

居然执行了三次复制操作,可谓十分消耗性能了。在 C++11 之后,开发者们就将复制语义全部更换为移动语义:

1
2
3
4
5
6
template <typename T>
void new_swap(T& x, T& y) {
T tmp{std::move(x)}; // copy #0
x = std::move(y); // move #1
y = std::move(tmp); // move #2
}

熟悉这些通用功能还能简化我们的代码。还记得我们之前实现的UniquePtr吗?它的移动构造函数就可以如下定义:

1
2
3
4
5
6
7
// ownership transfer
UniquePtr(UniquePtr&& rhs) noexcept
: _ptr{std::exchange(rhs._p, nullptr)} { }
UniquePtr& operator=(UniquePtr&& rhs) noexcept {
_ptr = std::exchange(rhs._ptr, nullptr);
return *this;
}

Wrappers

std::pairstd::tuple是将数个对象封装在一起的通用组件。由于tuple涵盖了绝大部分pair的特性,后文将仅以tuple举例。

tuple实例可以通过std::make_tuple或是赋值创建。

1
2
3
4
5
6
7
8
9
struct foo { };
struct bar { };
void creating_and_assigning() {
std::tuple<foo, bar, int> t0{{}, {}, 1};
auto t1 = std::make_tuple(foo{}, bar{}, 5);

t0 = t1;
t1 = std::move(t0);
}

封装类内部的元素可以通过std::get来获取。

1
2
3
4
5
6
7
8
9
10
void retrieving_by_index() {
auto t = std::make_tuple(foo{}, bar{}, 5);
// Getting lvalue references
foo& i0 = std::get<0>(t);
bar& i1 = std::get<1>(t);
int& i2 = std::get<2>(t);
// Moving out of a tuple
foo m0 = std::move(std::get<0>(t));
bar m1 = std::get<1>(std::move(t));
}

从 C++14 开始,在没有重复类型的封装容器内,std::get支持通过传入类型来获取元素。

1
2
3
4
5
void retriving_by_type() {
auto t = std::make_tuple(foo{}, bar{}, 5);
const auto& i = std::get<int>(t);
assert(i == 5);
}

std::tie可以为封装器解包。当需要为函数返回值解包时还是挺有用的。

1
2
3
4
5
6
std::tuple<foo, bar> get_t();
void destructuring() {
foo f; bar b;
std::tie(f, b) = get_t();
// 如果内部对象为右值则会从原封装器中移出。
}

C++17 起出现了std::apply函数,它能将函数应用于tuple中。

1
2
3
4
5
6
void apply_example (){
std::apply(
[](int x, int y) -> int { return x + y; },
std::make_tuple(1, 2)
);
}

类似的,std::make_from_tuple可以通过tuple元素来传入构造函数以创建对象。

1
2
3
4
5
6
struct foobar {
foobar(foo, bar) { }
};
void make_from_tuple_example() {
auto fb = std::make_from_tuple<foobar>(std::make_tuple(foo{}, bar{}));
}

善用tuple可以增加代码粘性,且能快速创建数据结构及其功能原型。

1
2
3
4
5
6
struct two_ints {
int _a, _b;
bool operator==(const two_ints& rhs) const{
return std::tie(_a, _b) == std::tie(rhs._a, rhs._b);
}
}

上述中==可以替换为>>=<<=等,按顺序比较。

std::move_iterator

STL 中提供了大量的基于迭代器的预置算法函数,但大部分算法不支持移动语义。幸运的是大佬们还是将std::move_iteratorstd::make_move_iterator添入了<iterator>头文件中。这使得开发者能够将现存的迭代器“适配”于移动语义。

1
2
3
4
5
6
7
8
9
10
11
12
std::vector<std::string> src{"hello", "world", "iik"};
std::vector<std::string> dst;
const auto condition = [] (const std::string& s) -> bool {return s.size() == 5;};
// 不使用移动语义
std::copy_if(std::begin(src), std::end(src),
std::back_inserter(dst),
condition);
// 使用移动语义
std::copy_if(std::make_move_iterator(std::begin(src)),
std::make_move_iterator(std::end(src)),
std::back_inserter(dst),
condition);

3.2 Rule of Five and Rule of Zero

C++11 之前其实只有 Rule of Three:

在一个类中需要显式地定义以下三项之一时,你应该同时显式地定义其它两个

  • 析构函数(Destructor)
  • 拷贝构造函数(Copy constructor)
  • 重载拷贝赋值函数(Copy assignment)

遵循这一规则能有效地避免开发者申请资源时的错误与疏忽。

而 C++11 之后的 Rule of Five 实际上只是新增了两个约束:移动构造函数(Move constructor)与重载移动赋值函数(Move assignment)。

Rule-of-Three becomes Rule-of-Five with C++11?

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
#include <utility>
struct file_handle;
void acquire(file_handle*); // 申请资源操作
void release(file_handle*); // 释放资源操作
file_handle* share(file_handle*); // 拷贝操作

class file {
private:
file_handle* _fh;
int _flags{0};
public:
~file() { release(_fh); } // 析构时释放资源
// 根据 Rule of Three,需要实现*拷贝构造函数*与*重载拷贝赋值函数*
file(const file& rhs)
: _fh{share(rhs._fh)},
_flags{rhs._flags} { }
file& operator=(const file& rhs) {
_fh = share(rhs._fh);
_flags = rhs._flags;
return *this;
}
// 根据 Rule of Five,需要实现*移动构造函数*与*重载移动赋值函数*
file(file&& rhs)
: _fh{std::exchange(rhs._fh, nullptr)},
_flags{rhs._flags} { }
file& operator=(file&& rhs) {
_fh = std::ecchange(rhs._fh, nullptr);
_flags = rhs._flags;
return *this;
}
}

Rule of Zero 是一种遵循单一职责原则(Sigle Responsibility Principle, SRP)的编码法则,它要求满足 Rule of Five 的类应该专门处理所有权,而其他业务类均不应该有自定义的析构、拷贝/移动构造函数、重载拷贝/移动赋值函数。这一原则作用与上例即将file_handle的处理单独写成一个类型,并只负责资源调度,而负责业务的file不能自定义上述五个函数。

当然我们也可以利用智能指针的特性来封装这个文件类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <memory>
#include <utility>
struct file_handle;
void acquire(file_handle*);
void release(file_handle*);

class unique_file {
private:
std::unique_ptr<file_handle, decltype(&release)> _fh;
int _flags{0};
public:
unique_file(file_handle* fh): _fh{fh, &release} { acquire(fh); }
};
class shared_file {
private:
std::shared_ptr<file_handle> _fh;
int _flags{0};
public:
shared_file(file_handle* fh): _fh{fh, &release} { acquire(fh); }
};

注意shared_ptrunique_ptr中 Deletor 的定义。

3.3 Example: Implementing std::vector

Rule of Five

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
43
44
45
template <typename T>
class vector {
private:
T* _data{nullptr};
std::size_t _size{0}, _capacity{0};
public:
vector() = default;
~vector() { delete[] _data; }
vector(vector&& rhs)
: _data{std::exchange(rhs._data, nullptr)},
_size{rhs._size}, _capacity{rhs._capacity} {}
vector& operator=(vector&& rhs) {
_data = std::exchange(rhs._data, nullptr);
_size = rhs._size;
_capacity = rhs._capacity;
return *this;
}
vector(const vector& rhs)
: _size{rhs._size}, _capacity{rhs._capacity} {
_data = new T{_capacity};
std::copy(rhs._data, rhs._data + _size, _data);
}
vector& operator=(const vector& rhs) {
_size = rhs._size; _capacity = rhs._capacity;
_data = new T{_capacity};
std::copy(rhs._data, rhs._data + _size, _data);
return *this;
}
void push_back(const T& x) {
if (_capacity == _size) {
const auto new_capacity = _capacity + 10;
T* tmp = new T[new_capacity];
std::copy(_data, _data + _capacity, tmp);
std::swap(tmp, _data);
delete[] tmp;
_capacity = new_capacity;
}
_data[_size] = x;
++_size;
}
const auto& at(std::size_t i) const {
assert(i < _size);
return _data[i];
}
};

Rule of Zero

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
template <typename T>
auto copy_uptr_array(const std::unique_ptr<T[]>& src,
std::size_t capacity, std::size_t size) {
auto result = std::make_unique<T[]>(capacity);
std::copy(src.get(), src.get() + size, result.get());
return result;
}
template <typename T>
class vector {
private:
std::unique_ptr<T[]> _data;
std::size_t _size{0}, _capacity{0};
public:
vector() = default;
~vector() { delete[] _data; }
// 使用`default`来避免人为申请资源带来的问题
vector(vector&& rhs) = default;
vector& operator=(vector&& rhs) = default;
vector(const vector& rhs)
: _size{rhs._size}, _capacity{rhs._capacity} {
_data = copy_uptr_array(rhs._data, _capacity, _size);
}
vector& operator=(const vector& rhs) {
_size = rhs._size; _capacity = rhs._capacity;
_data = copy_uptr_array(rhs._data, _capacity, _size);
return *this;
}
template <typename U>
void push_back(U&& x) {
if (_capacity == _size) {
const auto new_capacity = _capacity + 10;
_data = copy_uptr_array(_data, new_capacity, _size);
_capacity = new_capacity;
}
_data[_size] = std::forward<U>(x); // !important
++_size;
}
const auto& at(std::size_t i) const;
};

4. Discover Lambdas

4.1 Lambda Expressions: What Are They?

  • A lambda expression produces a closure
  • A closure is an unnamed function object capable of captureing variables in scope
1
[函数对象参数] (操作符重载函数参数) mutable 或 exception 声明 -> 返回值类型 {函数体}

各项语法与作用详阅:https://www.cnblogs.com/jimodetiantang/p/9016826.html

4.2 Lambda: Function Objects in Disguise

Performance Overhead:https://godbolt.org/g/MTX3mW

  • Every lambda expresgsion produce a unique anonymous type
  • There is no type-erasure: the compiler is able to inline aggresively
  • Lambda expression are zero-overhead abstracions

4.3Anatomy of a Lambda

1
[=, &b, &c](int a, float b)mutable -> int { return a+b; }
  • [/* */] is the capture-list
  • The capture-list is followed by the parameter-list (/* */)
  • The parameter-list is optionally followed by mutable and/or a trailing return type
  • The body of the lambda is always at the end

加上 mutable 修饰符后,可以修改传递进来的拷贝(注意是能修改拷贝,而不是值本身)。exception 声明用于指定函数抛出的异常,如抛出整数类型的异常,可以使用 throw(int)

从 C++14 开始,参数表可以写auto

1
2
3
4
5
6
const auto l = [](const auto& x) { std::cout << x; };
/* ↑ 等价于 ↓ */
struct anonymous {
template <typename T>
auto operator()(const T& x) const { std::cout << x; }
} l;

再举几个有意思的例子:

1
2
3
4
5
6
// 转发引用至目标函数`sink`
const auto l0 = [](auto&& x) { sink(std::forward<decltype(x)>(x)); };
// 不定参数
const auto l1 = [](auto... xs) { log(severity::error, xs...); };
// 往Lambda里传Lambda
const auto l2 = [](auto f) { f(); f(); }; const auto f = [] { std::cout<<"yes!"; };

函数对象参数

  • [ ] { } - 不捕捉任何对象
  • [ =] { } - 按传递所有局部变量(危险)
  • [ &] { } - 按引用传递所有局部变量(危险)
  • [ a] { } - 将 a 进行传递
  • [ &a] { } - 将 a 进行引用传递
  • [ &, a] { } - 将 a 进行传递,其余所有局部变量按引用传递
  • [ =, &a] { } - 将 a 进行引用传递,其余所有局部变量按传递

从 C++14 开始允许向 Lambda 表达式中初始化一些项目,例如:

  • [i = 0] { }
  • [x = std::move(foo)] { }
  • [a{10}, b{15}] { }
1
2
3
4
5
6
7
8
int b;
const auto l = [i = 0, a = b] { };
/* ↑ 等价于 ↓ */
struct anonymous {
int i = 0, a;
anonymous(int b) : a{b} {}
auto operator()() const {}
} l{b};

结合移动语义

1
2
auto up = std::make_unique<foo>();
auto l = [up = std::move(up)]() multable { sink(std::move(up)); };

mutable的有无直接影响这个函数的运作:

1
2
3
4
5
6
7
8
struct without_mutable {
std::unique_ptr<foo> up;
auto operator()() const { sink(std::move(this->up)); }
}
struct with_mutable {
std::unique_ptr<foo> up;
auto operator()() { sink(std::move(this->up)); } // ←少了`const`
}

constexpr 与 C++17

C++17 中,Lambda 表达式均被隐式地加上了constexpr标识。下面三者均等价:

1
2
3
[]{ return 10; }
[]() constexpr { return 10; }
struct ann { constexpr auto operator()() { return 10; } };

同时 Lambda 可以被塞进模板声明了(下列代码在 C++11/14 会发生编译期错误):

1
std::array<int, []{ return 10; }()> ints;

Summary

  • Learned that lambdas can flexibly capture the surrounding environment
  • Studied that new closure data members can be defined with “generalized lambda capures”
  • Discussed that mutable can be used to remove the const quealifier from the closure’s generated operator()
  • Learned that auto parameters can be used to generate closure with a templated operator()
  • Lambdas can be constexpr in C++17

5. Lambdas as First-Class Citizens

5.1 Lambdas: Versatile Tools

什么是第一类对象(First-class citizen)?指可以在执行器创造并作为参数传递给其他函数或存入变量的实体。一般第一类对象所持有的特性为:

  • 可以被存入变数或其他结构
  • 可以被作为参数传递给其他函数
  • 可以被作为函数的返回值
  • 可以在执行期创造,而无需完全在设计期全部写出
  • 即使没有被系结至某一名称,也可以存在

绝大多数语言中,数值与基础型别都是第一类对象,然而不同语言中对函数的区别很大,例如C语言与C++中的函数不是第一类对象,因为在这些语言中函数不能在执行期创造,而必须在设计时全部写好。相比之下,Scheme中的函数是第一类对象,因为可以用 Lambda 函数并作为第一类对象来操作。

C++ 中函数名实际上意味着函数的引用,当一个普通的函数void func(){}作为参数时,func&func是等价的。这也就意味着指向该函数的指针也能使用operator()()执行,即func_ptr()等价于(*func_ptr)()

正因为这个特性,面对参数重载的函数,编译器会不明白选取哪个函数:

1
2
3
4
5
6
7
8
bool predicate(const Foo&);
bool predicate(const Bar&);
void test() {
std::find_if(c.begin(), c.end(), predicate);
// Fails to compile
auto p = static_cast<bool(*)(const Foo&)>(predicate);
// OjbK!
}

<functional>库中有很多功能于 C++14 被弃用了,而这些都能用 Lambda 表达式重新实现:

1
2
3
4
5
6
7
// Deprecated:
auto b0 = std::bind1st(f, 42);

// Recommended:
auto b0 = [](auto&&... xs) {
return f(42, std::forward<decltype(xs)>(xs)...);
}

<functional>: What’s New, And Proper Usage”:https://www.youtube.com/watch?v=zt7ThwVfap0

与重载、 模板共同使用:

1
2
3
4
5
6
7
int add(int a, int b)       { return a + b; }
float add(float a, float b) { return a + b; }
auto add10 = [](auto x) {
return add(static_cast<decltype(x)>(10), x);
}
add10(10); // = 20
add10(10.5f); // = 20.5

5.2 Strong Callable Objects

1
2
3
4
5
6
7
void foo() { std::cout << "hello!\n"; }
////////////
auto p0 = &foo;
void (*p1)() = &foo;
std::vector<void (*)()> vec{&foo};
////////////
p0(); p1(); vec[0](); // ok

正如之前所提到的,Lambda 表达式可以类比为一个匿名的 struct,它具有自己的operator()重载,这意味着每一个匿名函数都会产生一个全新的类型:

1
2
3
4
5
6
// 由于类型不确定,Lambda 表达式必须要与`auto`或者模板相关联
auto p0 = []{ std::cout << "yesyesyes\n"; };
template <typename F>
struct wrapper { F _f; };

wrapper<decltype(p0)> w{std::move(p0)};

即使两个匿名函数拥有完全一致的定义、参数表等,它们的类型还是不一样!

1
2
auto l0 = []{}; auto l1 = []{};
assert(decltype(l0) != decltype(l1));

一个没有指定任何捕获的 Lambda 函数,可以显式转换成一个具有相同声明形式函数指针。所以,像下面这样做是合法的:

1
2
3
auto a_lambda_func = [](int x) { /*...*/ };
void(*func_ptr)(int) = a_lambda_func;
func_ptr(4); //calls the lambda.

而下面这个例子则会引发错误:

1
2
3
4
int i;
auto l0 = [&i]{ };
void(*p0)() = l0;
// error: cannot convert `main()::<lambda()>` to `void(*)()` in initialization

无参 Lambda 表达式可以通过一元运算符 +,显式地转换为对应的函数指针。这样就不需要把函数指针对应形式写出来了,直接 auto 走起:

1
2
3
4
5
auto l0 = []{ };
static_assert(!std::is_same_v<decltype(l0), void(*)()>);

auto p0 = +[]{ }; // converted to function pointer
static_assert( std::is_same_v<decltype(p0), void(*)()>);

Concepts

Concept 指一类数据类型能满足一定条件的模板集合,它可以通过require声明需满足的表达式来为template作出一系列限定。

FunctionObject就是原生的一个 Concept,如下定义:

类型 T 如果是 FunctionObject,需要满足:

  • 类型 T 满足 std::is_object

  • 给定如下条件

    • f, 为 T 的一个变量或常量实例
    • args, 一个匹配的参数表

    该表达式需合法:f(args),表现为函数的调用

Callable 则是需要满足 std::invoke 的调用。详见Cppref

1
2
3
4
5
6
7
8
9
10
11
12
std::invoke(add, -9, 23);

std::invoke([]() {print(42);});

Foo foo(1234);
std::invoke(&Foo::print, foo);

int num = std::invoke(&Foo::_num, foo);
assert(num == 1234);

std::invoke(printer{}, 18);
// Very useful in generic code and template

标准库中也有很多同 Callable 共同起作用的函数:

  • std::function
  • std::bind
  • std::result_of
  • std::thread::thread
  • std::call_once
  • std::async
  • std::packaged_task
  • std::reference_wrapper

Type Erasure

鉴于即使使用了auto f = +[]{ };也难以推断f的实际类型,标准库中引入了std::function来解决这个问题。

  • Type erasure is the idea of “erasing” the concrete type of an object into some common type that can store anything satisfying a particular concept
  • Usually requires indirection and/or dynamic allocations
1
2
3
4
5
struct A {void foo(); };
struct B {void bar(); };
std::any x = A{};
x = B{}; // OK
// ref: https://en.cppreference.com/w/cpp/utility/any

std::function 能为 Callable 对象进行类型擦除。

1
2
3
4
5
6
7
8
9
10
int add(int a, int b) { return a + b; }
const auto sub = [](int a, int b) { return a - b; };
struct mult {
int operator()(int a, int b) const { return a * b; }
}
///////////
std::function<int(int,int)> f = add;
f = sub;
f = mult{};
f = [](int a, int b) { return a / b; };
  • 类模板 std::function 是通用多态函数封装器
  • std::function 的实例能存储、复制及调用任何可调用 (Callable) 目标——函数、 lambda 表达式、 bind 表达式或其他函数对象,还有指向成员函数指针和指向数据成员指针。
  • 它也是对 C++ 中现有的可调用实体的一种类型安全的包裹(相对来说,函数指针的调用不是类型安全的)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// no-member functions
std::function<void(int)> f = print_num;
f(-9);
// lambda expressions
std::function<void()> f = [] { std::cout << 42; };
f();
// member functions
std::function<void(const Foo&, int)> f = &Foo::print;
Foo foo(1234);
f(foo, 1);
// data members
std::function<int(const Foo&)> f = &Foo::_number;
std::cout<< f(foo) << '\n';
// handwritten function objects
std::function<void(int)> f = printer{};
f(1234);

std::function甚至可以为空,并任意重新绑定:

1
2
3
4
5
6
7
int add(int a, int b);
int sub(int a, int b);
//////////////
std::function<int(int, int)> f; // <- empty
f = add; // <- `add`
f = sub; // <- `add`
f = nullptr; // <- empty again

std::function 也可以直接储存在容器等数据结构中:

1
2
3
4
5
6
7
8
struct button {
std::vector<std::function<void()>> on_click;
// ...
};
/////////////
button b;
b += [] { open_modal(1); };
b += [] { std::cout<<"opening modal ...\n"; };

Run-Time Overhead

  • std::function is not a zero-cost abstraction
    • Invoking the stored object requires indirection
    • Can dynamically allocate if the stored object is big
    • Hard to inline/optimize
  • https://godbolt.org/g/i9vyyP

Summary

  • Understood that functions can be stored in variables using function pointers
  • Learned that the hand-written function objects can be easily stored as theirtype is known
  • Learned that closures must be stored with auto as their type is “anonymous”
  • Understood that stateless closures can be implicitly converted to functionpointers
  • Studied that the unary operator + can be used to explicitly produce functionpointers from stateless lambda expressions
  • Learned about the Standard Library provides two concepts: Function0bject and Callable
  • Understood that Callable is more general as it includes member functionpointers and member data pointers
  • std::invoke can be used to invoke any Callable object
  • Looked at many Standard Library utilities that accept Callable objects for genericity
  • std::function is a general-purpose polymorphic Callable wrapperlt supports copyable Callable objects
  • lt uses type erasure and can potentially allocate memory
  • lt is copyable, can be stored in containers, can be “empty” , and can bearbitrarily rebound to other Callable objects

Guideline

  • Never use std::function unless you need type erasure
  • Use auto and templates instead - they can refer to arbitrary Callable` objects without type erasure
  • Use std::function when you need flexibility at run-time or need to store Callable objects with the same signature but different types homogeneously

5.3 Passing Functions to Functions

Mastering Lambdas

Lambdas and the Standard Library

Lambdas as Local Functions

Safer Interfaces with Higher-Order Function

Programming at Compile-Time

Comstant Expressions

constexpr in The Standard Library

Exceptions in constexpr Functions

Computations on Type

Metafunctions

Metaprogramming Utilities in the Standard Library

Example: Creating a Compile-Time Set Data Structure


E C E C
type manipulation 类型转换 paradigms 范式
Share