第 10 章 面向对象设计原则(上)

面向对象作为一种流行的编程模式,功能强大,但同时也很难被掌握。一位刚接触面向对象的初学者,从能写一些简单的类,到能独自完成优秀的面向对象设计,整个过程往往要花费数月,乃至数年的时间。

为了让面向对象编程变得更容易,许多前辈们将自己的宝贵经验,整理成了大量图书和资料。而这些书中最为有名的一本,当属 1994 年出版的《设计模式:可复用面向对象软件的基础》。

在《设计模式》中,4 位作者从各自的经验出发,提炼总结了共 23 种经典设计模式。这些设计模式涵盖面向对象编程的各个环节——比如对象创建、行为包装等,具备极大的参考价值和实用性。

但奇怪的是,虽然《设计模式》中的 23 种设计模式非常经典,我们却很少听到 Python 开发者们讨论这些模式,也很少在项目代码里见到它们的身影。为什么会这样呢?这和 Python 语言的动态特性有关。

《设计模式》中的大部分设计模式,都是作者用静态编程语言,在一个有着诸多限制的面向对象环境里创造出来的。但 Python 不同,Python 是一门动态到骨子里的编程语言,它有着一等函数对象、“鸭子类型”、可自定义的数据模型等各种灵活特性。因此,我们极少会用 Python 来一比一还原经典设计模式。取而代之的是,我们几乎总是会为每种设计模式找到更适合 Python 的表现形式。

比如,在 9.3.4 节就有一个与“单例模式”有关的例子。在示例代码里,我先是用 __new__ 方法实现了经典的单例设计模式。但随后,一个模块级全局对象用更少的代码,满足了同样的需求。

# 1:单例模式

class AppConfig:

    _instance = None

    def __new__(cls):
        if cls._instance is None:
            inst = super().__new__(cls)
            cls._instance = inst
        return cls._instance

# 2:全局对象

class AppConfig:
    ...

_config = AppConfig()

既然设计模式在 Python 里,无法像在其他语言里一样,带给我们太多实用价值。那我们还能如何学习面向对象设计?当我们编写面向对象代码时,怎样判断不同方案的优劣?怎样打磨出更好的设计?

SOLID 设计原则可以回答上面的问题。

在面向对象领域,除了 23 条经典的设计模式外,还有许多经典的设计原则。同具体的设计模式相比,原则通常更抽象、适用性更广,更适合融入到 Python 编程中。而在所有的设计原则中,SOLID 最为有名。

SOLID 原则的雏形最早出现在 Robert C. Martin(Bob 大叔)2000 年发表的一篇文章中,在这篇名为 “Design Principles and Design Patterns” 的文章里,Bob 大叔创造与整理了多条面向对象设计原则。在随后出版的《敏捷软件开发:原则、模式与实践》一书中, Bob 大叔提取了这些原则的首字母,组成了单词 SOLID 来帮助记忆。

SOLID 单词里的 5 个字母,分别代表 5 条不同设计原则。

  • S:Single responsibility principle(单一职责原则)

  • O:Open–closed principle(开放——关闭原则)

  • L:Liskov substitution principle(里式替换原则)

  • I:Interface segregation principle(接口隔离原则)

  • D:Dependency inversion principle(依赖倒置原则)

在编写面向对象代码时,遵循这些设计原则可以帮你避开常见的设计陷阱,让你更容易写出易于扩展的好代码。反之,如果你的代码违反了原则中某几条,那么你的设计可能有着相当大的改进空间。

接下来,我们将学习这 5 条设计原则的具体内容,我们会通过一些真实案例将原则实际应用到 Python 代码中。

由于 SOLID 原则内容较多,我将其拆分成了两章。在本章,我们将学习这 5 条原则中的前两条:

  • S:Single responsibility principle(单一职责原则)

  • O:Open–closed principle(开放——关闭原则)

让我们开始吧!

10.1 类型注解基础

为了让代码更具说明性,更好地描述出每条 SOLID 原则的特点,本章及下一章的所有代码将会使用 Python 的类型注解特性。

在第 1 章中,我曾简单介绍过 Python 的类型注解(type hint)功能。简而言之,类型注解是一种给函数参数、返回值,以及任何变量增加类型描述的技术,规范的注解可以大大提升代码可读性。

