如何在代码中提供更明确的语义?

注释是写给人看的,编译器不会参考注释,所以要尽可能的在代码中更清晰的表达意图,更强大的约束。这样使得编译器和其他工具也能对代码进行正确的处理和检查。下面结合了一些具体的使用场景来讲解这一点。

时间

这段代码中定义了一个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更改为listset),使用标准库函数可以使这种更改更容易,因为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类型。这些做法都能使得代码更易于理解和维护,同时减少错误的可能性。

参考

  1. 《C++ Core Guide Line》