文章 30 多年的编码经验浓缩成的 10 条最佳实践 原文出自 10 Tips for Writting Better Code。我认为这 10 条原则挺有帮助,所以本文想对这些原则做一些评价,说说我的看法,可以的话顺便给一些例子。建议看这篇文章之前先阅读原文。

事实上,我们可以将好的代码等同为 可重用 的代码

文章里说“可重用”也是文中罗列的 10 条原则的“背后驱动”。那么什么样的设计才是“可重用”的呢?其实早有大神提出了“高内聚,低耦合”的指标。“高内聚”说的是一个模块作为一个整体,功能要“专一”;“低耦合”说的是不同模块间的联系尽可能少。之后可以看到原文提到的 10 条原则很大程序上与之有关。

遵循单一职责原则

函数是程序员的工具中最重要的抽象形式。它们能更多地被重复使用,你需要编写的代码就越少,代码也因此变得更可靠。较小的函数遵循单一职责原则更有可能被重复使用。

这条原则几乎就是“高内聚”的另一种说法,只不是“高内聚”谈论的是模块,而这里谈论的是函数。

这里举一个 StackOverflow 关于高内聚的一个例子。假设你创建一个类用来将两个数相加,与此同时,这个类还创建了一个窗口用来显示相加的结果。这个类就是“低内聚”的。因为做加法和创建窗口这两件事没什么相关性,创建窗口是“显示”的部分,而加法是“逻辑”的部分。

按照“单一职责”的原则来说的话,这个类的职责是不单一的,因此我们很难去重用这个类,因为除非我们的需求正好是要做加法,同时要将结果在一个窗口显示,否则这个类并不能被重用。

换句话说,如果一个函数有多个职责,那只有在使用者同时需要这几个职责的时候,才能重用这个函数。因此保持函数/类的单一职责,有利于重用。

尽量减少共享状态

你应该尽量减少函数之间的隐式共享状态,无论它是文件作用域的变量还是对象的成员字段,这有利于明确要求把值作为参数。当能明确地显示函数需要什么才可以产生所需的结果时,代码会变得更容易理解和重用。

对此的一个推论是,在一个对象中,相对于成员变量,你更应该优先选择静态的无状态变量 (static stateless variables)。

首先讲讲什么是“共享状态”。这里提了两个:“文件作用域变量”及“对象的成员字段”。分别举例如下:

g_config = read_configuration('config.ini')
def log(message):
with open(g_config['log_file'], 'w') as fp:
fp.write(message)
def update_config(key, value):
g_config[key] = value

这里,g_config 变量存在于整个文件里,所以称为“文件作用域变量”。并且 log 函数与 update_config 函数之间共享了 g_config 这个状态。上面这种写法也可以写成类的形式:

class Logger:
def __init__(self, config_file):
self.g_config = read_configuration(config_file)
def log(self, message):
with open(self.g_config['log_file'], 'w') as fp:
fp.write(message)
def update_config(self, key, value):
self.g_config[key] = value

这一次,由于 g_config 是类的“成员字段”,而 logupdate_config 者依赖于这个变量,所以也称他们共享了这个状态。

为什么要减少状态共享?共享状态增加了函数间的“耦合”,可能会引起:

  1. 代码不好阅读,因为必须同时理解共享状态的各个函数。
  2. 当修改了其中一个函数时,另外的函数的逻辑可能会发生改变,因此代码难以维护。
  3. 不利于多线程运行。容易造成竞争。

因此,推荐尽量把函数运行需要的状态通过参数传递给函数,如:

def log(config, message):
with open(config['log_file'], 'w') as fp:
fp.write(message)
def update_config(config, key, value):
config[key] = value
g_config = read_configuration('config.ini')
log(g_config, "error here")

至于 static stateless variablesstatic 代表它不是“成员变量”, stateless 的含义应该等同于 final ,也就是说如果要共享状态,最好就用类变量而非成员变量,同时,变量最好是“不可变的”。

将“副作用”局部化

理想的副作用(例如:打印到控制台、日志记录、更改全局状态、文件系统操作等)应该被放置到单独的模块中,而不是散布在整个代码里面。函数中的一些“副作用”功能往往违反了单一职责原则。

“副作用(side effect)”是指在某个域(如函数域)里修改了域之外的状态。

  1. 副作用一般伴随着状态共享,这种代码非常难理解。
  2. 有副作用的代码一般都是“线程不安全”的。

这里不深入这个话题,最好的方法就是尽量减少副作用的代码。感举的话,可以考虑参考我之前的文章: 在面向对象语言中写纯函数!

优先使用不变的对象

如果一个对象的状态在其构造函数中仅被设置一次,并且从不再次更改,则调试会变得更加容易,因为只要构造正确就能保持有效。这也是降低软件项目复杂性的最简单方法之一。

有这样一句话:Shared Mutable State is the root of evil,共享的,可变的状态是万恶之源。为什么,因为共享意味着它们之间是“耦合的”,没法单独分析/工作。可变的意味着这个共享是会传染的,改变了共享的状态,所有依赖该状态的单元都可能发生变化。

有一些语言干脆禁止使用可变变量(不准确),如 Haskell, Clojure 等。另一些语言则试图阻止变量的“共享”,如 Rust。那么在如 C/C++/Java 之类的语言中,虽然语言本身没有过多的限制,但我们还是应该自己限制自己,减少不必要的麻烦。

多用接口少用类

