C++Tips

photo by 🧔‍♂️ Michal Kmeť(https://unsplash.com/@mitko?utm_source=templater_proxy&utm_medium=referral) on Unsplash|990x657

[!quote] Intuition is the supra-logic that cuts out all the routine processes of thought and leaps straight from the problem to the answer. — Robert Graves

C++的零成本抽象相关概念需要注意的点。C++要达到运行时额外开销,达到零成本执行代码逻辑,需要额外注意代码写法(自由的代价)。

虚函数

虚函数的代价: - 虚函数表中转,多一次寻址 - 间接调用函数,可能影响分支预测,导致降低CPU指令流水线执行效率(当然,这需要看实际场景,以及编译器优化) - 无法内联,这是最致命的 如果只是需要多态特性,如果利用编译器多态不太麻烦,那么是一种提升执行效率的方法。

隐藏拷贝

对于非平凡类型(trivial,涉及堆内存分配,不只有基础数值类型),在 member initialization 中需要注意写法

1
2
3
4
5
6
class A {
public:
A(B b): b_(std::move(b)) {}
private:
B b_;
}
如果不是 move,那么b 会在传参时构造一次,A构造时,再构造一次。

对于 for 循环遍历复杂类型的容器,注意使用 type&,而不是 type。

对于 lambda 捕获,注意使用 &val 或者 std::move(val) (>=C++14)。

对于复杂类型,注意发生隐式类型转换,比如:

1
2
3
4
std::unordered_map<int, std::string> map;
for(const std::pair<int, std::string>& p: map){
...
}
这段代码用了const引用,但是因为类型错了,所以还是会发生拷贝,因为unordered_map element的类型是std::pair<const int, std::string>,而不是代码中声明的 std::pair<int, std::string> 的 const &。 1. for 循环从 map 中取出一个元素,这个元素的真实类型是 const std::pair<const int, std::string>&。 2. 编译器发现需要将这个元素绑定到你的引用变量 p 上。 3. 但是,const std::pair<int, std::string>&const std::pair<const int, std::string>& 是两种不同的类型。一个非const的引用不能绑定到一个需要类型转换的对象上。 4. 因此,编译器找不到匹配的引用绑定规则,它会尝试寻找其他方式。它发现可以创建一个临时的、类型为 std::pair<int, std::string> 的对象。 5. 为了创建这个临时对象,编译器会调用 std::pair 的拷贝构造函数,将 map 中的原始元素(std::pair<const int, std::string>)拷贝到这个新的临时对象中。 所以在遍历时,推荐使用const auto&,对于map类型,也可以使用结构化绑定。 结构化绑定(Structured Binding)是 C++17 引入的一个非常方便的特性,在 python 中很常见。
1
for (const auto& [key, value] : map) { ... }

隐式析构

使用 RAII 以及作用域自动析构时,需要意识到,被自动调用的析构函数,也许会引入当前代码之外的额外开销。 比如,一个代码块中,某一个对象析构时需要做计时,资源回收等动作,那么,调用者需要时刻提醒自己,不要忘记这部分开销。如果可以缓存特殊对象,以复用,那么缓存它。

平凡析构请使用 = default

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A {
public:
int i;
int j;
~A() {};
};

// or

class A {
public:
int i;
int j;
~A() = default;
};

前一个实现中,非 default 的 nontrivial 类型析构,如果加入 {} 析构,可能导致编译器优化失效,保留了无意义的赋值指令。无论是否是编译器差异,建议使用后一种。

shared_ptr 非必要不要无脑用

关于C++中指针的使用指导意见是: 1. 不涉及资源所有权时,使用裸指针。 2. 使用 unique_ptr 或者 shared_ptr 都引入资源所有权管理。 3. 除非需要共享资源所有权,否则使用 unique_ptr。 shared_ptr 的构造/复制/析构,涉及原子操作,会比裸指针和 unique_ptr 慢 10%到20%。shared_ptr 也要尽量避免拷贝。 std::shared_ptr 一定要使用std::make_shared<T>()而不是std::shared_ptr<T>(new T)来构造,因为后者会分配两次内存,且原子计数和数据本身的内存是不挨着的,不利于cpu缓存。 如果不是因为懒,那么使用 shared_ptr 常见场景是:缓存,异步资源析构。

std::function和std::any 代价

类型擦除类型在 C++ 中是有代价的: 1. std::function要占用32个字节,而函数指针只需要8个字节 2. std::function本质上是一个虚函数调用,因此虚函数的问题std::function都有,无法内联 3. std::function可能涉及堆内存分配,比如用lambda捕获时,用std::function封装会需要在堆上分配内存

除了作为存储不确定类型的函数,其它不推荐使用。如果需要多态函数调用,建议使用编译静态模板分发。

std::any同理,也不推荐作为不确定类型存储之外的其它用途。

std::variant和std::optional 代价

  1. 多余内存开销:std::optional有两个成员变量,类型分别为bool和T,由于内存对齐的原因,sizeof(std::optional)最多会是sizeof(T)的两倍。相比之下,rust语言的option实现则有null pointer optimization,即如果一个类的合法内存表示一定不会全部字节为零,比如std::reference_wrapper,那就可以零开销地表示std::optional,而C++由于需要兼容C的内存对齐,不可能实现这项优化
  2. gcc 8.0.0 之前 std::optional 被作为 trivial 类型,导致 T 如果是 nontrivial ,那么必须也是 nontrivial 的 std::optional 和 gcc 冲突,出现问题。
  3. NRVO 不友好(NRVO 是:当一个函数的返回值是当前函数内的一个局部变量,且该局部变量的类型和返回值一致时,编译器会将该变量直接在函数的返回值接收处构造,不会发生拷贝和移动)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    std::optional<A> f() {
    A v = A();
    return v;
    }

    // 应该改为

    ```cpp
    std::optional<A> f() {
    std::optional<A> v;
    v = A();
    return v;
    }
    1
    2
    3
    4
    5
    6
    7

    ### std::async
    如果 async 使用了默认的 std::launch::deferred policy 执行,那么不会异步执行,而是在 future.get() 是才开始延迟执行,变成同步调用。
    async 返回的 future,它析构时,会同步等待函数返回结果才析构结束。所以
    ```cpp
    std::async(std::launch::async, func1);
    std::async(std::launch::async, func2);
    是同步执行的,而
    1
    2
    auto future1 = std::async(std::launch::async, func1);
    auto future2 = std::async(std::launch::async, func2);
    才是异步执行的。

然后,std::packaged_task 和 std::promise 构造的 std::future 却不会在析构时同步等待,需要注意。

滥用 std::move

move 在以下场景下无用: 1. 对象是 nontrivial 类型 2. 对象是常量引用 以下场景下为副作用: - 影响编译器 NRVO

1
2
3
4
A f() {
A v = A();
return std::move(v); // NRVO 失效
}

尾递归优化

如果某个函数的最后一步操作是调用自身,那么编译器完全可以不用调用的指令(call),而是用跳转(jmp)回当前函数的开头,省略了新开调用栈的开销。 C++ 中,坏在运行时隐式操作,比如析构函数。

1
2
3
4
5
6
7
8
9
unsigned btd_tail(std::string input, int v) {
if (input.empty()) {
return v;
} else {
v = v * 2 + (input.front() - '0');
// input.~string();
return btd_tail(input.substr(1), v);
}
}

此处,由于 input.~string() 这个 trivial 析构的存在,不可平凡析构,导致尾递归失效(有副作用)。 如果换成 std::string_view,这个可以 平凡析构 的 nontrivial 类型,编译器根本不需要调用析构函数,则不影响尾递归优化。

1
2
3
4
5
6
7
8
unsigned btd_tail(std::string_view input, int v) {
if (input.empty()) {
return v;
} else {
v = v * 2 + (input.front() - '0');
return btd_tail(input.substr(1), v);
}
}

向量化

编译器一般会利用现代CPU的向量化指令如 SSE/AVX 等 SIMD 操作指令。但是存在条件: 1. 循环内部访问连续内存 2. 没有 if 分支 3. 循环之间没有依赖 比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum Type { kAdd, kMul };
int add(int a, int b) { return a + b; }
int mul(int a, int b) { return a * b; }

std::vector<int> func(std::vector<int> a, std::vector<int> b, Type t) {
std::vector<int> c(a.size());
for (int i = 0; i < a.size(); ++i) {
if (t == kAdd) {
c[i] = add(a[i], b[i]);
} else {
c[i] = mul(a[i], b[i]);
}
}
return c;
}
可以优化掉 if 分支,以及使用内联函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum Type { kAdd, kMul };
inline __attribute__((always_inline)) int add(int a, int b) { return a + b; }
inline __attribute__((always_inline)) int mul(int a, int b) { return a * b; }

template <Type t>
std::vector<int> func(std::vector<int> a, std::vector<int> b) {
std::vector<int> c(a.size());
for (int i = 0; i < a.size(); ++i) {
if constexpr (t == kAdd) {
c[i] = add(a[i], b[i]);
} else {
c[i] = mul(a[i], b[i]);
}
}
return c;
}
使用模板,实现编译期分支优化。

End

C++ 的自由度,一定程度上使得高性能没那么容易实现,但是也正是因为它的灵活性,让我更喜欢这门语言,而不是目前常用的 Rust。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!