浅析软件工程中的一些常见设计原则

老子说:有道无术,术尚可求也。有术无道,止于术。如果说设计模式是“术”,那么设计原则就是“道”。今天,我们一起来聊聊软件工程中一些常用的设计原则。

DRY 原则(Don’t Repeat Yourself)

DRY原则可理解为不要写重复的代码。简单来讲,写代码的时候,如果出现雷同片段,就要想办法把他们提取出来,成为一段独立的代码。

DRY 是一个最简单的法则,也是最容易被理解的,但它也可能是最难被应用的(因为要做到这样,我们需要在泛型设计上做相当的努力,这并不是一件容易的事)。它意味着,当在两个或多个地方发现一些相似代码的时候,我们需要把它们的共性抽象出来形成一个唯一的新方法,并且改变现有地方的代码让它们以一些合适的参数调用这个新的方法。

代码重复有三种典型情况,它们分别是:实现逻辑重复、功能语义重复和代码执行重复。

实现逻辑重复

情景一:重复的代码被敲了两遍或者简单复制粘贴一下代码,这种情况明显违反 DRY 原则。

情景二:从代码实现逻辑上看起来是重复的,但是从语义上并不重复。比如:isValidUserName()isValidPassword() 两个函数,从功能上来看,这两个函数干的是完全不重复的两件事情,一个是校验用户名,另一个是校验密码。尽管代码的实现逻辑是相同的,但语义不同,我们判定它并不违反 DRY 原则。

功能语义重复

两个函数的命名不同,实现逻辑不同,但功能是相同的,都是用来判定 IP 地址是否合法的。之所以在同一个项目中会有两个功能相同的函数,那是因为这两个函数是由两个不同的同事开发的,其中一个同事在不知道已经有了 isValidIp() 的情况下,自己又定义并实现了同样用来校验 IP 地址是否合法的 checkIfIpValid() 函数。

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

我们应该在项目中,统一一种实现思路,所有用到判断 IP 地址是否合法的地方,都统一调用同一个函数;否则,可能在业务变更时,漏改了其中一个方法,而导致bug。

代码执行重复

这种情况既没有逻辑重复,也没有语义重复,但是代码中存在“执行重复”(比如,多个地方对同样的参数做参数校验),但仍然违反了 DRY 原则。

KISS 原则(Keep It Simple, Stupid)

保持每件事情都尽可能的简单,用最简单的解决方案来解决问题。

KISS 原则在设计上可能最被推崇,在家装设计、界面设计和操作设计上,复杂的东西越来越被众人所鄙视了,而简单的东西越来越被人所认可。

  • 宜家简约、高效的家居设计和生产思路;
  • 微软“所见即所得”的理念;
  • 谷歌简约、直接的商业风格,无一例外地遵循了“KISS”原则。
  • 而苹果公司的 iPhone 和 iPad 将这个原则实践到了极至。
    也正是“KISS”原则,成就了这些看似神奇的商业经典。

针对接口编程,而非(接口的)实现(Program to an interface, not an implementation)

这是设计模式中最根本的哲学,注重接口,而不是实现;依赖接口,而不是实现。接口是抽象和稳定的,实现则是多种多样的。

在面向对象设计的头五大原则( S.O.L.I.D)中会的依赖倒置原则,就是这个原则的另一种样子。

优先考略对象组合,而不是类继承(Composition over inheritance)

优先使用组合,使系统更灵活,其次才考虑继承,达到复用的目的。
注意区分“Has-A”与“Is-A”。

YAGNI原则(You Ain’t Gonna Need It)

你是否有个这样的经历,臆想某个功能以后可能会用到,然后就顺手把它实现了,实际到了后面并没用上,反而造成了代码冗余。

这个原则只考虑和设计必须的功能,避免过度设计。只实现目前需要的功能,在以后你需要更多功能时,可以再进行添加。如无必要,勿增复杂性。软件开发是一场 取舍(trade-off)的博弈。

因此,我们不能闭门臆想需要的功能,但是在架构上又要洞察趋势。

迪米特法则 (Law of Demeter)

这个原则又称为“最少知识原则”(Principle of Least Knowledge), 一个对象应该与其他对象保持最少的了解,只与直接朋友交谈,成员变量、方法参数、方法返回值中需要的类为直接朋友

关于迪米特法则有一些很形象的比喻:

  1. 如果你想让你的狗跑的话,你会对狗狗说还是对四条狗腿说?
  2. 如果你去店里买东西,你会把钱交给店员,还是会把钱包交给店员让他自己拿?

