0. 什么样的代码才是好代码?
这个问题一直存在争论,实干者说能满足需求的代码就是好代码,学术派可能不以为然,觉得好的代码是通过精心设计,易扩展,有着高大上的设计模式云云。个人认为不管哪种说法没有完全地对错,而是需要分不同场景来说,比如一个软件生命周期很短的系统,投入太多的时间做过多的设计来考虑后面的需求变化可能远不如快速开发一个版本来的实际。反之,对于生命周期比较长的系统,开始之初或者迭代过程中完全只是考虑如何实现功能,这样将会给系统后期迭代带来很大的挑战。
不管是第一种还是第二种看法,都是来自于对现实利弊的权衡,显然如果不了解设计模式以及不好的设计对系统会造成什么的影响,就很难说选择第一种方式来开发是权衡的结果还是习惯使然。
所以对于一个程序员来说,了解掌握基本的设计模式和原则还是很有必要的,在实际工作中尽量审慎的对待每一行代码,虽然无尽的需求可能让这一追求变得有点乌托邦。
1. 不好的设计对系统的影响
上图表明没有好的设计整个团队的生产力和时间的变化关系图,可以看出前期代码输出非常高,但是随着代码量越来越大,维护成本非常高,生产力持续下降,甚至逼近零!
不好的设计会让系统迭代变得越来越艰难。
- 一个地方的改变需要修改很多地方的代码,对扩展产生畏惧,迭代成本居高不下
- 修改的地方越多,也就意味这测试的工作量越大,系统的稳定性不能稳步提升
这些都是让人望而生畏的,但是又不得不扩展来满足需求,所以一般的做法就是
- 通过if/else 拉一个新的分支,然后拷贝大段重复代码,
- 修改一下某一段逻辑来满足我们的需求
之前的代码都不用动(也不敢动),这看起来似乎没有什么问题,但是重复代码会随着时间推移不断累积,导致系统存在大量的重复代码,而代码投入的成本不仅是在开发期间,而是在整个开发,测试,和漫长的维护迭代中都需要倾注心血,重复代码意味着这些工作量都需要翻倍,因此技术债务日积月累,越来越难以清理,恶性循环。
2.几种常见的bad case
- 重复代码:重复代码不仅意味着重复开发,还包括测试,后期迭代维护等工作量翻倍,另外这些代码极其相似但是又不尽相同,更是加大了维护的困难。
- 函数过长
- 类太大:万能类说明这个类职责不够单一,这样的类一般复用性差,而且由于职责不够单一,可能存在多个引起修改的原因。
- 参数列表过长:参数列难以理解, 不易使用
- 发散式修改:修改一处,导致多处需要修改,这很可能是因为模块职责不单一,模块间耦合严重导致的
- 令人迷惑的临时字段
- 注释过多:注释的实时性,代码应该具备自解释性,注释过多一般就表明这段逻辑很晦涩
- 函数对某个类的兴趣高于自身所在类:需要考虑这个函数的放置位置是否合理
3.设计原则
设计原则是概括性的内容,原则没有告诉我们如何去实施我们的代码,而只是告诉我们我们应该让我们的代码遵守这些原则,只要遵守了这些原则(实际情况下很难做到遵守所有的原则)我们的代码将会变的更加有弹性,这些原则可以用来指导我们的代码设计。
3.1 单一职责
仅有一个原因使的类的变更,不是自己职责范围之内的事情,坚决不干,降低单个类的复杂性,避免产生超级万能类
3.2 里氏替换
定义:所有引用其基类的地方都能透明的引用其子类
通俗的说法:子类可以扩展父类的功能,但是不能改变父类原有的功能。也就是说,在子类继承父类的时候,除了添加新的方法完成新增功能之外,尽量不要重写父类的方法
why:个人理解,父类中定义的方法(包括方法中使用到成员变量语义)这些都是子类抽象共有的共性部分,这些共性部分应该是稳定的,如果一个子类必须要重写父类的方法,需要考虑父类是否足够抽象,或者子类继承该父类是否合理,遵循里氏替换原则,可以保证父类中抽象出来的方法是稳定的。
3.3 依赖倒置
定义:程序依赖抽象接口而不依赖具体实现
- 高层模块不应该依赖低层模块,两者都应该依赖其抽象(模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的)
- 抽象不应该依赖细节
- 细节应该依赖抽象
why:个人理解,抽象接口是一个稳定,不变的,上层和下层依赖定义的抽象接口,可以将上层逻辑和底层的变化解耦,从而避免出现底层发生变化,上层逻辑层需要做大量的修改,如果依赖抽象接口层,底层变化对上层逻辑没有丝毫影响,从而使得代码更加具有弹性。同理上层逻辑的变化对底层也不会产生丝毫影响
3.4 接口隔离
定义:客户端不应该依赖它不需要的接口; 一个类对另一个类的依赖应该建立在最小的接口上。
使用接口隔离原则,意在拆分非常庞大臃肿的接口成为更小的和更具体的接口,符合我们常说的高内聚低耦合的设计思想,从而使得类具有很好的可读性、可扩展性和可维护性。
如果定义的接口包罗万象,
- 很难杜绝某些模块访问了不该访问的接口,如果给上层模块使用的接口都是最少且必须的,这样会大大减少这种错误。
- 如果都是一个个小接口,该接口的实现类的方法个数显然比大接口实现类要少很多,这样每个类的复杂度都会得到有效控制,而且扩展通过新增小的接口类就可以,而不是在过去大的接口上增加新的接口方法定义。
3.5 最少知识
一个对象应该对其他对象保持最少的了解
原因:类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大
如果一个类中的某些方法过多地理解另一个类的实现细节甚至超越自身,那么此时需要重新审视这个类的方法是否应该属于该类。
3.6 开放封闭
开放:对扩展开放,封闭:对修改封闭
这个是软件工程的最高终极理想,当需求发生变化时,我们不需要对已有稳定的代码做任何修改,而只需要增加新的类,新的文件来扩展功能,满足新的需求。但是这个一般很难完全做到
如果我们写的代码完全不满足这个原则的话,这就意味着
- 每次需求发生变化时,需要对过去已经稳定的代码做大量修改。
- 修改意味着需要重新编码,重新测试,包括先前沉淀的知识(比如团队中对于某一块代码实现逻辑认识的共识也要随之发生改变,这可能会造成混乱)都会发生改变,之前的成果将无法得到沉淀。
- 系统的稳定性不能稳步提升,而是不断徘徊,系统很难得到长足的发展。
4.常用设计模式
设计模式是具体的方法套路的总结,这些方法是前人从大量项目经验中总结提炼升华得到,也是让我们的代码尽可能的符合设计原则的规范(当然有些不一定完全满足)
4.1 单例模式
使用场景:整个系统中只需要一个实例对象
好处:
- 避免系统创建太多的实例任务
- 提供统一的数据访问点,减少数据不一致性的可能性
4.2 工厂模式
功能:1.解耦对象实例化和上层逻辑,从而方便的扩展功能
2.将对象实例的创建逻辑收拢到工厂类里面(特别是一些创建过程比较复杂的对象),给上层逻辑提供复用,而不是由上层逻辑自行创建
4.3 状态机模式
使用场景:如果整个逻辑是个典型的状态机驱动的逻辑过程,可以考虑使用状态机模式
优点:流程结构清晰,封装性好
4.4 模板模式
使用场景:如果多个不同的流程,虽然流程不同,但是流程却很相似,这个时候可以考虑使用模板模式,将多个不同流程的公共部分抽象出来得到一个模板类,而对于不同的实现可以通过抽象来进行扩展。
好处:将公共的流程抽象出来,减少重复代码,有时候公共流程是很复杂的,而不同流程之间差异性很小,这个时候更能够体会到模板模式的威力。
4.5 策略模式
定义一系列的算法,把它们一个个封装起来, 并且使它们可相互替换
使用场景:不同场景可能需要使用不同策略,比如:对于从文件中加载任务列表,然后对任务进行过滤,得到最终的任务列表。在该场景中,对任务进行过滤可能不同场景需要不同的过滤方法,此时就可以使用策略模式就可以很方便的扩展
4.6 命令模式
考虑一个场景:对于服务中的接口访问我们需要对访问进行统计,比如时延,接口访问次数,接口输入输出的审计日志等信息,我们应该怎么做
不好的做法:比如统计时延,在每个服务的接口函数中开始时计算开始时间,然后return前计算结束时间,得到请求的差值,从而得到服务的统计时延
这样的方式会存在几个问题:
- 每个接口都需要实现时延统计的逻辑,增加额外的工作量,另外也很难保证所有的接口都会加上统计时延的逻辑
- 接口的出口返回点特别多,需要在每个返回的地方前面都需要加上计算结束时间代码,如果漏掉,将会出错
上面两个问题很难保证我们的代码时刻保证正确,我们必须小心翼翼的来维护这段代码,而且极容易出错。
命令模式则能很方便的解决这个问题,命令模式将对接口(命令)的调用行为进行封装,对所有接口调用的行为定义在封装的调用类中,从而提供统一简洁的处理方式,命令模式对命令的调用行为和命令本身的逻辑解耦,我们只需要将全部心智关注在命令的具体逻辑上实现即可。
4.7 观察者模式
考虑这么个需求,一个文件被删除了,该消息要及时通知到其他类,比如释放句柄,或者清理什么的
不好的做法:在remove类的remove函数里面实现,1删除文件,2,调用A释放句柄,调用B进行清理,调用C做XXX
这样会存在一些问题
- 如果此时扩展一个功能类D,并且D在文件删除的时候,也要执行对应的逻辑,这个时候就要在remove函数中加入调用D做XXX的代码,remove函数一直不不断修改。
- 随着系统不断扩展remove函数越来越长,关联的类越来越多,remove的逻辑越来越复杂。
观察者模式可以很好的解决这个问题,使用该模式,在remove函数中只需要负责对文件删除即可,不必关心要通知具体哪些类,做什么事情,其他关注文件被删除的类,通过自动注册到remove类中,remove删除完函数通过注册列表挨个通知注册类即可。
4.8 门面模式(外观模式)
通过引入一个外观角色来简化客户端与系统之间的交互,为复杂的系统调用提供一个统一的入口,降低系统与客户端的耦合度。
比如:一个系统的流程涉及到多个子系统,如果不使用外观模式,客户端和该系统交互时,就必须要该系统中各个子系统交互,这样显然会带来两个问题
- 调用复杂,使用成本高,容易出错
- 过多的暴露系统的细节,封装程度不够,客户端和子系统耦合严重,系统升级成本增大
4.9 适配器模式
适配器模式将某个类的接口转换成客户端期望的另一个接口表示,主的目的是兼容性,让原本因接口不匹配不能一起工作的两个类可以协同工作。其别名为包装器(Wrapper)。
4.10 装饰者模式
考虑一个场景:文件读取类A完成文件的读取功能,现在需要新的功能文件读取出来进行过滤
不好的做法:直接修改类A,让类A的文件读取功能中加入文件过滤的功能
带来的问题:
- 显然会破坏类A的开放封闭原则,这就导致上层所有使用到类A的地方的稳定性都可能出现问题
好的做法:创建类B,组合使用类A对A读取出来的数据,进行过滤操作,这样类B复用类A的功能,然后在加上自己的过滤功能,是对类A功能的一个增强但是对类A完全不用修改。
4.11 迭代器模式
提供一种方法顺序访问一个对象集合中各个元素, 而又无须暴露该对象的内部表示
考虑场景:分别由两个类A,B。底层分别使用不同数据结构来对元素集合进行组织,而上层的需求是需要对顺序访问A,B的中的元素。
不好的做法:分别对A,B实现不同的顺序访问逻辑
这样做法带来的问题:
- 上层逻辑对底层实现的细节理解太多,如果此时底层数据结构发生变化,上层逻辑都需要发生改变
迭代器模式:通过抽象的迭代器,屏蔽掉底层数据元素组织的数据结构,对上层来说不管是对类A还是类B的顺序访问,都只需要访问迭代器就可以了,如果底层数据结构发生改变,上层对此无感知不用做任何修改,只需要修改对应迭代器中迭代逻辑即可。
5.代码的一些常用技巧
5.1 不建议一趟循环同时做多件事情
比如对数组中元素进行过滤,对过滤的函数进行求和(以下为伪码,请忽略语法正确性)
第一种实现
int Sum(int a[],int len) {
int sum = 0
for(i=0;i < len; i++)
if(a[i]是否保留){
sum+=a[i]
}
}
return sum
}
第二种实现
int Sum(int a[],int len) {
int a_left[len]
for(i=0,j = 0;i < len; i++)
if(a[i]是否保留){
a_left[j] = a[i]
j++;
}
}
int sum = 0
a_left_len = j
for(idx=0;idx < a_left_len; idx++)
sum+= a_left[idx]
return sum
}
考虑到逻辑需求发生变化,比如统计过滤完后奇数和偶数的个数等,然后对奇数求和,对偶数求和等等需求(毕竟变化总是说来就来)。
显然第一种会慢慢变的复杂,代码越来越晦涩难懂,这个时候就会比较容易出错,而第二种复杂度则不会增加。另外第二种写法,还可以分别把过滤循环 & 累计求和功能当做独立的函数抽象出来,后面各种突如其来的 需求都会被分解成一个个独立的函数(这些函数能够提供给其他函数复用),而不像第一种写法全部耦合在一个循环里面。
int Sum(int a[],int len) {
a_left = filter(a,len)
sum = sum(a_left )
return sum
}
5.2 避免太多的临时变量的定义
- 导致函数耦合严重,让大的函数不好提炼成一个个小函数
- 使用查询替换临时变量:增加可读性 & 复用函数
比如在一段模块中存在一个逻辑,来判断分数是否及格,如果及格就执行操作
func pass_do(){
bool is_pass = score > 60? true:false
if(is_pass ){
do_some_thing()
}
}
另外存在另一段逻辑来判断分数是否及格,如果不及格就执行操作
func not_pass_do(){
bool is_pass = score > 60? true:false
if(!is_pass ){
do_some_thing()
}
}
这样的写法在代码中不少见,但是如果需求发生变化,及格标准变成80分才算及格。此时就需要对整个项目中这段代码bool is_pass = score > 60? true:false都要修改。如果把这种只读的临时变量转换成查询的话,将不会存在这样的问题。
bool is_pass = score > 60? true:false 改为 bool is_pass = is_passed(socre)
func is_passed(socre){
return score > 60? true:false
}
这样只需要修改is_passed函数即可,方便简单,而且还不会出错。
- 一个临时变量只负责一种语义,避免混乱,
5.3 函数名应该用做什么而不是怎么做来命名:因为怎么做可能会发生变化,但是做什么却相对稳定
5.4 注释应该强调why而不是how:有价值的注释应该解释为什么这么做,这样做的出发点是什么。而至于怎么做的应该由代码自己告诉我们。好的代码是不太需要注释的,因为所有的定义的函数,文件名,对象名等名称具有自明性。
5.5 职责要单一,不该做的坚决不做
5.6 第三方组件隔离:第三方组件接口输入输出,详细的日志输出记录。原因
- 第三方库可能存在不稳定因素,这些不是我们所能控制的,所以必须将其与我们系统进行隔离
- 第三方库不稳定因素很多,本身bug,库版本升级导致不兼容等。
5.7 抽取复杂条件判断语句:复杂的条件判断语句,可以抽取出来独立成函数,给函数取一个好的名字,通过函数名字来表达判断语句的逻辑
5.8如何命名:
- 原则:
- 见名知意
- 准确直接
- 文件夹,文件名,类,变量,这些是实体属性,需要名词属性来命名。
- 函数名:表示动作,需要动词属性,比如动宾结构
- 去除无用前缀:冗余信息,ide好搜索
- 范围:文件夹,文件名, 类, 函数, 变量
5.9 通过提前返回来减少if else嵌套
比如:
func(){
if (condition1){
if(condition2){
todo1()
}else{
todo2()
}
}
}
改成:
func(){
if (!condition1){
return
}
if(condition2){
todo1()
}else{
todo2()
}
}
5.10 将逻辑转换成数据:
func(condition){
if condition == CONDITION1{
obj1.do_work()
}else if condition == CONDITION2{
obj2.do_work()
}else if condition == CONDITION3{
obj3.do_work()
}
}
func(condition){
obj_map = {CONDITION1:obj1,CONDITION2:obj2,CONDITION3:obj3}
obj_map[condition].do_work()
}
上面两段函数是实现的同一段功能,但是第一段如果需要扩展一个condition,就需要加一个else,如此重复函数里面将会存在大量的if /else 函数会越写越长。而第二种就是把if /else分支逻辑转换成了一个map查表映射过程,代码简洁,而且所有的条件跳转在一个map中进行维护即可。
6 总结语
保持代码简洁,保持持续的迭代能力,系统的性能才会稳步提升。设计模式由于大量的使用抽象,委托等面向对象的机制,虽然,这些会增加系统的额外开销,但是系统架构会更加清晰,可读性,可维护性更强,这为后期性能迭代增加了人力投入,虽然刚开始可能性能一般,但是具备良好扩展的系统,在后期迭代速度将会大大提升,这些将会让系统的性能稳步提升。另外,一个系统在开始设计之初可能很多性能的瓶颈点在我们考虑之外,而如果系统不具备快速迭代能力,就算发现性能优化点,也很难迭代,反而设计良好的系统,能够快速迭代优化性能,做到后来居上。