谈谈到底什么是抽象,以及软件设计的抽象原则-程序员宅基地

作者 | 章烨明

杏仁医生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 上的例子,以下是对一份报纸在多个不同层次的抽象:

  1. 我的 5 月 18 日的《旧金山纪事报》

  2. 5 月 18 日的《旧金山纪事报》

  3. 《旧金山纪事报》

  4. 一份报纸

  5. 一个出版品

可以看到,在不同层次的抽象,就是过滤掉了不同的信息。这里没有展现出来的是,我们需要确保最终留下来的信息,都是当前抽象层需要的信息。

生活中的抽象

其实我们生活中每时每刻都在接触或者进行各种抽象。接触最多的,应该就是数字了。其实原始人类并没有数字这个概念,他们可能能够理解三个苹果,也能够理解三只鸭子,但是对他们来说,是不存在数字“三”这个概念的。在他们的理解里,三个苹果和三只鸭子是没有任何联系的。直到某一天,某个原始人发现了这两者之间,有那么一个共性,也即是数字“三”,于是就有了数字这个概念。从那以后,人们就开始用数字对各类事物进行计数。

赫拉利在《人类简史》里说,人类之所以成为人类,是因为人类能够想象。这里的想象,我认为很大程度上也是指抽象。只有人类能够从具体的事物本身,抽象出各种概念。可以说,人类的几乎所有事情,包括政治(例如民族、国家)、经济(例如货币、证券)、文学、艺术、科学等等,都是建立在抽象的基础上的。绘画有一个流派叫抽象主义,很多人(包括我)都表示看不懂,但下面几幅毕加索画的牛,也许能够从直观上让我们更好的理解什么是抽象。



科学里的抽象就更广泛了,我们可以认为所有的科学理论和定理都是一种抽象。物体的质量是一种抽象,它不关注物体是什么以及它的形状或质地;牛顿定律是对物体运动规律的抽象,我们现在知道它不准确,但它在常规世界里,却依然是一个相当可靠的抽象。在科学和工程里,常常需要建立一些模型或者假设,比如量子力学的标准粒子模型、经济学的理性人假设,这些都是抽象。甚至包括现在 AI 里通过训练生成的模型,某种程度上说,也是一种抽象。

当然,哲学上对抽象有很多讨论,什么本体论、白马非马之类的,这些已经在本人的理解范围之外了,就不讨论了。

开发中的抽象

现在我们应该能大致理解抽象这个概念了,让我们回到软件开发领域。

在软件开发里面,最重要的抽象就可能是分层了。分层随处可见,例如我们的系统就是分层的。最早的程序是直接运行在硬件上的,开发成本非常高。然后慢慢开始有了操作系统,操作系统提供了资源管理、进程调度、输入输出等所有程序都需要的基础功能,开发程序时调用操作系统的接口就可以了。再后来发现操作系统也不够,于是又有了各种运行环境(如 JVM)。

编程语言也是一种分层的抽象。机器理解的其实是机器语言,即各种二进制的指令。但我们不可能直接用机器语言编程,于是我们发明了汇编语言、C 语言以及 Java 等各种高级语言,一直到 Ruby、Python 等动态语言。

开发中,我们应该也都听说过各种分层模型。例如经典的三层模型(展现层、业务逻辑层、数据层),还有 MVC 模型等。有一句名言:“软件领域的任何问题,都可以通过增加一个间接的中间层来解决”。分层架构的核心其实就是抽象的分层,每一层的抽象只需要而且只能关注本层相关的信息,从而简化整个系统的设计。

其实软件开发本身,就是一个不断抽象的过程。我们把业务需求抽象成数据模型、模块、服务和系统,面向对象开发时我们抽象出类和对象,面向过程开发时我们抽象出方法和函数。也即是说,上面提到的模型、模块、服务、系统、类、对象、方法、函数等,都是一种抽象。可想而知,设计一个好的抽象,对我们软件开发有多么重要。

抽象的原则

那么到底应如何做到好的抽象呢?在软件开发领域,前人们其实早帮我们整理出了 SOLID 等设计原则以及各种设计模式。对于 SOLID 原则,虽然很多人都听说过,但其实真正能理解这些原则的开发者并不多。那么我们就从抽象的角度,再来看下这些原则,也许会有更好的理解。

