如何在代码中提供更明确的语义?
注释是写给人看的,编译器不会参考注释,所以要尽可能的在代码中更清晰的表达意图,更强大的约束。这样使得编译器和其他工具也能对代码进行正确的处理和检查。下面结合了一些具体的使用场景来讲解这一点。
时间
这段代码中定义了一个Date
类,其中包含两个名为month
的成员函数。虽然两个函数都能实现相同的功能,但是第一个是推荐做法,第二个不推荐。
class Date {
public:
Month month() const; // 好
int month(); // 坏
// ...
};
第一个month
函数是一个常量成员函数,返回类型为Month
。这个函数的声明表明它不会修改Date
对象的状态。这是因为它后面带有const
关键字,这意味着这个函数不能修改类的任何成员变量(除非它们被声明为mutable
)。这是一个好的设计,因为它明确了函数的行为:这个函数只是获取月份,不会改变日期对象的状态。
第二个month
函数返回一个int
,并且它不是一个常量成员函数。这意味着它可能会修改Date
对象的状态。这是一个不好的设计,因为它的行为不清晰。从函数名month
来看,我们可能会认为这个函数应该只是获取月份,而不应该改变Date
对象的状态。但是,由于它不是一个常量成员函数,我们不能确定它是否会改变对象的状态。这可能会导致错误的使用,例如,如果一个函数只想获取月份,但不希望改变日期对象,而它错误地调用了这个版本的month
函数,那么就可能会导致意外的结果。
总的来说,当你设计类的成员函数时,应该尽可能地使函数的行为明确。如果一个函数不会修改对象的状态,那么就应该将其声明为常量成员函数。这样,使用者就能清楚地知道这个函数的行为,从而减少错误的可能性。
循环遍历
下面是两段功能一模一样的代码,都是循环便利一个数组的代码。但是第一段是不推荐的做法,第二段是建议做法。
void f(vector<string>& v)
{
string val;
cin >> val;
// ...
int index = -1; // 坏
for (int i = 0; i < v.size(); ++i) {
if (v[i] == val) {
index = i;
break;
}
}
// ...
}
这个例子中,使用了一个手动的循环来查找vector
中的一个元素。这种方法的问题在于,它需要手动管理循环变量和索引,而且如果忘记在找到元素后使用break
语句,可能会导致错误的结果。此外,这种方法的可读性较差,因为需要阅读和理解整个循环结构才能明白它的目的。
void f(vector<string>& v)
{
string val;
cin >> val;
// ...
auto p = find(begin(v), end(v), val); // 好
// ...
}
这个例子中,使用了标准库函数std::find
来查找vector
中的一个元素。这种方法的优点在于,它更简洁,更易于理解,因为std::find
的功能就是查找一个元素。此外,使用标准库函数可以减少错误的可能性,因为不需要手动管理循环变量和索引。最后,如果在未来需要更改容器类型(例如,从vector
更改为list
或set
),使用标准库函数可以使这种更改更容易,因为std::find
可以用于任何容器,而手动的循环可能需要根据容器的特性进行修改。
参数设计
change_speed(double s); // 不好:s表示什么?
// ...
change_speed(2.3);
在这个例子中,函数change_speed(double s)
的问题在于,参数s
的含义并不清晰。s
代表什么?是新的速度值?还是速度的增量?另外,s
的单位是什么?是米/秒,还是千米/小时?这些都是不清晰的。
更好的做法是使用一个明确的类型,如Speed
,来表示速度。这样,change_speed(Speed s)
函数的参数s
的含义就很清晰了:它是一个速度值。此外,Speed
类型可以包含单位信息,这样就可以避免单位不清的问题。
change_speed(Speed s); // 更好:s的含义已经指定
// ...
change_speed(2.3); // 错误:没有单位
change_speed(23_m / 10s); // 米每秒
然后,当你调用change_speed(2.3)
时,编译器会报错,因为2.3
是一个double
类型,而change_speed
函数需要一个Speed
类型的参数。这是一个好事,因为它防止了单位不清的问题。你应该使用change_speed(23_m / 10s)
来调用这个函数,其中23_m
表示23米,10s
表示10秒,所以整个表达式表示的是2.3米/秒。
如果你既想要表示绝对速度,又想要表示速度的增量,你可以定义一个Delta
类型。这样,你可以使用change_speed(Delta d)
来改变速度,其中d
是速度的增量。这样,你的代码就会更清晰,更容易理解,也更不容易出错。
一些例子
代码的预期行为通常是由开发者在编写代码时设定的。如果没有明确的说明(例如,函数或变量的名称,或者代码注释),那么其他人可能无法准确地理解代码的预期行为。
函数名
例如,考虑以下 C++ 代码片段:
int calculate(int a, int b) {
return a + b;
}
从这个函数的名称calculate
来看,我们无法确定它的具体行为。它可能是用来计算两个数的和,也可能是用来计算两个数的差,乘积,或者其他什么。只有当我们查看函数的实现,我们才能确定这个函数是用来计算两个数的和的。
但是,如果我们将函数名改为add
,那么就能明确地知道这个函数的预期行为是计算两个数的和,即使我们没有看到函数的实现。
int add(int a, int b) {
return a + b;
}
同样,如果我们在函数上添加适当的注释,那么即使函数的名称不够明确,其他人也能理解这个函数的预期行为。
// 计算两个数的和
int calculate(int a, int b) {
return a + b;
}
因此,为了使代码的预期行为更容易被理解,我们应该尽可能地使用描述性的名称,并在必要时添加适当的注释。
遍历集合
在遍历集合时,如何更好地表达代码的意图和避免潜在的错误。
首先,考虑这段代码:
gsl::index i = 0;
while (i < v.size()) {
// ... 对 v[i] 做一些事情 ...
}
这段代码使用了一个索引 i
来遍历集合 v
。但是,这种方式并没有明确表达出只是遍历 v
的元素的意图。此外,索引的实现细节被暴露出来,这可能导致误用。最后,i
在循环结束后仍然存在,这可能是或可能不是预期的。
更好的方式是使用范围 for 循环:
for (const auto& x : v) { /* 对 x 的值做一些事情 */ }
这段代码明确表示了我们只是遍历 v
的元素。此外,我们使用 const
引用 x
来访问元素,这样就无法意外地修改元素的值。如果我们想要修改元素的值,我们可以去掉 const
:
for (auto& x : v) { /* 修改 x */ }
有时候,使用命名的算法可能是更好的选择。例如,我们可以使用 for_each
算法来遍历集合:
for_each(v, [](int x) { /* 对 x 的值做一些事情 */ });
for_each(par, v, [](int x) { /* 对 x 的值做一些事情 */ });
这两个例子都使用了 for_each
算法来遍历 v
。第一个例子是单线程遍历,第二个例子是多线程遍历。在第二个例子中,我们使用了 par
参数来表示我们对 v
的元素的处理顺序不感兴趣,这意味着算法可以在多个线程中并行处理 v
的元素。
参数设计
当函数的参数列表包含多个相同类型的参数时,往往会使代码的阅读者感到困惑,因为他们可能不清楚每个参数的具体含义。
例如,考虑以下函数:
void draw_line(int, int, int, int);
这个函数接受四个 int
参数,但是从函数签名中我们无法确定这四个参数的具体含义。它们可能代表两个二维点的坐标 (x1, y1, x2, y2)
,也可能代表一个点的坐标和高度宽度 (x, y, h, w)
,或者其他的含义。为了理解这个函数的行为,我们可能需要查阅相关的文档。
相反,如果我们使用自定义的数据类型(例如,一个表示二维点的 Point
类),那么函数的签名就会变得更加清晰:
void draw_line(Point, Point);
这个函数接受两个 Point
参数,从函数签名中我们可以清楚地看出,这两个参数代表的是两个二维点。这样,代码的阅读者就可以更容易地理解这个函数的行为,而无需查阅相关的文档。
总结
注释虽然能帮助人们理解代码,但编译器并不会参考它,因此我们应该尽可能地在代码中清晰地表达意图。例如,使用const
关键字明确函数不会修改对象状态,使用标准库函数如std::find
替代手动循环提高代码可读性和减少错误,以及使用明确的类型和单位来表示函数参数,而不是依赖于不清晰的double
类型。这些做法都能使得代码更易于理解和维护,同时减少错误的可能性。
参考
- 《C++ Core Guide Line》