举个例子,下面的代码没有任何类型注解:

class Duck:
    """鸭子类

    :param color: 鸭子颜色
    """

    def __init__(self, color):
        self.color = color

    def quack(self):
        print(f"Hi, I'm a {self.color} duck!")


def create_random_ducks(number):
    """创建一批随机颜色鸭子

    :param number: 需要创建的鸭子数量
    """
    ducks = []
    for _ in number:
        color = random.choice(['yellow', 'white', 'gray'])
        ducks.append(Duck(color=color))
    return ducks

下面是给代码添加了类型注解后的样子:

from typing import List


class Duck:
    def __init__(self, color: str): (1)
        self.color = color

    def quack(self) -> None: (2)
        print(f"Hi, I'm a {self.color} duck!")


def create_random_ducks(number: int) -> List[Duck]: (3)
    ducks: List[Duck] = [] (4)
    for _ in number:
        color = random.choice(['yellow', 'white', 'gray']) (5)
        ducks.append(Duck(color=color))
    return ducks
1 给函数参数加上类型注解
2 通过 给返回值加上类型注解
3 你可以用 typing 模块的特殊对象 List 来标注列表成员的具体类型,注意,这里用的是 [] 符号,而不是 ()
4 声明变量时,也可以为其加上类型注解
5 类型注解是可选的,非常自由,比如这里的 color 变量就没加类型注解

typing 是类型注解用到的主要模块,除了 List 以外,该模块内还有许多与类型有关的特殊对象,举例如下。

  • Dict:字典类型,例如 Dict[str, int] 代表键为字符串,值对整型的字典。

  • Callable:可调用对象,例如 Callable[[str, str], List[str]] 表示接受两个字符串作为参数,返回字符串列表的可调用对象。

  • TextIO:使用文本协议的类文件类型,对应还有二进制类型:BinaryIO

  • Any:代表任何类型。

默认情况下,你可以把 Python 里的类型注解,当成一种增加代码可读性的特殊注释,因为它就像注释一样,只提升代码的说明性,不对程序的执行过程产生任何实际影响。

但是,如果引入静态类型检查工具,类型注解就不再仅仅是注解了。它在增加可读性之余,还能对程序正确性产生积极的影响。在的 13.1.5 节,我会介绍如何用 mypy 来做到这一点。

对类型注解的简介就先到这里,如果你想了解更多内容,可以查看 Python 官方文档的“类型注解”部分,里面的内容相当详细。

10.2 SRP:单一职责原则

本章将通过一个具体案例,来说明 SOLID 原则的前两条:SRP(单一职责原则)和 OCP(开放——关闭原则)。

10.2.1 案例:一个简单的 Hacker News 爬虫

Hacker News(后简称 HN)是一个知名的国外科技类资讯站点,在程序员圈子内很受欢迎。在 HN 的首页上,你可以阅读当前流行的文章,参与文章讨论。同时,你也可以向首页提交新的文章链接,系统会根据评分算法对文章进行排序,最受关注的热门文章会被排在最前面,HN首页截图如图 10-1 所示。

ch10 hacker news front
图 10-1 Hacker News 首页截图

我平时挺爱逛 HN 的,常会去上面找一些热门文章看。但每次逛 HN,我都需要打开浏览器,在收藏夹找到网站书签,步骤还是挺繁琐的——程序员嘛,都“懒”!

为了让浏览 HN 变得更方便,我想要写个程序,自动获取 HN 首页里最热门的条目标题和链接,把它们保存到普通文件里。这样我就能直接在命令行里浏览热门文章,岂不美哉?

作为 Python 程序员,写个小脚本自然不在话下。利用 requestslxml 等模块提供的强大功能,不到半小时,我就把程序写好了。

代码样例 10-1 Hacker News 新闻抓取脚本 news_digester.py
import io
import sys
from typing import Iterable, TextIO

import requests
from lxml import etree


class Post:
    """HackerNew 上的条目

    :param title: 标题
    :param link: 链接
    :param points: 当前得分
    :param comments_cnt: 评论数
    """

    def __init__(self, title: str, link: str, points: str, comments_cnt: str):
        self.title = title
        self.link = link
        self.points = int(points)
        self.comments_cnt = int(comments_cnt)


