写代码的一些原则(笔记流)

我们都曾经瞟一眼自己亲手造成的混乱,决定弃之而不顾,走向新的一天。我们都曾经看到自己的烂程序居然能运行,然后断言能运行的烂程序总比什么都没有强。我们都曾经说过有朝一日再回头清理。当然,在那些日子里,我们都没听过勒布朗(LeBlanc)法则:稍后等于永不(Later equals never)— Clean Code

随着需求的不断增加和变更,业务逻辑越来越复杂,参与开发的人也越来越多,多人协作开发不可避免的就有改动到同一处代码,同一处逻辑的需要,而一但每个人都随意的在同一处地方添加自己的逻辑之后,就会导致代码耦合严重,难以阅读和维护,降低我们每个人的开发效率和开发体验,从而影响甚至是阻碍了新功能的迭代。

啥叫设计原则

关于代码中的设计原则有很多,例如一些经典的设计原则 SOLID、KISS、YAGNI、DRY、LOD 等。原则可能看起来非常简单,但是运用到实际中却不是那么容易,这里列举出来,后续可以深入进行学习,这些设计原则,常看常新:

SOLID :单一职责原则(Single Responsibility Principle)、开闭原则(Open Closed Principle)、里式替换原则(Liskov Substitution Principle)、接口隔离原则(Interface Segregation Principle)和依赖倒置原则(Dependency Inversion Principle)。

KISS :Keep It Simple and Stupid 尽量保持简单。

YAGNI : You Ain’t Gonna Need It 不要做过度设计。

DRY: Don’t Repeat Yourself 不要重复。

LOD : Law of Demeter 不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。(原文是:Each unit should have only limited knowledge about other units: only units “closely” related to the current unit. Or: Each unit should only talk to its friends; Don’t talk to strangers.)

SOLID

单一职责原则(Single Responsibility Principle),缩写为 SRP:一个类或者模块只负责完成一个职责(或者功能)。不要设计大而全的类,要设计粒度小、功能单一的类。换个角度来讲就是,一个类包含了两个或者两个以上业务不相干的功能,那我们就说它职责不够单一,应该将它拆分成多个功能更加单一、粒度更细的类。

评价一个类的职责是否足够单一,并没有一个非常明确的、可以量化的标准。可以先写一个粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成几个更细粒度的类。

几个简单的职责不够单一的判断原则:

  • 类中的代码行数、函数或属性过多。
  • 类依赖的其他类过多,或者依赖类的其他类过多。
  • 私有方法过多。
  • 比较难给类起一个合适名字,很难用一个业务名词概括。

开闭原则(Open Closed Principle),‘对扩展开放、修改关闭’。

添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。

添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,这个是做不到的。类需要创建、组装、并且做一些初始化操作,才能构建成可运行的的程序,这部分代码的修改是在所难免的。我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。

在写代码的时候,我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。

最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程。

里式替换原则(Liskov Substitution Principle),子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。

多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。

几个违反里式替换原则的例子:

  • 子类违背父类声明要实现的功能
  • 子类违背父类对输入、输出、异常的约定
  • 子类违背父类注释中所罗列的任何特殊说明

接口隔离原则(Interface Segregation Principle),客户端不应该被强迫依赖它不需要的接口。

如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。

函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现。

接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。

依赖倒置原则(Dependency Inversion Principle),高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。

“控制反转”:在使用框架之后,整个程序的执行流程通过框架来控制。流程的控制权从程序员“反转”给了框架。框架提供了一个可扩展的代码骨架,用来组装对象、管理整个执行流程。程序员利用框架进行开发的时候,只需要往预留的扩展点上,添加跟自己业务相关的代码,就可以利用框架来驱动整个程序流程的执行。

“依赖注入”:不通过 new 的方式在类内部创建依赖类的对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类来使用。

KISS、YAGNI

几个简单的原则:

不要使用同事可能不懂的技术来实现代码。

