CppCoreGuidelines 二周目笔记 00

CppCoreGuidelines 二周目笔记 00

RayAlto OP

原话: One way of thinking about these guidelines is as a specification for tools that happens to be readable by humans.

我的观点是不要死记硬背,最简单的方法是把 clang-tidy 之类的工具集成到你的开发环境里

重读一遍 Cpp Core Guidlines ,记一些我喜欢的条目,不保证我的理解百分百正确。

00. 对复制操作廉价的输入参数使用值类型传递(F.16)

1
2
void foo(int i); // 没毛病,复制开销可忽略不计,甚至还可能被优化成移动
void foo(const int& i); // 不太行,会造成额外的间接访问

什么情况要用右值:

  • 函数需要保留参数的一个副本然后传给另一个目标(其他函数或存储到非局部位置),在 const T& 基础上加一个 T&& 重载,然后移动给另一个目标
  • 函数仅需要自己局部使用,使用 T
  • 函数需要无条件从参数进行移动,使用 T&&

不要为了「神奇的优化」到处加 T&&

空引用: C++ 没有这种东西,引用始终表示某个有效的对象,可以考虑用智能指针、 std::optional 或代表「没有值」的特殊值(大概像 std::string::npos ?)

01. 转发的使用情况(F.19)

函数中不直接使用的参数可以 std::forward 给目标,目的是不影响参数的 const 性质和右值性质:

1
2
3
4
template<typename F, typename... Args>
inline decltype(auto) invoke(F&& f, Args&&... args) {
return std::forward<F>(f)(std::forward<Args>(args)...);
}

02. 不要返回 std::move(local_variable) (F.48)

这样写就足够了:

1
2
3
4
5
Type foo() {
Type v;
// 对 v 做了一些修改后打算返回
return v; // 没毛病,编译器会隐式移动 v ,甚至 RVO 可能会完全消除移动操作
}

不要瞎操心:

1
2
3
4
5
Type foo() {
Type v;
// 对 v 做了一些修改后打算返回
return std::move(v); // 完全阻止了 RVO
}

返回 std::move(local_variable) 一般都是瞎操心,因为 RVO 最好的情况可以完全消除移动构造,最差的情况和 std::move(local_variable) 相同

03. 慎重在 lambda 里用引用捕获局部变量(F.53)

一般来说用引用捕获是最有效率的,但如果这个 lambda 并不一定在与局部变量相同的作用域里使用(比如是要被返回的、要传给另一条线程的、在堆上动态分配的),慎重用引用捕获来捕获局部变量,比如:

1
2
3
4
5
{ // 某个作用域里
int local_variable {114514};
thread_pool.queue_work([&]() -> void { process(local_variable); });
}
// 现在 local_variable 不是一个有效的变量了,但线程池还拿着它的引用, UB
  • 如果必须捕获这种对象的引用,可以把这种对象用 std::unique_ptr 包装,然后捕获 std::unique_ptr 来自动处理生命周期问题。
  • 如果要捕获 this ,可以采取 *this ,这样会创建一个副本。

04. 构造函数应该完整构造对象(C.41)

就是说对象被构造之后应该就是可用的,像这样的设计是错的:

1
2
3
Type v;
v.init(); // 不应该这样
v.do_something();

如果无论如何都需要构造函数以外的操作才能完整地初始化一个对象(比如需要调用虚函数),考虑工厂模式:

1
2
3
4
class Factory {
public:
static std::unique_ptr<Type> create();
};

( BS/HS 推荐了 std::unique_ptr<Type> 作返回值而不是 Type ,但我个人认为如果 Type 有移动构造而且很廉价,用 Type 作返回值也可以吧)

05. 可复制类应该有默认构造函数(C.43)

为了这样的语句是有效的:

1
std::vector<Type> v(100); // 含有 100 个默认构造的 Type 对象

如果没有默认构造函数,上面的语句不能编译通过,需要像这样来 work around :

1
std::vector<Type> v(100, Type{364, 364});

06. 不对自赋值进行检测(C.62, C.65)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Type& Type::operator=(const Type& v) {
if (this == &v) {
return *this; // 检测到了自赋值
}
foo = v.foo;
bar = v.bar;
return *this;
}

Type& Type::operator=(Type&& v) noexcept {
if (this == &v) {
return *this; // 检测到了自赋值
}
foo = std::move(v.foo);
bar = std::move(v.bar);
return *this;
}

这样确实是安全的,但如果一亿次赋值中只有一次自赋值,则会浪费近一亿次比较 this == &v ,会对效率造成一定影响,如果 foobar 都能保证其自赋值不会产生恶性影响,那么建议忽略自赋值的检测:

