很多时候,当我们接手一个开发了一年以上的工程代码时,通常都会有很多要吐槽的地方,但是在这些工程新建之初,想必也一定是仔细规划了整个架构,制定了一系列的规范和约束。并且,由于项目在开始阶段人员不多,大家彼此沟通也很顺畅。随着项目的不断迭代,越来越多的功能、越来越多的人加入进来,PM总有一揽子需要要做,公司也会有业务增长的压力。研发人员在这种多重压力下,开始对当初定下的一些规范和约束进行妥协,先上线和能用成为第一优先级。于是,为了效率,大家开始不注重命名,不注重复用,不注重边界,整个项目里充斥者各种看不懂的逻辑,牵一发而动全身。多少次你决定对代码进行重构,最后都在绝望中放弃了。
当代码变得腐化,整个团队的开发效率会大大降低,而出错的概率会大大提高。如何保持代码的整洁不仅仅是整个团队迫切的需要,更是每个开发人员自身代码素质的一种体现。所以,今天我们来谈一谈如何构建整洁的代码。
如何保持代码的整洁
如果你想盖一所房子,那么首先要规划出整个结构,然后再一砖一瓦的建设。如果砖头质量不佳,那么再好的架构也支撑不起一个结实的房子。同样,要想构建一个好的软件系统,应该从写整洁的代码开始。
代码层面
格式
代码格式关乎沟通,而沟通是开发者的头等大事。
代码格式最直接的影响是可读性,我们今天编写的功能,很可能在下一版本中被修改,但是,代码的可读性却会对以后可能发生的修改行为产生深远影响,从而间接的影响到代码的可维护性和扩展性。
纵观我们熟知的一些开源项目,不关乎具体的语言,平均单个文件的代码长度基本维持在200到500行这个范围,毕竟短文件通常比长文件更易于理解。而在代码行的宽度上,通常也是保持在120个字符以内。
除了文件的长度要在一个合理的范围,在垂直方向上,那些不同的概念间通常会用一行或两行的空行来分割,比如导入声明与主体,函数和函数等。这样做的好处是使我们的目光会不自觉的停留在空白行之后的那一行上,从而在代码的视觉外观上产生影响。
如果说空白行隔开了概念,靠近的代码则暗示了他们之间的紧密关系。
比如,我们在定义类的属性时,属性之间最好不要被空行、注释等分割开。关系密切的概念也尽量不要放到不同的文件中。这样做的好处就是避免读者在源文件和类中跳来跳去。
一些更具体的最佳实践如下:
- 本地变量 - 原则是靠近其使用的位置,对于较短的函数,本地变量应该在函数的顶部出现,在较长的函数中,变量可以在某个代码块的顶部。
- 实体变量 - 原则就是在大家都知道的地方声明,比如Java、Python中应该在类的顶部声明,C++则是放在底部。
- 相关函数 - 调用者与被调用者应该放到一起,并且调用者应该尽可能放到被调用者上面。这样就建立了一种自顶向下贯穿源代码的良好信息流。另一方面,这也保证了重要的概念能够先出来,而底层细节最后出来。
- 概念相关 - 概念相关的代码应该放到一起,并且相关性越强,彼此之间的距离就该越短。
在代码的横向格式上,可能没有那么多的要求,一般处理好缩进以及控制好代码行的长度就可以了,况且像Python这种语言在缩进上已经帮助我们做出了强制规定。
好的软件系统是由一系列读起来不错的代码文件组成的,他们需要一致和顺畅的风格,只要保证在所有源文件中看到的格式风格相同,就不会增加代码的复杂度。
命名
命名是在编码过程中经常要做但是也很难做好的一件事。取一个好的名字并非易事,但如果能遵循下面几条规律,相信你的命名至少不会太差。
首先,名称应该能表达你的真实意图。很多时候,当名称的表达力不够时,我们通常会使用注释来补充,但这也恰恰说明了,我们需要为他再想一个好点的名字。
例如下面的定义,d 什么也没说明,
d = None # 消逝的日期,以日计
如果改成 elapsedTimeInDays
,可读性就更强一些。通常情况下,长名称要优于短名称,如果一味追求短小而丧失了可读性是得不偿失的。
很多时候,我们的代码可能足够简洁,但是清晰度却不好,也就是上下文在代码中并没有被明确体现出来。比如下面的代码,我们只是更改成了更有意义的名称,就使得代码变得明确多了。
def get_them():
list1 = []
for x in the_list:
if x[0] == 4:
list1.append(x)
return list1
def get_flagged_cells():
flagged_cells = []
for cell in game_board:
if cell[STATUS_VALUE] == FLAGGED:
flagged_cells.append(cell)
return flagged_cells
另外一种没有正确表达意图的反例是对读者造成了误导。
所谓误导,就是读者从代码中接收到的信息跟作者想要表达的信息产生了误差,比如你写下了下面的代码: account_list = ,读者在看到{*}_list时会本能的会认为这就是一个list结构,但如果它真实的数据结构不是一个list,就会对用户的理解造成困扰。
本人之前就遇到过,staff_id = *,看到这行代码你会很自然的认为staff_id代表的是一个员工id,但是再看到下面的代码你会作何感想:departments = get_department_by_ids(staff_id)?
其次,名称之间要有有意义的区分。比如下面定义的几个方法,在不看方法实现的前提下,你能轻易判断出该使用哪个吗?
get_product()
get_products()
get_product_info()
我想是很难的。
类似Product、ProductInfo、ProductData这样的命名也是同样的问题,它们的名称虽然不同,但意思却无区别。
还比如,使用name_str/nameStr会比name好吗?难道name会是一个浮点数吗?类似这样的定义,我们称之为'废话'。所以,当我们定义好一个名称后,最好是能够站在读者的角度想一下,你能够区分出它们的不同吗?
最后,也是很重要的一点,给每个抽象概念选一个词,并在整个工程里面遵循这样一个抽象。
比如在Service/Dao层的方法命名,就可以约定:
- 获取单个对象的方法用get做前缀
- 获取多个对象的方法用list做前缀
- 获取统计值的方法用count做前缀
- 插入的方法用insert做前缀(不要使用save,因为save即可作为插入也可作为更新使用,这种双关的语意在使用上可能会造成一定的困扰)
- 删除的方法用delete做前缀
- 修改的方法用update做前缀
- 一些领域模型,比如数据对象:xxxDO,数据传输对象:xxxDTO,展示对象:xxxVO
当我们遵循上面这些编码规则后,通过前缀就可以判断出该调用哪个方法,而不再需要耗费大把的时间浏览各个文件及代码。
同样,如果在一堆代码中既有util又有helper还有tool,也会令人感到困惑,因为使用者很难说清楚他们有啥区别。
命名虽然是一个小事,但是必须认真对待,好的命名能够让代码看起来就像在读一篇文章那样顺滑。
函数
函数作为一个工程里面及其重要的组成部分,同样也要我们认真对待。
函数的第一规则是要短小,第二条规则是应该更短小。
当函数变得短小后,我们就更容易保证在这个函数中只做一件事,并且能够做好这件事。而只做一件事又带来的另一个好处是对函数的命名也变得简单。这里,我们再次强调了命名的重要性。
在前面格式一节说过,要让代码拥有自顶向下的阅读顺序,也就是被调用者应当放置于调用者的下方。这样的布局方式可以帮助我们划分函数的抽象层级。位于较高抽象层概念的函数可以先被看到,而不至于一开始就被过多的细节纠结在一起。
其次,我们再来看看函数的参数。
对于函数来说,最理想的是没有参数,其次是一个参数,再次是两个,当需要三个及以上参数时,就要慎重考虑了。参数多了,不但增加了理解函数的成本,也给测试带来困难,想要覆盖所有可能值的组合会非常之多。
在这里,有两种参数使用方法不得不说一下:
- 输出参数 - 也就是函数的运算结果通过参数输出,而非通过返回值从函数中输出。参数在多数情况下会被自然而然的看成是函数的输入,因此,这样的形式往往令人迷惑,如果函数需要对输入参数进行操作,就将操作的结果体现在返回值上。
- 标识参数 - 向函数中传入一个布尔值。这样的传参方式也同时意味着函数在做不止一件事,好一些的方案是将该函数一分为二。
当参数数量过多时,可以考虑使用参数对象,也就是将参数封装为类。虽然使用对象并没有减少要'传入'的参数的数量,但是我们很好的把它们封装在了一个概念里面,并通过清晰的命名,让函数使用者更容易理解和使用。
我们上面提到了命名的重要性,在这里,不得不再次重申。
试想一下,如果函数的实现与命名不符,会有什么样的后果呢?本来函数承诺只做A一件事,但实际上做了A和B两件事,B在这里被隐藏了起来。这样的函数可能会做出未能如我们预期的改动,而这种改动往往是具有破坏性的,会发生各种奇怪的问题。这样的副作用应该是极力避免的。
在编写函数实现的时候,我们无法规避的另外一个问题就是异常的处理。
在异常机制出现之前,应用程序普遍采用返回错误码的方式来通知调用方发生了异常。对于调用者来说,必须立刻处理错误,这进而导致了更深层次的嵌套结构。
但是,现在有了另一种选择,即使用异常机制,将错误处理代码从主路径代码中分离出来,比如在Java中可以使用try/catch,在Python中,可以使用try/except。try/catch代码块通常看起来都丑陋不堪,可以考虑将try和catch代码块的主体部分抽离出来,另外形成函数。
最后,我们还要致力于消除重复。拷贝代码一时爽,维护起来难上难。
系统层面
类与原则
同函数,类的第一条规则是应该短小,第二条规则是还要更短小。在函数那节,我们评判一个函数是否短小,主要看其行数,而对于类,衡量方法变为看其权责。
看下面的例子:
class Employee(object):
def calculate_pay():
pass
def report_hours():
pass
def save():
pass
我们定义了一个Employee类,里面包含了三个方法,从结构上看,这个类已经非常的简单了,但是我们再来详细分析一下每个方法。
calculate_pay 的逻辑可能是由财务部门制定的,report_hours逻辑可能是由人力资源制定而来,而save可能是dba制定的逻辑。一个类将三类行为者的行为耦合在了一起。有什么问题呢?
在开始的时候calculate_pay和report_hours可能会使用同样的逻辑来计算正常工作时数,开发人员为了避免重复,通常会将该算法单独实现为一个函数,这个时候,Employee类的实现逻辑是这样的:
class Employee(object):
def calculate_pay():
hours = self._regular_hours()
...
def report_hours():
hours = self._regular_hours()
...
def save():
pass
def _regular_hours():
return ...
接下来,假设财务需要修改正常工作时数的计算方法,而人力不需要这个修改,因为对数据的用法不一样。这时候,负责修改这个需求的开发人员会注意到calculate_pay调用了regular_hours,但可能不会注意到regular_hours会同时被report_hours调用。于是,该程序员就这样按照要求进行了修改,同时财务也验证了新算法正常工作,最后,修改被成功部署上线了。
这类问题的发生的根本原因是我们将不同行为者所依赖的代码强凑到了一起。由此引出了我们的第一个设计原则:
单一职责原则(SRP)
:它的学术定义是类或模块应该有且只有一个被修改的理由。说白点就是类不要做太多事,尤其是跟自己不相干的事。
当系统达到一定的规模后,都会包括大量的逻辑和复杂性,而管理这种复杂性的首要目标就是加以组织,让开发者知道到哪去找到东西,并且在特定的时间只需要理解直接有关的复杂性。这就是SRP的目的。
上面问题最简单的解决方法是将数据与函数分离:
或者是这样:
对于多数系统来说,修改将一直持续,而每处修改都让我们冒着系统其它部分不能如期望般工作的风险。为了应对这种风险,我们总是希望只要新加代码就可以满足新的需求,而不需要改动既有代码,这就是我们要说的第二个原则:
开闭原则(OCP)
:模块或类应当对扩展开放,对修改关闭。
来看下面这个例子,我们定义了两个类,Circle和Square:
class Circle(object):
def __init__(center_point, radius):
self.center = center_point
self.radius = radius
class Square(object):
def __init__(top_left_point, side):
self.top_left = top_left_point
self.side = side
然后定义一个Geometry类来计算面积:
class Geometry(object):
def area(any_shape):
if isinstance(any_shape, Circle):
...
elif isinstance(any_shape, Square):
...
现在如果新加一个三角形,就不得不打开Geometry类,对area函数做出修改。风险就这么产生了。
符合OCP的解决方案是这样的:
class Shape(object):
def area():
pass
class Cicrle(Shape):
def area():
...
class Square(Shape):
def area():
...
class Geometry():
def area(any_shape):
return any_shape.area()
当新加三角形时,只需要新建一个三角类,并让它继承Shape就可以了,这就符合了OCP。无需改动自身代码,就可以扩展它的行为。
注意了,这个例子并非是100%封闭的。一般而言,无论模型是多么的封闭,都会存在一些无法对之封闭的变化,没有对所有的情况都贴切的模型。这就要求我们能够对模块或类应该对哪种变化封闭做出选择,首先猜测出最有可能发生的变化种类,然后构造抽象来隔离这些变化。
从上面的例子也可以看到,OCP背后主要的机制是抽象和多态,而支持抽象和多态的关键又是继承。由此,我们又可以引出里氏替换原则(LSP),它可以指导我们如何使用继承。
里氏替换原则(LSP)
:子类型必须能够替换掉它们的基类型。
考虑一个Rectangle类和一个Square类,我们的经验告诉我们一个正方形就是一个矩形,因此,当我们写出下面的代码也就不足为奇:
class Rectangle(object):
...
class Square(Rectangle):
...
可问题是,上面的代码违反了LSP,比如,有一个函数,它接收一个Rectangle实例,来计算面积,
def some_func(rectangle):
rectangle.set_width(5)
rectangle.set_height(4)
assert rectangle.area() == 20
这个时候,如果我们传了一个Square对象进去,问题就产生了。解决这个问题的唯一办法就是在函数中增加用于区分Rectangle和Square的逻辑,但这样一来,函数就依赖于它所使用的类,两个类就不能互相替换了。
里氏替换原则除了能够指导如何使用继承关系,还更广泛的适用于指导接口与其实现方式的设计原则。
再回到上面Shape那个例子,我们可以说,Shape是抽象的接口,而Circle和Rectangle是具体的实现,当我们修改Shape时,也一定会去修改对应的具体实现,但是反过来,当我们修改具体实现时,却很少去修改相应的抽象接口。因此,我们可以认为,接口比实现更稳定。
所以如果想要设计一个灵活的系统,就要多引用抽象类型,而非具体实现。
举一个实际的例子,很多公司可能会使用钉钉,一些通知信息都是发的钉钉消息。像下面这样:
随着业务的发展,公司可能在某个时刻换成其他的IM软件,比如企业微信,这个时候,就不得不把所有用到DDMessageSender的地方全部修改一遍。
在这个例子中,背后的抽象是能够发送消息给指定的用户,具体通过什么途径无关紧要,通过钉钉或者是微信都是不会影响到抽象的具体细节。因此,我们可以改进一下设计:
在MessageSender与DDMessageSender之间的那条虚线,代表了抽象层与具体实现层的边界。有了这条边界后,可以看到,这里的控制流跨越边界的方向与源代码依赖跨越边界的方向正好相反,源代码依赖方向永远是控制流方向的反转,因此我们又称其为依赖反转原则(DIP)
。它的更正式的定义是这样的:
- 高层次的模块不应该依赖于低层次的模块,他们都应该依赖于抽象。
- 抽象不应该依赖于具体,具体应该依赖于抽象。
我们在平时开发时,并不是要生搬硬套这些原则,也不是说只要符合这些原则的代码就一定是好代码。它带给我们的指导意义更多的是在于对问题思考方式的改变。
对象和数据结构
对象和数据结构是类的两种更加具体的形态,这节就来说说它们有何区别与联系。
class Point(object):
def __init__():
self.x = None
self.y = None
上面定义了一个数据结构,并且暴露了其实现。而下面则是完全不同的另一种定义:
class Point(object):
def get_x():
pass
def get_y():
pass
第二种的好处是完全隐藏了细节,你不知道该实现是在矩形坐标还是极坐标系中。更重要的是,第二种方式是一种抽象,通过暴露的接口,让用户在无需了解数据实现的情况下就能操作数据本体。
数据和对象除了抽象上的差异外,两者还具有反对称性。
还记得我们上面Geometry的例子吗?
class Circle(object):
def __init__(center_point, radius):
self.center = center_point
self.radius = radius
class Square(object):
def __init__(top_left_point, side):
self.top_left = top_left_point
self.side = side
class Geometry(object):
def area(any_shape):
if isinstance(any_shape, Circle):
...
elif isinstance(any_shape, Square):
...
我们在定义area函数时使用了过程式代码,这看起来有点low。但我们仔细分析一下:当给Geometry添加一个primeter函数时,既有的这些形状类根本不会受影响,但如果添加了一个新形状,就得修改Geometry中的所有函数来处理它。
与此相反,面向对象实现方案在添加一个新形状时,Geometry中的函数一个也不会受影响,但是当添加新函数时所有的形状都得修改。
由此,我们可以得出结论:过程式代码便于在不改变既有数据结构的前提下添加新函数,而面向对象代码便于在不改动既有函数时添加新类。
所以,对于面向对象较难的事,对于过程式代码却很容易,反之亦然。
对于只有公共变量而没有函数的类我们又称为数据传输对象(DTO),而Active Record是一种特殊的DTO形式,但不幸的是开发者经常在这类数据结构中塞入业务规则方法,把这类数据结构当成了对象来用,这种数据结构和对象的混合体让代码维护起来异常困难。
说到这里,其实就不得不说说几种领域模型,但是由于篇幅限制,我们今后再单独写一篇文章来谈谈这个问题。
边界与分层
系统边界,即系统包含的功能与系统不包含的功能之间的界限。
比如我们会引用公司的二方包,依赖其他团队打造的组件或系统,我们需要将这些外来代码整合进自己的代码中。
外来代码一般具有的特点是追求普适性和灵活性,而我们在使用时却想要能够集中满足特定需求的接口。这种张力会很容易导致在系统边界上出现问题。
解决这种张力的一种方案是应用接口隔离原则(ISP)
,它告诉我们不应该强迫客户依赖于它们不用的方法。
实现ISP的一种方式是采用委托分离接口,比如经典的适配器模式。
除了系统边界,程序内部也要划分边界。最典型的分层模型就是对边界的一种划分。
layer.png
图中的这种应用分层方式应该是大家比较常见的,我们一个个来说一下:
-
开放接口层:可直接封装 Service 方法暴露成 RPC 接口;通过 Web 封装成 http 接口;进行网关安 全控制、流量控制等。
-
终端显示层:各个端的模板渲染并执行显示的层。比如web端,移动端展示等。
-
Web 层:主要是对访问控制进行转发,各类基本参数校验,或者不复用的业务简单处理等。
-
Service 层:相对具体的业务逻辑服务层。
-
Manager 层:通用业务处理层,它有如下特征:
1) 对第三方平台封装的层,预处理返回结果及转化异常信息。
2) 对 Service 层通用能力的下沉,如缓存方案、中间件通用处理。
3) 与 DAO 层交互,对多个 DAO 的组合复用。
-
DAO 层:数据访问层,与底层 MySQL、Hive 等进行数据交互。
-
外部接口或第三方平台:包括其它部门 RPC 开放接口,基础平台,其它公司的 HTTP 接口。
当系统按这种分层结构划分了边界,其实就是约束了我们应该在什么地方写什么样的代码,而不至于像毛线球一样,摘也摘不开。
培养整洁感
程序员经常想着,等业务没那么忙的时候,做点清理,做点模块局部的微重构,但事实通常跟想法相去甚远。如果定下了定期清理,定期检查拆分,就必须立马去做,做这些事的优先级要远高于业务需求。
Later equals never.
时时保持代码的整洁其实并不一定要花多少工夫,也许只是改好一个变量名,拆分一个优点过长的函数,消除一点重复代码,清理了一个嵌套的if语句。
PM会奋力维护进度和需求,那是他们该干的,而我们,应当以同样的热情维护代码。