不要重复造轮子,要善于使用已经有的工具类库。经验证明,自己去实现这些类库,出 bug 的概率会更高,维护的成本也比较高。

不要过度优化。

DRY:什么是重复,怎么定义重复

实现逻辑重复、功能语义重复和代码执行重复。

实现逻辑重复:代码的实现逻辑是相同的,但语义不同,我们判定它并不违反 DRY 原则。对于包含重复代码的问题,我们可以通过抽象成更细粒度函数的方式来解决。

功能语义重复:代码的实现逻辑不重复,但语义重复,也就是功能重复,我们认为它违反了 DRY 原则。

代码执行重复:相同的代码的实现逻辑重复的执行了两次。

代码的复用性是评判代码质量的一个非常重要的标准。提高代码可复用性的一些方法:

减少代码耦合:高度耦合的类或者函数往往移动一点代码,就要牵连到很多其他相关的代码。高度耦合的代码会影响到代码的复用性,我们要尽量减少代码耦合。

满足单一职责原则:大而全的类依赖它的代码或者它依赖的代码就会比较多,难以复用。越细粒度的代码,代码的通用性会越好,越容易被复用。

模块化业务与非业务逻辑分离:越是跟业务无关的代码越是容易复用,越是针对特定业务的代码越难复用。

通用代码下沉:越底层的代码越通用、会被越多的模块调用,越应该设计得足够可复用。

继承、多态、抽象、封装:越抽象、越不依赖具体的实现,越容易复用。代码封装成模块,隐藏可变的细节、暴露不变的接口,就越容易复用。

Rule of three:第一次用到某个功能时,你写一个特定的解决方法;第二次又用到的时候,你拷贝上一次的代码;第三次出现的时候,你才着手”抽象化”,写出通用的解决方法。

这样做有几个理由:

(1)省事。如果一种功能只有一到两个地方会用到,就不需要在”抽象化”上面耗费时间了。

(2)容易发现模式。”抽象化”需要找到问题的模式,问题出现的场合越多,就越容易看出模式,从而可以更准确地”抽象化”。

LOD:Law of Demeter

规则一:不该有直接依赖关系的类之间,不要有依赖。

规则二:有依赖关系的类之间,尽量只依赖必要的接口。

迪米特法则是希望减少类之间的耦合,让类越独立越好。每个类都应该少了解系统的其他部分。

关于LoD,请记住一条:方法中不要使用ChainMethods。chain中某一个方法的改动会导致整个chain受到影响。

坏的实践:

1
2
3
4
5
6
Amount = customer.orders().last().totals().amount()
// or
orders = customer.orders()
lastOders = orders.last()
totals = lastOders.totals()
amount = totals.amount()

LoD如何使用:
一个类中的方法只能调用:
1、该类中其他实例方法。
2、它自己的参数方法。
3、它创建对象的方法。
4、不要调用全局变量(包括可变对象、可变单例)。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class HtmlDownloader{
Html html;
public void downloadHtml(Transporter trans, String url){
if(checkUrl(url)){// ok 自己的实例方法
// return
}
rawData = trans.send(uri);// ok 参数对象的方法
Html html = createHtml(rawData); // ok 它创建的对象
html.save();// ok 它创建对象的方法
}
private boolean checkUrl(String url) {
// check
}
}

细节是魔鬼

命名

在足够表达其含义的情况下,命名当然是越短越好。可以利用上下文信息来简化命名。

命名要避免误导。

命名要可读、可搜索。

类名和对象名应该是名词或名词短语,方法名应当是动词或动词短语。

统一命名规则。

例如对于接口的命名有人习惯于在前面加上“I”,有人习惯于不加 “I”,而是在实现类上加上Impl。这种在项目中最好保持一致。

注释

在代码无法表达出清晰的意图,而需要补充额外的信息时才需要注释。注释存在的时间越久,就离其所描述的代码越远,越来越变得全然错误。实际中我们很难同步修改代码以及代码上的注释,从而无法保证代码和注释始终表达相同的意思。所以尽管需要注释,但是我们也应该要尽量减少注释量。

