抽象这个东西,说起来很抽象,其实很简单。

是什么

抽象是什么?维基百科给了一个定义:

“在计算机科学中,抽象化(英语:Abstraction)是将数据与程序以它的语义来呈现出它的外观,但是隐藏起它的实现细节。”

图片

“做技术、如艺术”,计算机中的“抽象”与艺术中的“抽象”颇有异曲同工之妙

这个定义看起来还是太“抽象”。我们举几个日常生活和工作的具体例子。

在生活中,我们经常会说“今晚有空吗,一起吃个饭”、“这家饭馆主打川菜,很不错”。这里的“吃个饭”、“川菜”,就是一种抽象。通过这些抽象,我们可以知道“噢耶,今晚吃大餐咯”、“不好,明天肠胃要造反了”。不过,只看这些抽象定义,我们无法知道今晚到底吃麻婆豆腐、宫保鸡丁还是红油火锅。

在工作中,我们经常会说“我是一个开发”、“这事儿你得找产品”。这里的“开发”、“产品”,也是一种抽象。通过这些抽象,我们可以知道“这个人负责代码”、“那个人负责写需求文档”,但无法确切知道“他是用IDEA还是用VsCode写代码”、“他是用Word还是Wiki写需求文档”。这些实现细节隐藏在抽象的“职位”之下、由具体的“员工”来完成。

图片

看到职位,我们就能知道这是做什么的;但具体怎么做?得看具体的员工。

在技术上,这样的例子更是俯拾皆是。例如,Slf4j声明了一个日志的抽象,它定义了“打印日志”相关的“语义外观”,但是它隐藏了实际打印日志的实现细节——是log4j、还是logback?使用Slf4j时我们是不知道的。

类似的,Jdbc的Driver、Connection和Statement定义了“操作数据库”相关的“语义外观”,但是它也没有实际去操作数据库。这些实现细节是由抽象之下的具体实现来处理的。

图片

怎么打印日志?Slf4j会告诉你:logger.info("a={}",a)。但Slf4j不会告诉你,这个logger到底是Log4j还是Logback。

不只是技术框架,在业务系统中,“抽象”的实例也随处可见。

我们有一个短信签约接口,它定义了submit、sendCode、submitCode三个方法。这个接口就是一个业务抽象,它定义了“短信签约操作有三个步骤”这个语义外观。至于每个步骤具体如何实现——如何复用代码、如何实现差异化处理——这正是抽象要隐藏的实现细节。

又如,我们有一个冻结订单的接口,定义了frozenFlow、frozenLimit、forzenTransport三个方法。这也是一个业务抽象,这个抽象定义了“冻结一笔订单时,必须冻结Flow、Limit和Transport这三类数据”这样一个语义外观。至于这三类数据具体如何冻结么——是物理删除、逻辑删除,还是回退状态、甚至直接忽略——这又是抽象要隐藏的实现细节了。

可见,“抽象”其实并不抽象。具体点说,抽象就是这样一个东西:它告诉了你它是什么、能做什么、但不告诉你它会怎么做。

图片

就像抽象艺术:就算明白告诉你这是艺术,你也不明白它怎么就成了艺术了。

为什么

抽象能够隐藏底层实现,这又有什么好处呢?我们为什么要在“抽象”这虚的东西上下功夫呢?

借用另一篇文章的话来说:抽象设计得越好,代码就越简单易用;代码可替代性就越好;可扩展性就越好。

简单易用

为什么说抽象设计得越好,代码就越简单易用呢?

一个好的抽象设计能够隐藏它的底层实现。因此,在使用抽象语义的时候,我们不需要关注底层的细节,因而可以省去很多不必要的操作。

就好比自动挡作为一个“换挡”的抽象,隐藏了踩离合、拨档杆等实现细节。开自动挡的车时不用关心离合换挡的事儿,开起来当然比手动挡要简单方便得多。

图片

手动还是自动?这是一个问题。