单一职责原则(Single Responsibility Principle, SRP)

单一职责是指一个模块应该只做一件事,并把这件事做好。其实对照应抽象的定义,可以发现这个原则本身就是抽象的核心体现。如果一个类包含了很多方法,或者一个方法特别长,就要引起我们的特别注意了。例如下面这个 Employee 类,既有业务逻辑(calculatePay)、又有数据库逻辑(saveToDb),那它其实至少做了两件事情,也就不符合单一职责原则,当然也就不是一个好的抽象。

class Employee {
     
   public Pay calculatePay() {...}    
   public
void saveToDb() {...}
}

有些人觉得单一职责不太好理解,有时候很那分辨一个模块到底是不是单一职责。其实单一职责的概念,常常需要结合抽象的分层去理解。

在同一个抽象层里,如果一个类或者一个方法做了不止一件事,一般是比较容易分辨的。例如一个违反单一职责原则的典型征兆是,一个方法接受一个布尔类型或者枚举类型的参数,然后一个大大的 if/else 或者 switch/case,分支里也是大段的代码处理各种情况下的逻辑。这时我们可以用简单工厂模式、策略模式等设计模式去优化设计。

假如说我们用了简单工厂模式,改进了一段代码,重构后代码可能像是下面是这样的。

public Instance getInstance(final int type){
     
   switch (type) { case 1: return new AAInstance; case 2: return new BBInstance; default: return new DefaultInstance(); }
}

有人可能会有疑问,代码里依然还是存在 if/else 或者 switch/case,这不还是做了不止一件事情么?其实不是的,使用了简单工厂模式,其实就是增加了一个抽象层。在这个抽象层里,getInstance 的职责很明确,就是创建对象。而原来分支里的逻辑处理,则下沉到了另外一个抽象层里去了,也就是 Instance 的实现所在的抽象层。

再看下面 Scala 实现的 updateOrder 方法,它似乎也只是做了一件事情:处理订单,那算不算单一职责呢?

protected def updateOrder(t: TransationEntity) = {
     
// 1 获取订单 ManagedRepo.find[Order]("orderNo" -> t.tradeNo).map { order => // 2 检查订单是否已支付 val ps = SQL("""select statue from Order where id ={id} for update""").on("id" -> order.id).as(scalar[Long].singleOpt).getOrElse(0l) if (ps == PAID) { throw ServiceException(ApiError.SUBSCRIPTION_UPDATE_FAIL) } else { // 3 更新订单信息,标记为已支付             val updatedOrder = // 略... updatedOrder.saveOrUpdate() // 4 生成收入记录 createIncome(updatedOrder) } }
}

答案当然是不算,因为很明显,这个方法里面既有业务逻辑的代码,又有数据库处理的代码,这两类应该是在不同的抽象层的。我们把数据库处理的代码抽取出来,下沉到数据层,它就能符合单一职责原则了。

protected def updateOrder(t: TransationEntity) = {
     
findUnpaidOrder(rtent.tradeNo).map { order => val updatedOrder = updateOrderForPayment(rtent) createIncome(updatedOrder) }
}

开放封闭原则(Open/Closed Principle, OCP)

开放封闭原则是指对扩展开放,对修改封闭。当需求改变时,我们可以扩展模块以满足新的需求;但扩展时,不应该需要修改原模块的实现。

下面两段代码都实现了方形、矩形以及圆形的面积计算。第一种用的是面向过程的方法,第二种用的是面向对象的方法。那么,到底哪一种更符合开放封闭原则呢?

面向过程方法:

public class Square {
     
   public double side;
}
public class Rectangle { public double height; public double width;
}
public class Circle { public double radius;
}
public class Geometry { public double area(Object shape) { if (shape instanceof Square) { Square s = (Square) shape; return s.side * s.side; } else if (shape instanceof Rectangle) { Rectangle r = (Rectangle) shape; return r.height * r.width; } else if (shape instanceof Square) { Circle c = (Circle) shape; return PI * c.radius * c.radius; } else { throw new NoSuchShareException(); } }
}

面向对象方法:

public class Square implements Share {
     
   public double side; public double area() { return side * side; }
}
public class Rectangle implements Share { public double height; public double width; public double area() { return height * width; }
}
public class Circle implements Share { public double radius; public double area() { return PI * radius * radius; }
}

估计很多人会觉得面向对象的方式更好,更符合开放封闭原则。但真相其实没那么简单。想象如果我们需要添加一个新的形状,比如说椭圆,那面向对象的实现肯定更方便,我们只需要实现一个椭圆的类以及它的 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 的子类吗?

public class Rectangle {
     
   public double height; public double width; public void setHeight(int height) { ... } public void setWidth(int width) { ... }
}
public class Square extends Rectangle { ???
}

虽然几何上说,Square 是一个特殊的 Rectangle,但把 Square 作为 Rectangle 的子类,却未必合适,因为它已经不存在宽和高的概念了。如果一个抽象不能符合里氏替换原则,那我们就需要考虑下这个抽象是不是合适了。

接口隔离原则(Interface Segregation Principle, ISP)

接口隔离原则是指客户端不应该被迫依赖它们不使用的方法。例如下面的 Square 类如果继承了Shape 接口,该如何计算体积以实现volume方法?

interface Shape {
     
   public function area(); public function volume();
}
public class Square extends Shape { ???
}

同样,如果一个抽象不符合接口隔离原则,那可能就不是一个合适的抽象。

迪米特法则(Law of Demeter)

迪米特法则不属于 SOLID 原则,但我觉得也值得说一下。它是指模块不应该了解它所操作的对象的内部情况。想象一下,如果你想让你的狗狗快点跑的话,你会对狗狗说,还是对四条狗腿说?如果你去店里买东西,你会把钱交给店员,还是会把钱包交给店员让他自己拿?

下面是一段违反迪米特法则的典型代码。这样的代码把对象内部实现暴露了出来,应该考虑讲将功能直接暴露为接口,或者合理使用设计模式(如 Facade)。

final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

总结

关于抽象,今天我们就说到这里。不过要注意的是,软件开发并不是仅仅只依靠抽象能力就能完成的,最终我们还是要把我们抽象出来的架构、模型等,落地到真正的代码层面,那就还需要逻辑思维能力、系统分析能力等。以后如果有机会,我们可以继续探讨。

我希望各位看完本文,对抽象的理解能够更加深入一点。我们以奥卡姆剃刀原则来结束吧:一个抽象应该足够简单,但又不至于过于简单。这其实就是抽象的真谛。


全文完



以下文章您可能也会感兴趣:



杏仁技术站


长按左侧二维码关注我们,这里有一群热血青年期待着与您相会。



版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/y4x5M0nivSrJaY3X92c/article/details/78863467

智能推荐

c# 调用c++ lib静态库_c#调用lib-程序员宅基地

文章浏览阅读2w次,点赞7次,收藏51次。四个步骤1.创建C++ Win32项目动态库dll 2.在Win32项目动态库中添加 外部依赖项 lib头文件和lib库3.导出C接口4.c#调用c++动态库开始你的表演...①创建一个空白的解决方案,在解决方案中添加 Visual C++ , Win32 项目空白解决方案的创建:添加Visual C++ , Win32 项目这......_c#调用lib

deepin/ubuntu安装苹方字体-程序员宅基地

文章浏览阅读4.6k次。苹方字体是苹果系统上的黑体,挺好看的。注重颜值的网站都会使用,例如知乎:font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, W..._ubuntu pingfang

html表单常见操作汇总_html表单的处理程序有那些-程序员宅基地

文章浏览阅读159次。表单表单概述表单标签表单域按钮控件demo表单标签表单标签基本语法结构<form action="处理数据程序的url地址“ method=”get|post“ name="表单名称”></form><!--action,当提交表单时,向何处发送表单中的数据,地址可以是相对地址也可以是绝对地址--><!--method将表单中的数据传送给服务器处理,get方式直接显示在url地址中,数据可以被缓存,且长度有限制;而post方式数据隐藏传输,_html表单的处理程序有那些

PHP设置谷歌验证器(Google Authenticator)实现操作二步验证_php otp 验证器-程序员宅基地

