xuzhengfu / pilot

进入编程世界的第一课
1 stars 0 forks source link

p1-8-oo-2 第八章 理解对象与类:概念篇 #26

Open xuzhengfu opened 4 years ago

xuzhengfu commented 4 years ago

1. 了解 面向对象的争议

面向对象是最广为人知的编程范型,有大量的书籍、专著阐述面向对象,大量编程语言和工具以面向对象作为核心特征。编程世界里有个规律,越是流行和广泛应用的东西,越是争议多,OO 也不例外,在 OO 的吐槽者里不乏重量级大牛。

这些大牛主要是觉得 OO 的体系过于复杂、有很多不必要的限制,因为他们都是顶级聪明的人,顶级聪明人对效率有着近乎偏执的追求,所以他们难以容忍一些东西,而这些东西对这个行业里大部分不那么聪明的人来说可能还真有些用。

对我们来说比较简单,争议归争议,应用归应用 —— 就好像英语的弊端不见得比其他语言少,可就是最流行,那怎么办呢?用呗 —— 虽然该抱怨的时候也得抱怨抱怨,在软件这一行待久了,就知道其实抱怨是创新的动力。

2. 认识 面向对象的两个核心特性

前面提过,编程范型会通过精心选择的概念、术语和语言特性来让自己更容易学习、使用,并对常规问题提供标准的解决方案。

面向对象是非常独特的一个编程范型,因为它的核心概念有两层:

  1. 一层是真正的核心概念,解决了前面提到的模块化思想以及软件工程实践中需要解决的大量实际问题;
  2. 而另一层是一个 “与人们熟悉的现实世界对应的隐喻”,后面这一层显著提升了面向对象方法的亲和力,降低了学习难度,这很可能是面向对象如此流行的重要原因。

我们先介绍这个 现实世界的隐喻层,再来了解 面向对象 最本质的那些特性。

3. 了解 类和对象

顾名思义,面向对象的方法把一切事物都看作 “对象(object)”,而把类似事物的 共性特征 抽象出来称为 “类(class)”。

反过来说,class 定义一类事物的特征,就像一个模板,需要时可以按照这个模板创造(或者描述)一个具体的 object 出来。

在 面向对象 的编程语言里,程序具体操作的都是 object,而 class 只是描述一类 object 共性的模板。

举例来说,我的工作台上有个台灯,这个台灯是一个对象,它拥有亮度、色温、电压等属性,还有一个操作界面 —— 开关,操作一次就打开了,再操作一次就关了。

经过仔细思考,我们发现所有的灯基本都有这些属性和操作界面,所以我们可以抽象出一个 “灯” 的类(class)来,有亮度、色温、电压这些属性,再提供一个接口叫 开关,不管什么灯,其属性和接口都叫一样的名字,操作方法都一样。使用灯的我们(或者其他程序,现在不是有很多计算机控制的灯嘛),只需要与 “开关” 这个接口打交道,而不必关心灯泡内部的设计和原理 —— 说实话,这是个很伟大的设计思想,不仅实现了模块化,而且用现实世界做参照,一下子就能理解和学会。

在程序设计过程中,我们常常需要对标 现实世界里的事物 做抽象(abstract),抽象是为了更高效地描述现实世界而进行的 “取舍”,只抓准 “必要的特征”,其他的都省略。

这种 “必要性” 和具体问题有关,对于不同的问题,同一个特征可能有不同的必要性,要把哪些特征加入到抽象出来的 class 里去,需要我们根据 具体问题 来决定。

这种被选出来的 “必要的特征”,叫做 对象的 “属性(attributes)”,进而,这些抽象的对象,实际上也能做一些抽象过后被保留下来的 “必要的行为”,这些叫做对象的 “方法(methods)”。

从面向对象的编程语言角度去看世界,要定义一类事物,就建立一个 class,class 定义两类东西:

  • 属性:用自然语言描述,通常是名词,表示这类事物拥有的共性特征;
  • 方法:用自然语言描述,通常是动词,表示我们可以对这类事物做什么,或者请求它做什么。

面向对象的编程语言 会在需要时用这个 class 做模板,创建出一个具体 object,让我们很方便的操作这个对象,就像操作一件真实的物品。

这种思维模式非常经济,而且易于理解:基于现实世界参照物,去掉不必要的东西,只留下对我们有用的抽象模型。可以这么说,如果没有这个思维方法,今天从事软件开发的人大概会少一半。

4. 了解 访问控制

面向对象的语言允许设置 类的属性 和 方法 是否对外可见,外部可以访问的叫公共的(public),不可见的叫私有的(private)。

