OOP五大基本原则SOLID介绍与应用

本文介绍了面向对象编程的SOLID原则,包括单一职责原则、依赖反转原则、开闭原则、里氏替换原则和接口隔离原则。通过具体的例子,如"洗车服务"的代码重构,阐述了这些原则的解读、应用和重要性,帮助读者理解如何编写高质量、易于维护的代码。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前几天有同学留言叫讲一下设计模式。我们先锻炼好一些基本能力,以后再扩展就会更容易。

今天说说面向对象设计的五大基本原则。既然是基本原则,那是我们在各层次的模块、接口的设计中都会参考遵守的。熟练运用它们之后,就已经能够大幅提升代码设计的质量。再遇到针对性的情景时,辅助运用编程语言的一些特性,就会发现“咦,这不就是工厂模式么?这不就是装饰器模式么?这不就是适配器模式么?这不就是Java的XX模式么?这不就是Pythonic的XX模式么?”

SOLID简介

SOLID是由罗伯特·C·马丁(即Bob大叔,其著作有《敏捷软件开发——原则、模式与实践》、《Clean Code》)在21世纪早期引入的记忆术首字母缩略字,指代了面向对象编程和面向对象设计的五个基本原则(单一职责、开闭原则、里氏替换、接口隔离以及依赖反转)

注意:原则并不是规则,更不是教条,对智者来说是指导,对愚者来说是遵从。

首字母 指代 概念
S 单一职责 对象应该仅具有单一的功能
O 开闭 软件体应该对扩展是开放的,但对修改封闭的
L 里氏替换 程序中的对象应该是可以在不改变程序正确性的前提下
被它的子类对象所替换的
I 接口隔离 多个特定客户端接口要好于一个宽泛用途的接口
D 依赖反转 高层次的模块不应该依赖于低层次的模块,两者都应该依赖于抽象接口;
抽象接口不应该依赖于具体实现,而具体实现则应该依赖于抽象接口。

在SOLID的指导下,容易编写易于维护的、复用率高的、易于测试的面向对象代码。

应用举例

后文对SOLID原则的应用示范均基于下述需求。要求以OOP的方式实现一段“洗车服务”的代码。

业务需求如下:

洗车服务
  - 洗车作业任务
      汽车进入洗车机时
        注册洗车任务
  - 顾客通知
      洗车完毕
        向顾客发出消息通知
  - 报表
      客户端发出报表请求时
        向该顾客展示他的所有洗车信息

单一职责原则

对象应该仅具有单一的功能

解读

顾名思义,单一职责是指对象的职责应该是单一的。换句话说,一个对象、接口或模块,应突出主要功能,最好只有一个功能。

交警虽然可以劝阻路边的打架斗殴事件,但他的职责在于维护道路交通正常运转。如果交警做了民警该做的事,道路交通又谁管?在治安不好的地方没有民警,那是系统性问题,而不是让交警越俎代庖。

分清楚每个角色(方法、类、接口、模块)“必须做的分内事”,只做必须做的,各司其职。分外事应由其他角色处理,如无相关角色则创造之。

应用

示例代码一:

现在审视“洗车服务”的示例代码一,问题如下:

  • 并未实现客户端的查询功能。
  • 第4,9,13行完成的是存储层的初始化、写数据、读数据,若要实现客户端查询功能实则也是对存储层的读取,该代码中没有一个角色来专门负责存储层的操作。相关操作都是分散在代码各处,新需求有与旧需求有相同操作时,代码复用性差。
  • 第5,14,15行是两条语句完成的是消息发送,问题在于已经是sms_sender了,还需要send吗?还有not_sendreceive等方法吗?对消息通知对象的职责分析不到位。可读性也不好。

重构如下:

依赖反转原则

  • 高层次的模块不应该依赖于低层次的模块,两者都应该依赖于抽象接口;
  • 抽象接口不应该依赖于具体实现,而具体实现则应该依赖于抽象接口。

