SOLID 设计原则 (有点长但很透彻)

面向对象设计原则 SOLID 应该是职业程序员必须掌握的基本原则,每个程序员都应该了然于胸,遵守这 5个原则可以帮助我们写出易维护、易拓展的高内聚低耦合的代码。

它是由罗伯特·C·马丁(知名的 Rob 大叔)21世纪初期 (准确来说,2000年在他的论文Design Principles and
Design Patterns
中提到过后 4 个原则,2003 年在它的The Principles of OOD文章中提出了 5 大原则)引入的概念,虽然这 5 个原则不都是 Rob 大叔原创的,但是是他首次把这 5 个原则组合并推广起来的,并很快得到业界认可和推崇。

据我粗略统计,身边写了很多年代码的人,大多数对 SOLID 的认识只停留在表面,甚至讲不清楚其概念。本文通过讲述概念和代码示例让大家全面了解这 5 大原则,然后再通过讨论他们之间的联系以及终极目标,让大家从宏观上领略其含义,以便能在日常开发中使用。

SOLID 概览

图1

备注:关于 SOLID 每一条原则的描述,我见到过不同的版本,有细微差别,但是传递的信息都是一样的。下面贴一个 Rob 大叔在《The Principles of OOD》的中的描述:
图2

单一职责

一个类应该仅具有一种单一功能,或者说有且仅有一个原因使类变更。
这个原则最容易理解,但也是最容易被违反的原则。我是做移动端开发的,所经历过的项目,大多数类会逐渐变成多功能类,就像下图中的多功能瑞士军刀。
图3
这个概念容易理解,但是不好把握。单一职责到底要“单一”到什么程度?这是有商量余地的,甚至没有一个标准答案。

如果把“车”定义为一个类,从人类工具这个角度去看,它足够单一了,能明确和其它工具区别开来。如果从交通工具这个角度来看,它还是太笼统,不够单一,我们需要把它拆分成不同的类,比如“拉人的车”、“拉货物的车”。还可以继续细分为“自动驾驶的轿车”和“非自动驾驶的轿车”等等。

通常,我们会根据以下几个角度进行分类:

  • 用途:例如工具类,处理不同种类事务的函数放在不同的工具类中;
  • 变化频率:处理数据的类变化频率低,而负责用户交互或展示的类变化频率较高;
  • 业务类别:例如登录业务和注册业务要分开;
  • 设计模式中的分层:例如 MVC, MVVM, VIPER 等。

但是具体要分的多细,我们得根据实际情况,项目在不同阶段不同规模下,“单一”的颗粒度是实时变化的,在动态中寻求一个平衡。就像厨师在学炒菜时,是如何掌握“盐少许”的。他会不断地品尝,直到味道刚好为止。写代码也一样,你需要识别需求变化的信号,不断“品尝”你的代码,当“味道”不够好时,持续重构,直到“味道”刚刚好。

代码示例:

class Square {
    var side: Float
    init(side: Float) {
        self.side = side
    }

    func calculateArea() -> Float {
        return side * side
    }

    func calculatePerimeter() -> Float {
        return side * 4
    }

    func draw() {
        // render an square image
    }

    func rotate(degree: Float) {
        // rotate the square image to the degree and re-render
    }
}

这是一个“正方形”类,仔细看的话我们会发现一些坏味道。calculateArea()calculatePerimeter()是计算面积和周长的,属于数据处理范畴,并且我们知道这两个函数基本是不会变化的。而draw()rotate(degree: Float)是展示相关的操作,可能会根据不同屏幕分辨率进行调整,所以应该被剥离开。那么我们可以根据“单一职责”对它进行优化,分成两个类:


class Square {
    func calculateArea() -> Float {
        return side * side
    }
    
    func calculatePerimeter() -> Float {
        return side * 4
    }
}

class SquareUI {
    func draw() {
        // render an square image
    }
    
    func rotate(degree: Float) {
        // rotate the square image to
        // the degree and re-render
    }
}

开闭原则

软件应该是对于扩展开放的,但是对于修改封闭的。
听起来好苛刻,只能拓展,不能修改?好难哦!