坏的注释:

  • 多余的注释
  • 废话型注释
  • 对每个变量名都进行注释
  • 表达 “是什么”的注释
  • 每个类上的归属与署名类的注释
  • 注释掉的代码

好的注释:

  • 对代码意图的解释或补充型注释
  • 警示型注释
  • TODO 注释

注释的内容主要包含这样三个方面:做什么、为什么、怎么做。对于一些复杂的类和接口,我们可能还需要写明“如何用”。

函数

函数越短小越好。一个函数只做一件事。

将大函数中的代码分割成小的代码块,并保证函数中的语句都要在同一抽象层级上。相同抽象层级的函数应该放到一起。函数中混杂不同抽象层级,往往让人迷惑。

函数参数:最理想的参数数量是零(零参数函数),其次是一(单参数函数),再次是二(双参数函数),应尽量避免三(三参数函数)。有足够特殊的理由才能用三个以上参数(多参数函数)。函数包含 3、4 个参数的时候还是能接受的,大于等于 5 个的时候,会影响到代码的可读性。

对于过多参数函数的处理方法:

  • 考虑函数是否职责单一,是否能通过拆分成多个函数的方式来减少参数。
  • 将函数的参数封装成对象。

不要用参数来控制函数的逻辑:

不要在函数中使用布尔类型的标识参数来控制内部逻辑,true 的时候走这块逻辑,false 的时候走另一块逻辑。这明显违背了单一职责原则和接口隔离原则。建议将其拆成两个函数,可读性上也要更好。

函数的嵌套层次过深:最好不要超过两层。

解决嵌套过深的方法:

使用编程语言提供的 continue、break、return 关键字,提前退出嵌套。

1
2
3
4
if (digitalEnable && isDigitalHuman) {
return
}
...

调整执行顺序来减少嵌套:先写异常逻辑,再写正常逻辑

1
2
3
4
5
if (!useNewStrategy) {
null
} else {
...
}

将部分嵌套逻辑封装成函数调用。

单一权责原则(SRP)认为,类或模块应有且只有一条加以修改的理由。

系统应该由许多短小的类而不是少量巨大的类组成。

依赖磁铁(dependency magnet)类:常量类。我们把所有的常量都定义到一处地方,当该常量类修改时,所有依赖于它的这些其他的类和模块都需要重新编译和部署。

对象 与数据结构:

对象把数据隐藏于抽象之后,曝露操作数据的函数。数据结构曝露其数据,没有提供有意义的函数。

在任何一个复杂系统中,都会有需要添加新数据类型而不是新函数的时候。这时,对象和面向对象就比较适合。另一方面,也会有想要添加新函数而不是数据类型的时候。在这种情况下,过程式代码和数据结构更合适。

数据结构只简单地拥有公共变量,没有函数,而对象则拥有私有变量和公共函数。

方法不应调用由任何函数返回的对象的方法。

DTO是非常有用的结构,尤其是在与数据库通信、或解析套接字传递的消息之类场景中。我们不幸经常发现开发者往这类数据结构中塞进业务规则方法,把这类数据结构当成对象来用。这是不智的行为,因为它导致了数据结构和对象的混杂体。

重构

重构绝对不等于将代码拆分到不同的文件中去。

大型重构的手段有:分层、模块化、解耦、抽象可复用组件等等

小范围重构的手段:主要是针对类、函数、变量等代码级别的重构,比如规范命名、规范注释、消除超大类或函数、提取重复代码等等。

持续重构意识更重要。我们要正确地看待代码质量和重构这件事情。技术在更新、需求在变化、人员在流动,代码质量总会在下降,代码总会存在不完美,重构就会持续在进行。


写代码的一些原则(笔记流)
https://xcxyh.github.io/2024/07/06/写代码的一些原则(笔记流)/
作者
xcxyh
发布于
2024年7月6日
许可协议