解读

为了解释清楚这个应用非常广泛的原则,有必要先解释清楚几个与之经常一起出现的或容易混淆的概念:依赖注入(Dependency Injection)控制反转(Inversion of Control)面向接口编程(Programming to Interfaces),还有依赖反转原则(Dependency Inversion Principle)自己。

首先要了解反转这个术语。方向相反即为反转。在这里,依赖关系当然是有方向性的,控制关系也有方向性,所以才有依赖反转,控制反转的说法。那么反转之前的正常方向又是如何确定的?是根据结构化分析与设计技术(SADT)而来。

简而言之SADT是一种自顶向下的拆分设计方法。欲解决大问题A(设计一个大模块A),拆分为B,C,D三个子问题(子模块),正常方式就是A依赖于(或调用)B,C,D;这个过程反过来就是反转。

依赖注入(DI)

依赖注入是关于一个对象是如何获取依赖的。常见的注入方式有:构造器注入,Setter注入,接口注入。

例如在“地产大亨”游戏里,玩家掷一对色子。在软件中,玩家对象需要向一对色子对象发出roll()消息。玩家对象如何取得对色子对象的引用?游戏系统需要告诉玩家takeATurn(:Dice)并且给玩家一对色子,这就是方法级别的依赖注入。

依赖注入是实现依赖反转的一种方法,还可以通过服务定位模式,插件模式等实现依赖反转。若A依赖于B,不让A直接调用B,而是将B传递给A,使之成为A的一部分,这就是依赖注入。

控制反转(IoC)

控制反转是关于谁发出调用的。也就是程序控制权的转变。如果是你写的代码发起了调用,那就不是IoC;反之,若一个容器/系统/库/框架,反过来调用你提供给它的代码,这就是控制反转。

例如,在“地产大亨”游戏里,系统创建了一批玩家。游戏需要协调玩家之间的互动。当轮到某个玩家时,游戏会询问该玩家是否有卖房子或酒店之类的预先动作,然后根据掷色子的点数移动玩家。注意,游戏知道玩家何时可以自主决定并提示玩家,而非玩家自己主动决定可以做什么。

马丁·福勒(《重构——改善既有代码的设计》作者)在解释IoC的时候说到,控制反转是框架(framework)和库(library)的关键区别。库本质上是一组供你调用的函数或类,每个调用完成之后,都会将控制权回交给调用者;而框架则侧重于抽象设计,有更多内置行为,为了使用框架,你需要子类化或插件化地提供你自己的代码,这些代码被嵌入到框架的各个地方,然后框架调用你写的代码。

依赖反转原则(DIP)

我们不会把台灯与墙上的电路直接焊接在一起。台灯是高层次,电路是低层次,两者依赖于插头和插座,而非直接交互。三针插头可以被台灯用,也可以被冰箱用,所以抽象接口(插头)并不依赖于具体实现(台灯/冰箱)。而冰箱因为功率较大一定用三针插头,所以具体实现依赖于抽象接口。

DIP主要目的是为了将高层模块对底层模块的依赖解耦,使得替换低层模块变得容易(即高层组件的复用性更强)。马丁·福勒说,DIP也是争取让依赖项向更高级别抽象化。

注意:依赖反转并不是说让高层依赖低层变成了低层依赖高层,而是让高层与低层都依赖于抽象。

换言之,DIP的核心就是在高低层之间增加抽象层,而且这个抽象是由高层定义的。这里说抽象,就是指抽象这个概念,不指代任何编程技巧和方法。

面向接口编程

这是可重用的面向对象设计关键原则,将类的实现与抽象解耦,这使实现方式可以更灵活多变。而接口主要封装设计,不是把一些无关的方法堆砌在一起,它应该是提炼对象之间协作(方法应该是动词,属性应该是名词)。好的接口应该使交互易于理解,如果一头扎进某种具体实现,可能难以发现问题的本质。

应用

示例代码二:

上述代码的问题主要出在SmsNotifier.send_sms这行,如果改为被注释的写法,仍然不好。

一是隐式依赖,并不能从CarWashService的初始化方法中知道它依赖了SmsNotifier类,从调用者的角度讲,CarWashService莫名其妙就有通知功能。

二是依赖于具体实现CarWashService对象直接依赖了send_sms方法。这样扩展性差,如果要同事使用电话、邮件等工具通知顾客,仅因修改通知功能却也不得不来修改洗车服务的代码,同样违背了开闭原则

重构后的代码:

上述改进后的代码,通过构造器注入的方式,将notifier对象作为初始化参数传递给CarWashService类,化解了上面提到的缺点。

开闭原则(OCP)

软件体应该对扩展是开放的,但对修改封闭的

解读

当你想增加自己的御寒能力只用在身体外加衣服而非做个开胸手术。软件体也一样,观察人体这个造物者的完美之作,把它的规律用在软件体上,就可以造出更完美的软件。好的设计可以让你在为系统新增功能时添加新代码即可而无需修改老代码。

应用

洗车服务的数据需要得到保存,可能保存在内存、文件、数据库等等。但这些功能都是几乎一致的,所以你很可能写出了如下抽象类,希望其他子类都来继承它。

示例代码三:

以上写法并没有太大问题。这是符合开闭原则的,因为对于扩展成用文件保存时,只需要另外增加一个InFileJobReository类并写出相关实现就好了,并不会动任何一行已有代码。

秉承鸭子类型的理念,我们还可以简化代码,可以无需写那个抽象类。只需要让子类都继承Python的object类,例如InMemoryJobReository(object), 剩下的一个字符都不用变,也能达到同样效果,但这似乎也留下了更多犯错误的可能,其中平衡点自行拿捏。

里氏替换原则(LSP)

程序中的对象应该是可以在不改变程序正确性的前提下被它的子类对象所替换的

解读

古猿作为基类,直立人是古猿的后代,现代人是直立人的后代,现代人可以代替直立人这是很自然的事情。这是自然法则和规律,为什么不可以应用到软件中来?如果某天你看见一个外貌像现代人,叫起来声音也像现代人,而她却需要充电,那她肯定是基于错误的基类生成出来的。

应用

示例代码四:

在示例三的老代码中,是把dict类的对象赋给self._storage以完成功能。原来直接使用dict,现在示例四中使用dict的子类也能完成工作。而且子类InMemoryJobReository的对象,是可以替换父类dict的对象而不发生错误的。

正如你可能认为的那样,就是子类可以扩展父类的功能,但不能改变父类原有的功能。这和开闭原则也是相呼应的。

扩展思考

有人说,在数学和常识上都认为正方形是矩形的子类,矩形有set_lengthset_width的方法很符合逻辑。而正方形具有这两个方法确实没意义的,因为改变其中一个,另一个也必须跟着变。故而正方形这个子类不能代替父类矩形了,所以,正方形问题打败了里氏替换原则。

你怎么看这个正方形问题呢?欢迎在微信留言与阿驹讨论。

接口隔离原则(ISP)

多个特定客户端接口要好于一个宽泛用途的接口

解读

人嘴巴和鼻孔的功能应该不一样吧?否则的话,全人体只需要一个孔就行了(嘿~ 嘿~ 嘿~)

应用

此原则和开闭原则,单一职责原则也是相呼应的。本节不再有示例代码,在上述设计过程中,我们已经拆分了一些接口。比如对于洗车服务相关数据的存储,文件存储与内存存储接口是隔离的,并没有揉成一团。

总结

绝大多数情况下,根据这五大基本原则设计出的代码就已经相当优秀。这些基本原则也是相互促进,相互兼容,相互满足的。在实际编程过程中,根据指导思想做不同的变通,实际我们已经运用了各种设计模式在里面。


*END*
这里是 驹说码事,分享程序猿的码路历程
感谢您的关注

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值