class HNTopPostsSpider:
    """抓取 Hacker News Top 内容条目

    :param fp: 存储抓取结果的目标文件对象
    :param limit: 限制条目数,默认为 5
    """

    items_url = 'https://news.ycombinator.com/'
    file_title = 'Top news on HN'

    def __init__(self, fp: TextIO, limit: int = 5):
        self.fp = fp
        self.limit = limit

    def write_to_file(self):
        """以纯文本格式将 Top 内容写入文件"""
        self.fp.write(f'# {self.file_title}\n\n')
        for i, post in enumerate(self.fetch(), 1): (1)
            self.fp.write(f'> TOP {i}: {post.title}\n')
            self.fp.write(f'> 分数:{post.points} 评论数:{post.comments_cnt}\n')
            self.fp.write(f'> 地址:{post.link}\n')
            self.fp.write('------\n')

    def fetch(self) -> Iterable[Post]:
        """从 HN 抓取 Top 内容

        :return: 可迭代的 Post 对象
        """
        resp = requests.get(self.items_url)

        # 使用 XPath 可以方便的从页面解析出你需要的内容,以下均为页面解析代码
        # 如果你对 xpath 不熟悉,可以忽略这些代码,直接跳到 yield Post() 部分
        html = etree.HTML(resp.text)
        items = html.xpath('//table[@class="itemlist"]/tr[@class="athing"]')
        for item in items[: self.limit]:
            node_title = item.xpath('./td[@class="title"]/a')[0]
            node_detail = item.getnext()
            points_text = node_detail.xpath('.//span[@class="score"]/text()')
            comments_text = node_detail.xpath('.//td/a[last()]/text()')[0]

            yield Post(
                title=node_title.text,
                link=node_title.get('href'),
                # 条目可能会没有评分
                points=points_text[0].split()[0] if points_text else '0',
                comments_cnt=comments_text.split()[0],
            )


def main():
    # with open('/tmp/hn_top5.txt') as fp:
    #     crawler = HNTopPostsSpider(fp)
    #     crawler.write_to_file()

    # 因为 HNTopPostsSpider 接收任何 file-like 的对象,所以我们可以把 sys.stdout 传进去
    # 实现往控制台标准输出打印的功能
    crawler = HNTopPostsSpider(sys.stdout)
    crawler.write_to_file()


if __name__ == '__main__':
    main()
1 enumerate() 接收第二个参数,表示从这个数开始计数(默认为 0)

执行这个脚本,我就能在命令行里看到 HN 站点上的 Top5 条目:

$ python news_digester.py
# Top news on HN

> TOP 1: The auction that set off the race for AI supremacy
> 分数:72 评论数:10
> 地址:https://www.wired.com/story/secret-auction-race-ai-supremacy-google-microsoft-baidu/
------
> TOP 2: Introducing the Wikimedia Enterprise API
> 分数:47 评论数:12
> 地址:https://diff.wikimedia.org/2021/03/16/introducing-the-wikimedia-enterprise-api/
------
...

你可以明显看出来,上面的代码是符合面向对象风格的。因为在代码里,我定义了如下所示的两个类:

  1. Post:代表一个 HN 内容条目,包含标题、链接等字段,是一个典型的“数据类”,主要用来衔接程序的“数据抓取”与“文件写入”行为。

  2. HNTopPostsSpider:抓取 HN 内容的爬虫类,包含抓取页面、解析、写入结果等行为,是完成主要工作的类。

虽然这个脚本基于面向对象风格编写(换句话说,也就是定义了几个 class 而已),可以满足我的需求。但从设计角度看,它却违反了 SOLID 原则中的第一条:“Single responsibility principle”(单一职责原则,后简称 SRP),让我们来看看这是为什么吧。

单一职责原则是 SOLID 原则里的第一条,该原则认为:一个类应该仅仅只有一个被修改的理由。换句话说,每个类都应该只承担一种职责。

要理解 SRP 原则,最重要的是理解原则里所说的“修改的理由”代表什么。显而易见,软件本身是没有生命的,修改的理由不会来自软件自身。你的程序不会突然跳起来说“我觉得我执行起来有点慢,需要优化一下”这种话。

所有修改软件的理由,都来自与软件相关的人,人是导致程序被修改的“罪魁祸首”。