和狗的四肢说话?让店员自己从钱包里拿钱?这听起来有点儿荒唐,不过在我们的代码里这几乎是见怪不怪的事情了。

对于 LoD,正式的表述如下:对于对象‘O’中一个方法’M’,M 应该只能够访问以下对象中的方法

  • 对象 O;
  • 与 O 直接相关的组件对象(Component Object);
  • 由方法 M 创建或者实例化的对象;
  • 作为方法 M 的参数的对象。

面向对象的 S.O.L.I.D 原则

S.O.L.I.D是面向对象设计和编程(OOD&OOP)中几个重要编码原则的首字母缩写。这几条原则是非常基础而且重要的面向对象设计原则。正是由于这些原则的基础性,因此,理解、融汇贯通这些原则需要不少的经验和知识的积累。

职责单一原则(Single Responsibility Principle)

其核心的思想是:一个类,只做一件事,并把这件事做好,其只有一个引起它变化的原因。 单一职责原则可以看作是低耦合、高内聚在面向对象原则上的引申,将职责定义为引起变化的原因,以提高内聚性来减少引起变化的原因。

职责过多,可能引起它变化的原因就越多,这将导致职责依赖,相互之间就产生影响,从而极大地损伤其内聚性和耦合度。

单一职责,通常意味着单一的功能,因此不要为一个模块实现过多的功能点,以保证实体只有一个引起它变化的原因。

开闭原则(Open/Closed Principle)

其核心的思想是:模块是可扩展的,而不可修改的。也就是说,对扩展是开放的,而对修改是封闭的。

  • 对扩展开放,意味着有新的需求或变化时,可以对现有代码进行扩展,以适应新的情况。
  • 对修改封闭,意味着类一旦设计完成,就可以独立完成其工作,而不要对类进行任何修改。这样可以保证稳定性和延续性。

OCP 建议我们应该对系统进行重构,那么以后再进行同样改动,只需添加新代码而不必改动已正常运行的代码。

在很多方面,OCP 都是面向对象设计的核心所在,可增强灵活性、可重用性、可维护性等。

OCP 的关键是抽象,其背后的主要机制是抽象和多态。模块应该依赖于一个固定的抽象体,因此,它对于更改可以是关闭的,同时,通过从这个抽象体派生,也可以扩展此模块的行为。

里氏代换原则(Liskov substitution principle)

软件工程大师罗伯特·马丁(Robert C. Martin)把里氏代换原则最终简化为一句话:“Subtypes must be substitutable for their base types”。也就是,子类必须能够替换成它们的基类。即子类应该可以替换任何基类能够出现的地方,并且经过替换以后,代码还能正常工作

另外,不应该在代码中出现 if/else 之类对子类类型进行判断的条件。里氏替换原则 LSP 是使代码符合开闭原则的一个重要保证。正是由于子类型的可替换性才使得父类型的模块在无需修改的情况下就可以扩展。

注意:

一般而言,无论模块是多么的“封闭“,都会存在一些无法对之封闭的变化,没有对于所有的情况都贴切的模型。所以,必须有策略地对待这个问题。

设计人员必须对他所设计的模块应该对哪种变化封闭做出选择,必须先猜测出最有可能发生的变化种类,然后构造抽象来隔离那些变化。但大多数情况,猜测都是错误的。后续即使不使用这些抽象也必须去支持和维护它们,这不是一件好事,所以,通常我们会一直等到确实需要那些抽象时再去进行抽象

接口隔离原则(Interface Segregation Principle )

不能强迫用户去依赖那些他们不使用的接口。换句话说就是使用多个专门的接口比使用单一的总接口要好。

举个例子,我们对电脑有不同的使用方式,比如:写作、通讯、看电影、打游戏、上网、编程、计算和数据存储等。如果我们把这些功能都声明在电脑的抽象类里面,那么,我们的上网本、PC 机、服务器和笔记本的实现类都要实现所有的这些接口,这就显得太复杂了。所以,我们可以把这些功能接口隔离开来,如工作学习接口、编程开发接口、上网娱乐接口、计算和数据服务接口,这样,我们的不同功能的电脑就可以有所选择地继承这些接口。

同时,小接口更容易实现,提升了灵活性和重用的可能性。由于很少的类共享这些接口,相应接口的变化而需要变化的类数量就会降低,增加了鲁棒性。

