漫谈面向对象编程

任何计算机问题都可以通过加一层来解决,如果解决不了,那就再加一层。

如果有人问我怎么解释面向对象编程,我感觉我很难用一句言简意赅的话来回答,但如果一定要试着解释,我可能会说:“面向对象编程是一种在代码中模拟现实世界的编程理念,它是把程序里的逻辑实体当做物体来组织它们的关系并处理问题的方法论。”

显然这不是一句人话,可能有些人还会认为这种解释是故弄玄虚,因为直觉告诉我们,如果一个人不能用一句言简意赅的话来总结一个理论,那么他可能根本就没有搞懂这个理论。我不敢说现在的自己对面向对象编程的理解有多么的深,但是我觉得我还是可以阐述一下我的浅薄理解。既然一句话说不清,那我们就来一场漫谈吧。

从抽象谈起

我先用一个例子来开始这段漫谈,假如我说我要做以下这些事:

拿出一瓶矿泉水。 拧开瓶盖。 把瓶口对准嘴巴。 抬起瓶底让水从瓶口流进嘴里。 把进入嘴里的水咽下。 把瓶底放低让剩下的水流回瓶里。 拧上瓶盖。 你看我说这些可能要急死,不就是“喝水”吗,干嘛要说得这么啰嗦。是的,对于一个正常人类来说,没有人会罗列上面的一系列动作来描述喝水,只会用“喝水”这种词语来描述这一系列动作。其实这个过程就是一种 抽象 ,即把上面的一系列动作抽象为“喝水”这个概念,而人类的思维习惯是建立在抽象概念上的。

正是因为有了抽象,我们才能从琐碎的具体步骤中解放出来,来思考和解决逻辑复杂度更高的问题,而且越复杂的逻辑必然抽象的程度越高。但是在编程领域,计算机没有人类抽象的意识,我们必须要用具体的指令来指挥它做事。那么在编程的时候有没有什么方法能既满足人类的逻辑习惯,又能指挥计算机正确地做事呢?这就需要我们首先做一件事:封装

封装与继承

我认为广义的封装可以概括为 隐藏具体,暴露抽象 ,比如喝水的例子,我们把喝水的步骤聚集在一起统一起个名字叫做“喝水”,以后任何人都用“喝水”这个词来交流,隐藏了内部具体步骤,这就是一种封装。

在编程的时候,封装无处不在,甚至我们日常使用的各种高级语言本身就是在底层语言基础上层层封装而来的。在我们编写逻辑代码的时候,如果某一段代码可以概括为一件事情,那么为了抽象和代码重用,我们通常会把它定义为一个 函数 ,而这个函数的调用参数和返回规则这种与外界交互的东西,也就是我们经常说到的 接口

当我们我们写了一堆函数以后,也许很快会发现,有一些函数的参数有重复。比如一个 Python 的脚本:

import numpy as np

def get_data_mean(file_path):
    array = np.load(file_path)
    return array.mean()

def get_data_std(file_path):
    array = np.load(file_path)
    return array.std()

if __name__ == '__main__':
    file_path = './array.npy'
    mean = get_data_mean(file_path)
    std = get_data_std(file_path)

这里有 2 个函数,都是用 numpy 打开一个文件并计算它的一些统计值,假如我们是计算同一个文件的这 2 个统计量,那么我们就需要对文件打开 2 遍,虽然反复调用的时候做到了代码重用,但是没有做到数据重用,增加了系统的 IO 压力和不必要的开销。

那么我们怎么来解决这些问题呢?这个时候就可以定义自定义对象了:

import numpy as np

class MyStatistic:
    def __init__(self, file_path):
        self.array = np.load(file_path)

    def get_data_mean(self):
        return self.array.mean()
    
    def get_data_std(self):
        return self.array.std()

if __name__ == '__main__':
    file_path = './array.npy'
    ms = MyStatistic(file_path)
    mean = ms.get_data_mean()
    std = ms.get_data_std()

现在我们自定义一个 MyStatistic 对象,在初始化的时候加载 file_path 文件,然后后续的每一次计算都基于已经加载的这个文件而无需重复加载。

做完这些,不经意间我们已经进行了一次面向对象编程所定义的 封装 ,也就是 隐藏对象的属性和实现细节,仅对外公开接口 ,MyStatistic 对外暴露的接口就是那 2 个计算统计量的“函数”(比较学究的叫法是 对象方法 ),而加载的数组数据可以通过 属性 的方式在对象内部共享,对外隐藏了这些的数据。

