拒绝代码野蛮增长,浅谈软件设计原则
背景:跑起来和跑的稳定,是两种代码。代码质量和软件设计是相辅相成的,先说软件设计;
通常来说,要想构建一个好的软件系统,应该从写整洁的代码开始做起。毕竟,如果建筑所使用的砖头质量不佳,那么架构所能起到的作用也会很有限。反之亦然,如果建筑的架构设计不佳,那么其所用的砖头质量再好也没有用。这就是SOLID设计原则所要解决的问题。
SOLID原则的主要作用就是告诉我们如何将数据和函数组织成为类,以及如何将这些类链接起来成为程序。注意,这里虽然用到了“类”这个词,但是并不意味着我们将要讨论的这些设计原则仅仅适用于面向对象编程。这里的类仅仅代表了一种数据和函数的分组,每个软件系统都会有自己的分类系统,不管它们各自是不是将其称为“类”,事实上都是SOLID原则的适用领域。
一般情况下,我们为软件构建中层结构的主要目标如下:
●使软件可容忍被改动。
●使软件更容易被理解。
●构建可在多个软件系统中复用的组件。
我们在这里之所以会使用“中层”这个词,是因为这些设计原则主要适用于那些进行模块级编程的程序员。SOLID原则应该直接紧贴于具体的代码逻辑之上,这些原则是用来帮助我们定义软件架构中的组件和模块的。
当然了,正如用好砖也会盖歪楼一样,采用设计良好的中层组件并不能保证系统的整体架构运作良好。这就涉及到复杂架构的设计原则(架构设计不展开描述不是这篇主题)。
软件设计原则之SOLID原则
1.单一责任原则(SRP)
SRP是SOLID五大设计原则中最容易被误解的一个。也许是名字的原因,很多程序员根据SRP这个名字想当然地认为这个原则就是指:每个模块都应该只做一件事。
没错,后者的确也是一个设计原则,即确保一个函数只完成一个功能。我们在将大型函数重构成小函数时经常会用到这个原则,但这只是一个面向底层实现细节的设计原则,并不是SRP的全部。
现在最流行的描述是:任何一个软件模块都应该只对某一类行为者负责。
那么,上文中提到的“软件模块”究竟又是在指什么呢?大部分情况下,其最简单的定义就是指一个源代码文件。然而,有些编程语言和编程环境并不是用源代码文件来存储程序的。在这些情况下,“软件模块”指的就是一-组紧密相关的函数和数据结构。
在这里,“相关”这个词实际上就隐含了SRP这一原则。代码与数据就是靠着与某一类行为者的相关性被组合在一起的。
举例:某个工资管理程序中的Employee类有三个函数calculatePay()、reportHours ()和save() ,如下图。
这个类的三个函数分别对应的是三类非常不同的行为者,违反了SRP设计原则。
● calculatePay ()函数是由财务部门制定的,他们负责向CFO汇报。
● reportHours ()函数是由人力资源部门制定并使用的,他们负责向COO汇报。
● save()函数是由DBA制定的,他们负责向CTO汇报。
这三个函数被放在同一个源代码文件,即同一个Employee类中,程序员这样做实际上就等于使三类行为者的行为耦合在了一起,这有可能会导致CFO团队的命令影响到COO团队所依赖的功能。
例如,calculatePay()函数和reportHours ()函数使用同样的逻辑来计算正常工作时数。程序员为了避免重复编码,通常会将该算法单独实现为一一个名为regularHours()的函数,如下图
接下来,假设CFO团队需要修改正常工作时数的计算方法,而COO带领的HR团队不需要这个修改,因为他们对数据的用法是不同的。这时候,负责这项修改的程序员会注意到calculatePay()函数调用了regularHours()函数,但可能不会注意到该函数会同时被reportHours()调用。于是,该程序员就这样按照要求进行了修改,同时CFO团队的成员验证了新算法工作正常。这项修改最终被成功部署上线了。
但是,COO团队显然完全不知道这些事情的发生,HR仍然在使用reportHours()产生的报表,随后就会发现他们的数据出错了,这类问题发生的根源就是因为我们将不同行为者所依赖的代码强凑到了一-起。对此,SRP 强调这类代码一定要被分开。
2.开放封闭原则(OCP)
该设计原则认为:设计良好的计算机软件应该易于扩展,同时抗拒修改。
换句话说,一个设计良好的计算机系统应该在不需要修改的前提下就可以轻易被扩展。
其实这也是我们研究软件架构的根本目的。如果对原始需求的小小延伸就需要对原有的软件系统进行大幅修改,那么这个系统的架构设计显然是失败的。
尽管大部分软件设计师都已经认可了OCP是设计类与模块时的重要原则,但是在软件架构层面,这项原则的意义则更为重大。
思想实验来做说明:
3.里氏替换原则(LSP)
4.接口隔离原则(ISP)
5.依赖反转原则(DIP)
该设计原则认为:如果想要设计一个灵活的系统,在源代码层次的依赖关系中就应该多引用抽象类型,而非具体实现。
也就是说,在静态类型的编程语言中,在使用use、import、include、using这些语句时应该只引用那些包含接口、抽象类或者其他抽象类型声明的源文件,不应该引用任何具体实现。
把这条设计原则严格执行是不现实的,因为软件系统在实际构造中不可避免地需要依赖到一些具体实现。例如.net中的String类就是这样一个具体实现,我们将其强迫转化为抽象类是不现实的,而在源代码层次上也无法避免对String的依赖,并且也不应该尝试去避免。
但String类本身是非常稳定的,因为这个类被修改的情况是非常罕见的,而且可修改的内容也受到严格的控制,所以程序员和软件架构师完全不必担心String类上会发生经常性的或意料之外的修改。同理,在应用DIP时,我们也不必考虑稳定的操作系统或者平台设施,因为这些系统接口很少会有变动。我们主要应该关注的是软件系统内部那些会经常变动的( volatile)具体实现模块,这些模块是不停开发的,也就会经常出现变更。
稳定的抽象层
我们每次修改抽象接口的时候,一定也会去修改对应的具体实现。但反过来,当我们修改具体实现时,却很少需要去修改相应的抽象接口。所以我们可以认为接口比实现更稳定。优秀的软件设计师和架构师会花费很大精力来设计接口,以减少未来对其进行改动。毕竟争取在不修改接口的情况下为软件增加新的功能是软件设计的基础常识。也就是说,如果想要在软件架构设计上追求稳定,就必须多使用稳定的抽象接口,少依赖多变的具体实现。
●应在代码中多使用抽象接口,尽量避免使用那些多变的具体实现类。这条守则适用于所有编程语言,无论静态类型语言还是动态类型语言。同时,对象的创建过程也应该受到严格限制,对此,我们通常会选择用抽象工厂(abstract factory)这个设计模式。
●不要在具体实现类上创建衍生类。上一条守则虽然也隐含了这层意思,但它还是值得被单独拿出来做一次详细声明。在静态类型的编程语言中,继承关系是所有一切源代码依赖关系中最强的、最难被修改的,所以我们对继承的使用应该格外小心。即使是在稍微便于修改的动态类型语言中,这条守则也应该被认真考虑。
●不要覆盖(override)包含具体实现的函数。调用包含具体实现的函数通常就意味着引入了源代码级别的依赖。即使覆盖了这些函数,我们也无法消除这其中的依赖一-这些函数继承了那些依赖关系。在这里,控制依赖关系的唯一办法,就是创建一个抽象函数,然后再为该函数提供多种具体实现。
●应避免在代码中写入与任何具体实现相关的名字,或者是其他容易变动的事物的名字。这基本上是DIP原则的另外一个表达方式。
《架构整洁之道》观后感,未完待续
砖头质量(代码规范)
不区分语言的规范
net
java
太多了,考虑抽成一个独立的
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 一文读懂知识蒸馏
· 终于写完轮子一部分:tcp代理 了,记录一下