依赖倒置原则(Dependency Inversion Principle)

高层模块不应该依赖于底层模块(高层与低层是相对而言,也就是调用者与被调用者的关系),二者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。

举个例子:墙面的开关不应该依赖于电灯的开关实现,而是应该依赖于一个抽象的开关的标准接口

这样,当我们扩展程序的时候,开关同样可以控制其它不同的灯,甚至不同的电器。也就是说,电灯和其它电器继承并实现我们的标准开关接口,而开关厂商就可以不需要关于其要控制什么样的设备,只需要关心那个标准的开关标准。这就是依赖倒置原则。

高内聚, 低耦合(High Cohesion & Low/Loose coupling)

这个原则是 UNIX 操作系统设计的经典原则,把模块间的耦合降到最低,而努力让一个模块做到精益求精。

  • 内聚,指一个模块内各个元素彼此结合的紧密程度;
  • 耦合,指一个软件结构内不同模块之间互连程度的度量。

内聚意味着重用和独立,耦合意味着多米诺效应牵一发动全身。

包的内聚性原则

原则用途:用来指导软件开发者如何将类合理的划分到相应的包中。

前提条件:我们已经设计好了一些类,并且它们之间的相互关系也基本明确。由此可见,类的设计先于包的设计,实际开发中我们确实是采用“自底向上”的方式设计和规划包的。

重用的概念说明

重用主要是从用户的观点来看的

对用户来说,使用某个发布单位(组件,类,类群等),如果作者因为某种原因对其作了修改而发布了一个新的版本,用户会期望在升级为新版本之后,不会影响到原系统的正常运作。

也就是说,对于一个可重用(能供其它用户或系统使用) 的元素(组件,类,类群等),作者应该承诺新版本能够兼容旧版本。否则,用户将拒绝使用该元素。

代码可以看作为可重用的代码,当且仅当:

  • 它的使用者(下称用户)无需看它的源代码
  • 用户只需联结静态库或包含动态库
  • 当库发生改变(错误纠正,功能增强)时,用户只需要得到一个新的版本便能集成到原有的系统

重用/发布等价原则(The Reuse/Release Equivalence Principle)

REP指出,一个包的重用粒度可以和发布粒度一样大。我们所重用的任何东西都必须同时被发布和跟踪。

任何一个开发者都知道,我们对源代码的重用必须是基于包的。简单的编写一个类,然后声称它是可重用的做法是不现实的。只有在建立一个跟踪系统,为潜在的使用者提供所需要的变更通知、安全性以及支持后,重用才有可能。

由于重用性必须是基于包的,所以可重用的包必须包含可重用的类。因此,至少某些包应该由一组可重用的类组成。

可以重用的包中不能包含不可重用的类。因为如果一个为重用而设计的发布单位里,包含了不可重用的元素,当不可重用的元素发生改变时,用户也不得不改变原有系统以适应新的版本。这显然违反了重用的定义规则。

共同重用原则(Common Reuse Principle)

一组功能相关的类或一组强耦合的类如果它们具有重用性,则它们应该存在于通一个包中。即一个包中的所有类应该是共同重用的。如果重用了包中的一个类,那么就要重用包中的所有类。换个说法就是,没有被一起重用的类不应该组合在一起。

举个例子:容器类以及与它关联的迭代器类。这些类彼此之间紧密耦合在一起,因此必须共同重用,所以它们应该在同一个包中。

CRP 原则帮助我们决定哪些类应该被放到同一个包里。依赖一个包就是依赖这个包所包含的一切。CRP规定相互之间没有紧密联系的类不应该在同一个包中

类很少被孤立的重用,一般来说,可重用的类需要与作为该可重用抽象一部分的其它类协作。CRP规定了这些类应该属于同一个包。在这样的一个包中,我们会看到类之间有很多的相互依赖。

当一个包发生了改变,并发布新的版本,使用这个包的所有用户都必须在新的包环境下验证他们的工作,即使被他们使用的部分没有发生任何改变。虽然包中包含未被使用的类,即使用户不关心该类是否改变,但用户还是不得不升级该包并对原来的功能加以重新测试。

共同封闭原则(Common Closure Principle)

一个包中所有的类应该对同一种类型的变化关闭。一个变化影响一个包,便影响了包中所有的类,而对于其他的包不造成任何影响。一个更简短的说法是:一起修改的类,应该组合在一起(同一个包里)如果必须修改应用程序里的代码,那么我们希望所有的修改都发生在一个包里(修改关闭),而不是遍布在很多包里