举个例子,在上面的爬虫脚本里,你可以轻易找到两个需要修改 HNTopPostsSpider 类的理由。

  • 理由 1:HN 网站的程序员突然更新了页面样式,旧 xpath 解析算法没法正常解析新页面,因此 fetch() 方法里的解析逻辑需要被修改。

  • 理由 2:程序的用户(也就是我)觉得纯文本格式不好看,想要改成 Markdown 样式,因此 write_to_file() 方法里的输出逻辑需要被修改。

从这两条理由看来,HNTopPostsSpider 明显违反了 SRP 原则,它同时承担了“抓取帖子列表” 和 “将帖子列表写入文件” 这两种完全不同的职责。

10.2.2 违反 SRP 的坏处

假如某个类违反了 SRP 原则,我们就会经常出于不同的原因去修改它,这很可能会导致不同功能之间互相影响。比如,某天我为了适配 Hacker News 站点的新样式,调整了页面解析逻辑,却发现输出的文件内容也全被破坏了。

另外,单个类承担的职责越多,意味着这个类就越复杂,越难维护。在面向对象领域,有一种臭名昭著的类:God Class,God Class 专指那些包含了太多职责,代码特别多,什么事情都能做的类。God Class 是所有程序员的噩梦,每个理智尚存的程序员在碰到 God Class 后,第一个想法总是逃跑,逃得越远越好。

最后,违反 SRP 原则的类也很难被复用。假如我现在要写另一个和 HN 有关的脚本,需要复用 HNTopPostsSpider 类的抓取和解析逻辑。我会发现这事根本做不到,因为我必须得提供一个莫名其妙的文件对象给 HNTopPostsSpider 类才行。

违反 SRP 原则的坏处说了一箩筐,那么,究竟怎么修改脚本才能让它符合 SRP 原则呢?办法有很多,其中最传统的就是:把大类拆分为小类。

10.2.3 大类拆小类

为了让 HNTopPostsSpider 类的职责变得更纯粹,我把其中与“写入文件”相关的内容拆了出去,成为了一个新的类:PostsWriter

class PostsWriter:
    """负责将帖子列表写入到文件"""

    def __init__(self, fp: io.TextIOBase, title: str):
        self.fp = fp
        self.title = title

    def write(self, posts: List[Post]):
        self.fp.write(f'# {self.title}\n\n')
        for i, post in enumerate(posts, 1):
            self.fp.write(f'> TOP {i}: {post.title}\n')
            self.fp.write(f'> 分数:{post.points} 评论数:{post.comments_cnt}\n')
            self.fp.write(f'> 地址:{post.link}\n')
            self.fp.write('------\n')

然后,对于 HNTopPostsSpider 类,我直接把 write_to_file() 方法删掉,让它只保留 fetch() 方法。

class HNTopPostsSpider:
    """抓取 Hacker News Top 内容条目"""

    def __init__(self, limit: int = 5):
        ...

    def fetch(self) -> Iterable[Post]:
        ...

这样修改以后,HNTopPostsSpiderPostsWriter 类都各自符合了单一职责原则。只有当解析逻辑变化时,我才会去修改 HNTopPostsSpider 类,同样,修改 PostsWriter 类的理由也只有调整输出格式一种。

这两个类各自的修改可以单独进行而不会相互影响。

最后,由于现在两个类都只各自负责一件事,我需要一个新角色把它们的工作串联起来,因此,我实现了一个新的函数 get_hn_top_posts()

def get_hn_top_posts(fp: Optional[TextIO] = None):
    """获取 Hacker News 的 Top 内容,并将其写入文件中

    :param fp: 需要写入的文件,如未提供,将往标准输出打印
    """
    dest_fp = fp or sys.stdout
    crawler = HNTopPostsSpider()
    writer = PostsWriter(dest_fp, title='Top news on HN')
    writer.write(list(crawler.fetch()))

新函数通过组合 HNTopPostsSpiderPostsWriter 类,完成了主要工作。

函数同样可以“单一职责”

虽然单一职责是面向对象领域的设计原则,通常被用来形容类。但在 Python 中,单一职责的适用范围完全可以不限于类——通过定义函数,我们同样能让上面的代码符合单一职责原则。

在下面的代码里,“写入文件”的逻辑就被拆分成了一个函数,它专门负责将帖子列表写入文件里:

