2022/2/28-2022/3/9
读CppCoreGuidelines,记录感觉有用的条款。
尽量减少函数参数的数量
函数有太多参数的两个最常见的原因是:
-
缺少抽象。 缺少抽象,因此复合值作为单个元素而不是作为强制不变的单个对象传递。这不仅扩展了参数列表,而且会导致错误,因为组件值不再受强制不变量的保护。
-
违反“一项职能,一项职责”。 该功能试图做不止一项工作,可能应该重构。
选择 将 a 定义struct为参数类型并相应地命名这些参数的字段:
struct SystemParams {
string config_file;
string output_path;
seconds timeout;
};
void initialize(SystemParams p);
这往往会使未来的读者清楚地调用它,因为参数通常在调用站点按名称填写。
更喜欢空抽象类作为类层次结构的接口
空的抽象类(没有非静态成员数据)比有状态的基类更可能是稳定的。使用抽象类更好:
class Shape { // better: Shape is a pure interface
public:
virtual Point center() const = 0; // pure virtual functions
virtual void draw() const = 0;
virtual void rotate(int) = 0;
// ...
// ... no data members ...
// ...
virtual ~Shape() = default;
};
对于稳定的库 ABI,考虑 Pimpl idiom
因为私有数据成员参与类布局,私有成员函数参与重载决策,所以对这些实现细节的更改需要重新编译使用它们的类的所有用户。持有指向实现的指针 (Pimpl) 的非多态接口类可以以间接为代价将类的用户与其实现中的更改隔离开来。
例子
界面(widget.h)
class widget {
class impl;
std::unique_ptr<impl> pimpl;
public:
void draw(); // public API that will be forwarded to the implementation
widget(int); // defined in the implementation file
~widget(); // defined in the implementation file, where impl is a complete type
widget(widget&&); // defined in the implementation file
widget(const widget&) = delete;
widget& operator=(widget&&); // defined in the implementation file
widget& operator=(const widget&) = delete;
};
实施(widget.cpp)
class widget::impl {
int n; // private data
public:
void draw(const widget& w) { /* ... */ }
impl(int n) : n(n) {}
};
void widget::draw() { pimpl->draw(*this); }
widget::widget(int n) : pimpl{std::make_unique<impl>(n)} {}
widget::widget(widget&&) = default;
widget::~widget() = default;
widget& widget::operator=(widget&&) = default;
保持函数简短和简单
例子 考虑:
double simple_func(double val, int flag1, int flag2)
// simple_func: takes a value and calculates the expected ASIC output,
// given the two mode flags.
{
double intermediate;
if (flag1 > 0) {
intermediate = func1(val);
if (flag2 % 2)
intermediate = sqrt(intermediate);
}
else if (flag1 == -1) {
intermediate = func1(-val);
if (flag2 % 2)
intermediate = sqrt(-intermediate);
flag1 = -flag1;
}
if (abs(flag2) > 10) {
intermediate = func2(intermediate);
}
switch (flag2 / 10) {
case 1: if (flag1 == -1) return finalize(intermediate, 1.171);
break;
case 2: return finalize(intermediate, 13.1);
default: break;
}
return finalize(intermediate, 0.);
}
这太复杂了。你怎么知道是否所有可能的替代方案都得到了正确处理?是的,它也违反了其他规则。
我们可以重构:
double func1_muon(double val, int flag)
{
// ???
}
double func1_tau(double val, int flag1, int flag2)
{
// ???
}
double simple_func(double val, int flag1, int flag2)
// simple_func: takes a value and calculates the expected ASIC output,
// given the two mode flags.
{
if (flag1 > 0)
return func1_muon(val, flag2);
if (flag1 == -1)
// handled by func1_tau: flag1 = -flag1;
return func1_tau(-val, flag1, flag2);
return 0.;
}
笔记 “它不适合屏幕”通常是“太大”的一个很好的实用定义。一到五行功能应该被认为是正常的。
对于“in”参数,通过值传递廉价复制的类型,通过引用传递其他类型const
原因 两者都让调用者知道函数不会修改参数,并且都允许通过右值进行初始化。
什么是“复制成本低”取决于机器架构,但通常最好按值传递两个或三个字(双精度、指针、引用)。当复制很便宜时,没有什么比复制的简单性和安全性更好的了,对于小对象(最多两个或三个字),它也比通过引用传递更快,因为它不需要从函数中进行额外的间接访问。
例子
void f1(const string& s); // OK: pass by reference to const; always cheap
void f2(string s); // bad: potentially expensive
void f3(int x); // OK: Unbeatable
void f4(const int& x); // bad: overhead on access in f4()
从不(直接或间接)返回一个指针或对本地对象的引用
原因 避免使用这种悬空指针可能导致的崩溃和数据损坏。例子,不好 从函数返回后,其本地对象不再存在:
int* f()
{
int fx = 9;
return &fx; // BAD
}
void g(int* p) // looks innocent enough
{
int gx;
cout << "*p == " << *p << '\n';
*p = 999;
cout << "gx == " << gx << '\n';
}
void h()
{
int* p = f();
int z = *p; // read from abandoned stack frame (bad)
g(p); // pass pointer to abandoned stack frame to function (bad)
}
笔记 这也适用于引用:
int& f()
{
int x = 7;
// ...
return x; // Bad: returns reference to object that is about to be destroyed
}
int是返回类型main()
原因 这是一个语言规则,但经常被“语言扩展”违反,值得一提。声明main(程序的一个全局变量main)void限制了可移植性。
例子
void main() { /* ... */ }; // bad, not C++
int main()
{
std::cout << "This is the way to do it\n";
}
笔记 我们之所以提到这一点,只是因为这个错误在社区中持续存在。
不要return std::move(local)
原因 在保证复制省略的情况下,现在几乎总是std::move在 return 语句中明确使用它。
例子,不好
S f()
{
S result;
return std::move(result);
}
例子,不错
S f()
{
S result;
return result;
}
将相关数据组织成结构(structs或classes)
原因 易于理解。如果数据是相关的(出于根本原因),则该事实应反映在代码中。
例子
void draw(int x, int y, int x2, int y2); // BAD: unnecessary implicit relationships
void draw(Point from, Point to); // better
笔记 没有虚函数的简单类意味着没有空间或时间开销。
笔记 从语言的角度来看class,struct不同之处仅在于其成员的默认可见性。
使用class而不是struct如果任何成员是非公开的
原因 可读性。为了清楚地表明某些东西正在被隐藏/抽象。这是一个有用的约定。
例子,不好
struct Date {
int d, m;
Date(int i, Month m);
// ... lots of functions ...
private:
int y; // year
};
就 C++ 语言规则而言,这段代码没有任何问题,但从设计的角度来看,几乎一切都是错误的。私人数据远离公共数据隐藏。数据被分成类声明的不同部分。数据的不同部分具有不同的访问权限。所有这些都会降低可读性并使维护复杂化。
不要创建数据成员const或引用
原因 它们没有用,并且由于微妙的原因使它们不可复制或部分不可复制,从而使类型难以使用。
例子, 坏的
class bad {
const int i; // bad
string& s; // bad
// ...
};
const和数据成员使这个&类“仅可复制”——可复制构造但不可复制分配。
笔记 如果您需要一个成员指向某物,请使用指针(原始或智能,gsl::not_null如果它不应该为空)而不是引用。
如果您可以避免定义默认操作,请执行
原因 它是最简单的并且给出了最清晰的语义。
例子
struct Named_map {
public:
// ... no default operations declared ...
private:
string name;
map<int, int> rep;
};
Named_map nm; // default construct
Named_map nm2 {nm}; // copy construct
由于std::map
并且string
具有所有特殊功能,因此无需进一步工作。
笔记 这被称为“零规则”。
如果您定义或=delete任何复制、移动或析构函数,请定义或=delete全部
原因 复制、移动和销毁的语义密切相关,因此如果需要声明一个,则很可能其他的也需要考虑。
声明任何复制/移动/析构函数,即使是=defaultor =delete,都将抑制移动构造函数和移动赋值运算符的隐式声明。声明移动构造函数或移动赋值运算符,即使是 =defaultor =delete,也会导致隐式生成的复制构造函数或隐式生成的复制赋值运算符被定义为删除。因此,一旦声明了其中任何一个,就应该声明其他所有的,以避免不必要的影响,例如将所有潜在的移动变成更昂贵的副本,或者使一个类只移动。
例子,不好
struct M2 { // bad: incomplete set of copy/move/destructor operations
public:
// ...
// ... no copy or move operations ...
~M2() { delete[] rep; }
private:
pair<int, int>* rep; // zero-terminated set of pairs
};
void use()
{
M2 x;
M2 y;
// ...
x = y; // the default assignment
// ...
}
鉴于析构函数需要“特别注意”(这里是解除分配),隐式定义的复制和移动赋值运算符正确的可能性很低(这里,我们会得到双重删除)。
笔记 这被称为“五法则”。
析构函数
“这个类需要析构函数吗?” 是一个令人惊讶的有见地的设计问题。
对于大多数类来说,答案是“否”,要么是因为该类没有资源,要么是因为销毁是由零规则处理的;也就是说,它的成员可以照顾自己,因为担心破坏。
如果答案是“是”,则该类的大部分设计都遵循(参见五规则)。
如果一个类有一个拥有的指针成员,定义一个析构函数
原因 拥有的对象必须deleted在销毁拥有它的对象时。例子 指针成员可以表示资源。 AT不应该这样做,但在旧代码中,这很常见。考虑一个T可能的所有者并因此怀疑。
template<typename T>
class Smart_ptr {
T* p; // BAD: vague about ownership of *p
// ...
public:
// ... no user-defined default operations ...
};
void use(Smart_ptr<int> p1)
{
// error: p2.p leaked (if not nullptr and not owned by some other code)
auto p2 = p1;
}
请注意,如果定义析构函数,则必须定义或删除所有默认操作:
template<typename T>
class Smart_ptr2 {
T* p; // BAD: vague about ownership of *p
// ...
public:
// ... no user-defined copy operations ...
~Smart_ptr2() { delete p; } // p is an owner!
};
void use(Smart_ptr2<int> p1)
{
auto p2 = p1; // error: double deletion
}
默认的复制操作只会复制p1.pintop2.p导致p1.p. 明确所有权:
template<typename T>
class Smart_ptr3 {
owner<T*> p; // OK: explicit about ownership of *p
// ...
public:
// ...
// ... copy and move operations ...
~Smart_ptr3() { delete p; }
};
void use(Smart_ptr3<int> p1)
{
auto p2 = p1; // OK: no double deletion
}
笔记 通常,获得析构函数的最简单方法是将指针替换为智能指针(例如std::unique_ptr),并让编译器安排隐式进行适当的析构。
笔记 为什么不要求所有拥有的指针都是“智能指针”呢?这有时需要进行重要的代码更改,并且可能会影响 ABI。
执法
- 带有指针数据成员的类是可疑的。
- 一个类owner应该定义它的默认操作。
基类析构函数应该是公共的和虚拟的,或者是受保护的和非虚拟的
原因 防止未定义的行为。如果析构函数是公共的,则调用代码可以尝试通过基类指针销毁派生类对象,如果基类的析构函数是非虚拟的,则结果未定义。如果析构函数是受保护的,那么调用代码就不能通过基类指针进行销毁,并且析构函数不需要是虚拟的;它确实需要受到保护,而不是私有,以便派生的析构函数可以调用它。通常,基类的编写者不知道销毁时要执行的适当操作。讨论 请参阅讨论部分。
例子,不好
struct Base { // BAD: implicitly has a public non-virtual destructor
virtual void f();
};
struct D : Base {
string s {"a resource needing cleanup"};
~D() { /* ... do some cleanup ... */ }
// ...
};
void use()
{
unique_ptr<Base> p = make_unique<D>();
// ...
} // p's destruction calls ~Base(), not ~D(), which leaks D::s and possibly more
笔记 虚函数定义了派生类的接口,无需查看派生类即可使用该接口。如果接口允许销毁,那么这样做应该是安全的。
笔记 析构函数必须是非私有的,否则会阻止使用以下类型:
class X {
~X(); // private destructor
// ...
};
void use()
{
X a; // error: cannot destroy
auto p = make_unique<X>(); // error: cannot destroy
}
例外 我们可以想象一种情况,您可能需要一个受保护的虚拟析构函数:当一个派生类型的对象(并且只有这种类型)应该被允许通过指向基的指针来销毁另一个对象(而不是它自己)时。不过,我们在实践中还没有看到这样的案例。
执法
- 具有任何虚函数的类应该有一个析构函数,该析构函数要么是公共的和虚拟的,要么是受保护的和非虚拟的。
- 如果一个类从基类公开继承,则基类应该有一个析构函数,该析构函数要么是公共的和虚拟的,要么是受保护的和非虚拟的。
不要定义只初始化数据成员的默认构造函数;改用类内成员初始化器
原因 使用类内成员初始化器可以让编译器为您生成函数。编译器生成的函数可以更有效。
例子,不好
class X1 { // BAD: doesn't use member initializers
string s;
int i;
public:
X1() :s{"default"}, i{1} { }
// ...
};
例子
class X2 {
string s = "default";
int i = 1;
public:
// use compiler-generated default constructor
// ...
};
执法 (简单)默认构造函数应该做的不仅仅是用常量初始化成员变量。
类内初始化器优先于常量初始化器的构造函数中的成员初始化器
原因 明确表示希望在所有构造函数中使用相同的值。避免重复。避免维护问题。它导致最短和最有效的代码。
例子,不好
class X { // BAD
int i;
string s;
int j;
public:
X() :i{666}, s{"qqq"} { } // j is uninitialized
X(int ii) :i{ii} {} // s is "" and j is uninitialized
// ...
};
维护者如何知道是否j故意未初始化(无论如何可能是个坏主意)以及是否有意在一种情况下和另一种情况下提供s默认值(几乎可以肯定是一个错误)?(忘记初始化成员)的问题通常发生在将新成员添加到现有类时。""qqqj
例子
class X2 {
int i {666};
string s {"qqq"};
int j {0};
public:
X2() = default; // all members are initialized to their defaults
X2(int ii) :i{ii} {} // s and j initialized to their defaults
// ...
};
替代方案:我们可以从构造函数的默认参数中获得部分好处,这在旧代码中并不少见。但是,这不太明确,会导致传递更多参数,并且在有多个构造函数时重复:
class X3 { // BAD: inexplicit, argument passing overhead
int i;
string s;
int j;
public:
X3(int ii = 666, const string& ss = "qqq", int jj = 0)
:i{ii}, s{ss}, j{jj} { } // all members are initialized to their defaults
// ...
};
如果您必须明确使用默认语义,请使用=default
原因 编译器更有可能获得正确的默认语义,并且您无法比编译器更好地实现这些功能。
例子
class Tracer {
string message;
public:
Tracer(const string& m) : message{m} { cerr << "entering " << message << '\n'; }
~Tracer() { cerr << "exiting " << message << '\n'; }
Tracer(const Tracer&) = default;
Tracer& operator=(const Tracer&) = default;
Tracer(Tracer&&) = default;
Tracer& operator=(Tracer&&) = default;
};
因为我们定义了析构函数,所以我们必须定义复制和移动操作。这= default是最好和最简单的方法。
如果基类用作接口,则使其成为纯抽象类
原因 如果一个类不包含数据,则它会更稳定(不那么脆弱)。接口通常应该完全由公共纯虚函数和默认/空的虚拟析构函数组成。
例子
class My_interface {
public:
// ...only pure virtual functions here ...
virtual ~My_interface() {} // or =default
};
抽象类通常不需要用户编写的构造函数
原因 抽象类通常没有任何数据供构造函数初始化。
例子
class Shape {
public:
// no user-written constructor needed in abstract base class
virtual Point center() const = 0; // pure virtual
virtual void move(Point to) = 0;
// ... more pure virtual functions...
virtual ~Shape() {} // destructor
};
class Circle : public Shape {
public:
Circle(Point p, int rad); // constructor in derived class
Point center() const override { return x; }
};
虚函数应该精确地指定virtual, override, 或final
原因 可读性。检测错误。编写显式virtual、override或final是自记录的,使编译器能够捕获基类和派生类之间类型和/或名称的不匹配。但是,编写这三个中的一个以上既是多余的,也是潜在的错误来源。
这很简单明了:
virtual
仅表示“这是一个新的虚拟功能”。
override
准确且仅表示“这是一个非最终覆盖器”。
final
准确且仅表示“这是最终的替代者”。
例子,不好
struct B {
void f1(int);
virtual void f2(int) const;
virtual void f3(int);
// ...
};
struct D : B {
void f1(int); // bad (hope for a warning): D::f1() hides B::f1()
void f2(int) const; // bad (but conventional and valid): no explicit override
void f3(double); // bad (hope for a warning): D::f3() hides B::f3()
// ...
};
例子,不错
struct Better : B {
void f1(int) override; // error (caught): Better::f1() hides B::f1()
void f2(int) const override;
void f3(double) override; // error (caught): Better::f3() hides B::f3()
// ...
};
在设计类层次结构时,区分实现继承和接口继承
原因 接口中的实现细节使接口变得脆弱;也就是说,使其用户很容易在实现更改后不得不重新编译。基类中的数据增加了实现基类的复杂性,并可能导致代码复制。笔记 定义:
- 接口继承是使用继承将用户与实现分开,特别是允许添加和更改派生类而不影响基类的用户。
- 实现继承是使用继承来简化新设施的实现,通过为相关新操作的实现者提供有用的操作(有时称为“差异编程”)。
纯接口类只是一组纯虚函数
例子,不好
class Shape { // BAD, mixed interface and implementation
public:
Shape();
Shape(Point ce = {0, 0}, Color co = none): cent{ce}, col {co} { /* ... */}
Point center() const { return cent; }
Color color() const { return col; }
virtual void rotate(int) = 0;
virtual void move(Point p) { cent = p; redraw(); }
virtual void redraw();
// ...
private:
Point cent;
Color col;
};
class Circle : public Shape {
public:
Circle(Point c, int r) : Shape{c}, rad{r} { /* ... */ }
// ...
private:
int rad;
};
class Triangle : public Shape {
public:
Triangle(Point p1, Point p2, Point p3); // calculate center
// ...
};
问题:
- 随着层次结构的增长和向 中添加更多数据Shape,构造函数变得更难编写和维护。
- 为什么要计算中心Triangle?我们可能永远不会使用它。
- 将数据成员添加到Shape(例如,绘图样式或画布),所有派生自的类Shape和使用的所有代码Shape都需要审查、可能更改,并且可能重新编译。
例子 可以使用接口继承重写此 Shape 层次结构:
class Shape { // pure interface
public:
virtual Point center() const = 0;
virtual Color color() const = 0;
virtual void rotate(int) = 0;
virtual void move(Point p) = 0;
virtual void redraw() = 0;
// ...
};
请注意,纯接口很少有构造函数:没有什么可构造的。
class Circle : public Shape {
public:
Circle(Point c, int r, Color c) : cent{c}, rad{r}, col{c} { /* ... */ }
Point center() const override { return cent; }
Color color() const override { return col; }
// ...
private:
Point cent;
int rad;
Color col;
};
接口现在不那么脆弱了,但是在实现成员函数方面还有更多工作要做。例如,center必须由从Shape.
避免琐碎的 getter 和 setter
原因 微不足道的 getter 或 setter 不会增加语义价值;数据项也可以是public.
例子
class Point { // Bad: verbose
int x;
int y;
public:
Point(int xx, int yy) : x{xx}, y{yy} { }
int get_x() const { return x; }
void set_x(int xx) { x = xx; }
int get_y() const { return y; }
void set_y(int yy) { y = yy; }
// no behavioral member functions
};
考虑让这样一个类成为一个struct——也就是说,一堆无行为的变量,所有公共数据,没有成员函数。
struct Point {
int x {0};
int y {0};
};
使用unique_ptr或shared_ptr避免忘记delete使用创建的对象new
原因 避免资源泄漏。
例子
void use(int i)
{
auto p = new int {7}; // bad: initialize local pointers with new
auto q = make_unique<int>(9); // ok: guarantee the release of the memory-allocated for 9
if (0 < i) return; // maybe return and leak
delete p; // too late
}
比“普通”枚举更喜欢类枚举
原因 为了尽量减少意外:传统的枚举太容易转换为 int。
例子
void Print_color(int color);
enum Web_color { red = 0xFF0000, green = 0x00FF00, blue = 0x0000FF };
enum Product_info { red = 0, purple = 1, blue = 2 };
Web_color webby = Web_color::blue;
// Clearly at least one of these calls is buggy.
Print_color(webby);
Print_color(Product_info::blue);
而是使用enum class:
void Print_color(int color);
enum class Web_color { red = 0xFF0000, green = 0x00FF00, blue = 0x0000FF };
enum class Product_info { red = 0, purple = 1, blue = 2 };
Web_color webby = Web_color::blue;
Print_color(webby); // Error: cannot convert Web_color to int.
Print_color(Product_info::red); // Error: cannot convert Product_info to int.
如果您的项目尚未遵循其他约定,请.cpp为代码文件和接口文件使用后缀.h
原因 这是一个由来已久的约定。但是一致性更重要,所以如果您的项目使用其他东西,请遵循它。
笔记 此约定反映了一种常见的使用模式:标头更常与 C 共享以编译为 C++ 和 C,通常使用.h. .hC. 另一方面,实现文件很少与 C 共享,因此通常应与.c文件区分开来,因此通常最好将所有 C++ 实现文件命名为其他名称(例如.cpp)。
特定名称.h和.cpp不是必需的(仅推荐作为默认值),其他名称已广泛使用。示例是.hh、.C和.cxx。等效地使用这些名称。在本文档中,我们将.hand.cpp称为头文件和实现文件的简写,即使实际扩展名可能不同。
避免源文件之间的循环依赖
原因 循环使理解复杂化并减慢编译速度。它们还会使转换复杂化以使用语言支持的模块(当它们可用时)。
笔记 消除循环;不要只是用#include警卫破坏它们。
例子,不好
// file1.h:
#include "file2.h"
// file2.h:
#include "file3.h"
// file3.h:
#include "file1.h"
使用一致的命名风格
基本原理:命名和命名风格的一致性提高了可读性。
笔记 有许多样式,当您使用多个库时,您不能遵循它们所有不同的约定。选择“house style”,但保留“imported”库的原始样式。
例子 ISO 标准,仅使用小写字母和数字,用下划线分隔单词:
int
vector
my_map
避免双下划线__。
例子 Stroustrup:ISO 标准,但您自己的类型和概念使用大写:
int
vector
My_map
例子 CamelCase:将多词标识符中的每个词大写:
int
vector
MyMap
myMap
有些约定将首字母大写,有些则不。