1
2
3
4
5
6
7
8
9
10
11
Type& Type::operator=(const Type& v) {
foo = v.foo;
bar = v.bar;
return *this;
}

Type& Type::operator=(Type&& v) noexcept {
foo = std::move(v.foo);
bar = std::move(v.bar);
return *this;
}

07. 多态类避免公开的复制构造(C.67, C.130, C.145)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Base {
public:
Base(const Base&) = default;
Base& operator=(const Base&) = default;
};

class Derived : public Base {
public:
Derived(const Derived&) = default;
Derived& operator=(const Derived&) = default;
};

void foo(const Base& b) {
Base b1 = b;
// ...
}

// ...

Derived d;
foo(d);

这里 foo 里的 b1 只能拿到 Base 里的数据,虽然传进去的是 Derived 类型,但里面的 b1 只能复制成 Base 类型,所以最好把复制构造放进 private/protected= delete 。如果实在需要复制,可以定义 clone 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Base {
public:
Base(const Base&) = delete;
Base& operator=(const Base&) = delete;

virtual std::unique_ptr<Base> clone() const;
};

class Derived : public Base {
public:
Derived(const Derived&) = delete;
Derived& operator=(const Derived&) = delete;

std::unique_ptr<Base> clone() const override;
};

void foo(const Base& b) {
std::unique_ptr<Base> b1 = b.clone();
// ...
}

这样会导致这样的代码是错误的:

1
2
3
4
5
6
7
void foo(Base b) {
// ...
}

Derived d{...};
Base b{d}; // 复制构造被删了,这行编译不过
foo(d); // 同样的理由,编译不过

访问时用指针或引用:

1
2
3
4
5
6
7
void foo(Base& b) {
// ...
}

Derived d{...};
Base& b{d};
foo(d);

08. 为值类型提供 noexcept 且不会失败的 swap 函数(C.83, C.84, C.85)

1
2
3
4
5
6
7
8
9
10
class Type {
public:
void swap(Type& rhs) noexcept {
m1.swap(rhs.m1);
std::swap(m2, rhs.m2);
}
private:
Foo m1;
int m2;
};

为了调用者方便,还可以在同一个命名空间定义一个非成员的 swap

1
2
3
void swap(Type& l, Type& r) {
l.swap(r);
}

标准库里的容器和算法有很多用到 swap 的地方,如果不能保证 swap 不会失败(且 noexcept ),标准库的相关工具也无法正常工作,比如这种写法是设计上失败的:

1
2
3
4
5
void swap(Type& l, Type& r) {
Type temp = l;
l = r;
r = temp;
}

如果创建 temp 涉及到了堆内存分配,就可能抛出异常,而且这种写法也是低效的,有种 Java 的美。

09. 比较运算符两边操作数应该对称且应该是 noexcept 的(C.86, C87)

!=, <, <=, >, >= 这些运算符应该是 noexcept 的,而且两边的操作数应该一致,比如这样:

1
2
3
4
5
6
7
8
struct Data {
std::string name;
int id;
};

bool operator==(const Data& l, const Data& r) noexcept {
return std::tie(l.name, l.id) == std::tie(r.name, r.id);
}

但如果是这样:

1
2
3
4
5
6
7
8
struct Data {
std::string name;
int id;

bool operator==(const Data& r) const noexcept {
return std::tie(name, id) == std::tie(r.name, r.id);
}
};

两边操作数不对称,左边必须是 Data 类型,而右边可以类型转换,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Base {
public:
virtual bool operator==(const Base& r) const noexcept {
return foo == r.foo;
}

private:
int foo;
};

class Derived : public Base {
public:
virtual bool operator==(const Derived& r) const noexcept {
return Base::operator==(r) && bar == r.bar;
}

private:
int bar;
};

Base b{...};
Derived d{...};

b == d // 忽略了 bar

TL;DR 这些比较运算符不应该是成员函数,也不应该是 virtual 的。

10. 如果非要自己特化 hash 就做成 noexcept 的(C.89)

cppreference 的一个例子上改的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct S {
std::string first_name;
std::string last_name;
};

bool operator==(const S& lhs, const S& rhs) noexcept {
return std::tie(lhs.first_name, rhs.first_name) == std::tie(lhs.last_name, rhs.last_name);
}

template<>
struct std::hash<S> {
std::size_t operator()(const S& s) const noexcept {
std::size_t h1 = std::hash<std::string>{}(s.first_name);
std::size_t h2 = std::hash<std::string>{}(s.last_name);
return h1 ^ (h2 << 1);
}
};

11. 避免 protected 成员变量(C.133)

优先用 privateprotected 成员函数没有问题。

我属于是跟 Gtkmm 学坏的,即使 CppCoreGuidelines 这么说了,我觉得 Gtkmm 用 protected 也是合理的,我用 Gtkmm 时也会用 protected (大概)