def write_posts_to_file(posts: List[Post], fp: TextIO, title: str):
    """负责将帖子列表写入文件"""
    fp.write(f'# {title}\n\n')
    for i, post in enumerate(posts, 1):
        fp.write(f'> TOP {i}: {post.title}\n')
        fp.write(f'> 分数:{post.points} 评论数:{post.comments_cnt}\n')
        fp.write(f'> 地址:{post.link}\n')
        fp.write('------\n')

这个函数只做一件事,同样符合 SRP 原则。

将某个职责拆分为新函数是一个具有 Python 特色的解决方案,它虽然没有那么“面向对象”,但却非常实用,甚至在许多场景下比编写类更简单、更高效。

10.3 OCP:开放——关闭原则

SOLID 原则的第二条是“Open–closed principle”(开放-关闭原则),简称 OCP 原则。OCP 原则认为:类应该对扩展开放,对修改封闭。换句话说:你应该可以在不修改某个类的前提下,扩展它的行为。

这是一个看上去自相矛盾,让人一头雾水的设计原则。不修改代码的话,又怎么能改变行为呢?难道用超能力吗?

其实,OCP 原则没你想的那么神秘,你身边就有一个符合 OCP 原则的例子:内置排序函数 sorted()sorted() 是一个对可迭代对象进行排序的内置函数,它的使用方法如下:

>>> l = [5, 3, 2, 4, 1]
>>> sorted(l)
[1, 2, 3, 4, 5]

默认情况下,sorted() 的排序策略是递增的,小的在前,大的在后。

现在,假如我想改变 sorted 的排序逻辑,比如,让它使用所有元素对 3 取模后的结果来排序。我是不是得去修改 sorted() 函数的源码呢?当然不用,我只要在调用函数时,传入自定义的 key 参数就行了。

>>> l = [8, 1, 9]
>>> sorted(l, key=lambda i: i % 3) (1)
[9, 1, 8]
1 按照元素对 3 取模的结果排序,能被 3 整除的 9 排在了最前面,随后是 1 和 8

通过上面的例子,你可以发现,sorted() 函数是一个符合 OCP 原则的绝佳例子,因为它:

  • 对扩展开放:你可以通过传入自定义 key 函数来扩展它的行为

  • 对修改关闭:你无须修改 sort 函数本身[1]

接下来,让我们回到我的 Hacker News 爬虫脚本,看看 OCP 原则对它会产生什么影响。

10.3.1 接受 OCP 原则的考验

距离上次用“单一职责”改造完 Hacker News 爬虫脚本后,已经过去了三天。在这三天里,我发现虽然脚本可以快速把内容抓回来,用起来很方便,但在多数情况下,脚本抓回来的那些内容,都不是我想看的。

当前版本的脚本,会不分来源的把热门条目都抓取回来,但其实,我只对那些来自特定站点——比如 GitHub——的内容感兴趣。

因此,我需要对脚本做点小小的改造。我需要修改 HNTopPostsSpider 类的代码来对结果进行过滤。

很快,代码就被修改完毕:

from urllib import parse


class HNTopPostsSpider:
    ...

    def fetch(self) -> Iterable[Post]:
        """从 HN 抓取 Top 内容"""
        # ...
        counter = 0
        for item in items:
            if counter >= self.limit:
                break
            # ...
            link = node_title.get('href')

            # 只关注来自 github.com 的内容
            parsed_link = parse.urlparse(link) (1)
            if parsed_link.netloc == 'github.com':
                counter += 1
                yield Post(...)
1 调用 urlparse() 会返回某个 URL 地址的解析结果——一个 ParsedResult 对象,该结果对象包含多个属性,其中 netloc 代表主机地址(域名)

接下来,让我简单测试一下修改后的效果:

$ python news_digester_O_before.py
# Top news on HN

> TOP 1: Mimalloc – A compact general-purpose allocator
> 分数:291 评论数:40
> 地址:https://github.com/microsoft/mimalloc
------
...

看起来,新写的过滤代码起了作用,现在只有当内容条目来自 github.com 时,才会被写入到结果中。

不过,正如希腊哲学家赫拉克利特所言:这世间唯一不变的,只有变化本身。没过几天,我的兴趣就发生了变化,我突然觉得,除了 GitHub 以外,来自 Bloomberg[2] 的内容也都很有意思。因此,我得给脚本的筛选逻辑加个新域名:bloomberg.com。

