计算机基础原来可以如此好懂!——「面向抽象编程」

  1. 云栖社区>
  2. 图灵教育>
  3. 博客>
  4. 正文

计算机基础原来可以如此好懂!——「面向抽象编程」

图灵教育 2019-01-15 15:17:35 浏览1668
展开阅读全文

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

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

本文我们先谈“面向抽象编程”,即数据的抽象化。

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仅仅是默认值,默认值当然是具体的。

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

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

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

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

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的两种武器。

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

——本文选自《代码里的世界观:通往架构师之路》


_

编程中有很多通用的知识点,它们是10年甚至20年都不会淘汰的编程技术,市面上也极少有将它们综合起来并讲得有意思的书。

上面这本书是一位IBM架构师结合了自己13年编程经验,结合自己的理解和领悟,把许多知识点汇入到了这本书里。它们并不是潮流的知识点,而是厚重的基础知识。

图书目录

第1章 程序世界的两个基本元素

1.1 数据和代码的相互伪装

1.2 数据和代码的关系

第2章 用面向对象的方式去理解世界

2.1 好的程序员是安徒生

2.2 封装——招兵买马,等级森严

2.3 继承——快速进化

2.4 多态——抽象的基石

2.5 总结

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

3.1 抽象最讨厌的敌人:new

3.2 消灭new 的两件武器

3.3 抽象到什么程度

3.4 总结

第4章 耦合其实无处不在

4.1 耦合的种类

4.2 耦合中既有敌人也有朋友

4.3 坏耦合的原因

4.4 解耦的原则

4.5 总结

第5章 数据的种类——生命如此多娇

5.1 常用数据类型

5.2 按生命周期划分数据

5.3 两个重要的数据容器

5.4 对象的种类

5.5 描述数据的数据

5.6 总结

第6章 数据驱动——把变化抽象成数据

6.1 三个案例

6.2 数据驱动的好帮手:反射

6.3 总结

第7章 对象之间的关系——父子、朋友或情人

7.1 继承——父子关系

7.2 组合——朋友关系

7.3 依赖——情人关系

7.4 总结

第8章 函数的种类——迷宫的结构

8.1 面向对象的函数叫方法

8.2 参数是函数的原材料

8.3 返回值对函数的意义

8.4 值传递、引用传递和指针传递

8.5 有状态函数和无状态函数

8.6 静态函数和普通函数

8.7 能驾驭其他函数的函数

8.8 编译器做过手脚的函数

8.9 总结

第9章 面向接口编程——遵循契约办事

9.1 接口和抽象类——分工其实挺明确

9.2 接口的应用场景

9.3 接口和函数指针

9.4 函数指针的应用场景

9.5 总结

第10章 if...else 的多面性

10.1 两条兄弟语句

10.2 if...else 的黑暗面

10.3 开闭原则——if...else 的天敌

10.4 化解if...else 黑暗面

10.5 总结

第11章 挖掘一件神秘武器——static

11.1 static 神秘在哪里

11.2 static 的特性

11.3 static 的应用场景

11.4 总结

第12章 把容易变化的逻辑,放在容易修改的地方

12.1 一个和用户的故事

12.2 一个和销售的故事

12.3 一个和产品经理的故事

12.4 一个和运维的故事

12.5 总结

第13章 隐式约定——犹抱琵琶半遮面

13.1 拨开隐式约定的神秘面纱

13.2 调料包数据

13.3 越简单的功夫越厉害

13.4 总结

第14章 异常,天使还是魔鬼

14.1 三个江湖派别

14.2 异常的种类

14.3 异常的throw:手榴弹什么时候扔

14.4 异常的catch——能收炸弹的垃圾筐

14.5 异常的使用技巧

14.6 总结

第15章 多线程编程——在混沌中永生

15.1 几个基础概念

15.2 互斥——相互竞争

15.3 同步——相互协作

15.4 异步——各忙各的

15.5 阻塞与非阻塞

15.6 总结

第16章 单元测试——对代码庖丁解牛

16.1 单元测试的诞生

16.2 单元测试的进化

16.3 编写单元测试的基本原则

16.4 如何让代码面向单元测试

16.5 最后的忠告:无招胜有招

16.6 总结

第17章 代码评审——给身体排排毒

17.1 排毒要养成习惯

17.2 磨刀不误砍柴工

17.3 经验点滴——关键是流程化

17.5 总结

第18章 编程就是用代码来写作

18.1 程序员与作家的区别

18.2 如何提高写作水平

18.3 案例解析——咬文嚼字很重要

18.4 谨慎对待注释

18.5 总结

第19章 程序员的精神分裂——扮演上帝与木匠

19.1 一个脑袋,两种身份

19.2 上帝模式:开天辟地,指点江山

19.3 木匠模式:致富只有勤劳一条路

19.4 总结

第20章 程序员的技术成长——打怪升级之路

20.1 技术成长三部曲

20.2 码农都是好老师

20.3 重视编程效率

20.4 尽量通过工作去锻炼

20.5 三分之一的工匠精神

20.6 明白架构师的含义

20.7 总结

第21章 语言到底哪种好——究竟谁是屠龙刀

21.1 军队的背后是国家实力的较量

21.2 专一和多情哪个好

21.3 如何快速学习一门新语言

21.4 总结

第22章 程序员的组织生产——让大家更高效和亲密

22.1 敏捷开发:及时反馈,小步快跑

22.2 双人编程:双人搭配,干活超累

22.3 封闭开发:并不是蹲大狱

22.4 总结

第23章 程序员的职业生涯——选择比努力更重要

23.1 程序员到底能干多久

23.2 程序员的中年危机

23.3 自问一:你适不适合当程序员

23.4 自问二:程序员是否最适合你

23.5 自问三:问问自己有没有双门槛

23.6 自问四:程序员最适合转什么行

23.7 总结


更多IT图书尽在iTuring

网友评论

登录后评论
0/500
评论
图灵教育
+ 关注
所属团队号: 图灵教育