这种设计是为了贯彻 “责任分离” 的原则,如果类里面有些数据和过程只被类自己内部使用,那么就不应该被外部看到,从而留下最大限度的修改灵活性;而所有 public 属性和方法,是会被其他程序使用的,其输入输出的规格需要尽可能稳定,否则一改就有一大堆用到的地方要跟着改,非常麻烦而且容易出错。

简言之,类的 public 部分就像店铺的门面招牌,里面可以随便折腾,但招牌轻易不能动。

Public 属性和方法 因为具有这样特殊又重要的定位,赢得了一个特定名词叫 “接口(interface)”,接口的意义重大,有的面向对象编程语言干脆把 interface 单独拿出来,作为和 class 一样的的关键字。

5. 了解 抽象层次

前面可以看到 面向对象的方法 通过把现实世界的事物抽象为 class 来实现 “分而治之”,而通过 多层次抽象 可以加强这种 “责任分离”,同时带来更清晰和优雅的 “复用”。

现在,我们碰到了一种新式的灯,除了拥有一般灯的特征,还可以多档调节亮度,研究一番之后我们发现这种新式灯拥有一般灯的所有属性和方法,再加一个 “亮度范围” 的 attribute,一个 “调节亮度” 的 method,就能描述好了。

在编程的世界里长得(几乎)一模一样的两个东西永远是不好的,因为它们有相当大部分是可以复用的,如果各起炉灶从头做,既重复劳动又不好维护(如果有问题你就要改两个地方),而 面向对象的方法 提供了现成的解决方案:继承和子类。

面向对象的编程语言允许我们为 “灯” 这个类创建一个 “子类(sub-class)”,这个子类拥有父类的一切(不需要再写一遍),然后还可以拥有自己添加的任何东西,这叫做 “继承(inheritance)”,这个术语又是对现实世界的隐喻,而且真像那么回事。

“灯” 这个类处理了电压、亮度、色温和开关,“可调亮度灯” 这个类继承了这一切,再处理了调节亮度的问题,非常完美的做到了责任分离。最妙的是,这个操作可以一直做,从 “灯” 开始,你可以派生出各种各样的灯,会变色的、分档调亮和无级调亮的等等。

与 继承 相反,还有一种反向操作,就是抽象出更一般性的类,这叫 “泛化(generalization)”。比如我们发现除了灯以外,还有别的东西也是带一个开关的,比如水龙头、电扇、电视等等,我们可以给所有这些有开关的事物抽象出一个类 “可开关设备”,里面就只有一个方法叫 “开关”,并把 “灯” 里关于开关的处理代码挪到 “可开关设备” 里,然后让 “灯” 继承 “可开关设备”,这样 “灯” 仍然保持以前的属性和方法,但是我们在创建比如 “电视” 类时,就可以继承 “可开关设备”,直接得到 “开关” 这个方法的所有逻辑和代码,“分而治之” 和 “复用” 进一步得到了提升。

面向对象的思维方法和编程语言,提供了强有力的抽象和建模工具。软件开发人员分析现实世界的问题和概念,然后建立对应的 class 来描述不同概念的属性和方法,把更一般性的共性用 “泛化” 抽象成公共的父类,根据需要 “派生” 出特化的子类,就能建立一个现实世界在计算机里的模型,而当现实世界的事物发生变化,只要找出变化部分对应的 class 进行相应修改就好了。

现实世界映射 是面向对象方法中常用的手段,但也有很多 class 并不来源于现实世界的事物,而只是为了满足我们的抽象需要,比如上面那个 “可开关设备”,就是一个抽象的共性特征而已。

6. 了解 多态

多态(polymorphism)是理解面向对象方法的一个分水岭,前面都是和现实世界有类比的比较直观好懂的概念,从这里开始,抽象程度一下子就上去了,但这是非常非常重要也避不开的概念,很多专家甚至认为 多态 是比 继承 更本质的概念,因为继承有替代方案(以后我们会讲到),而多态没有。

多态是一个计算机程序范畴的概念,其概念适用于任何面向对象编程语言。

我们还是回到灯的例子。上面其实留了个小问题,那就是 “可调亮度灯” 的 “亮度” 这个属性,其实和 “不可调亮度的灯” 有点差别,它到底是指最低亮度、最高亮度还是当前亮度呢?我们选择的方案是用来指当前亮度,而后增加一个亮度范围来描述可调的最低最高亮度。这种情况在实际软件开发实践中很常见,就是子类对父类定义过的属性或者方法会有一定的修改或者特化,在面向对象的术语中,这种修改和特化叫 “覆盖(override)”。