这时我发现,为了增加 bloomberg.com,我必须得修改现有的 HNTopPostsSpider 类代码,调整那行 if parsed_link.netloc == 'github.com' 判断语句,才能达到我的目的。

还记得 OCP 原则说什么吗?“类应该通过扩展而不是修改的方式改变自己的行为”,按照这个定义,现在的代码明显违反了 OCP 原则,因为我必须得修改类代码,才能调整域名过滤条件。

那么,怎样才能让类符合 OCP 原则,达到不改代码就能调整行为的状态呢?第一个办法是使用继承。

10.3.2 通过继承改造代码

继承是面向对象编程里的一个重要概念,它提供了强大的代码复用能力。

继承与 OCP 原则之间有着重要的联系。继承允许我们用一种新增子类,而不是修改原有类的方式来扩展程序的行为,这恰好符合 OCP 原则。而要做到有效地扩展,关键点在于:先找到父类中不稳定、会变动的内容。只有将这部分变化封装成方法(或属性),子类才能通过继承重写这部分行为。

话题回到我的爬虫脚本。在目前的需求场景下,HNTopPostsSpider 类里会变动的不稳定逻辑,其实就是“用户对条目是否感兴趣”部分(谁让我一天一个想法呢?)。

因此,我可以将这部分逻辑抽出来,提炼成一个新的方法:

class HNTopPostsSpider:
    ...

    def fetch(self) -> Iterable[Post]:
        # ...
        for item in items:
            # ...
            post = Post(...)
            # 使用测试方法来判断是否返回该帖子
            if self.interested_in_post(post):
                counter += 1
                yield post

    def interested_in_post(self, post: Post) -> bool:
        """判断是否应该将帖子加入结果中"""
        return True

有了这样的结构后,假如我只关心来自 github.com 的帖子,那么我只要定义一个继承 HNTopPostsSpider 的子类,然后重写父类的 interested_in_post() 方法即可。

class GithubOnlyHNTopPostsSpider(HNTopPostsSpider):
    """只关心来自 GitHub 的内容"""

    def interested_in_post(self, post: Post) -> bool:
        parsed_link = parse.urlparse(post.link)
        return parsed_link.netloc == 'github.com'


def get_hn_top_posts(fp: Optional[TextIO] = None):
    # crawler = HNTopPostsSpider()
    # 使用新的子类
    crawler = GithubOnlyHNTopPostsSpider()
    ...

假如某天,我的兴趣发生了变化?没关系,不用修改旧代码,只要增加新子类就行:

class GithubNBloomBergHNTopPostsSpider(HNTopPostsSpider):
    """只关心来自 GitHub/BloomBerg 的内容"""

    def interested_in_post(self, post: Post) -> bool:
        parsed_link = parse.urlparse(post.link)
        return parsed_link.netloc in ('github.com', 'bloomberg.com')

在这个框架下,只要需求变化和“是否对条目感兴趣”有关,我都不需要修改原本的 HNTopPostsSpider 父类,我只要不断在它的基础上,创建新的子类就行。通过继承,我最终实现了 OCP 原则所说的:对扩展开放,对改变关闭,如图 10-2 所示。

ch10 ocp extends
Figure 1. 图 10-2 通过继承实现 OCP 原则

10.3.3 使用组合与依赖注入

虽然继承功能强大,但它并非通往 OCP 原则的唯一途径。除了继承外,我们还可以使用另一种思路:组合(composition),更具体一点,使用基于组合思想的依赖注入(dependency injection)技术。

与继承不同,依赖注入允许我们在创建对象时,将业务逻辑中易变的部分(也常被称为“算法”)通过初始化参数注入到对象里,最终利用多态特性达到“不改代码来扩展类”的效果。

如之前所分析的,在这个脚本里,“条目过滤算法”是业务逻辑里的易变部分。要实现依赖注入,我们需要先给过滤算法建模。

先定义了一个名为 PostFilter 的抽象类:

from abc import ABC, abstractmethod

class PostFilter(ABC):
    """抽象类:定义如何过滤帖子结果"""

    @abstractmethod
    def validate(self, post: Post) -> bool:
        """判断帖子是否应该被保留"""

