谈谈到底什么是抽象,以及软件设计的抽象原则 作者 | 章烨明 杏仁医生CTO。中老年程序员,各种技术和团队管理。 我们在日常开发中,我们常常会提到抽象。但很多人常常搞不清楚,究竟什么是抽象,以及如何进行抽象。今天我们就来谈谈抽象。 什么是抽象? 首先,抽象这个词在中文里可以作为动词也可以作为名词。作为动词的抽象就是指一种行为,这种行为的结果,就是作为名词的抽象。Wikipedia 上是这么定义抽象的:Conceptual abstractions may be formed by filtering the information content of a concept or an observable phenomenon, selecting only the aspects which are relevant for a particular subjectively valued purpose. 也就是说,抽象是指为了某种目的,对一个概念或一种现象包含的信息进行过滤,移除不相关的信息,只保留与某种最终目的相关的信息。例如,一个*皮质的足球*,我们可以过滤它的质料等信息,得到更一般性的概念,也就是*球*。从另外一个角度看,抽象就是简化事物,抓住事物本质的过程。 需要注意的是,抽象是分层次的。还是用 Wikipedia 上的例子,以下是对一份报纸在多个不同层次的抽象:我的 5 月 18 日的《旧金山纪事报》5 月 18 日的《旧金山纪事报》《旧金山纪事报》一份报纸一个出版品 可以看到,在不同层次的抽象,就是过滤掉了不同的信息。这里没有展现出来的是,我们需要确保最终留下来的信息,都是当前抽象层需要的信息。 生活中的抽象 其实我们生活中每时每刻都在接触或者进行各种抽象。接触最多的,应该就是数字了。其实原始人类并没有数字这个概念,他们可能能够理解三个苹果,也能够理解三只鸭子,但是对他们来说,是不存在数字“三”这个概念的。在他们的理解里,三个苹果和三只鸭子是没有任何联系的。直到某一天,某个原始人发现了这两者之间,有那么一个共性,也即是数字“三”,于是就有了数字这个概念。从那以后,人们就开始用数字对各类事物进行计数。 赫拉利在《人类简史》里说,人类之所以成为人类,是因为人类能够想象。这里的想象,我认为很大程度上也是指抽象。只有人类能够从具体的事物本身,抽象出各种概念。可以说,人类的几乎所有事情,包括政治(例如民族、国家)、经济(例如货币、证券)、文学、艺术、科学等等,都是建立在抽象的基础上的。绘画有一个流派叫抽象主义,很多人(包括我)都表示看不懂,但下面几幅毕加索画的牛,也许能够从直观上让我们更好的理解什么是抽象。
科学里的抽象就更广泛了,我们可以认为所有的科学理论和定理都是一种抽象。物体的质量是一种抽象,它不物体是什么以及它的形状或质地;牛顿定律是对物体运动规律的抽象,我们现在知道它不准确,但它在常规世界里,却依然是一个相当可靠的抽象。在科学和工程里,常常需要建立一些模型或者假设,比如量子力学的标准粒子模型、经济学的理性人假设,这些都是抽象。甚至包括现在 AI 里通过训练生成的模型,某种程度上说,也是一种抽象。 当然,哲学上对抽象有很多讨论,什么本体论、白马非马之类的,这些已经在本人的理解范围之外了,就不讨论了。 开发中的抽象 现在我们应该能大致理解抽象这个概念了,让我们回到软件开发领域。 在软件开发里面,最重要的抽象就可能是分层了。分层随处可见,例如我们的系统就是分层的。最早的程序是直接运行在硬件上的,开发成本非常高。然后慢慢开始有了操作系统,操作系统提供了资源管理、进程调度、输入输出等所有程序都需要的基础功能,开发程序时调用操作系统的接口就可以了。再后来发现操作系统也不够,于是又有了各种运行环境(如 JVM)。 编程语言也是一种分层的抽象。机器理解的其实是机器语言,即各种二进制的指令。但我们不可能直接用机器语言编程,于是我们发明了汇编语言、C 语言以及 Java 等各种高级语言,一直到 Ruby、Python 等动态语言。 开发中,我们应该也都听说过各种分层模型。例如经典的三层模型(展现层、业务逻辑层、数据层),还有 MVC 模型等。有一句名言:“软件领域的任何问题,都可以通过增加一个间接的中间层来解决”。分层架构的核心其实就是抽象的分层,每一层的抽象只需要而且只能本层相关的信息,从而简化整个系统的设计。 其实软件开发本身,就是一个不断抽象的过程。我们把业务需求抽象成数据模型、模块、服务和系统,面向对象开发时我们抽象出类和对象,面向过程开发时我们抽象出方法和函数。也即是说,上面提到的模型、模块、服务、系统、类、对象、方法、函数等,都是一种抽象。可想而知,设计一个好的抽象,对我们软件开发有多么重要。 抽象的原则 那么到底应如何做到好的抽象呢?在软件开发领域,前人们其实早帮我们整理出了 SOLID 等设计原则以及各种设计模式。对于 SOLID 原则,虽然很多人都听说过,但其实真正能理解这些原则的开发者并不多。那么我们就从抽象的角度,再来看下这些原则,也许会有更好的理解。 单一职责原则(Single Responsibility Principle, SRP) 单一职责是指一个模块应该只做一件事,并把这件事做好。其实对照应抽象的定义,可以发现这个原则本身就是抽象的核心体现。如果一个类包含了很多方法,或者一个方法特别长,就要引起我们的特别注意了。例如下面这个 Employee 类,既有业务逻辑(calculatePay)、又有数据库逻辑(saveToDb),那它其实至少做了两件事情,也就不符合单一职责原则,当然也就不是一个好的抽象。 有些人觉得单一职责不太好理解,有时候很那分辨一个模块到底是不是单一职责。其实单一职责的概念,常常需要结合抽象的分层去理解。 在同一个抽象层里,如果一个类或者一个方法做了不止一件事,一般是比较容易分辨的。例如一个违反单一职责原则的典型征兆是,一个方法接受一个布尔类型或者枚举类型的参数,然后一个大大的 if/else 或者 switch/case,分支里也是大段的代码处理各种情况下的逻辑。这时我们可以用简单工厂模式、策略模式等设计模式去优化设计。 假如说我们用了简单工厂模式,改进了一段代码,重构后代码可能像是下面是这样的。 有人可能会有疑问,代码里依然还是存在 if/else 或者 switch/case,这不还是做了不止一件事情么?其实不是的,使用了简单工厂模式,其实就是增加了一个抽象层。在这个抽象层里,getInstance 的职责很明确,就是创建对象。而原来分支里的逻辑处理,则下沉到了另外一个抽象层里去了,也就是 Instance 的实现所在的抽象层。 再看下面 Scala 实现的 updateOrder 方法,它似乎也只是做了一件事情:处理订单,那算不算单一职责呢? 答案当然是不算,因为很明显,这个方法里面既有业务逻辑的代码,又有数据库处理的代码,这两类应该是在不同的抽象层的。我们把数据库处理的代码抽取出来,下沉到数据层,它就能符合单一职责原则了。 开放封闭原则(Open/Closed Principle, OCP) 开放封闭原则是指对扩展开放,对修改封闭。当需求改变时,我们可以扩展模块以满足新的需求;但扩展时,不应该需要修改原模块的实现。 下面两段代码都实现了方形、矩形以及圆形的面积计算。第一种用的是面向过程的方法,第二种用的是面向对象的方法。那么,到底哪一种更符合开放封闭原则呢? 面向过程方法: 面向对象方法: 估计很多人会觉得面向对象的方式更好,更符合开放封闭原则。但真相其实没那么简单。想象如果我们需要添加一个新的形状,比如说椭圆,那面向对象的实现肯定更方便,我们只需要实现一个椭圆的类以及它的 area 方法。这时候我们可以说面向对象的方法更符合开放封闭原则。 但如果我们需要添加一个新的方法呢?比如说,我们发现我们还需要计算形状的周长。这时候,面向对象的实现似乎就没那么方便了,要在每个类里面添加计算周长的方法。而面向过程的方法,则只需要添加一个方法就行了。这时候,我们反而发现面向过程的方法更符合开放封闭原则。 所以开放封闭其实是相对的,有时候,如何进行抽象,取决于我们对未来最有可能的扩展的预判。 依赖倒置原则(Dependency Inversion Principle, DIP) 依赖倒置原则是指高层模块不应该依赖于低层模块的实现,两者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖与抽象。前面提到,“软件领域的任何问题,都可以通过增加一个间接的中间层来解决” ,DIP 就是最典型的增加中间层的方式,也是我们需要解耦两个模块的最重要的方法之一。 依赖倒置原则的一个例子是 Java 的 JDBC。如果没有 JDBC,那我们的系统就会严格依赖我们使用的那个数据库。这时如果我们想要切换到另外一个数据库,就需要修改大量代码。但 Java 提供了 JDBC 接口,而所有关系数据库的连接库都实现了这个接口,我们的系统也只需要调用 JDBC 即可完成数据库操作。这时我们的系统和数据库的依赖就解除了。除了 JDBC,其实 SQL 本身也是一种依赖倒置的实现。另外一个很典型的例子就是 Java 的日志接口 Slf4j。
其实所有的协议和标准化都是 DIP 的一种实现。包括 TCP、HTTP 等网络协议、操作系统、JVM、Spring 框架的 IOC 等等。设计模式里有不少模式,也是典型的依赖倒置,例如状态模式、工厂模式、代理模式、策略模式等等,下图是策略模式的结构图。
我们日常生活中也有很多依赖倒置的例子。比如电源插座,家庭的供电只需要提供符合国家标准的电源插座,我们购买电器产品时,就不用担心买回来无法接入电源。汽车和轮胎、铅笔和笔、USB/耳机接口等等,也都是同一思想的体现。 里氏替换原则(Liskov Substitution Principle, LSP) 里氏替换原则是指子类必须能够替换成它们的基类。例如下面这个最常见的例子,Square 可以是 Rectangle 的子类吗? 虽然几何上说,Square 是一个特殊的 Rectangle,但把 Square 作为 Rectangle 的子类,却未必合适,因为它已经不存在宽和高的概念了。如果一个抽象不能符合里氏替换原则,那我们就需要考虑下这个抽象是不是合适了。 接口隔离原则(Interface Segregation Principle, ISP) 接口隔离原则是指客户端不应该被迫依赖它们不使用的方法。例如下面的 Square 类如果继承了Shape 接口,该如何计算体积以实现volume方法? 同样,如果一个抽象不符合接口隔离原则,那可能就不是一个合适的抽象。 迪米特法则(Law of Demeter) 迪米特法则不属于 SOLID 原则,但我觉得也值得说一下。它是指模块不应该了解它所操作的对象的内部情况。想象一下,如果你想让你的狗狗快点跑的话,你会对狗狗说,还是对四条狗腿说?如果你去店里买东西,你会把钱交给店员,还是会把钱包交给店员让他自己拿? 下面是一段违反迪米特法则的典型代码。这样的代码把对象内部实现暴露了出来,应该考虑讲将功能直接暴露为接口,或者合理使用设计模式(如 Facade)。 总结 关于抽象,今天我们就说到这里。不过要注意的是,软件开发并不是仅仅只依靠抽象能力就能完成的,最终我们还是要把我们抽象出来的架构、模型等,落地到真正的代码层面,那就还需要逻辑思维能力、系统分析能力等。以后如果有机会,我们可以继续探讨。 我希望各位看完本文,对抽象的理解能够更加深入一点。我们以奥卡姆剃刀原则来结束吧:一个抽象应该足够简单,但又不至于过于简单。这其实就是抽象的真谛。 全文完 我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。 欢迎搜索:杏仁技术站(号 xingren-tech)。
2024最新激活全家桶教程,稳定运行到2099年,请移步至置顶文章:https://sigusoft.com/99576.html
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请联系我们举报,一经查实,本站将立刻删除。 文章由激活谷谷主-小谷整理,转载请注明出处:https://sigusoft.com/22374.html