CCP 原则就是把因为某个同样的原因而需要修改的所有类组合进一个包里。如果两个类从物理上或者从概念上联系得非常紧密,它们通常一起发生改变,那么它们应该属于同一个包。这样做会减少软件的发布、重新验证、重新发行的工作量,这增加了可维护性,在大多数的应用程序中,可维护性的重要性是超过可重用性的。

CCP 延伸了开闭原则(OCP)的“关闭”概念,当因为某个原因需要修改时,把需要修改的范围限制在一个最小范围内的包里。

小结

CCP 则让系统的维护者受益,CCP 让包尽可能大(CCP 原则加入功能相关的类)。而CRP 则让包尽可能小(CRP 原则是剔除不使用的类)。它们的出发点不一样,但不相互冲突。

这3个关于包内聚性的原则揭示了在选择要共同组织到包中的类时,必须要考虑可重用性与可开发性之间的相反作用力。在这些作用力和应用的需求之间进行平衡不是一件简单的工作。此外,这个平衡几乎总是动态的。

包的耦合性原则

原则用途:用来指导软件开发者如何处理包之间的相互关系的。

前提条件:我们根据聚合性原则进行了基本的包划分,包的数量和功能基本已经确立,此时我们需要进一步确认这些包之间的相互关系从而验证我们以前规划的包是否合理。

包的划分可能会动态的改变,如当项目的重心从可开发性向可重用性转变时,包的组成很可能会变动并随时间而演化。

无环依赖原则(Acyclic Dependencies Principle)

包(或服务)之间的依赖结构必须是一个直接的无环图形,也就是说,在依赖结构中不允许出现环(循环依赖)。

如果包的依赖形成了环状结构,怎么样打破这种循环依赖呢?

有两种方法可以打破这种循环依赖关系:

  • 第一种方法是创建新的包。如果 A、B、C 形成环路依赖,那么把这些共同类抽出来放在一个新的包 D 里。这样就把 C 依赖 A 变成了 C 依赖 D 以及 A 依赖 D,从而打破了循环依赖关系。
  • 第二种方法是使用 DIP(依赖倒置原则)和 ISP(接口分隔原则)设计原则。例如:如果A和B是循环依赖,则应该把B中被A依赖的类进行抽象,然后将抽象放入A中,这样A中的类依赖于本包中的抽象而不依赖于B中的实现。

无环依赖原则(ADP)为我们解决包之间的关系耦合问题。在设计模块时,不能有循环依赖。

稳定依赖原则(Stable Dependencies Principle)

包的依赖关系总是朝着稳定的方向进行依赖。 也就是说,要使一个软件包是稳定的或要判别出一个软件包是稳定的,那么肯定可行的方法是让许多其他的软件包依赖于它。

例如:一个软件中的公共函数包一般是稳定的,且业务包、界面包、操作包等都依赖于公共函数包。我们在设计的包的时候通常要实时考虑到这一点,减少互相依赖,尽量让包都依赖于同一个包,从而满足SDP原则。

稳定抽象原则(Stable-Abstractions Principle)

大家都知道抽象和接口一般都是很稳定的,所以包的抽象程度应该和其稳定程度一致。 也就是说:一个相对稳定的包应该是抽象程度很高的包,这样它具有很好的扩展性同时它也是最稳定的。

一个相对不稳定的包应该是具体的,即包中的类都是具体的实现,这样它的具体代码经常需要修改同时它也是最不稳定的。

好莱坞原则(Hollywood Principle)

好莱坞原则就是一句话:“don’t call us, we’ll call you.”。意思是,好莱坞的经纪人不希望你去联系他们,而是他们会在需要的时候来联系你。也就是说,所有的组件都是被动的,所有的组件初始化和调用都由容器负责。

简单来讲,就是由容器控制程序之间的关系,而非传统实现中,由程序代码直接操控。

这也就是所谓“控制反转”的概念所在:

  1. 不创建对象,而是描述创建对象的方式。
  2. 在代码中,对象与服务没有直接联系,而是容器负责将这些联系在一起。控制权由应用代码中转到了外部容器,控制权的转移,是所谓反转。

好莱坞原则就是IoC(Inversion of Control) 或DI(Dependency Injection)]的基础原则。

惯例(约定)优于配置原则(Convention over Configuration)