12. 优先用 virtual 函数再考虑 dynamic_cast (C.146, C.147)

首先 dynamic_cast 保证了安全,是应该使用的,但同时可能会对性能造成影响,如果可能的话用 virtual 函数,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Base {
int i;
virtual void f() const {
std::cout << i << '\n';
}
};

struct Derived : public Base {
int ii;
void f() const override {
std::cout << ii << '\n';
}
};

这样写就是低效的:

1
2
3
4
5
6
7
8
9
void foo(Base& b) {
Derived* pd = dynamic_cast<Derived*>(&b);
if (pd != nullptr) { // b 是 Derived 类型的
pd->f();
}
else { // b 不是 Derived 类型的
b.f();
}
}

这里对指针进行 dynamic_cast 如果失败,结果会是 nullptr ,不会抛出异常,所以如果类型转换失败是一种有效的可能的情况,可以对指针使用 dynamic_cast

不如直接:

1
2
3
void foo(Base& b) {
b.f();
}

或者如果已经通过一些检查确定了对象的具体类型,且十分在意 dynamic_cast 带来的性能损耗( BS 的意思是目前 dynamic_cast 的实现是很快的),可以优先使用 static_cast

1
2
3
4
5
6
7
8
9
10
void foo(Base& b) {
if ( ... ) { // 确认了 b 是 Derived 类型的
// ...
Derived& d = static_cast<Derived&>(b);
d.herpderp();
}
else {
// ...
}
}

static_cast 这样用是导致漏洞的一大原因,见仁见智吧。

如果类型转换失败是一种错误,可以对引用使用 dynamic_cast ,失败会抛出 bad_cast

1
2
3
4
5
6
7
void foo(Base& b) {
if ( ... ) {
Derived& d = dynamic_cast<Derived&>(b); // 失败会抛出 bad_cast
// ...
}
// ...
}

13. 实参依赖查找 (ADL)

比如:

1
2
3
int main() {
std::cout << "hello world!\n";
}

里面的 <<operator<<(std::ostream&, const char*)」 并没有在当前的命名空间,而是在 std 命名空间里,所以对编译器比较友善的写法是这样的:

1
std::operator<<(std::cout, "hello world!\n");

而实际上第一种写法是可以通过编译的,这就是靠 ADL 实现的,因为 operator<< 的左实参 std::cout 指定了其在 std 命名空间里, ADL 就可以顺便找到 std::operator<<(std::ostream&, const char*) ,但 ADL 只能找函数调用表达式中的函数名, i.e. 这样的写法是错误的:

1
std::cout << endl;

ADL 只会帮你找到 << ,而不会帮 << 找到它的右实参 endl

还有一些奇怪的写法:

1
2
endl(std::cout); // 正确的
(endl)(std::cout); // 错误的,这里「(endl)」不是函数调用表达式

14. 枚举不要全大写(Enum.5)

会与宏产生冲突,比如:

1
2
3
4
5
#define RED   0xFF0000
#define GREEN 0x00FF00
#define BLUE 0x0000FF

enum class Color { RED, GREEN, BLUE }; // 冲突

我觉得不好评价

15. 不要无理由指定枚举底层类型(Enum.7)

我经常这样写:

1
enum class Enum : std::uint8_t { FOO, BAR, BAF };

不指定底层类型默认采用 int ,这样是与 C 的 enum 兼容的,读写都是最简单的。

16. shared_ptr, unique_ptrweak_ptr 的使用情景(R.20..24,32..37)

还在 new, delete 的都是原始人,现在都流行用 unique_ptr, shared_ptr 表示所有权了,什么?你说你还在:

1
2
std::unique_ptr<Data> data {new Data{114514}};
std::shared_ptr<Data> data {new Data{114514}};

原始人!现在都流行:

1
2
std::unique_ptr<Data> data = std::make_unique<Data>{114514};
std::shared_ptr<Data> data = std::make_shared<Data>{114514};

优先用 unique_ptr ,需要共享所有权时再用 shared_ptr (比如局部范围内使用)。

作参数时

  • unique_ptr<Type> 表示所有权的转移
  • unique_ptr<Type>& 表示对资源的重新分配(修改这个指针的目标,如赋值或 reset() ),如果单纯是对里面的对象进行修改,可以改用 Type&
  • const unique_ptr<Type>& 烂活,改用 const Type&
  • shared_ptr<Type> 表示共享所有权
  • shared_ptr<Type>& 表示对资源的重新分配
  • const shared_ptr<Type>& 表示后面会把这个参数复制/移动到别的地方,其他任何情况可以改用 Type& 类型参数
  • shared_ptr<Type>&& 烂活,不如直接传值 shared_ptr<Type>