接收接口的函数(或 C++ 中的模板参数和概念)比在类上运行的函数更具可重用性。

我认为究其原因,主要是一般定义接口的时候不会指定成员变量,也就是说不会去限制这些接口(方法)的实现细节,而定义类的时候往往会这么做,这就意味着接口具有更高的可扩展性,(API 的)用户也更可能去实现某个接口而非继承某个类,因此一个接收接口的函数更有可能被调用。

另外,java 是不允许多继承的,但可以实现多个接口,如果函数接收的是类,那么意味着用户的类必须继承我们指定的类,那用户自己就无法构建类的继承结构了。

对模块应用良好的原则

寻找机会将软件项目分解成更小的模块(例如库和应用程序),以促进模块级别的重用。对于模块,应该遵循的一些关键原则是:

  1. 尽可能减少依赖
  2. 每个项目应该有一个明确的职责
  3. 不要重复自身

这里的原则其实跟上面说的其它原则有一定重复:

  • “尽可能减少依赖”。其实就是减少该模块和其它模块的耦合。
  • “每个项目应该有一个明确的职责” 则对应着“高内聚”
  • “不要重复自身” (don’t repeat yourself) 翻译有误,应该指不要自己写重复的代码,也就是说重复的代码要写成函数。

避免继承

在面向对象编程中,继承 —— 特别是和虚拟函数结合使用时,在可重用性方面往往是一条死胡同。我很少有成功的使用或编写重载类的库的经历。

这点可能有人会质疑,但我个人是深信不疑的。在 实践中 我们很少能真正写出一个能重用的类,这里的重用指的是被继承。

归要结底,(我认为)这是面向对象这种方法的缺陷,世上的事物真的能用类继承的方式良好地表达吗?通常面向对象的教材会举两个例子,一个是“动物”,另一个是“图形”。“图形”的例子是说各种图形都有“求面积”的方法,正方形可以继续并实现自己的“面积”算法,“圆形”也相似。因此可以通过继承来表达,“面积”函数就是虚函数,实现多态。“动物”的例子也类似,例如狗会叫,但不同的狗叫声不同,因此可以继承“狗”类。

有人(找不到出处了)质疑,上面的例子都是良好定义的一些关系,但现实中遇到的问题真的能良好的表达吗?不可否认面向对象有它适合的领域,如 GUI 的各个组件等。还有一些问题能用面向对象(继承)但不一定是最佳的方案,例如报表,及不同细节的报表。另一些问题可能就不太能用面向对象来表达了。

这一点我建议阅读一些其它的讨论:

  1. Does OOP really model the real world
  2. Is OOP hard because it is not natual

最后,要注意的是“继承”继承的是父类的“数据+方法”,更多的时候我们关心的只是“方法”的“继承”。

将测试作为设计和开发的一部分

我不是测试驱动开发的坚定分子,但开始编码时先编写测试代码会使得代码十分自然地遵循许多指导原则。这也有助于尽早发现错误。不过要注意避免编写无用的测试,良好的编码实践意味着更高级别的测试(例如单元测试中的集成测试或特征测试)在揭示缺陷方面更有效。

我的测试经验不是特别丰富,同时我也不是测试驱动开发的坚定分子。

关于“编码之前先写测试”,我认为它最重要的作用是让我们对该函数/类的功能有更清晰的认识,而不是一开始就把头扎到实现细节中,这点非常用帮助。

“避免编写无用的测试”这点也很重要。测试与开发的矛盾点在于,测试是要保证开发的功能是没问题的,但开发(函数的内容/作用)是会随着时间变动的。因此测试的粒度是一个十分重要的问题。目前我也在学习中。

优先使用标准库而不是手写的

我经常看到更好版本的 std::vector 或 std::string,但这几乎总是浪费时间和精力。一个明显的事实是 —— 你正在为一个新的地方引入 bug,其他开发者也不太可能重用你的代码,因为没有被广泛理解、支持和测试。

+10086。去 hack 一个标准库应该永远是你 最后 想到的解决方法。你永远无法想象一个标准库需要经过多少测试,踩过多少坑才能稳定。并且,如果出现 bug,他们的支持也是十分富贵的,我们的时间永远不够用。

避免编写新的代码

这是每个程序员都应遵循的最重要的教诲:最好的代码就是还没写的代码。你写的代码越多,你将遇到的问题就越多,查找和修复错误就越困难。

在写一行代码之前先问一问自己,有没有一个工具、函数或者库已经实现了你所需要的功能?你真的需要自己实现这个功能,而不是调用一个已经存在的功能吗?

跟上一点有点重复,但我觉得这里有两个要点:

  1. 能不写尽量不要自己写。
  2. 如果非要写,尽量写得短。

同上一点一样,要写出 bug free 的代码很困难,并且后续维护需要很大的精力。最后即使是同一个功能,一般代码量小的更好,因为你需要处理(记忆/思考)的量小。

总结

所谓的编码原则,不是说非遵守不可,我们要去了解它背后的原理,原因然后因地制宜。理论上,如果你是一个天才,可以处理无穷的复杂事物,那么原则毫无意义。但对于普通人而言,如果事件变得越来越复杂,我们的处理能力是下降的,我们状态差的时候更是如此。

所以,平时遵守一些原则能提高我们在状态差时候的处理能力。

最后,我认为“高内聚,低耦合”的内因实际上是减少我们同时需要处理/理解/记忆的代码量,以此来提高我们的效率。希望对你有所启发。