该原则旨在减少软件开发人员需做决定的数量,获得简单的好处,而又不失灵活性。简单点说,就是将一些公认的配置方式和信息作为内部缺省的规则来使用。

例如,Hibernate 的映射文件,如果约定字段名和类属性一致的话,基本上就可以不要这个配置文件了。你的应用只需要指定不是惯例(convention)的信息即可。

设计不好的框架通常需要多个配置文件,每一个都有许多设置。配置文件在很多时候相当影响开发效率。大量包含太多参数的配置文件通常是过度复杂的应用设计的指标(代码坏味道)。

  • Ruby on Rails 中很少有配置文件(但不是没有,数据库连接就是一个配置文件)。Rails 的粉丝号称其开发效率是 Java 开发的 10 倍,估计就是这个原因。
  • Maven 也使用了 CoC 原则,当你执行 mvn -compile 命令的时候,不需要指定源文件放在什么地方,而编译以后的 class 文件放置在什么地方也没有指定,这就是 CoC 原则。

关注点分离(Separation of Concerns)

SoC 是计算机科学中最重要的努力目标之一。这个原则,就是在软件开发中,通过各种手段,将问题的各个关注点分开。如果一个问题能分解为独立且较小的问题,就是相对较易解决的。问题太过于复杂,要解决问题需要关注的点太多,而程序员的能力是有限的,不能同时关注于问题的各个方面。

正如程序员的记忆力相对于计算机知识来说那么有限一样,程序员解决问题的能力相对于要解决的问题的复杂性也是一样的非常有限。在我们分析问题的时候,如果我们把所有的东西混在一起讨论,那么就只会有一个结果——

实现关注点分离的方法主要有两种:一种是标准化,另一种是抽象与包装。

  • 标准化就是制定一套标准,让使用者都遵守它,将人们的行为统一起来,这样使用标准的人就不用担心别人会有很多种不同的实现,使自己的程序不能和别人的配合。就像是开发镙丝钉的人只专注于开发镙丝钉就行了,而不用关注镙帽是怎么生产的,反正镙帽和镙丝钉按照标准来就一定能合得上。
  • 不断地把程序的某些部分抽象并包装起来,也是实现关注点分离的好方法。一旦一个函数被抽象出来并实现了,那么使用函数的人就不用关心这个函数是如何实现的。同样的,一旦一个类被抽象并实现了,类的使用者也不用再关注于这个类的内部是如何实现的。诸如组件、分层、面向服务等这些概念都是在不同的层次上做抽象和包装,以使得使用者不用关心它的内部实现细节。

契约式设计(Design by Contract)

所谓契约,也就是合约,规定两个交互物件上的权利和责任。DbC 的核心思想是对软件系统中的元素之间相互合作以及“责任”与“义务”的比喻。这种比喻从商业活动中“客户”与“供应商”达成“契约”而得来。

如果在程序设计中一个模块提供了某种功能,那么它要:

  • 期望所有调用它的客户模块都保证一定的进入条件:这就是模块的先验条件(客户的义务和供应商的权利,这样它就不用去处理不满足先验条件的情况)。
  • 保证退出时给出特定的属性:这就是模块的后验条件(供应商的义务,显然也是客户的权利)。

在进入时假定,并在退出时保持一些特定的属性:不变式。

契约就是这些权利和义务的正式形式。我们可以用“三个问题”来总结DbC,并且作为设计者要经常问:

1.它期望的是什么?
2.它要保证的是什么?
3.它要保持的是什么?

很多语言都有对这种断言的支持。然而,DbC认为这些契约对于软件的正确性至关重要,它们应当是设计过程的一部分。实际上,DbC提倡首先写断言

对于一个方法的契约通常包含下面这些信息:

  1. 可接受和不可接受的值或类型,以及它们的含义
  2. 返回的值或类型,以及它们的含义
  3. 可能出现的错误以及异常情况的值和类型,以及它们的含义
  4. 副作用
  5. 先验条件
  6. 后验条件
  7. 不变式
  8. (不太常见) 性能上的保证,如所用的时间和空间

总结

本文主要讲解了面向对象设计的5大原则、模块和包的内聚性以及耦合性原则,也讲述了KISS原则(用最简单的解决方案来解决问题)、YAGNI原则(只实现目前需要的功能,避免过度设计)、约定优于配置原则、契约式设计原则等,同时还提到了好莱坞原则,它是IoC(Inversion of Control) 或DI(Dependency Injection)]的基础原则。

  • 4
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

吃果冻不吐果冻皮

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值