例如,我们看看下面这个接口:

public interface QueryService{
    public Bean queryFromRemote(long id);
    public Bean queryFromLocal(long id);
}

这个接口声明了两个方法,它们的入参、出参都是一模一样的,区别只在于方法名——以及名字所暗示的实践逻辑:是从“远程”查询、还是从“本地”查询。

如果调用方在使用时,确实需要区分数据来源,这个设计倒也无可厚非。但是,实际上调用这两个方法时,所有的代码都是这个样子的:

Bean bean = queryService.queryFromLocal(id);
if(bean == null){
    bean = queryService.queryFromRemote(id);
}
if(bean == null){
    throw new Excepton();
}

这样的代码既啰嗦又麻烦,而且出现了至少五次。为什么这么啰嗦又麻烦呢?因为这个接口把自己底层的实现——是从远程获取数据、还是从本地获取数据——暴露出来了。换句话说,这个接口的抽象设计得不够好。

如果我们把这个接口改成这样:

public interface QueryService{
    public Beean query(long id);
}

顺便,底层这样实现:

public class QueryServiceImpl implements QueryService{
    public Bean query(log id ){
        Bean bean = queryFromLocal(id);
        if(bean == null){
            bean = queryFromRemote(id);
        }
        if(bean == null){
            throw new RuntimeException();
        }
        return bean;
    }
}

那么,我们就可以这样调用这个接口了:

Bean bean = queryService.query(id);

这样的接口设计、业务调用,是不是简单、方便多了?这就是良好的抽象设计的第一个优点。

可替代性

为什么说抽象设计得越好,代码的可替代性就越好呢?

这种可替代性,同样来自于抽象隐藏了实现细节。因此,只要抽象所定义的“语义外观”不变,我们可以对实现细节做任意修改、任意替换,而毫不影响调用方。这种“任意修改、任意替换”的能力,就是可替代性。

就好比我们去银行柜台取钱:只要能把钱正确取出来,柜员是男是女、是胖是瘦、甚至于是人还是机器,这都无所谓。这就是柜员的可替代性。

图片

看看,是不是哪个妹子都OK?

我参与设计过一套账务系统。这套系统把所有的转账操作全部抽象为这样一个接口:从from账户向to账户转入金额amount元,记账科目是type:

public interface AccountService{
    public void trans(Account from, Account to, Money amount, TransType type);
}

从这个接口上,我们无法得知转账操作的实现细节:是实时发起支付,还是用批处理发起?是按单边账、双边账、还是会计科目记账?正是靠着抽象隐藏了实现细节这一“掩护”,我们对底层实现做过很多次重构和优化,最终找到了功能完备、架构合理、服务可靠、性能出色的最佳方案。所有这些底层改造,没有一次变更影响到了接口调用方。

这就是在好的抽象设计下的代码可替代性带来的好处。

因抽象暴露了实现细节,而降低代码可替代性的反例,我也遇到过。我参与设计过一套Java操作Excel文件的工具组件,其底层是POI组件。这套组件的核心接口是这样的:

public interface ExcelService<T>{
    public List<T> read(HSSFWorkbook workBook);
}

这个接口的语义是将一个Excel文件中的数据解析为一组对象T。但这个接口将自己的底层实现——也就是HSSFWorkbook——暴露到了抽象之外。这就导致了它只能处理2003版的Excel文件,也就是.xls文件。当调用方需要解析2007版Excel文件,也就是.xlsx文件时,它就无能为力了。

更糟糕的是,如果要把这套工具升级到2007版,那么所有的调用方都要改动代码。在我们的系统里,这意味着要多改二十多处代码、多回归四五十个业务流程。其中的工作量和风险可想而知。

总之,如果抽象设计得更好,代码的可替代性就更高,重构、优化、需求变更时,受到影响、需要变更的地方就更少。变更越少,开发和测试的工作量、加班量就越少,线上出现bug的风险也会更低。何乐而不为呢?

可扩展性