我么为什么要遵循这个原则?要知道,每一次修改都会引入破坏现有功能的风险,而且不方便。小时候玩过小霸王游戏机吧?要玩不同的游戏,只需要插上不同的游戏卡就可以,不需要把游戏机拆开修改一翻吧。手柄坏了只需要买个新的插上去就行。
在这里插入图片描述
再比如,假设你是一名成功的开源类库作者,很多开发者使用你的类库。如果某天你要扩展功能,只能通过修改某些代码完成,结果导致类库的使用者都需要修改代码。更可怕的是,他们被迫修改了代码后,又可能造成别的依赖者也被迫修改代码。这种场景绝对是一场灾难。

早些时候,大家通过继承的方式实现开闭原则。新建的类通过继承原有的类实现来重用原类的代码。后来由于抽象化接口的出现,多态成为实现开闭原则的主流形式。多态开闭原则的定义倡导对抽象基类的继承。接口规约可以通过继承来重用,但是实现不必重用。已存在的接口对于修改是封闭的,并且新的实现必须,至少,实现那个接口。

代码示例:

假设有一个支付管理器,它支持现金支付和银联支付:

class PaymentManager {
    func makeCashPayment(amount: Double){
        // perform
    }  
    func makeVisaPayment(amount: Double){
        // perform
    }
}

某天需求发生了变化,需要增加微信支付和支付宝支付功能。那么我们就需要修改这个类,增加两个函数:

    func makeWechatPayment(amount: Double){
        // perform
    }
    
    func makeAlipayPayment(amount: Double){
        // perform
    }

类似这样,每次需求发生变化,我们都得改这个类,并且调用方代码也可能需要被改动。坏味道就出来了,也明显违反了开闭原则。我们用多态把它重构成符合开闭原则的代码。

// 协议 / 接口
protocol PaymentProtocol {
    func makePayment(amount: Double)
}

// 遵从协议的具体类
class CashPayment: PaymentProtocol {
    func makePayment(amount: Double) {
        // perform
    }
}

// 遵从协议的具体类
class VisaPayment: PaymentProtocol {
    func makePayment(amount: Double) {
        // perform
    }
}

// 这个类以后就不需要修改了,要增加新的支付方式的话,直接新建遵从PaymentProtocol的类
class PaymentManager {
    func makePayment(amount: Double, payment: PaymentProtocol) {
        payment.makePayment(amount: amount)
    }
}

重构后,PaymentManager就是小霸王游戏机的主机,各个具体类就是游戏卡。

里氏替换

程序中的对象应该是可以在不改变程序正确性的前提下被它的子类所替换的。
里氏替换是约束继承的原则。如果正确遵守了历史替换原则,子类可以替换父类而不会破坏功能,那么也就帮助我们实现了开闭原则。

大家都知道,面向对象语言中,子类本来就可以替换父类,为什么还要强调里氏替换原则?重点在“不改变程序正确性”,这里是指业务的正确性,而不是编译的正确性。子类替换父类,在编译时不会报错,但运行时业务可能就错了。

例如,鸵鸟是鸟类的一种,那么“鸵鸟”就可以继承“鸟类”,这似乎合乎常理。但是如果“鸟类”中存在一个 fly() 函数,那么“鸵鸟”就得实现它,可鸵鸟不会飞啊,鸵鸟的 fly() 函数必定是空函数或者抛出异常。这个时候用“鸵鸟”替换“鸟类”就出问题了。
在这里插入图片描述
通常我们会依靠 “A 是 B 的一种” 语法进行子类继承设计,比如“鸵鸟是鸟类的一种”,“正方形是矩形的一种”,这样的划分往往会因为过于粗糙而违反了里氏替换原则。

那么如何解决? 接口分离原则就是为它服务的。

接口分离原则

客户端不应该强制实现他们不需要的函数(多个特定客户端接口要好于一个宽泛用途的接口)。

在上述“鸵鸟”继承“鸟类”的例子中,“鸟类”作为一个大而全的接口存在,它可能是这样:

protocol BirdProtocal {
    func eat()
    func fly()
    func fastRun()
    func swim()
}