随后,为了实现脚本的原始逻辑:不过滤任何条目。我们创建了一个继承该抽象类的默认算法类:DefaultPostFilter,它的过滤逻辑是保留所有结果。

要实现依赖注入,HNTopPostsSpider 类也需要做一些调整,它必须在初始化时,接收一个名为 post_filter 的结果过滤器对象:

class DefaultPostFilter(PostFilter):
    """保留所有帖子"""

    def validate(self, post: Post) -> bool:
        return True


class HNTopPostsSpider:
    """抓取 Hacker News Top 内容条目

    :param limit: 限制条目数,默认为 5
    :param post_filter: 过滤结果条目的算法,默认为保留所有
    """

    items_url = 'https://news.ycombinator.com/'

    def __init__(self, limit: int = 5, post_filter: Optional[PostFilter] = None):
        self.limit = limit
        self.post_filter = post_filter or DefaultPostFilter() (1)

    def fetch(self) -> Iterable[Post]:
        # ...
        counter = 0
        for item in items:
            # ...
            post = Post(...)
            # 使用测试方法来判断是否返回该帖子
            if self.post_filter.validate(post):
                counter += 1
                yield post
1 因为 HNTopPostsSpider 类所依赖的过滤器,是通过初始化参数被注入进来的,所以这个技术被称为“依赖注入”。

如代码所表示的,当我不提供 post_filter 参数时,HNTopPostsSpider.fetch() 会保留所有的结果,不做任何过滤。假如需求发生了变化,当前的过滤逻辑需要被修改。那么我只要创建一个新的 PostFilter 类即可。

下面就是分别过滤 GitHub 与 Bloomberg 的两个 PostFilter 类:

class GithubPostFilter(PostFilter):
    def validate(self, post: Post) -> bool:
        parsed_link = parse.urlparse(post.link)
        return parsed_link.netloc == 'github.com'


class GithubNBloomPostFilter(PostFilter):
    def validate(self, post: Post) -> bool:
        parsed_link = parse.urlparse(post.link)
        return parsed_link.netloc in ('github.com', 'bloomberg.com')

在创建 HNTopPostsSpider 对象时,我可以选择传入不同的过滤器对象,以此满足不同的过滤需求:

crawler = HNTopPostsSpider() (1)
crawler = HNTopPostsSpider(post_filter=GithubPostFilter()) (2)
crawler = HNTopPostsSpider(post_filter=GithubNBloomPostFilter()) (3)
1 不过滤任何内容
2 过滤仅 GitHub 站点内容
3 过滤 GitHub 与 Bloomberg 站点
ch10 ocp dep injection
Figure 2. 图 10-3 通过依赖注入实现 OCP 原则

通过抽象与提炼过滤器算法,并结合多态与依赖注入技术,我同样让代码符合了 OCP 原则。

抽象类不是必须的

你可以发现,我编写的过滤器算法类,其实没有共享抽象类里的任何代码,也没有任何通过继承来复用代码的需求。因此,我其实可以完全不定义 PostFilter 抽象类,直接编写后面的过滤器类。

这样做对于程序的运行效果不会有任何影响。因为 Python 是一门“鸭子类型”语言,它在调用不同算法类的 .validate() (也就是“多态”)前,不会做任何类型检查工作。

但是,如果少了 PostFilter 抽象类,当我在编写 HNTopPostsSpider 类的 __init__ 方式时,我就没法给 post_filter 增加类型注解了——post_filter: Optional[这里写什么?],因为我根本找不到一个具体的类型。

所以我必须编写一个抽象类,以此来满足类型注解的需求。

这件事情告诉我们:类型注解是一种让 Python 更接近静态语言的东西。启用类型注解,你就必须给任何东西找到一个能作为注解的实体类型。类型注解会强制我们把大脑里的隐式“接口”和“协议”,显式的表达出来。

10.3.4 使用数据驱动

在实现 OCP 原则的众多手法中,除了继承与依赖注入外,还有另一种常被用到的方式:数据驱动。数据驱动的核心思想在于:将经常变动的部分以数据的方式抽离出来,当需求变化时,只改动数据,代码逻辑可以保持不动。

听上去,数据驱动和依赖注入有点像,它们都是把变化的东西抽离到类外部。二者的不同点在于:依赖注入抽离的通常是类,而数据驱动抽离的则是纯粹的数据。