向对象的封装将处理步骤与数据结合在一起,成为了一个实体,也就模拟了现实世界的物体(数据)和行为(方法),做到了代码重用 + 数据重用。

但是对于程序员来说仅这种程度的重用就够了吗?当然不,程序员希望对象可以更像现实世界一些,现实世界中,实体都是物以类聚的,不同的实体都有既有相似又有不同,而且同类的实体似乎又都是遵循某些模板而精心设计出来的。那么在代码中如果想要做出类似的效果,就必须引出另一种机制:继承

所谓继承跟现实世界中的继承类似,就是把一个对象的一些属性和方法复制传递给另一个或者多个对象,二者形成父子关系,子类们继承了父类的一些共同的属性和方法的同时,又各自实现一些特有的属性和方法。

继承可以进一步提高代码的重用性,也是因为有了继承,让我们可以在程序设计中实现逻辑上的层级管理,建立父类与子类的关系以构建出复杂而丰富的体系。继承的含义很好理解,例子也有很多,这里就不再过多赘述。

在封装和继承的基础上,我们似乎已经可以很好地用比特类比现实世界了,但是不好意思,这些还不够,我们还需要一个魔法:多态

从鸭子类型到多态

你认识鸭子吗?这个问题听起来有点可笑,但这却是计算机科学一个著名的命题:鸭子类型。

鸭子类型这个命题大概的意思是,我们在区分一个动物是不是鸭子的时候,可以通过它的行为来判断,如果它走路像鸭子,叫声像鸭子,那么我们认为它就是鸭子。

这段描述听起来感觉像是废话,它又和多态有什么关系呢?理解鸭子类型是理解多态的基础。如果说封装和继承是为了提高代码的重用性,那么多态就是为了提高接口的重用性。

现在我们先忘记鸭子类型,用一个更生动的例子来描述类似的场景,假如有一个 PM 说:“我们的设计非常完美,现在就缺一个写代码的了,有没有人来给我写代码?”

这个 PM 需要的是什么?是一个写代码的人对吧,对他来说,是男是女重要吗?年纪大小重要吗?长得好不好看重要吗? 除了最后一条 都不重要对吧。 也就是说,他关注的是 写代码 这个行为,而非写代码的人,不管这个人是男是女,年纪大小,只要能写代码,他都欢迎。

在编程时我们也会面临类似的场景,我们需要的是完成某件事情,而并不在意执行对象的本质是什么。回过头来看鸭子类型的命题,可能就能理解它的真正含义了,我们要的不是鸭子,而是走路像鸭子、叫声像鸭子的东西,因为我要最终需要的,可能只是鸭子的脚印和叫声。

现在我们再来看看维基百科里对多态的定义:

多态 (英语:polymorphism)指为不同数据类型的实体提供统一的接口。

在鸭子类型中,“像鸭子走路”和“像鸭子叫”是 统一接口 ,而背后做这件事的各种对象(你想象一只能像鸭子一样走路,叫声也像鸭子的老鼠或水牛)即是 不同类型

如果说封装和继承是为了在比特世界里创造更丰富的实体,那么多态就是为这些实例建立更灵活的关系。

事实上我们的现实世界也是一个实现了多态的世界,比如我们可以听到猫的叫声,猫也可以听到我们说话,就是因为虽然我们属于不同类型的实体,但都实现了统一的“发出声音”和“听到声音”的接口。

Python 里一个基于多态的著名魔法是装饰器,因为在 Python 里一切皆是对象,函数也不例外,而且 Python 中函数是一等公民,也就是说它是可以作为参数传递的,那么你就可以构建一个闭包。

例如下面这个例子,fix_zero_division 是在函数抛出 ZeroDivisionError 时返回一个numpy 的 NaN 对象:

from functools import wraps
import numpy as np

def fix_zero_division(func):
    “””Check if there is zero division and fix it.”””
    @wraps(func)
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except ZeroDivisionError:
            return np.nan
    return wrapper

它的实际调用方式是:

def divide_by_zero():
    return 1/0

def not_divide_by_zero():
    return 2/1

new_divide_by_zero = fix_zero_division(divide_by_zero)
new_not_divide_by_zero = fix_zero_division(not_divide_by_zero)
print(new_divide_by_zero())  # nan
print(new_not_divide_by_zero()) # 2.0

