设计原则思维导图
核心理论
基于接口编程
“基于接口而非实现编程” - “Program to an interface, not an implementation”
。
“接口”就是一组“协议”或者“约定”,是功能提供者提供给使用者的一个“功能列表”。“接口”在不同的应用场景下会有不同的解读,比如服务端与客户端之间的“接口”,类库提供的“接口”。“基于接口而非实现编程”这条原则中的“接口”,可以理解为编程语言中的接口或者抽象类。
实际上,“基于接口而非实现编程”这条原则的另一个表述方式,是“基于抽象而非实现编程”。在软件开发中,最大的挑战之一就是需求的不断变化,这也是考验代码设计好坏的一个标准。越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。而抽象就是提高代码扩展性、灵活性、可维护性最有效的手段之一
。
举例:假设我们的系统中有很多涉及图片处理和存储的业务逻辑。图片经过处理之后被上传到阿里云上。为了代码复用,我们封装了图片存储相关的代码逻辑,提供了一个统一的 AliyunImageStore
类,供整个系统来使用。
package main
type AliyunImageStore struct {
// ... 省略属性
}
func NewAliyunImageStore( /* 省略参数 */ ) *AliyunImageStore {
return &AliyunImageStore{}
}
func (ais *AliyunImageStore) createBucketIfNotExisting(bucketName string) {
// ...创建bucket代码逻辑...
// ...失败会抛出异常..
}
func (ais *AliyunImageStore) generateAccessToken() (token string) {
// ...根据accesskey/secrectkey等生成access token
return
}
func (ais *AliyunImageStore) uploadToAliyun(image []byte, bucketName string, accessToken string) {
//...上传图片到阿里云...
//...返回图片存储在阿里云上的地址(url)...
}
func (ais *AliyunImageStore) downloadFromAliyun(url string, accessToken string) (image []byte) {
//...从阿里云下载图片...
return
}
func main() {
const BucketName = "ai_images_bucket"
var image = readImage() // 获取图片
imageStore := NewAliyunImageStore( /* 省略参数 */ )
imageStore.createBucketIfNotExisting(BucketName)
accessToken := imageStore.generateAccessToken()
imageStore.uploadToAliyun(image, BucketName, accessToken)
}
func readImage() (image []byte) {
// ... 省略代码
return
}
过了一段时间后,我们自建了私有云,不再将图片存储到阿里云了,而是将图片存储到自建私有云上。而对于私有云来说,上传流程和阿里云并不一致。为了满足这样一个需求的变化,我们该如何修改代码呢?
思路:
- 函数的命名不能暴露任何实现细节。比如,前面
uploadToAliyun()
就不符合要求,应该改为去掉aliyun
这样的字眼,改为更加抽象的命名方式,比如:upload()
。 - 封装具体的实现细节。比如,跟阿里云相关的特殊上传(或下载)流程不应该暴露给调用者。我们对上传(或下载)流程进行封装,对外提供一个包裹所有上传(或下载)细节的方法,给调用者使用。
- 为实现类定义抽象的接口。具体的实现类都依赖统一的接口定义,遵从一致的上传功能协议。使用者依赖接口,而不是具体的实现类来编程。
package main
import "fmt"
// 接口定义
type ImageStore interface {
upload(image []byte, bucketName string) string
download(url string) []byte
}
// 实现一:阿里云
type AliyunImageStore struct {
// ... 省略属性
}
func NewAliyunImageStore() *AliyunImageStore {
return &AliyunImageStore{}
}
func (ais *AliyunImageStore) upload(image []byte, bucketName string) (url string) {
ais.createBucketIfNotExisting(bucketName)
accessToken := ais.generateAccessToken()
fmt.Println(accessToken)
//...上传图片到阿里云...
// ...返回图片在阿里云上的地址(url)...
return
}
func (ais *AliyunImageStore) download(url string) (image []byte) {
accessToken := ais.generateAccessToken()
fmt.Println(accessToken)
//...从阿里云下载图片...
return
}
func (ais *AliyunImageStore) createBucketIfNotExisting(bucketName string) {
// ...创建bucket代码逻辑...
// ...失败会抛出异常..
}
func (ais *AliyunImageStore) generateAccessToken() (token string) {
// ...根据accesskey/secrectkey等生成access token
return
}
// 实现二:私有云
type PrivateImageStore struct {
// ... 省略属性
}
func NewPrivateImageStore() *PrivateImageStore {
return &PrivateImageStore{}
}
func (pis *PrivateImageStore) upload(image []byte, bucketName string) (url string) {
//...上传图片到私有云...
// ...返回图片的url...
return
}
func (pis *PrivateImageStore) download(url string) (image []byte) {
//...从私有云下载图片...
return
}
func main() {
const BucketName = "ai_images_bucket"
var image = readImage() // 获取图片
imageStore := NewPrivateImageStore() // imageStore := NewAliyunImageStore()
imageStore.upload(image, BucketName)
}
func readImage() (image []byte) {
// ... 省略代码
return
}
问题:是否需要为每个类定义接口?
这条原则的设计初衷是,将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高代码的扩展性。
从这个设计初衷上来看,如果在我们的业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那我们就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就可以了。
组合 vs 继承
继承是面向对象的四大特性之一,用来表示类之间的 is-a 关系(Apple is a Fruit)
,可以解决代码复用的问题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。
举例:设计一个关于鸟的类
继承
package main
import "errors"
var (
ErrCannotFly = errors.New("I can't fly.'")
)
// 父类
type Bird struct{}
func (b *Bird) fly() (err error) {
return
}
// 子类 1 - 鹦鹉
type Parrot struct {
Bird
}
// 子类 2 - 鸵鸟
type Ostrich struct {
Bird
}
func (o *Ostrich) fly() (err error) {
return ErrCannotFly
}
对于上面的代码,如果一种鸟不会飞,就需要重写 fly
方法。一方面会增加代码工作量;一方面也违反了最小知识原则,即暴露不该暴露的接口给外部,增加了类使用过程中被误用的概率。
你可能会说,那我们再派生两个类:会飞的鸟和不会飞的鸟
type Bird struct{}
// 会飞的鸟
type FlyableBird struct {
Bird
}
func (fb *FlyableBird) fly() (err error) {
return
}
// 不会飞的鸟
type UnFlyableBird struct {
Bird
}
type Ostrich struct {
UnFlyableBird
}
从图中我们可以看出,继承关系变成了三层。不过,整体上来讲,目前的继承关系还比较简单,层次比较浅,也算是一种可以接受的设计思路。我们再继续加点难度。在刚刚这个场景中,我们只关注“鸟会不会飞”,但如果我们还关注“鸟会不会叫”,那这个时候,我们又该如何设计类之间的继承关系呢?
如果我们还需要考虑“是否会下蛋”这样一个行为,那估计就要组合爆炸了。
类的继承层次会越来越深、继承关系会越来越复杂。而这种层次很深、很复杂的继承关系,一方面,会导致代码的可读性变差。因为我们要搞清楚某个类具有哪些方法、属性,必须阅读父类的代码、父类的父类的代码……一直追溯到最顶层父类的代码。另一方面,这也破坏了类的封装特性,将父类的实现细节暴露给了子类。子类的实现依赖父类的实现,两者高度耦合,一旦父类代码修改,就会影响所有子类的逻辑。
组合
组合表示类之前 has-a
的关系,比如 House has Bathroom
,从例子就可以看出组合和继承的差异。
接下来我们看一个例子,来加深对组合的理解。前面讲到的接口表示具有某种行为特性。针对“会飞”这样一个行为特性,我们可以定义一个 Flyable
接口,只让会飞的鸟去实现这个接口。
package main
type Flyable interface {
fly()
}
type Tweetableyd interface {
tweet()
}
type EggLayable interface {
layEgg()
}
// 接口 TweetEggLayable has Tweetableyd & EggLayable
type TweetEggLayable interface {
Tweetableyd
EggLayable
}
type Ostrich struct {}
func (o *Ostrich) tweet() {}
func (o *Ostrich) layEgg() {}
不过,我们知道,接口只声明方法,不定义实现。也就是说,每个会下蛋的鸟都要实现一遍 layEgg()
方法,并且实现逻辑是一样的,这就会导致代码重复的问题。那这个问题又该如何解决呢?
我们可以针对每个接口再定义各自的实现类:
type TweetAbility struct{} // 类似java中的抽象类
func (f TweetAbility) tweet() {}
type EggLayAbility struct{}
func (f EggLayAbility) layEgg() {}
// 实现类 OstrichV2 has TweetAbility & EggLayAbility
type OstrichV2 struct {
tweetAbility TweetAbility
eggLayAbility EggLayAbility
}
func (o *OstrichV2) tweet() {
o.tweetAbility.tweet()
}
可以看到,基于 Go 的 interface 及 struct,我们能很轻松得实现组合。Go 语言本身很多地方也使用到了组合,比如 io 包的 ReadWriteCloser、WriteCloser 等
。
// ReadWriter is the interface that groups the basic Read and Write methods.
type ReadWriter interface {
Reader
Writer
}
// ReadCloser is the interface that groups the basic Read and Close methods.
type ReadCloser interface {
Reader
Closer
}
// WriteCloser is the interface that groups the basic Write and Close methods.
type WriteCloser interface {
Writer
Closer
}
// ReadWriteCloser is the interface that groups the basic Read, Write and Close methods.
type ReadWriteCloser interface {
Reader
Writer
Closer
}
问题:如何判断该用组合还是继承?
尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。从上面的例子来看,继承改写成组合意味着要做更细粒度的类的拆分。这也就意味着,我们要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。所以,在实际的项目开发中,我们还是要根据具体的情况,来具体选择该用继承还是组合。
如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承。
除此之外,还有一些设计模式会固定使用继承或者组合。比如,装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使用了组合关系,而模板模式(template pattern)使用了继承关系。
SOLID
单一职责
单一职责原则的英文是 Single Responsibility Principle,缩写为 SRP
。这个原则的英文描述是这样的:A class or module should have a single responsibility
。如果我们把它翻译成中文,那就是:一个类或者模块只负责完成一个职责(或者功能)。
注意,这个原则描述的对象包含两个,一个是类(class
),一个是模块(module
)。通常可以把模块看做比类更抽象的概念,比如函数、类、由类组合的功能单元都可以是一个模块。
-
如何理解单一职责原则(
SRP
)?
一个类(或模块)只负责完成一个职责或者功能。不要设计大而全的类,要设计粒度小、功能单一的类。单一职责原则是为了实现代码高内聚、低耦合,提高代码的复用性、可读性、可维护性。 -
如何判断类的职责是否足够单一?
不同的应用场景、不同阶段的需求背景、不同的业务层面,对同一个类的职责是否单一,可能会有不同的判定结果。实际上,一些侧面的判断指标更具有指导意义和可执行性,比如,出现下面这些情况就有可能说明这类的设计不满足单一职责原则:
- 类中的代码行数、函数或者属性过多
- 类依赖的其他类过多,或者依赖类的其他类过多
- 私有方法过多
- 比较难给类起一个合适的名字
- 类中大量的方法都是集中操作类中的某几个属性
- 代码中存在大量注释
- 类的职责是否设计得越单一越好?
单一职责原则通过避免设计大而全的类,避免将不相关的功能耦合在一起,来提高类的内聚性。同时,类职责单一,类依赖的和被依赖的其他类也会变少,减少了代码的耦合性,以此来实现代码的高内聚、低耦合。但是,如果拆分得过细,实际上会适得其反,反倒会降低内聚性,也会影响代码的可维护性。
开放封闭
开闭原则的英文全称是 Open Closed Principle,简写为 OCP
。它的英文描述是:software entities (modules, classes, functions, etc.) should be open for extension , but closed for modification
。我们把它翻译成中文就是:软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”
。
开放封闭原则可以说是 SOLID
中最有用的原则。之所以说这条原则最有用,那是因为,扩展性是代码质量最重要的衡量标准之一。在 23
种经典设计模式中,大部分设计模式都是为了解决代码的扩展性问题而存在的,主要遵从的设计原则就是开闭原则。
举例:比如基于接口编程部分举的 ImageStore
的例子,当我们要引入一种新的云存储时,只需要新增一个实现类(扩展),然后替换调用的实现类即可,业务调用逻辑和原有类并没有发生修改。
-
如何理解“对扩展开放、对修改关闭”?
添加一个新的功能,应该是通过在已有代码基础上扩展代码(新增模块、类、方法、属性等),而非修改已有代码(修改模块、类、方法、属性等)的方式来完成。关于定义,我们有两点要注意。
第一点是,开闭原则并不是说完全杜绝修改,而是以最小的修改代码的代价来完成新功能的开发。
第二点是,同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能又被认定为“扩展”。
第二点举例:我们对一个类添加新的方法。添加方法相当于修改类,在类这个层面,这个代码改动可以被认定为“修改”;但这个代码改动并没有修改已有的属性和方法,在方法(及其属性)这一层面,它又可以被认定为“扩展”。 -
如何做到“对扩展开放、修改关闭”?
我们要时刻具备扩展意识、抽象意识、封装意识。在写代码的时候,我们要多花点时间思考一下,这段代码未来可能有哪些需求变更,如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,在不改动代码整体结构、做到最小代码改动的情况下,将新的代码灵活地插入到扩展点上。很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是23
种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态)
。
里式替换
里式替换原则的英文翻译是:Liskov Substitution Principle,缩写为 LSP
。这个原则最早是在 1986
年由 Barbara Liskov
提出,他是这么描述这条原则的:
If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program。
在 1996
年,Robert Martin
在他的 SOLID
原则中,重新描述了这个原则,英文原话是这样的:
Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it。
我们综合两者的描述,将这条原则用中文描述出来,是这样的:子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。
package main
import "net/http"
// class: Request
type Request struct{}
func NewRequest() Request {
return Request{}
}
// 网络传输接口 I开头表示interface
type ITransporter interface {
sendRequest(request Request)
}
// class: Transporter
type Transporter struct {
httpClient *http.Client
}
func NewTransporter(httpClient *http.Client) *Transporter {
return &Transporter{httpClient: httpClient}
}
func (t *Transporter) sendRequest(request Request) {
// ...use httpClient to send request
}
// class: SecurityTransporter 继承了Transporter,是其子类
type SecurityTransporter struct {
Transporter
appID string
appToken string
}
func NewSecurityTransporter(httpClient *http.Client, appID, appToken string) *SecurityTransporter {
return &SecurityTransporter{Transporter{httpClient: httpClient}, appID, appToken}
}
func (st *SecurityTransporter) sendRequest(request Request) {
// ... 重写 sendRequest,加入对 appID, appToken 的处理
}
// 里式替换原则
func demoFunction(transporter ITransporter) {
request := NewRequest()
//...省略设置request中数据值的代码...
transporter.sendRequest(request)
}
func main() {
httpClient := &http.Client{}
transporter1 := NewTransporter(httpClient)
demoFunction(transporter1)
transporter2 := NewSecurityTransporter(httpClient, "loki", "test")
demoFunction(transporter2)
}
在上面的代码中,子类 SecurityTransporter
的设计完全符合里式替换原则,可以替换父类出现的任何位置,并且原来代码的逻辑行为不变且正确性也没有被破坏。
问题:刚刚的代码设计不就是简单利用了面向对象的多态特性吗?多态和里式替换原则说的是不是一回事呢?
我们对子类的 sendRequest
做如下更改:
func (st *SecurityTransporter) sendRequest(request Request) {
// ... 重写 sendRequest,加入对 appID, appToken 的处理
if st.appID == "" || st.appToken == "" {
panic("invalid params") // 子类SecurityTransporter在这里会抛出异常
}
// ... 后续发送请求逻辑
}
在改造之后的代码中,如果传递进 demoFunction()
函数的是父类 Transporter
对象,那 demoFunction()
函数并不会有异常抛出,但如果传递给 demoFunction()
函数的是子类 SecurityTransporter
对象,那 demoFunction()
有可能会有异常抛出。可以说,子类替换父类传递进 demoFunction
函数之后,整个程序的逻辑行为有了改变。从设计思路上来讲,SecurityTransporter
的设计是不符合里式替换原则的。
多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。而里式替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。
问题:哪些代码明显违背了 LSP?
-
子类违背父类声明要实现的功能
父类中提供的sortOrdersByAmount()
订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个sortOrdersByAmount()
订单排序函数之后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。 -
子类违背父类对输入、输出、异常的约定
在父类中,某个函数约定(假设在Go
中返回的是interface{}
):运行出错的时候返回nil
;获取数据为空的时候返回空集合(empty slice
)。而子类重载函数之后,实现变了,运行出错返回错误(error
),获取不到数据返回nil
。那子类的设计就违背里式替换原则。
在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出,也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里式替换原则。
在父类中,某个函数约定,只会抛出ArgumentNullException
异常(错误),那子类的设计实现中只允许抛出ArgumentNullException
异常(错误),任何其他异常的抛出,都会导致子类违背里式替换原则。 -
子类违背父类注释中所罗列的任何特殊说明
父类中定义的withdraw()
提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,而子类重写withdraw()
函数之后,针对VIP
账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的。
接口隔离
接口隔离原则的英文翻译是“ Interface Segregation Principle”,缩写为 ISP
。Robert Martin
在 SOLID
原则中是这样定义它的:“Clients should not be forced to depend upon interfaces that they do not use
。”直译成中文的话就是:客户端不应该被强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者。
- 如何理解“接口隔离原则”?
理解“接口隔离原则”的重点是理解其中的“接口”二字。这里有三种不同的理解。
如果把“接口”理解为一组接口集合,可以是某个微服务的接口,也可以是某个类库的接口等。如果部分接口只被部分调用者使用,我们就需要将这部分接口隔离出来,单独给这部分调用者使用,而不强迫其他调用者也依赖这部分不会被用到的接口。(比如将获取用户敏感数据的相关接口和获取普通信息的接口做隔离)
如果把“接口”理解为单个 API
接口或函数,部分调用者只需要函数中的部分功能,那我们就需要把函数拆分成粒度更细的多个函数,让调用者只依赖它需要的那个细粒度函数。
如果把“接口”理解为 OOP
中的接口,也可以理解为面向对象编程语言中的接口语法。那接口的设计要尽量单一,不要让接口的实现类和调用者,依赖不需要的接口函数。Go
语言 io
包中的 Reader、Writer
接口实现,就是“接口隔离原则”的最佳实践。
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
- 接口隔离原则与单一职责原则的区别
单一职责原则针对的是模块、类、接口的设计。接口隔离原则相对于单一职责原则,一方面更侧重于接口的设计,另一方面它的思考角度也是不同的。接口隔离原则提供了一种判断接口的职责是否单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。
依赖反转
依赖反转、控制反转等思想主要用来指导框架设计,内容比较多,这里仅简单介绍下概念
-
控制反转
实际上,控制反转是一个比较笼统的设计思想,并不是一种具体的实现方法,一般用来指导框架层面的设计。这里所说的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程通过框架来控制。流程的控制权从程序员“反转”给了框架。实现控制反转的方式有很多,比如下面要说的依赖注入,就是实现控制反转的方式之一。 -
依赖注入
依赖注入和控制反转恰恰相反,它是一种具体的编码技巧。我们不通过new
的方式在类内部创建依赖类的对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类来使用。依赖注入也是实现组合最简单的方式。
type A struct {}
func NewA() A {
return A{}
}
type B struct {
a A
}
func NewB(a A) B {
return B{a:a}
}
func main() {
a := NewA()
// 依赖注入
// 将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类来使用
b := NewB(a)
}
通过依赖注入的方式来将依赖的类对象传递进来,这样就提高了代码的扩展性,我们可以灵活地替换依赖的类(此时依赖的是一个接口而非一个具体的类)。
type interface ITest {}
type A struct {} // implement ITest
type B struct {
test ITest
}
// 传入的 test,可以是 A 的对象,也可以是其他实现了 ITest 的对象
func NewB(test ITest) B {}
-
依赖注入框架
我们通过依赖注入框架提供的扩展点,简单配置一下所有需要的类及其类与类之间依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情。 -
依赖反转
原则依赖反转原则也叫作依赖倒置原则。这条原则跟控制反转有点类似,主要用来指导框架层面的设计。高层模块不依赖低层模块,它们共同依赖同一个抽象。抽象不要依赖具体实现细节,具体实现细节依赖抽象。
KISS
KISS 原则的英文描述有好几个版本,比如下面这几个。
- Keep It Simple and Stupid.
- Keep It Short and Simple.
- Keep It Simple and Straightforward.
不过,仔细看你就会发现,它们要表达的意思其实差不多,翻译成中文就是:尽量保持简单。
KISS
原则是保证代码可读性和可维护性的重要手段。KISS
原则中的“简单”并不是以代码行数来考量的。代码行数越少并不代表代码越简单,我们还要考虑逻辑复杂度、实现难度、代码的可读性等。而且,本身就复杂的问题,用复杂的方法解决,并不违背 KISS
原则。除此之外,同样的代码,在某个业务场景下满足 KISS
原则,换一个应用场景可能就不满足了。
对于如何写出满足 KISS
原则的代码,有下面几条指导原则:
- 不要使用同事可能不懂的技术来实现代码
- 不要重复造轮子,要善于使用已经有的工具类库
- 不要过度优化
DRY
DRY 原则(Don’t Repeat Yourself)
几乎人尽皆知。你可能会觉得,这条原则非常简单、非常容易应用。只要两段代码长得一样,那就是违反 DRY
原则了。真的是这样吗?答案是否定的。这是很多人对这条原则存在的误解。实际上,重复的代码不一定违反 DRY
原则,而且有些看似不重复的代码也有可能违反 DRY
原则。
通常存在三种典型的代码重复情况,它们分别是:实现逻辑重复、功能语义重复和代码执行重复。这三种代码重复,有的看似违反 DRY
,实际上并不违反;有的看似不违反,实际上却违反了。
实现逻辑重复
type UserAuthenticator struct {}
func (ua * UserAuthenticator) authenticate(username, password string) {
if !ua.isValidUsername(username) {
// ... code block 1
}
if !ua.isValidPassword(username) {
// ... code block 1
}
// ...省略其他代码...
}
func (ua * UserAuthenticator) isValidUsername(username string) bool {
}
func (ua * UserAuthenticator) isValidPassword(password string) bool {
}
假设 isValidUserName()
函数和 isValidPassword()
函数代码重复,看起来明显违反 DRY
原则。为了移除重复的代码,我们对上面的代码做下重构,将 isValidUserName()
函数和 isValidPassword()
函数,合并为一个更通用的函数 isValidUserNameOrPassword()
。
经过重构之后,代码行数减少了,也没有重复的代码了,是不是更好了呢?答案是否定的。单从名字上看,我们就能发现,合并之后的 isValidUserNameOrPassword()
函数,负责两件事情:验证用户名和验证密码,违反了“单一职责原则”和“接口隔离原则”。
实际上,即便将两个函数合并成 isValidUserNameOrPassword()
,代码仍然存在问题。因为 isValidUserName()
和 isValidPassword()
两个函数,虽然从代码实现逻辑上看起来是重复的,但是从语义上并不重复。所谓“语义不重复”指的是:从功能上来看,这两个函数干的是完全不重复的两件事情,一个是校验用户名,另一个是校验密码。尽管在目前的设计中,两个校验逻辑是完全一样的,但如果按照第二种写法,将两个函数的合并,那就会存在潜在的问题。在未来的某一天,如果我们修改了密码的校验逻辑,那这个时候,isValidUserName()
和isValidPassword()
的实现逻辑就会不相同。我们就要把合并后的函数,重新拆成合并前的那两个函数。
对于包含重复代码的问题,我们可以通过抽象成更细粒度函数的方式来解决。
功能语义重复
在同一个项目代码中有下面两个函数:isValidIp()
和 checkIfIpValid()
。尽管两个函数的命名不同,实现逻辑不同,但功能是相同的,都是用来判定 IP
地址是否合法的。
func isValidIp(ipAddress string) bool {
// ... 正则表达式判断
}
func checkIfIpValid(ipAddress string) bool {
// ... 字符串方式判断
}
在这个例子中,尽管两段代码的实现逻辑不重复,但语义重复,也就是功能重复,我们认为它违反了 DRY
原则。我们应该在项目中,统一一种实现思路,所有用到判断 IP
地址是否合法的地方,都统一调用同一个函数。
代码执行重复
type UserService struct {
userRepo UserRepo
}
func (us *UserService) login(email, password string) {
existed := us.userRepo.checkIfUserExisted(email, password)
if (!existed) {
// ...
}
user := us.userRepo.getUserByEmail(email)
}
type UserRepo struct {}
func (ur *UserRepo) checkIfUserExisted(email, password string) bool {
if !ur.isValidEmail(email) {
// ...
}
}
func (ur *UserRepo) getUserByEmail(email string) User {
if !ur.isValidEmail(email) {
// ...
}
}
上面这段代码,既没有逻辑重复,也没有语义重复,但仍然违反了 DRY
原则。这是因为代码中存在“执行重复”。这个问题解决起来比较简单,我们只需要将校验逻辑从 UserRepo
中移除,统一放到 UserService
中就可以了。
问题:如何提高代码复用性?
- 减少代码耦合
- 满足单一职责原则
- 模块化业务与非业务逻辑分离
- 通用代码下沉
- 继承、多态、抽象、封装
- 应用模板等设计模式
LOD
迪米特法则的英文翻译是:Law of Demeter,缩写是 LOD
。单从这个名字上来看,我们完全猜不出这个原则讲的是什么。不过,它还有另外一个更加达意的名字,叫作最小知识原则,英文翻译为:The Least Knowledge Principle
。
迪米特法则法则强调不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。迪米特法则是希望减少类之间的耦合,让类越独立越好。每个类都应该少了解系统的其他部分。一旦发生变化,需要了解这一变化的类就会比较少。
前面提到的“单一职责原则”、“接口隔离原则”以及“最小知识原则”,都是实现高内聚低耦合的有效指导思想。“最小知识原则”更强调类与类之间的关系。