提供的内容深入探讨了C++编程中的一些关键概念,特别是如何编写清晰、易维护的代码,并展示了一些C++17的新特性。我将对这些内容做中文的解释和总结。
1. 良好的代码设计原则
什么是“良好的代码”?
- 能工作:代码实现了预期功能。
- 能在其他编译器或平台上运行:跨平台的代码很重要。
- 能预先回答常见问题:处理好内存管理、边界情况等问题。
- 富有表现力:代码应该易于理解,能清晰表达其目的。
- 透明且具有沟通性:代码易于他人理解,代码意图清晰。
这些内容强调了写清晰且可维护的代码的重要性,不仅仅是代码是否能正确工作。
2. Roger Orr 喜爱的代码片段
通过代码示例,可以对比如何通过一些小的改进让代码变得更加清晰和易于扩展。
原始版本:
class Holder
{
private:int number;
public:Holder(int i);Holder();void inc() { number++; }int getNumber() { return number; }std::string to_string();
};
- 问题:
getNumber
方法没有标记为const
,to_string
方法也没有标记为const
或者是override
。 - 缺乏清晰的意图:没有明确表明哪些方法会修改类的状态。
改进版本:
class Holder
{
private:int number;
public:explicit Holder(int i); // 防止隐式转换Holder();void inc() { number++; }int getNumber() const { return number; } // 表明不会修改对象状态virtual std::string to_string() const; // 用const和virtual标记,确保可以重写
};
- 改进点:
explicit
: 防止构造函数进行隐式类型转换,从而避免一些难以发现的错误。const
:getNumber
和to_string
方法标记为const
,表示这些方法不会改变对象的状态。virtual
:to_string
方法标记为virtual
,确保子类能够重写该方法。
3. C++ 中的对立概念
C++中许多构造都成对出现或者互为对立。我们来看看一些常见的例子。
操作符和括号:
- 操作符:
- 算术:
+
(加法)与*
(乘法) - 指针:
*
(解引用)与&
(取地址)
- 算术:
- 括号:
()
(函数调用){}
(代码块/作用域)[]
(数组下标)<>
(模板参数列表)
对立的关键字:
if
/else
: 条件执行。noexcept
/noexcept(false)
:noexcept
用于声明一个函数不会抛出异常,noexcept(false)
用于声明该函数可能抛出异常。
没有对立的部分:
一些C++构造没有直接的“对立”或“反义词”,如:
break
、continue
、return
、foo(x)
、while
、for
、switch
等控制流语句没有对立的概念。
4. C++ 17的新属性(Attributes)
C++17引入了一些新属性(attributes),比如[[fallthrough]]
、[[maybe_unused]]
、[[nodiscard]]
,它们有助于改善代码质量和清晰度。
[[fallthrough]]
在 switch
语句中:
有时你希望故意跳过break
语句,使代码从一个case
顺利执行到下一个case
。
switch (i)
{
case 1:
case 2:msg += "case 1 or case 2. ";break;
case 3:msg += "case 3 or ";[[fallthrough]]; // 明确标记是故意的fallthrough
case 4:msg += "case 4.";
default:break;
}
- 没有
[[fallthrough]]
,编译器可能会警告你关于不小心遗漏break
的情况,而加上[[fallthrough]]
则表明这是故意的。
[[maybe_unused]]
:
用于那些可能未被使用的变量或函数,但不希望编译器发出警告。
[[maybe_unused]] int j = FunctionWithSideEffects();
assert(j > 0);
- 即使
j
可能未使用,编译器也不会对此发出警告。
[[nodiscard]]
:
此属性帮助确保函数的返回值不会被忽略。
[[nodiscard]] int getNumber() { return 42; }
auto num = getNumber(); // 正常使用
getNumber(); // 编译器会警告:返回值被丢弃
- **
[[nodiscard]]
**会在函数的返回值被忽略时触发警告,提醒开发者该返回值可能很重要。
5. 其他重要概念
explicit
:
explicit
关键字用于防止构造函数进行隐式转换,这样可以避免一些潜在的错误。
const
和 mutable
:
const
:表示方法不会修改对象的状态,常用于常成员函数或者常量变量。mutable
:即使在const
对象中,某些成员变量可以被修改,通常用于缓存等需要在不改变对象状态的情况下修改的变量。
引用限定符(Ref-qualifiers):
引用限定符用于指定一个函数只能在特定的对象类型(左值或右值)上被调用。
void foo() & { /* 只能在左值上调用 */ }
void foo() && { /* 只能在右值上调用 */ }
总结
explicit
与 隐式转换:explicit
防止隐式转换,从而避免一些潜在的隐式转换错误。const
和mutable
:const
确保方法不会修改对象状态,而mutable
用于允许某些成员在const
对象中被修改。[[fallthrough]]
、[[maybe_unused]]
、[[nodiscard]]
:这些C++17属性帮助清晰地表达代码意图,并让编译器能给出有用的警告。virtual
和override
:通过标记方法为virtual
,确保子类能够重写该方法;而override
则确保方法正确地重写了基类方法。
这些特性和原则帮助我们写出表达性强、可维护且易于理解的代码。同时,C++17引入的属性增强了编译器的优化能力和错误检查功能,使得代码更健壮。
你提供的内容主要是关于如何通过清晰地表达意图来编写更易理解和维护的C++代码。以下是对这些内容的中文解释和总结:
1. 如何更清楚地表达意图?
避免默认值(Avoid defaults)
- 不使用默认参数值:在函数或者类的构造函数中,最好明确指定每个参数,不使用隐式的默认值。这样可以避免一些意外的行为,让代码更易理解。
在类或结构体中始终明确指定 public:
和 private:
- 即使是一个简单的两元素结构体(如
Point
),也应明确标注public:
和private:
区域。struct Point {int x, y; public:Point(int x, int y) : x(x), y(y) {} private:void privateMethod() {} };
- 意图:明确告知其他开发者哪些成员是公开的,哪些是私有的。
在 void
函数中加上 return
- 即便是
void
函数,最好在函数末尾显式添加return
,这表明你有意结束函数执行。void someFunction() {// Do somethingreturn; }
使用那些可选项(Use optional things)
- 标记虚函数的重写:通过
override
明确标示函数是重写了基类的方法。class Base {virtual void foo() {} }; class Derived : public Base {void foo() override {} // 明确表明这是对基类方法的重写 };
noexcept
:如果你明确认为函数不会抛出异常,使用noexcept
进行标注,增加可读性并避免误解。void someFunction() noexcept {// No exceptions expected }
- 意义:虽然这些关键字(如
override
和noexcept
)不一定是必须的,但它们提供了重要的意图信息,帮助读者理解代码的设计和限制。
2. 表达意图的极限
在表达意图时,有时我们需要在代码中做出平衡。虽然有很多关键字可以使用,但有些关键字并不一定出现在C++中,我们也不一定需要它们。
不常见的关键字:
implicit
:C++ 没有类似implicit
的关键字来表示隐式转换。const(false)
:没有这个关键字来表示“不可修改”。nonvirtual
:C++ 没有类似nonvirtual
的关键字来禁止虚函数。ByVal
:C++ 中也没有ByVal
关键字来表示按值传递。
我们该如何处理这些情况?- 可以通过清晰的命名和注释来表达意图。例如,“我知道自己在做什么,请不要修改此部分”。
3. 上下文的意义
缺少关键字意味着什么?
- 第一种情况:表示“我已经考虑过这个问题,因此不需要使用关键字”。
- 第二种情况:表示“我从未听说过这个关键字,或者至少没考虑过它是否应该在此使用”。
如何传达给读者:如果你在代码库中始终如一地使用某些关键字,那么读者可以推测你已经考虑过它们的使用。例如,如果你在每个虚函数上都使用override
,那么读者可以很清楚地理解你有意标明这个函数是重写的。
4. 注释的使用
- 注释:注释应仅用于那些可能让读者误解的地方,而不是在每个函数旁边都加上不必要的注释。例如,
foo
方法看起来像是一个虚拟函数的重写,但它可能只是一个签名不同的函数。在这种情况下,你可以加上一些注释来澄清。// 我知道这看起来像是 foo 的重写,但实际上这是一个不同签名的函数
- 注释不是表达意图的主要方式,而是用于澄清可能的误解。
5. 可选的返回语句
在 void
函数中,尽管返回值是 void
类型,但最好明确地加上 return
语句,以表明函数的结束。
void Thimbule(int robbit)
{robbit++;if (robbit)return;robbit--;
}
void Sprial(int oob, int boo)
{oob++;while (true){if (++oob > boo)return;}
}
- 可选返回语句的好处:在一些复杂的条件分支中,显式的
return
会使代码更加清晰,避免不必要的复杂性。
6. 范围 for
循环(Ranged For)
- 按值传递(
auto emp : department
):for (auto emp : department) {// ... }
- 适用于当你不需要修改元素时,复制元素的副本。
- 按引用传递(
auto& emp : department
):for (auto& emp : department) {// ... }
- 用于避免不必要的复制,直接操作元素的引用。
- 常量引用(
auto const & emp : department
):for (auto const & emp : department) {// ... }
- 如果不需要修改元素且希望避免复制,可以使用常量引用。
7. 参数传递
如何传递参数?
- 按值传递(
Order createOrder(Customer c, OrderItem oi);
)- 适用于参数较小或者需要复制的情况。
- 按引用传递(
Order createOrder(Customer& c, OrderItem oi);
)- 适用于对象较大,且希望避免复制的情况。
- 按常量引用传递(
Order createOrder(Customer const& c, OrderItem oi);
)- 如果不需要修改对象,并且希望避免复制,这是最佳选择。
8. 省略参数名称
- 在声明时,参数名称可以省略,编译器并不关心。但人类开发者会关心,因此最好不要省略参数名称。尤其是在定义函数时,如果某个参数未使用,可以在定义中省略其名称:
int DetermineTotalTaxes(int, int, int);
- 但是,在定义时,如果某个参数不使用,最好加上注释,说明这样做的原因,避免其他人误解。
int DetermineTotalTaxes(int ProvRate, int FedRate, int) {// do somethingreturn 42; }
总结
- 表达意图的清晰性是编写可维护代码的关键。通过使用适当的关键字(如
override
、noexcept
等),标明函数行为,帮助其他开发者更容易理解代码的设计。 - 明确代码意图:尽量避免默认值,确保参数传递方式清晰,函数的返回值明确,并使用注释澄清潜在的误解。
- 一致性:通过在整个代码库中一致使用关键字和模式,增强代码的可读性和可维护性。
这部分内容探讨了如何通过代码中隐含的设计选择,传达更多的意图和信息,进而提高代码的可读性和可维护性。以下是对这些内容的中文解释:
1. 其他选择也能传达大量信息
原始指针是否总是非拥有的指针?
bool sendEmails(Employee* pe)
和Message* sendEmails(Employee* pe)
- 这些代码片段的设计中,是否使用了智能指针?
- 是否频繁使用了
new
和delete
? - 这会涉及到“规则 3 或 5”(Rule of 3/5),即类如果有资源管理行为(如分配内存),它应该提供拷贝构造函数、赋值运算符和析构函数来管理这些资源。
- 代码中是否有析构函数?
传递原始指针或智能指针的选择,能够传达代码的资源管理策略。如果使用了智能指针,代码可以自动管理内存,减少资源泄漏的风险。如果是原始指针,则意味着可能需要手动管理内存,这就需要仔细考虑拷贝、赋值、析构等操作。
&
和 *
的意义?
&
表示引用,*
表示指针。- 传递地址或引用是否总是会修改数据?
- 传递引用(
T&
)和传递指针(T*
)都可能导致数据修改,但是否会修改取决于是否是const类型。 - 在一些编程习惯中,传递指针可以暗示转移所有权,即调用者不再需要负责对象的生命周期,而是交给被调用函数。
- **“是否拥有”**并不是编译器关心的问题,但它能表达出代码设计中的意图。通过这些选择,你可以隐式地传达某个对象是否由当前函数或对象负责管理其生命周期。
- 传递引用(
- 传递地址或引用是否总是会修改数据?
传统的 for
循环是否总是在做一些奇怪的事?
- 为什么选择这种循环?
- 这个问题的重点是:是否需要使用传统的
for
循环?for
循环在某些情况下非常有用,但它可能会比使用范围for
(for (auto& emp : department)
)更复杂且不易理解。选择传统的for
循环可能意味着你在“手动”处理某些特定的逻辑,而不是依赖于标准库提供的算法。
- 这个问题的重点是:是否需要使用传统的
- 为什么不使用范围
for
循环?for
循环能对每个元素进行逐一操作,而 范围for
循环(Ranged for)能够更简洁地遍历容器。
- 是否有算法可以完成这项工作?
- C++ 标准库中有许多算法,如
find
、count
、all_of
、sort
等,它们提供了比传统循环更清晰、更简洁的解决方案。
结论:使用标准算法而非传统循环能显得你对现有工具的理解更深入,表达了你对代码简洁性和可读性的重视。
- C++ 标准库中有许多算法,如
2. 初始化
- 构造函数没有初始化成员变量时的含义
- 如果构造函数中没有在
:
后进行成员变量的初始化,可能有以下几种情况:- 成员变量有非静态成员初始化器,即它们已经在类中指定了默认值。
- 在函数体内进行了初始化,可能是忘记在构造函数的初始化列表中进行初始化。
- 为什么会这样?:
- 可能是忘记在构造函数中初始化某些成员。
- 在多个构造函数中,可能有一个构造函数忘记了初始化某个成员。
为什么将某个成员初始化为其默认值?
- 例如,
string s = "";
或vector<Employee> department(0);
。 - 这种行为表示可能在某些情况下不需要特别设置成员的初始值,或者是某个“默认”状态。
- 如果构造函数中没有在
3. 语言能否提供帮助?
- 我们是否应该添加关键字或属性?你会使用它们吗?
implicit
、const(false)
、nonvirtual
、ByVal
等关键字,在当前 C++ 标准中没有,但它们可能能让代码更清晰。- 为什么不使用
fallthrough
和maybe_unused
?- 在 C++17 中引入了
[[fallthrough]]
和[[maybe_unused]]
属性,这能帮助更清晰地表达意图:[[fallthrough]]
表示在switch
语句中的 case 之间有意不加break
,表明是“故意跳过”。[[maybe_unused]]
用来标记那些可能未使用的变量,避免编译器警告。
- 在 C++17 中引入了