下面,让我们在脚本中尝试一下数据驱动方案。

改造成数据驱动的第一步是定义数据的格式。在这个需求中,变动的部分是“我感兴趣的站点地址”,因此我可以简单地用一个字符串列表 filter_by_hosts: [List[str]] 来指代这个地址。

下面是修改过的 HNTopPostsSpider 类代码:

class HNTopPostsSpider:
    """抓取 Hacker News Top 内容条目

    :param limit: 限制条目数,默认为 5
    :param filter_by_hosts: 过滤结果的站点列表,默认为 None,代表不过滤
    """

    def __init__(self, limit: int = 5, filter_by_hosts: Optional[List[str]] = None):
        self.limit = limit
        self.filter_by_hosts = filter_by_hosts

    def fetch(self) -> Iterable[Post]:
        counter = 0
        for item in items:
            # ...
            post = Post(...)
            # 判断链接是否符合过滤条件
            if self._check_link_from_hosts(post.link):
                counter += 1
                yield post

    def _check_link_from_hosts(self, link: str) -> True:
        """检查某链接是否属于所定义的站点"""
        if self.filter_by_hosts is None:
            return True
        parsed_link = parse.urlparse(link)
        return parsed_link.netloc in self.filter_by_hosts

修改完 HNTopPostsSpider 类后,它的调用方也要进行调整。在创建 HNTopPostsSpider 实例时,我得把想要过滤的站点列表传进去:

hosts = None (1)
hosts = ['github.com', 'bloomberg.com'] (2)
crawler = HNTopPostsSpider(filter_by_hosts=hosts)
1 不过滤任何内容
2 过滤来自 github.com 和 bloomberg.com 的内容

之后,每当我需要调整过滤站点,只要修改 hosts 列表即可,无须调整 HNTopPostsSpider 类的任意一行代码。这种数据驱动的方式,同样满足了 OCP 原则的要求。

同前面的继承与依赖注入相比,使用数据驱动的代码明显更简洁,因为它不需要定义任何额外的类。

但数据驱动也有一个缺点:它的可定制性不如其他两种方式。举个例子,假如我现在想要以“链接是否以某个字符串结尾”来做过滤,现在的数据驱动代码就做不到。

影响每种方案可定制性的根本原因,在于各方案的所处的抽象级别不一样。比如,在依赖注入方案里,我选择抽象的内容是“条目过滤行为”,而在数据驱动方案下,抽象内容则是“条目过滤行为的有效站点地址”。很明显,后者的层级更低,关注的内容更具体,所以灵活性自然不如前者。

在日常工作中,如果你想写出符合 OCP 原则的代码,除了使用这里演示的继承、依赖注入和数据驱动外,还有许多不同的处理方式。每种方式各有优劣,你需要深入分析具体的需求场景,才能判断出哪种方式最为适合。这是一个无法一蹴而就、需要大量练习才能掌握的过程。

10.4 总结

在本章,我通过一个具体的案例,向你描述了 SOLID 设计原则中的前两位成员:单一职责原则与开放——关闭原则。

这两条原则看似简单,背后其实蕴藏了许多从好代码中提炼而来的智慧,它们的适用范围也不仅仅局限于面向对象编程。一旦你深入理解这两条原则后,你会在许多设计模式与框架中惊奇地发现它们的影子。

在下一章,我将向你继续介绍 SOLID 原则的后三条,在此之前,先让我们回顾一下前两条原则的要点。

要点

  1. 单一职责原则(SRP)

    • SRP 原则认为:一个类只应该有一种被修改的原因

    • 编写更小的类通常更不容易违反 SRP 原则

    • SRP 原则同样适用于函数,你可以让函数和类协同工作

  2. 开放——关闭原则(OCP)

    • OCP 原则认为:类应该对改动关闭,对扩展开放

    • 通过分析需求,找到代码中易变的部分,是让类符合 OCP 原则的关键

    • 使用子类继承的方式可以让类符合 OCP 原则

    • 通过算法类与依赖注入,也可以让类符合 OCP 原则

    • 将数据与逻辑分离,使用数据驱动的方式也是符合 OCP 原则的好办法


1. 即使你想修改也做不到,因为它是编译在 Python 里的内置函数。
2. 一个英文财经资讯网站