“面向抽象编程,面向接口编程”这句话流传甚广,它像一面旗帜插在每个人前进的道路上,引导大家前行。每个程序员都免不了和抽象打交道,差距可能在于能否更好地提炼。这句话包含两部分含义:“面向抽象编程”本质上是对数据的抽象化,“面向接口编程”本质上是对行为的抽象化。
我们先来谈谈数据的抽象化——面向抽象编程。
抽象最讨厌的敌人:new
因为直接讲什么是抽象不太好讲,容易描述的话那就不是抽象了,所以我们换个角度,先聊聊抽象的反面:什么是具体。在具体里,有个先锋人物,就是我们都熟悉的 new。大家知道,new 是最简单和最常见的关键字,用来创建对象。但被创建出来的一定是具体的对象,所以new 代表着具体,它是抽象最讨厌的敌人。
大家要有这种敏感:什么时机创建对象,在哪里创建,是很有讲究的。为了阐述这个话题,我们先看下面这行代码:
Animalanimal= new Tiger(); //Animal是抽象类
我曾经对这句简单的赋值语句思考很久:左边抽象,右边具体,感觉不对等,这样写好不好?答案不简单啊。
接下来,我们分成两个方向细细讨论。
假设一:如果它是某个类的成员变量的定义。例如:
privateAnimal animal =newTiger();
先下结论:如果类里其他地方没有对 animal 这个变量的赋值操作,此后再没有更改它的逻辑了,那么它基本不是好写法(有少许例外)。那么,什么是好写法?
哈,这里先卖个关子。
这里需要注意的是,我们讨论的是左边是抽象,右边是具体的 new。如果 new 的两边是平级概念的类,例如:
Tiger tiger= new Tiger();
它左右两边没有抽象之分,那么不在我们讨论范围之内。
假设二:如果它是某个函数内部的变量定义语句。示例如下:
voidShow() {Animal animal =newTiger();......// 出场前的准备活动ShowAnimal(animal);}
我曾经疑惑:为何不直接定义成子类类型?就这样写:
Tiger tiger= new Tiger();
根据继承原理,子类能调用抽象类的方法。所以也不会影响接下来的函数调用。例如:所有的animal.Eat 替换为 tiger.Eat 一定成立。
同时根据里氏替换原则,但凡出现animal 的地方,都可以把tiger 代替进去,所以也不会影响我的参数传递。例如:ShowAnimal(animal)替换为ShowAnimal(tiger)也一定成立。
可一旦把 Tiger 类型上溯转为抽象的 Animal 类型,那么 Tiger 自身的特殊能力(例如Hunt)在“出场前的准备活动”那部分就用不了,例如:
tiger.Hunt();// 老虎进行狩猎animal.Hunt();// 不能通过编译
也就是说,Animal animal = new Tiger();里Animal 的抽象定义,只有限制我自由的作用,而没有带来任何实质的好处!这种写法不是很糟糕吗?
你会有一天顿悟:这种对自由的限制,恰恰是最珍贵的!大部分时候,我们缺的不是自由,而是自律。任何人的自由,都不能以损害别人的利益为代价。
ShowAnimal(animal);之前的那段“出场前的准备活动”代码,将来很有可能是别人来维护的。在架构设计上,一定要考虑“时间”这个变量带来的不确定性。如果你定义成:
Tiger tiger= new Tiger();
这看起来更灵活,但你没法阻止这只老虎被别人将来使用Hunt 函数滥杀无辜。
一旦定义为:
Animal animal= new Tiger();
那么,这只老虎将会是一只温顺的老虎,只遵循普通的动物准则。
所以如果“出场前的准备活动”这部分的业务需求里只用到Animal 的基本功能函数,那么:
Animal animal= new Tiger();
要优于
Tiger tiger= new Tiger();
好了,等号左边的抽象问题解决了,但等号右边的 new 呢?这个场景里,Animal animal =new Tiger();是函数的局部变量,也没有传导到全局变量中。到目前为止,这个new 是完全可以接受的。面向抽象,是要在关键且合适的地方去抽象,如果处处都抽象,代价会非常大,得不偿失。如果满分是 100 分的话,目前能得 95 分,已经很好了,这也是我们大多数时候的写法。
但你还是要知道:一旦接受了这个 new,好比是和魔鬼做了契约,会付出潜在代价的。此处的代价是这段代码不能再升级成框架性的抽象代码了。想要完美得到 100 分,则需要消灭这个new,怎么办呢?
消灭 new 的两件武器
上面站在理论高度“批判”了new,其实并不是说 new 真的不好,而是说很多人会滥用。就好比火是人类文明的起源,好东西,但是滥用就会造成火灾。把火源限定在特定工具才能点火,隔离开,用起来才安全。new 其实也一样,下面讲的本质上不是消灭 new,而是隔离 new 的两件武器。
控制反转——脏活让别人去干
还记得前面卖的关子吗?如果 animal 是类成员变量:
privateAnimal animal =newTiger();
这并不是好写法,那么什么是好写法呢?这种情况下,比较简单的是对它进行参数化改造:
void setAnimal(Animalanimal) { this.animal=animal;}
然后让客户去调用注入:
Tigertiger =newTiger();obj.setAnimal(tiger);
有了上面的注入代码,private Animal animal = new Tiger();这句话反而变得可以接受了。因为等号右边的 Tiger 仅仅是默认值,默认值当然是具体的。
上面的参数化改造手法,我们可以称为“依赖注入”,其核心思想是:不要调我,我会去调你!依赖注入分为属性注入、构造函数注入和普通函数注入。很明显,上面的例子是属性注入。
依赖注入和标题的“控制反转”还不能完全划等号。确切地说,“依赖注入”是实现“控制反转”的方式之一。
这种干脆把创建对象的任务甩手不干的事情,反而是个好写法,境界高!这样,你不知不觉把自己的代码完全变成了只负责数据流转的框架性代码,具备了通用性。
在通往架构师的道路上,你要培养出一种感觉:要创建一个跨作用域的实体对象(不是值对象)是一件很谨慎的事情(越接触大型项目,你对这点的体会就越深),不要随便创建。最好不要自己创建,让别人去创建,传给你去调用。那么问题来了:都不愿意去创建,谁去创建?这个丢手绢的游戏最终到底要丢给谁呢?
先把问题揣着,我们接着往下看。
工厂模式——抽象的基础设施
我们回到这段Show 代码:
voidShow() { Animal animal =newTiger();// 上面说过,这里的 new 目前是可以接受的......// 出场前的准备活动ShowAnimal(animal);}
但如果Show 方法里创建动物的需求变得复杂,new 会变得猖狂起来:
voidShow(stringname) { Animal animal;if(name =="Tiger") animal =newTiger();elseif(name =="Lion") animal =newLion();......// 其他种类ShowAnimal(animal);}
此时将变得不可接受了。对付这么多同质的 new(都是创建Animal),一般会将它们封装进专门生产 animal 的工厂里:
Animal ProvideAnimal(stringname) { Animal animal;if(name =="Tiger") animal =newTiger();elseif(name =="Lion") animal =newLion();......// 其他种类}
进而优化了 Show 代码:
voidShow(stringname){ Animal animal = ProvideAnimal(name);// 等号两边都是同级别的抽象,这下彻底舒服了ShowAnimal(animal);}
因此,依赖注入和工厂模式是消灭 new 的两种武器。此外,它们也经常结合使用。
上面的 ProvideAnimal 函数采用的是简单工厂模式。由于工厂模式是每个人都会遇到的基本设计模式,所以这里会对它进行更深入的阐述,让大家能更深入地理解它。工厂模式严格说来有简单工厂模式和抽象工厂模式之分,但真正算得上设计模式的,是抽象工厂模式。简单工厂模式仅仅是比较自然的简单封装,有点配不上一种设计模式的称呼。因此,很多教科书会大篇幅地介绍抽象工厂,而有意无意地忽略了简单工厂。但实际情况正好相反,抽象工厂大部分人一辈子都用不上一次(它的出现要依赖于对多个相关类族创建对象的复杂需求场景),而简单工厂几乎每个人都用得上。
和一般的设计模式不一样,有些设计模式的代码结构哪怕你已经烂熟于心,却依然很难想象它们的具体使用场景。工厂模式是面向抽象编程,数据的创建需求变复杂之后很自然的产物,很多人都能无师自通地去使用它。将面向抽象编程坚持到底,会自然地把创建对象的任务外包出去,丢给专门的工厂去创建。
可见,工厂模式在整个可扩展的架构中扮演的不是先锋队角色,而是强有力的支持“面向抽象编程”的基础设施之一。
最后调侃一下,我面试候选人的时候,很喜欢问他们一个问题:“你最常用的设计模式有哪些?”
排第一的是“单例模式”,而“工厂模式”是当之无愧的第二名,排第三的是“观察者模式”。这侧面说明这三种模式应该是广大程序员最容易用到的设计模式。大家学习设计模式时,首先应该仔细研究这三种模式及其变种。
new 去哪里了呢
这里回到最开始也是最关键的问题:如果大家都不去创建,那么谁去创建呢?把脏活丢给别人,那别人是谁呢?下面我们从两个方面阐述。
■ 局部变量。局部变量是指在函数内部生产又在函数内部消失的变量,外部并不知晓它的存在。在函数内部创建它们就好,这也是我们遇到的大多数情况。例如:
voidShow() { Animal animal =newTiger();......// 出场前的准备活动ShowAnimal(animal);}
前面说过,这段代码里的 new 能得95 分,没有问题。
■ 跨作用域变量。对这类对象的创建,总是要小心一些的。
○ 如果是零散的创建,就让各个客户端自己去创建。这里的客户端是泛指的概念,不是服务器对应的客户端。凡是调用核心模块的发起方,均属于客户端。每个客户端是知道自身具体细节的,在它内部创建无可厚非。
○ 如果写的是框架性代码,是基于总体规则的创建,那就在核心模块里采用专门的工厂去创建。
抽象到什么程度
前面说过,完全具体肯定不行,缺乏弹性。但紧接着另一个问题来了:越抽象就越好吗?不见得。我们对抽象的态度没必要过分崇拜,下面就专门讨论一下抽象和具体之间如何平衡。比如Java 语言,根上的 Object 类最抽象了,但 Object 定义满天飞显然不是我们想要的,例如:
Objectobj =newTiger();
那样你会被迫不停地进行下溯转换:
Animalanimal= (Animal)obj;
所以不是越抽象越好。抽象是有等级之分的,要抽象到什么程度呢?有一句描述美女魔鬼身材的语句是“该瘦的地方瘦,该肥的地方肥”。那么,这句话可改编一下,即可成为抽象编程的原则,即“该实的地方实,该虚的地方虚”。也就是说,抽象和具体之间一定有个平衡点,这个平衡点正是应该时刻存在程序员大脑里的一件东西:用户需求!
你需要做的是精确把握用户需求,提供给用户的是满足用户需求的最根上的那层数据。什么意思呢?我们通过下面这个例子详细阐述。
村里的家家户户都要提供一种动物去参加跑步比赛,于是每家都要实现一个ProvideAnimal函数。你家里今年养了一只老虎,老虎属于猫科。三层继承关系如下:
publicabstractclassAnimal{publicvoidRun();}publicclassCat:Animal{publicintJump();}publicclassTiger:Cat{publicvoidHunt(Animal animal);}
现在有个问题:ProvideAnimal 函数的返回类型定义为什么好呢?Animal、Cat 还是Tiger?这就要看用户需求了。
如果此时是举行跑步比赛,那么只需要你的动物有跑步能力即可,此时返回Animal 类型是最好的:
publicAnimalProvideAnimal(){returnnewTiger();}
如果要举办跳高比赛,是Cat 层级才有的功能,那么返回Cat 类型是最好的:
publicCatProvideAnimal(){returnnewTiger();}
切记,你返回的类型,是客户需求对应的最根上的那个类型节点。这是双赢!
如果函数返回值是最底下的 Tiger 子类型:
publicTigerProvideAnimal(){returnnewTiger();}
这会带来如下两个潜在的问题。
问题1:给别人造成滥用的可能
这给了组织者额外的杂乱信息。本来呢,对于跑步比赛,每一个参赛者只有一个 Run 函数便清晰明了,但在老虎身上,有 Run 的同时,还附带了跳高 Jump 和捕猎 Hunt 的功能。这样组织者需要思考一下到底应该用哪个功能。所以提供太多无用功能,反而给别人造成了困扰。
同时也给了组织者犯错误的机会。万一,他一旦好奇,或者错误操作,比赛时调用了 Hunt方法,那这只老虎就不是去参加跑步比赛,而是追捕别的小动物吃了。
问题2:丧失了解耦子对象的机会
一旦对方在等号两边傻傻地按照你的子类型去定义,例如:
Tiger tiger= ProvideAnimal();
从此组织者就指名道姓地要你家的老虎了。如果比赛当天,你的老虎生病了,你本可以换一头猎豹去参加比赛,但因为别人预定了看你家的老虎,所以非去不可。结果便丧失了宝贵的解耦机会。
如果是Animal 类型,那么你并不知道是哪一种动物会出现,但你知道它一定会动起来,跑成什么样子,你并不知道。这样的交流,是比较高级的交流。绘画艺术上有个高级术语叫“留白”,咱们编程玩“抽象”也算是“留白”。我先保留一些东西,一开始没必要先确定的细节就不先确定了。那这个“留白”留多少呢?根据用户需求而定!
总结
多态这门特技,成就了人们大量采用抽象去沟通,用接口去沟通。而抽象也不负众望地让沟通变得更加简洁、高效;抽象也让相互间依赖更少,架构更灵活。
参数化和工厂模式是消灭或隔离new 的两种武器。
用户需求是决定抽象到何种程度的决定因素。
——本文选自《代码里的世界观:通往架构师之路》
编程中有很多通用的知识点,它们是10年甚至20年都不会淘汰的编程技术,市面上也极少有将它们综合起来并讲得有意思的书。
上面这本书是一位IBM架构师结合了自己13年编程经验,结合自己的理解和领悟,把许多知识点汇入到了这本书里。它们并不是潮流的知识点,而是厚重的基础知识。
点击这里,看图书目录。