文章浏览阅读1.2k次。使用说明:开启Google的登陆二步验证(即Google Authenticator服务)后用户登陆时需要输入额外由手机客户端生成的一次性密码。实现Google Authenticator功能需要服务器端和客户端的支持。服务器端负责密钥的生成、验证一次性密码是否正确。客户端记录密钥后生成一次性密码。下载谷歌验证类库文件放到项目合适位置(我这边放在项目Vender下面)https://github.com/PHPGangsta/GoogleAuthenticatorPHP代码示例://引入谷_php otp 验证器

【Python】matplotlib.plot画图横坐标混乱及间隔处理_matplotlib更改横轴间距-程序员宅基地

文章浏览阅读4.3k次,点赞5次,收藏11次。matplotlib.plot画图横坐标混乱及间隔处理_matplotlib更改横轴间距

docker — 容器存储_docker 保存容器-程序员宅基地

文章浏览阅读2.2k次。①Storage driver 处理各镜像层及容器层的处理细节,实现了多层数据的堆叠,为用户 提供了多层数据合并后的统一视图②所有 Storage driver 都使用可堆叠图像层和写时复制(CoW)策略③docker info 命令可查看当系统上的 storage driver主要用于测试目的,不建议用于生成环境。_docker 保存容器

随便推点

网络拓扑结构_网络拓扑csdn-程序员宅基地

文章浏览阅读834次,点赞27次,收藏13次。网络拓扑结构是指计算机网络中各组件(如计算机、服务器、打印机、路由器、交换机等设备)及其连接线路在物理布局或逻辑构型上的排列形式。这种布局不仅描述了设备间的实际物理连接方式,也决定了数据在网络中流动的路径和方式。不同的网络拓扑结构影响着网络的性能、可靠性、可扩展性及管理维护的难易程度。_网络拓扑csdn

JS重写Date函数,兼容IOS系统_date.prototype 将所有 ios-程序员宅基地

文章浏览阅读1.8k次,点赞5次,收藏8次。IOS系统Date的坑要创建一个指定时间的new Date对象时,通常的做法是:new Date("2020-09-21 11:11:00")这行代码在 PC 端和安卓端都是正常的,而在 iOS 端则会提示 Invalid Date 无效日期。在IOS年月日中间的横岗许换成斜杠,也就是new Date("2020/09/21 11:11:00")通常为了兼容IOS的这个坑,需要做一些额外的特殊处理,笔者在开发的时候经常会忘了兼容IOS系统。所以就想试着重写Date函数,一劳永逸,避免每次ne_date.prototype 将所有 ios

如何将EXCEL表导入plsql数据库中-程序员宅基地

文章浏览阅读5.3k次。方法一:用PLSQL Developer工具。 1 在PLSQL Developer的sql window里输入select * from test for update; 2 按F8执行 3 打开锁, 再按一下加号. 鼠标点到第一列的列头,使全列成选中状态,然后粘贴,最后commit提交即可。(前提..._excel导入pl/sql

Git常用命令速查手册-程序员宅基地

文章浏览阅读83次。Git常用命令速查手册1、初始化仓库git init2、将文件添加到仓库git add 文件名 # 将工作区的某个文件添加到暂存区 git add -u # 添加所有被tracked文件中被修改或删除的文件信息到暂存区,不处理untracked的文件git add -A # 添加所有被tracked文件中被修改或删除的文件信息到暂存区,包括untracked的文件...

分享119个ASP.NET源码总有一个是你想要的_千博二手车源码v2023 build 1120-程序员宅基地

文章浏览阅读202次。分享119个ASP.NET源码总有一个是你想要的_千博二手车源码v2023 build 1120

【C++缺省函数】 空类默认产生的6个类成员函数_空类默认产生哪些类成员函数-程序员宅基地

文章浏览阅读1.8k次。版权声明:转载请注明出处 http://blog.csdn.net/irean_lau。目录(?)[+]1、缺省构造函数。2、缺省拷贝构造函数。3、 缺省析构函数。4、缺省赋值运算符。5、缺省取址运算符。6、 缺省取址运算符 const。[cpp] view plain copy_空类默认产生哪些类成员函数

推荐文章

热门文章

相关标签