这个例子里,divide_by_zeronot_divide_by_zero 都是不同的对象,但它们都实现了 可调用 这个统一的接口,于是 fix_zero_division 就可以同时作用于它们并发挥作用,这就是利用多态完成了装饰器的魔法,当然我们也可以写成语法糖的形式:

@fix_zero_division
def divide_by_zero():
    return 1/0

@fix_zero_division
def not_divide_by_zero():
    return 2/1

而对于没有实现 可调用 接口的,就无法执行,例如:

>>> fix_zero_division(1)()

# TypeError: 'int' object is not callable

由于1作为整数是不可调用的,因此这个装饰器无法对它起作用。Python 中其他利用多态特性的魔法还有很多很多。

开闭原则

前面介绍了面向对象编程的基本概念,那么我们为什么需要这种面向对象的编程方式呢?在我看来,广义上来说面向对象编程是为了更方便地编写出架构良好的程序,狭义来说是为了对代码逻辑进行解耦。现在我们来了解两个概念:内聚性耦合性

软件项目,尤其是大型软件项目,都是众多包、模块互相之间配合而实现的,而在这个配合的过程中,就会产生各种各样的联系,这种联系如果过多、过于复杂,那么对整个软件是不利的。模块之间依赖关系的复杂度即为 耦合性 ,如果模块之间的依赖关系盘根错节,那么对于任何修改,都有可能造成一系列的连锁反应(通常是副作用),用一句俗话来说就是“牵一发而动全身”,这样就造成软件后期迭代开发的成本和风险大大提高。

内聚性 是一个与耦合度相反的概念,它是指模块减少对外部的依赖,尽量把任务都在模块内部解决,这样外部的变化可以尽可能少地对内部产生影响,或者说这种影响是有限的、可控的。

通常来说,内聚性和耦合性就像跷跷板的两头,一边高另一边就低。在讨论软件架构设计的话题时,一句挂载嘴边的口号就是:低耦合!高内聚!

事实上,我们的现实世界就可以说是一个低耦合、高内聚的系统, 气象科学家表示反对, 想象一下,在一个不闹鬼的屋子里,当你打开电视的时候,冰箱门不会跟着打开,灯也不会忽然开始闪烁,因为这些物体都是各司其职,它们互相之间不会存在不必要的依赖关系。

低耦合高内聚是软件架构设计的目标,那么如何实现这一目标呢?这时候 开闭原则 (The Open/Closed Principle, OCP) 就登场了。开闭原则可以说是模式设计中的核心原则,其他的设计原则几乎都是为了更好地实现它而提出的。

开闭原则的定义:

Software entities like classes, modules and functions should be open for extension but closed for modifications 一个软件实体,如类,模块,函数等应该对扩展开放,对修改封闭。

听起来不像人话是吧,我来举个现实中实现了开闭原则的例子:蓝牙。我们平时用的电脑大部分都支持蓝牙对吧,而很多外设都可以使用蓝牙与电脑连接,比如你的蓝牙鼠标、蓝牙键盘、蓝牙耳机、蓝牙体脂称、蓝牙洗脚盆、蓝牙牙刷等等,不论什么外设,只要支持蓝牙协议,电脑就都能轻松与之连接(对扩展开放),而不需要把电脑拆开(对修改关闭)。

我们再回到软件设计上,在设计软件的时候,为了让软件尽可能健壮、有活力和拥有更强的适应力,在架构上就要充分考虑它的可扩展性,保留接口的灵活度,以期达到未来可以在不修改逻辑代码,只添加扩展插件的方式下增加软件的功能。

为什么遵循开闭原则的软件可以做到高内聚、低耦合呢?因为按照开闭原则的设计理念,互相平行的扩展模块之间应该是平等且互相隔离、互相独立的,这样也就杜绝了不必要的依赖和联系,在你增加或者修改扩展模块功能的时候,也就不用担心它会对其他模块的功能造成影响。此外,由于扩展模块与主体模块有清晰的接口规则,这也让实施单个扩展模块重构成为可能。

从这个角度出发,那么以对象为中心的编程方式就是最佳的编程模式。封装和继承可以在操作层面直接提高对象的内聚性、减少耦合度,而多态可以高效地实现开闭原则。

再举个例子,假如我需要一个根据文件名后缀来使用合适方式打开文件的函数,如果不用对象式编程的方式,它大概会是这个样子:

import os

def correctly_open_file_by_suffix(file_path):
    _, suffix = os.path.splitext(file_path)
    if suffix == '.txt':
        '解析.txt的逻辑代码'
        return 
    elif suffix == '.json':
        '解析.json的逻辑代码'
        return 
    elif suffix == '.csv':
        '解析.csv的逻辑代码'
        return 
    else:
        raise ValueError('不支持的文件格式')

这个函数的一个问题是,如果我想再加一种数据格式,就需要修改这个函数,再加上一条 elif 逻辑,这样就不符合开闭原则里 对修改封闭 的描述。

但如果我修改一下:

import os

class JSONParser:
    'JSON文件解码器'
    def __init__(self, file_path):
        '文件初始化'
    def parse(self):
        '文件解析'
        return 
    
class TXTParser:
    'TXT文本解码器'
    def __init__(self, file_path):
        '文件初始化'
    def parse(self):
        '文件解析'
        return 
    
class CSVParser:
    'CSV文件解码器'
    def __init__(self, file_path):
        '文件初始化'
    def parse(self):
        '文件解析'
        return 
    
parsers = {
    '.txt': TXTParser,
    '.csv': CSVParser,
    '.json': JSONParser
}
    

def correctly_open_file_by_suffix(file_path):
    _, suffix = os.path.splitext(file_path)
    
    if suffix in parsers:
        return parsers[suffix](file_path).parse()
    else:
        raise ValueError('不支持的文件格式')

用这种方式编写的 correctly_open_file_by_suffix 函数,如果我们想要再增加一种文件格式,只需要再创建一个实现了 .parse 方法的解码器对象,并把它注册到 parsers 字典里就行了,correctly_open_file_by_suffix 函数本身不需要做任何的修改,这样也就实现了开闭原则,当然这个例子的实现也是得益于多态的特性。

优秀案例 scrapy

如果要列举一些遵从了开闭原则的软件,那么 scrapy 一定位列其中,scrapy 是一个十分流行的异步爬虫框架,由于它的架构简介清晰,不需要太多的篇幅就能讲清楚,因此我想在这里用它作为良好架构设计的案例来介绍。

我们先来看看目前最新版本 scrapy 官方文档提供的项目架构图:

可以看到 scrapy 的实体一共也就只有蜘蛛对象(Spider)、中间件对象(Middleware)、处理管道对象(Pipeline)、条目对象(Item)、引擎(Engine)和调度器(Scheduler)这六类。图中的数字表示的是项目运行时执行的逻辑顺序。

六大实体中可扩展的实体(开放部分)包括:

  • 蜘蛛对象(Spider)
  • 中间件对象(Middleware)
  • 处理管道对象(Pipeline)
  • 条目对象(Item) 不可扩展实体(封闭部分)包括:
  • 引擎(Engine)
  • 调度器(Scheduler)

为了更直观地感受到两类实体在爬虫开发中的区别,我们用 scrapy 提供的脚手架命令行工具来搭建结构,以官方文档提供的样例为例,执行脚手架命令:

$ scrapy startproject tutorial

执行完成以后它会预生成一套项目的文件结构:

tutorial/
    scrapy.cfg            # deploy configuration file
    tutorial/             # project's Python module, you'll import your code from here
        __init__.py
        items.py          # project items definition file
        middlewares.py    # project middlewares file
        pipelines.py      # project pipelines file
        settings.py       # project settings file
        spiders/          # a directory where you'll later put your spiders
            __init__.py

可以看到 scrapy 为我们初始化的项目文件中,为所有可扩展实体都预留了模块文件/包,也就是说这些模块都是对用户开放的 ,遵守了对扩展开放的原则 。

  • items.py 存放条目对象
  • middlewares.py 存放中间件对象
  • pipelines.py 存放处理管道对象
  • spiders/ 以包的形式存放蜘蛛对象

但是我们看到项目结构中并没有为引擎或者调度器预留位置,或者说引擎和调度器对象是对用户隐藏的 ,这样就践行了对修改封闭的原则 。

这样设计的好处是,用户只用根据需要选择性地编写与业务逻辑相关的实体对象即可,而与业务逻辑无关的异步调度工作则交给框架来完成,而且用户所有需要定义的实体都是以扩展模块的形式存在的,也是隔离的、可拔插的,因此不用担心新添加的实体会对原有实体产生副作用。

更新于 2023-04-24