为什么说抽象设计得越好,代码的可扩展性就越好呢?

不难看出,可扩展性高这是可替代性高的推论。可替换性讨论的是具有相同能力的不同实现之间相互替换的能力,例如用马替换牛来拉车。如果我们讨论的是用一个具有新能力的实现替换掉原有实现,就从可替代性跨入了可扩展性的范畴,例如用圣诞老人的麋鹿替换拉车的马,就为马车扩展了“飞行”功能。

叮叮当,叮叮当,我要出去浪~~~

我们有一个查银行卡列表的接口,客户端查到列表后,需要根据不同的场景来展示或“置灰”某些卡。如划扣场景下要置灰不支持自动划扣的卡;解绑定场景下,要置灰某些不能解绑的卡;业务绑卡场景下,则要置灰已经绑定过的卡……等等等等。

为了这套业务,我们设计了一个这样的接口:

/** 接口定义 */
public interface CardListService{
    List<Card> query(long userId, Scene scene);
}

/** 返回字段是这样的 */
public class Card{
    /** 客户端根据这个字段的值来判断当前银行卡是展示还是置灰 */
    private boolean enabled;
    // 其它卡号、银行名等字段,和accessor略去
}
/** 入参中的枚举是这样的 */
public enum Scene{
    DEDUCT,
    UN_BIND,
    BIND;
}

这个接口把“怎样判断这张卡的展示状态”这一实现细节隐藏了起来。调用方无需关心底层逻辑,只需指定场景,然后根据返回值中的enabled字段展示或置灰卡信息即可。

由于与调用方无关,服务方可以恣意调整和扩展接口实现。在我们的系统中,UN_BIND场景曾多次置灰条件,场景枚举还增加了LOANREPAY等业务场景和对应的判断逻辑。所有这些改造,都只需服务方处理,调用方毫无感知。而且,服务方的改造点也不多,开发和测试的工作量都非常少。总之,扩展性非常好。

小结

业务系统的一个重要特点,就是业务需求在不断变化、频繁变化:今天拍一下脑门,要这样做;明天拍一下大腿,要那样做;后天拍一拍屁股,不做了;大后天又拍一下脑门,再改一版……如果系统设计和实现被业务需求牵着鼻子走,那我们就有写不完的代码、加不完的班了。 图片

谁都不想加班、秃头吧。

一个简单易用、可替代性高、可扩展性好的系统,才有可能像汽车的减震系统一样,吸收需求端的震动、保障系统版本稳定、平稳迭代;才有可能做到“需求大改、系统小改;需求小改、系统不改”;才有可能让开发从业务代码的CURD中抬起头来,去优化系统、提升技术,去平衡工作和生活、去追寻诗和远方。

面向对象思想通过建立“抽象”来隐藏具体实现,从而让系统坐拥简单易用、可替代性、可扩展性等特性。在最细的粒度上,一个抽象也许就是一个接口、一个抽象类;在较高的层次上,一个抽象也许由一套领域模型、一组业务服务或数据结构组成。

当然,抽象设计也有优劣。抽象设计得当,才能很好地隐藏实现细节,才能提供上述特性。如果抽象设计得不好,就有可能南辕北辙,欲速而不达。

怎么做

那么,怎样设计一个好的抽象呢?

面向对象思想已经提供了很多方法论和工具箱,如高内聚/低耦合、封装/继承/多态、SOLID、设计模式……等等等等,不一而足。理解到位、运用得当,我们就能设计出优秀的抽象来。

只不过,以前讨论这些方法和工具时,更多地是在“就事论事”地讨论它们自身,而并没有考虑到它们与面向对象思想、与“抽象”、与彼此的关系。怎样更全面、更深入的去理解和应用这些方法和工具、又怎样更灵活、更恰当地运用它们来建立良好的抽象呢?且听下回分解。

往期索引

《面向对象是什么》

从具体的语言和实现中抽离出来,面向对象思想究竟是什么?