第3章 面向抽象编程——玩玩虚的更健康

“面向抽象编程,面向接口编程”这句话流传甚广,它像一面旗帜插在每个人前进的道路上,引导大家前行。每个程序员都免不了和抽象打交道,差距可能在于能否更好地提炼。

这句话包含两部分含义:“面向抽象编程”本质上是对数据的抽象化,“面向接口编程”本质上是对行为的抽象化。

由于书中第9章专门介绍接口,所以本章只谈数据的抽象化。

3.1 抽象最讨厌的敌人:new

因为直接讲什么是抽象不太好讲,容易描述的话那就不是抽象了,所以我们换个角度,先聊聊抽象的反面:什么是具体。在具体里,有个先锋人物,就是我们都熟悉的new。大家知道,new是最简单和最常见的关键字,用来创建对象。但被创建出来的一定是具体的对象,所以new代表着具体,它是抽象最讨厌的敌人。

大家要有这种敏感:什么时机创建对象,在哪里创建,是很有讲究的。为了阐述这个话题,我们先看下面这行代码:

Animal animal = new Tiger(); // Animal是抽象类

我曾经对这句简单的赋值语句思考很久:左边抽象,右边具体,感觉不对等,这样写好不好?答案不简单啊。

接下来,我们分成两个方向细细讨论。

假设一:如果它是某个类的成员变量的定义。例如:

private Animal animal = new Tiger();

先下结论:如果类里其他地方没有对animal这个变量的赋值操作,此后再没有更改它的逻辑了,那么它基本不是好写法(有少许例外,后面会讲)。那么,什么是好写法?

哈,这里先卖个关子。

这里需要注意的是,我们讨论的是左边是抽象,右边是具体的new。如果new的两边是平级概念的类,例如:

Tiger tiger = new Tiger();

它左右两边没有抽象之分,那么不在本章讨论范围之内。

假设二:如果它是某个函数内部的变量定义语句。示例如下:

void Show() {
Animal animal = new Tiger();
    ...... // 出场前的准备活动
    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,怎么办呢?

3.2 消灭new的两件武器

前一节站在理论高度“批判”了new,其实并不是说new真的不好,而是说很多人会滥用。就好比火是人类文明的起源,好东西,但是滥用就会造成火灾。把火源限定在特定工具才能点火,隔离开,用起来才安全。new其实也一样,本节讲的本质上不是消灭new,而是隔离new的两件武器。

3.2.1 控制反转——脏活让别人去干

还记得前面卖的关子吗?如果animal是类成员变量:

private Animal animal = new Tiger();

这并不是好写法,那么什么是好写法呢?这种情况下,比较简单的是对它进行参数化改造:

void setAnimal(Animal animal) {
    this.animal = animal;
}

然后让客户去调用注入:

Tiger tiger = new Tiger();
obj.setAnimal(tiger);

有了上面的注入代码,private Animal animal = new Tiger();这句话反而变得可以接受了。因为等号右边的Tiger仅仅是默认值,默认值当然是具体的。

上面的参数化改造手法,我们可以称为“依赖注入”,其核心思想是:不要调我,我会去调你!依赖注入分为属性注入、构造函数注入和普通函数注入。很明显,上面的例子是属性注入。依赖注入和标题的“控制反转”还不能完全划等号。确切地说,“依赖注入”是实现“控制反转”的方式之一。有关“控制反转”的更详细内容,详见第5章。

这种干脆把创建对象的任务甩手不干的事情,反而是个好写法,境界高!这样,你不知不觉把自己的代码完全变成了只负责数据流转的框架性代码,具备了通用性。

在通往架构师的道路上,你要培养出一种感觉:要创建一个跨作用域的实体对象(不是值对象)是一件很谨慎的事情(越接触大型项目,你对这点的体会就越深),不要随便创建。最好不要自己创建,让别人去创建,传给你去调用。那么问题来了:都不愿意去创建,谁去创建?这个丢手绢的游戏最终到底要丢给谁呢?

先把问题揣着,我们接着往下看。

3.2.2 工厂模式——抽象的基础设施

我们回到这段Show代码:

void Show() {
    Animal animal = new Tiger(); // 上面说过,这里的new目前是可以接受的
    ...... // 出场前的准备活动
    ShowAnimal(animal);
}

但如果Show方法里创建动物的需求变得复杂,new会变得猖狂起来:

void Show(string name) {
    Animal animal;
    if(name == "Tiger")
        animal = new Tiger();
    else if(name == "Lion")
        animal = new Lion();
    ...... // 其他种类
    ShowAnimal(animal);
}

此时将变得不可接受了。对付这么多同质的new(都是创建Animal),一般会将它们封装进专门生产animal的工厂里:

Animal ProvideAnimal(string name) {
    Animal animal;
    if(name == "Tiger")
        animal = new Tiger();
    else if(name == "Lion")
        animal = new Lion();
        ...... // 其他种类
}

进而优化了Show代码:

void Show(string name) {
    Animal animal = ProvideAnimal(name); // 等号两边都是同级别的抽象,这下彻底舒服了
    ShowAnimal(animal);
}

因此,依赖注入和工厂模式是消灭new的两种武器。此外,它们也经常结合使用。

上面的ProvideAnimal函数采用的是简单工厂模式。由于工厂模式是每个人都会遇到的基本设计模式,所以这里会对它进行更深入的阐述,让大家能更深入地理解它。工厂模式严格说来有简单工厂模式和抽象工厂模式之分,但真正算得上设计模式的,是抽象工厂模式。简单工厂模式仅仅是比较自然的简单封装,有点配不上一种设计模式的称呼。因此,很多教科书会大篇幅地介绍抽象工厂,而有意无意地忽略了简单工厂。但实际情况正好相反,抽象工厂大部分人一辈子都用不上一次(它的出现要依赖于对多个相关类族创建对象的复杂需求场景),而简单工厂几乎每个人都用得上。

和一般的设计模式不一样,有些设计模式的代码结构哪怕你已经烂熟于心,却依然很难想象它们的具体使用场景。工厂模式是面向抽象编程,数据的创建需求变复杂之后很自然的产物,很多人都能无师自通地去使用它。将面向抽象编程坚持到底,会自然地把创建对象的任务外包出去,丢给专门的工厂去创建。

可见,工厂模式在整个可扩展的架构中扮演的不是先锋队角色,而是强有力的支持“面向抽象编程”的基础设施之一。

最后调侃一下,我面试候选人的时候,很喜欢问他们一个问题:“你最常用的设计模式有哪些?”

排第一的是“单例模式”,而“工厂模式”是当之无愧的第二名,排第三的是“观察者模式”。这侧面说明这三种模式应该是广大程序员最容易用到的设计模式。大家学习设计模式时,首先应该仔细研究这三种模式及其变种。在其他章节中,还会详细介绍另外两种模式。

3.2.3 new去哪里了呢

这里回到最开始也是最关键的问题:如果大家都不去创建,那么谁去创建呢?把脏活丢给别人,那别人是谁呢?下面我们从两个方面阐述。

 局部变量。局部变量是指在函数内部生产又在函数内部消失的变量,外部并不知晓它的存在。在函数内部创建它们就好,这也是我们遇到的大多数情况。例如:

void Show() {
    Animal animal = new Tiger();
    ...... // 出场前的准备活动
    ShowAnimal(animal);
}

前面说过,这段代码里的new能得95分,没有问题。

 跨作用域变量。对这类对象的创建,总是要小心一些的。

 如果是零散的创建,就让各个客户端自己去创建。这里的客户端是泛指的概念,不是服务器对应的客户端。凡是调用核心模块的发起方,均属于客户端。每个客户端是知道自身具体细节的,在它内部创建无可厚非。

 如果写的是框架性代码,是基于总体规则的创建,那就在核心模块里采用专门的工厂去创建。

3.3 抽象到什么程度

前面说过,完全具体肯定不行,缺乏弹性。但紧接着另一个问题来了:越抽象就越好吗?不见得。我们对抽象的态度没必要过分崇拜,本节就专门讨论一下抽象和具体之间如何平衡。

比如Java语言,根上的Object类最抽象了,但Object定义满天飞显然不是我们想要的,例如:

Object obj = new Tiger();

那样你会被迫不停地进行下溯转换:

Animal animal = (Animal)obj;

所以不是越抽象越好。抽象是有等级之分的,要抽象到什么程度呢?有一句描述美女魔鬼身材的语句是“该瘦的地方瘦,该肥的地方肥”。那么,这句话可改编一下,即可成为抽象编程的原则,即“该实的地方实,该虚的地方虚”。也就是说,抽象和具体之间一定有个平衡点,这个平衡点正是应该时刻存在程序员大脑里的一件东西:用户需求!

你需要做的是精确把握用户需求,提供给用户的是满足用户需求的最根上的那层数据。什么意思呢?本节通过下面这个例子详细阐述。

村里的家家户户都要提供一种动物去参加跑步比赛,于是每家都要实现一个ProvideAnimal函数。你家里今年养了一只老虎,老虎属于猫科。三层继承关系如下:

public abstract class Animal {
    public void Run();
}
public class Cat : Animal {
    public int Jump();
}
public class Tiger : Cat {
    public void Hunt(Animal animal);
}

现在有个问题:ProvideAnimal函数的返回类型定义为什么好呢?Animal、Cat还是Tiger?这就要看用户需求了。

如果此时是举行跑步比赛,那么只需要你的动物有跑步能力即可,此时返回Animal类型是最好的:

public Animal ProvideAnimal() {
    return new Tiger();
}

如果要举办跳高比赛,是Cat层级才有的功能,那么返回Cat类型是最好的:

public Cat ProvideAnimal() {
    return new Tiger();
}

切记,你返回的类型,是客户需求对应的最根上的那个类型节点。这是双赢!

如果函数返回值是最底下的Tiger子类型:

public Tiger ProvideAnimal() {
    return new Tiger();
}

这会带来如下两个潜在的问题。

问题1:给别人造成滥用的可能

这给了组织者额外的杂乱信息。本来呢,对于跑步比赛,每一个参赛者只有一个Run函数便清晰明了,但在老虎身上,有Run的同时,还附带了跳高Jump和捕猎Hunt的功能。这样组织者需要思考一下到底应该用哪个功能。所以提供太多无用功能,反而给别人造成了困扰。

同时也给了组织者犯错误的机会。万一,他一旦好奇,或者错误操作,比赛时调用了Hunt方法,那这只老虎就不是去参加跑步比赛,而是追捕别的小动物吃了。

问题2:丧失了解耦子对象的机会

一旦对方在等号两边傻傻地按照你的子类型去定义,例如:

Tiger tiger = ProvideAnimal();

从此组织者就指名道姓地要你家的老虎了。如果比赛当天,你的老虎生病了,你本可以换一头猎豹去参加比赛,但因为别人预定了看你家的老虎,所以非去不可。结果便丧失了宝贵的解耦机会。

如果是Animal类型,那么你并不知道是哪一种动物会出现,但你知道它一定会动起来,跑成什么样子,你并不知道。这样的交流,是比较高级的交流。绘画艺术上有个高级术语叫“留白”,咱们编程玩“抽象”也算是“留白”。我先保留一些东西,一开始没必要先确定的细节就不先确定了。那这个“留白”留多少呢?根据用户需求而定!

3.4 总结

多态这门特技,成就了人们大量采用抽象去沟通,用接口去沟通。而抽象也不负众望地让沟通变得更加简洁、高效;抽象也让相互间依赖更少,架构更灵活。

参数化和工厂模式是消灭或隔离new的两种武器。

用户需求是决定抽象到何种程度的决定因素。

目录

  • 前言
  • 第1章 程序世界的两个基本元素
  • 第2章 用面向对象的方式去理解世界
  • 第3章 面向抽象编程——玩玩虚的更健康
  • 第4章 耦合其实无处不在
  • 第5章 数据的种类——生命如此多娇
  • 第6章 数据驱动——把变化抽象成数据
  • 第7章 对象之间的关系——父子、朋友或情人
  • 第8章 函数的种类——迷宫的结构
  • 第9章 面向接口编程——遵循契约办事
  • 第10章 if...else的多面性
  • 第11章 挖掘一件神秘武器——static 
  • 第12章 把容易变化的逻辑,放在容易修改的地方
  • 第13章 隐式约定——犹抱琵琶半遮面 
  • 第14章 异常,天使还是魔鬼
  • 第15章 多线程编程——在混沌中永生 
  • 第16章 单元测试——对代码庖丁解牛 
  • 第17章 代码评审——给身体排排毒
  • 第18章 编程就是用代码来写作 
  • 第19章 程序员的精神分裂——扮演上帝与木匠
  • 第20章 程序员的技术成长——打怪升级之路
  • 第21章 语言到底哪种好——究竟谁是屠龙刀
  • 第22章 程序员的组织生产——让大家更高效和亲密
  • 第23章 程序员的职业生涯——选择比努力更重要