那么不管是“鸵鸟”还是“白鹭”, 直接继承它总会违背历史替换原则,也违背了接口分离原则。

让我们把这个接口分离一下:

protocol BirdProtocal {
    func eat()
}
// 会飞的鸟
protocol BirdCanFly: BirdProtocal {
    func fly()
}
// 会快跑的鸟
protocol BirdCanFastRun: BirdProtocal {
    func fastRun()
}
// 会游泳的鸟
protocol BirdCanSwim: BirdProtocal {
    func swim()
}

那么“鸵鸟”和“白鹭”的具体实现类就会是这样子:

// 鸵鸟会快速奔跑、会吃食物
class Ostrich: BirdCanFastRun {
    func eat() {
        //
    }
    func fastRun() {
        //
    }
}
// 白鹭会飞、会游泳、会吃食物
class Egret: BirdCanFly, BirdCanSwim {
    func eat() {
        //
    }
    func fly() {
        //
    }
    func swim() {
        //
    }
}

接口这样细分之后,具体类就不会被强制实现他们不需要的函数。
如果“鸵鸟”继承“会快跑的鸟”也不会违反里氏替换原则了。
同时大家也可以看出,这样细分之后的接口,职责也更单一了,也符合了单一职责原则。

依赖倒置

依赖于抽象而不是一个实例。或者可以解释为高层模块不应该依赖底层模块,两者都应该依赖其抽象。要针对接口编程,不要针对实现编程。

他的核心思想是面向接口(协议)编程。可以依靠了依赖注入的方式,实现了解耦。

还是支付的例子。

class PayHandler {
    func makePayment(type: String, amount: Double) {
        if type == "CASH" {
            let cashPayment = CashPayment()
            cashPayment.makePayment(amount: amount)
        } else if type == "VISA" {
            let visPayment = VisaPayment()
            visPayment.makePayment(amount: amount)
        } else {
            // defult payment
        }
        // ...
    }
}

这是一个典型的面向过程的编程方式,PayHandler依赖各个具体的Payment模块.
依赖倒置原则能有效避免过程试编程,拥抱面向对象编程。
我们让各个具体的Payment模块遵守PaymentProtocol接口,就像开闭原则示例代码那样。用依赖注入的方式重构PayHandler

class PayHandler {
    let paymentManager: PaymentProtocol
    init(paymentManager: PaymentProtocol) {
        self.paymentManager = paymentManager
    }
    func makePayment(ammount: Double) {
        let result = self.paymentManager.makePayment(amount: ammount)
        // do something else
    }
}

这样改了之后,PayHandler和各个低层次模块都依赖PaymentProtocol协议(接口), 我们从外部注入低层次模块,直接降低了PayHandler和各个低层次模块的耦合度。这样我们就用依赖倒置原则实现了开闭原则。
在这里插入图片描述

总结

SOLID 的这 5 个设计原则,单独存在的威力不大,应该把它作为一个整体来理解和应用,从而更好地指导你的软件设计。他们的共同目的就是帮助你写出高内聚、低耦合的代码。其中单一职责是基础,接口分离是体现单一职责的最好体现,开闭原则是理想目标,其他几个原则都会直接或间接达成开闭原则,里氏替换是针对继承的约束原则,依赖倒置指导我们从面向过程走向面向对象。他们是有内在联系的,就像练习拳击,耐力、速度、力量、步法、灵活性,都是为打出高质量拳服务的,它们既有联系,又缺一不可。
在这里插入图片描述
有人会说,SOLID 原则太理想化了,实际开发中根本做不到百分百遵守,尤其开闭原则,拿到新需求后,不改动旧代码,只添加新代码进行拓展,不可能啊。没关系,这根本不妨碍它成为我们代码设计的终极目标。起码我们知道了什么是好的设计,这样才能不断往这个目标迈进。

参考资料:
写了这么多年代码,你真的了解SOLID吗?
https://en.wikipedia.org/wiki/SOLID
Design Principles and Design Patterns. Robert C. Martin
The Principles of OOD. Robert C. Martin

  • 3
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值