我们实现了一个 获取灯的当前亮度 的功能,这显然对所有灯都适用,所以应该在父类 “灯” 里添加这个 “获取亮度” 的方法,这样 “灯” 和它所有子类都有了这个功能。当然,在 “可调亮度灯” 类里,这个获取亮度的方法会有区别,可以 override 这个方法的实现,但接口遵循父类的定义没变,还是返回亮度数值,这种父类和子类 “接口一致但实现各异” 的做法叫做面向接口编程(interface-based programming),是实现多态的一个关键前提。

假定我们需要开发一个统一家里所有灯的中央控制系统,这个系统能显示所有灯的亮度,假如家里有十盏灯,我们就有十个灯的对象,有的是 “灯” 类的,有的是 “可调亮度灯” 类的,有的是别的不知道什么类的,这些灯的对象在安装的时候就创建好放在中央控制系统中,用一个列表 lights_in_house 保存着。

这个列表里的对象要么是 “灯” 类对象,要么是 “灯” 的某个子类对象,所以都有 “获取亮度” 这个方法,所以显示所有灯亮度的方法可以这么写:

for light in lights_in_house:
    display(light.brightness)

这两行代码的意思是:依次取出 lights_in_house 里的每一个元素(将其赋值给循环变量 light),然后执行:

  1. 获取 light 的 brightness 属性的数值;
  2. 将其作为输入参数调用 display 函数(简单起见我们没写出这个函数实现,但它做的事情容易理解,就是把亮度数值显示出来)。

注意:获取灯的亮度是用 light.brightness 这一段实现的,但这个 light 可能是不同类的对象,程序在运行时才知道实际上是什么类,程序运行中会根据它的类型自动运行那个类对应的代码来获取亮度,不管它是一般的还是可调亮度的灯。

这就意味着,我们在编写代码的时候可以不管一个对象是什么类的对象,只要它支持某个属性或者方法,就可以直接使用,编程语言(编译器、运行环境或者解释器)会在运行时自动根据其实际类型执行正确版本的代码。简单地说,这就是面向对象语言的 “多态(polymorphism)” 特性。

这是一种强大的 ”责任分离(separation of concern)” 工具,上面的例子中,为了显示亮度,完全不需要知道 “灯” 类有哪些子类,只要知道 “灯” 和它的子类都有 brightness 这个属性 就行了,只要这一点不变,上面那段代码一直成立,我们不管以后买了什么奇奇怪怪的灯,给 “灯” 类扩展了多少子类,都不影响上面那段代码。多么优雅而又方便!

Override 和 polymorphism 让我们可以分别定义 父类 和 子类 对相同接口的不同实现,而在运行时系统会根据调用时 对象的实际类型 自动选择正确版本来运行,从而将类的定义和使用充分解耦,给出了大量实际场景下 “责任分离” 的优雅方案。

7. 了解 面向对象编程的分支

客观的说,面向对象编程的一些术语和机制确实存在不必要的复杂、重叠甚至冲突,这和面向对象方法及技术的发展历史有关。

历史上面向对象的发展其实有两个分支,一个以 Smalltalk 语言为代表,另一个以 C++ 语言为代表,他们都有 class 和 object 的概念,但也有非常大的差异,最后 C++ 依靠 C 的兼容性赢下了标准之争,成为实际上面向对象的 “正宗”,而 Smalltalk 只能作为一个小众语言存在,虽然在很多专家心目中 Smalltallk 才是更优秀/先进的那个,这也是计算机行业屡见不鲜的事了。

不过 Smalltalk 有个大弟子叫做 Objective-C,是 Steve Jobs 离开 Apple 创办 NeXT 的时候选择的系统语言,后来被带回苹果,成为苹果生态下 OS X 和 iOS 的唯一开发语言,直到 Swift 语言出现。顺便说一句,Objective-C 也是兼容 C 语言的,在 Web 成为一大主流之前 C 语言简直是编程世界的主宰。

这两个派系有些东西是各自独有的,有些东西虽然两边都有,但是叫法和实现思路迥异,最大的一个差异就是前面提过的 Joe Armstrong 喜欢的 “messaging”,Smalltalk 的这个概念用了一个独特的隐喻,不强调对象的属性和方法,而代之以 “消息”,当要使用某个对象时唯一的操作就是向这个对象发送一条消息,这个消息说明了发送方想要什么(获取信息或请求对象执行某些操作),接受消息的对象响应这些消息返回相应数据或者执行相应操作,这个隐喻也很直观,而且从这个概念发展开去,Smalltalk 建立了一套简洁灵活的面向对象编程工具集。

这涉及到更深入的软件设计思想和实践,我们就不在这里展开了。

8. 记住叮嘱

所有这些概念看着可能有些抽象和枯燥,但必须有个地方把这些都说清楚,如果有些东西没搞明白也没关系,在下面的实例学习中可以随时对照着来回看。

Logging

2020-02-22 23:01:12 initialize