面向对象编程

面向对象编程

几乎所有现代编程语言都支持面向对象功能,但由于设计理念不同,不同编程语言所支持的面向对象有许多差异。在 Python 里,万物皆对象,最基础的浮点数也是一个对象。

类常用知识

通过类,我们可以把头脑中的抽象概念进行建模,进而实现复杂的功能。封装(Encapsulation)是面向对象编程里的一个重要概念,为了更好地体现类的封装性,许多编程语言支持将属性设置为公开或私有,只是方式略有不同。

当你使用 __{var} 的方式定义一个私有属性时,Python 解释器只是重新给了它一个包含当前类名的别名 _{class}__{var},因此你仍然可以在外部用这个别名来访问和修改它。

设计哲学:期望程序员做正确的事,而不是在语言上增加太多条条框框。

在某些特殊场景下,合理利用 __dict__ 可以帮你完成常规做法难以做到的一些事情:

  • 实例的 __dict__ 里,保存着当前实例的所有数据;
  • 类的 __dict__ 里,保存着类的文档、方法等所有数据;

内置类方法装饰器

在创建类时,你除了可以定义普通方法外,还可以通过装饰器定义许多特殊对象,这些对象在各自的适宜场景下可以发挥重要作用。

类方法

当你用 def 在类里定义一个函数时,这个函数通常称作方法,调用方法需要先创建一个类实例。虽然普通方法无法通过类来调用,但你可以用 @classmethod 装饰器定义一种特殊的方法:类方法,它属于类但是无须实例化也可调用:

1
2
3
4
5
6
7
8
9
10
11
class Duck:
def __init__(self, color):
self.color = color

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

@classmethod
def create_random(cls):
color = random.choice(['yellow', 'white', 'gray'])
return cls(color=color)

类方法最常见的使用场景,就是像上面一样定义工厂方法来生成新实例。类方法的主角是类型本身,当你发现某个行为不属于实例,而是属于整个类型时,可以考虑使用类方法。

静态方法

如果你发现某个方法不需要使用当前实例里的任何内容,那就可以使用 @staticmethod 来定义一个静态方法:

1
2
3
4
5
6
7
8
9
10
11
12
class Cat:
def __init__(self, name):
self.name = name

def say(self):
sound = self.get_sound()
print(f'{self.name}: {sound}...')

@staticmethod
def get_sound():
repeats = random.randrange(1, 10)
return ' '.join(['Meow'] * repeats)

和普通方法相比,静态方法不需要访问实例的任何状态,是一种状态无关的方法,因此静态方法其实可以改写成脱离于类的外部普通函数:

  • 如果静态方法特别通用,与类关系不大,那么把它改成普通函数可能会更好;
  • 如果静态方法与类关系密切,那么用静态方法更好;
  • 相比函数,静态方法有一些先天优势,比如能被子类继承和重写等;

属性装饰器

在一个类里,属性和方法有着不同的职责:属性代表状态,方法代表行为

  • 属性可以通过 inst.attr 的方式直接访问;
  • 方法需要通过 inst.method() 来调用;

@property 是个非常有用的装饰器,它让我们可以基于方法定义类属性,精确地控制属性的读取、赋值和删除行为,灵活地实现动态属性等功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class FilePath:
def __init__(self, path):
self.path = path

@property
def basename(self):
return self.path.rsplit(os.sep, 1)[-1]

@basename.setter
def basename(self, name):
new_path = self.path.rsplit(os.sep, 1)[:-1] + [name]
self.path = os.sep.join(new_path)

@basename.deleter
def basename(self):
raise RuntimeError('Can not delete basename!')

人们在读取属性时,总是期望能迅速拿到结果,调用方法则不一样——快点儿慢点儿都无所谓。让自己设计的接口符合他人的使用预期,也是写代码时很重要的一环。

鸭子类型及其局限性

If it walks like a duck and it quacks like a duck, then it must be a duck.
-- Wikipedia

鸭子类型(Duck Typing)不是什么真正的类型系统,而是一种特殊的编程风格。如果想操作某个对象,你不会去判断它是否属于某种类型,而会直接判断它是不是有你需要的方法(或属性)。这大大提高了代码的灵活性,但也有其局限性:

  • 缺乏标准:虽然我们不需要做严格的类型校验,但是仍然需要频繁判断对象是否支持某个行为,而这方面并没有统一的标准;
  • 过于隐式:对象的真实类型变得不再重要,取而代之的是对象所提供的接口(或协议)变得非常重要,但它们都是隐式的,零碎地分布在代码的各个角落;

抽象类

鸭子类型只关心行为,不关心类型,所以 isinstance() 函数天生和鸭子类型的理念相悖。但是有了抽象类以后,我们便可以使用 isinstance(obj, type) 来进行鸭子类型编程风格的类型校验了。只要待匹配类型 type 是抽象类,类型检查就符合鸭子类型编程风格——只校验行为,不校验类型

1
2
3
4
5
6
7
8
9
10
11
12
13
from abc import ABC, abstractmethod

class Validator(ABC):
@classmethod
def __subclasshook__(cls, C):
# 所有实现了 validate 方法的类都是我的子类
if any("validate" in B.__dict__ for B in C.__mro__):
return True
return NotImplemented

@abstractmethod
def validate(self, value):
raise NotImplementedError

__subclasshook__ 类方法是抽象类的一个特殊方法,当你使用 isinstance 检查对象是否属于某个抽象类时,如果后者定义了这个方法,那么该方法就会被触发:

  • 实例所属类型会作为参数传入该方法;
  • 如果方法返回了布尔值,该值表示实例类型是否属于抽象类的子类;
  • 如果方法返回 NotImplemented,本次调用会被忽略,继续进行正常的子类判断逻辑;

通过 __subclasshook__ 钩子和 .register() 方法,实现了一种比继承更灵活、更松散的子类化机制——结构化子类,并以此改变了 isinstance() 的行为。

利用 abc 模块的 @abstractmethod 装饰器,你可以把某个方法标记为抽象方法,假如抽象类的子类在继承时,没有重写所有抽象方法,那么它就无法被正常实例化,这个机制可以帮我们更好地控制子类的继承行为,强制要求其重写某些方法;collectioins.abc 模块里的许多抽象类(如 Set、Mapping 等)像普通基类一样实现了一些公用方法,降低了子类的实现成本。

多重继承与 MRO

许多编程语言在处理继承关系时,只允许子类继承一个父类,而 Python 里的一个类可以同时继承多个父类。在解决多重继承的方法优先级问题时,Python 使用了一种名为 MRO(Method Resolution Order)的算法,该算法会遍历类的所有基类,并将它们按优先级从高到底排好序。

super() 使用的不是当前类的父类,而是它在 MRO 链条里的上一个类,因此你在方法中调用 super() 时,其实无法确定它会定位到哪一个类。在大多数情况下,你需要的并不是多重继承,而也许只是一个更准确的抽象模型,在该模型下,最普通的继承关系就能完美解决问题。

编程建议

Mixin 是一种把额外功能混入某个类的技术,在 Python 中,我们可以用多重继承来实现 Mixin 模式:

1
2
3
4
5
6
7
8
9
10
11
12
class InfoDumperMixin:
def dump_info(self):
d = self.__dict__
print("Number of members: {}".format(len(d)))
print("Details:")
for key, value in d.items():
print(f' - {key}: {value}')

class Person(InfoDumperMixin):
def __init__(self, name, age):
self.name = name
self.age = age

不过,虽然 Mixin 是一种行之有效的编程模式,但不假思索地使用它仍然可能会带来麻烦,你需要精心设计 Mixin 类的职责,让它们和普通类有所区分,这样才能让 Mixin 模式发挥最大的潜力。

继承是一种类与类之间紧密的耦合关系,让子类继承父类,虽然看上去毫无成本地获取了父类的全部能力,但同时也意味着,从此以后父类的所有改动都可能影响子类:

  • 我要让 B 类继承 A 类,但 B 和 A 真的代表同一种东西吗?如果它俩不是同类,为什么要继承?
  • 即使 B 和 A 是同类,但它们真的需要继承来表明类型关系吗?要知道,Python 是鸭子类型的,你不需要继承也能实现多态;
  • 如果继承只是为了让 B 类复用 A 类的几个方法,那么用组合来替代继承会不会更好?

针对事物的行为建模,而不是对事物本身建模。同样是复用代码,组合产生的耦合关系比继承松散得多——多用组合,少用继承。但这并不代表我们应该完全弃用继承,继承所提供的强大复用能力,仍然是组合所无法替代的,许多设计模式(比如模版方法模式)都是依托继承来实现的。

在组织类方法时,我们应该关注使用者的诉求,把他们最想知道的内容放在前面,把他们不那么关心的内容放在后面。下面是一些关于组织方法顺序的建议:

  • 作为惯例,__init__ 实例化方法应该总是放在类的最前面,__new__ 方法同理;
  • 公有方法应该放在类的前面,因为它们是其他模块调用类的入口,是类的门面,也是所有人最关心的内容;
  • 以 _ 开头的私有方法,大部分是类自身的实现细节,应该放在靠后的位置;
  • 以 __ 开头的魔法方法比较特殊,通常会按照方法的重要程度来决定它们的位置;
  • 当你从上往下阅读类时,所有方法的抽象级别应该是不断降低的;

在写代码时,如果你在原有的面向对象代码上,撒上一点儿函数作为调味品,就会发生奇妙的化学反应。下面是最常见的单例模式实现,当 __new__ 方法被重写后,类的每次实例化返回的不再是新实例,而是同一个已经初始化的旧实例 cls._instance:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class AppConfig:
_instance = None

def __new__(cls):
if cls._instance is None:
inst = super().__new__(cls)
# TODO: 从外部配置文件读取配置
cls._instance = inst
return cls._instance

def get_database(self):
# TODO: 读取数据库配置

def reload(self):
# TODO: 重新读取配置文件,刷新配置

预绑定方法模式(Prebound Method Pattern)是一种将对象方法绑定为函数的模式,在 Python 里,实现单例压根儿不用这么麻烦,我们有一个随手可得的单例对象——模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
class AppConfig:
def __init__(self):
# TODO: 从外部配置文件读取配置

def get_database(self):
# TODO: 读取数据库配置

def reload(self):
# TODO: 重新读取配置文件,刷新配置

_config = AppConfig()
get_database_conf = _config.get_database
reload_config = _config.reload

面向对象设计原则

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

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

SRP:单一职责原则

A class or module should have a single responsibility.

Hacker News 是一个知名的国外科技类资讯站点,在程序员圈子内很受欢迎。为了让浏览 Hacker News 变得更方便,我想写个程序,它能自动获取首页最热门的条目标题和链接,把它们保存到普通文件里。利用 requests、lxml 等模块提供的强大功能,不到半小时,我就把程序写好了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class Post:
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:
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):
self.fp.write(f'# {self.file_title}\n\n')
for i, post in enumerate(self.fetch(), 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]:
resp = requests.get(self.items_url)
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():
crawler = HNTopPostsSpider(sys.stdout)
crawler.write_to_file()

if __name__ == '__main__':
main()

上面的代码是面向对象风格的,代码里定义了如下两个类:

  • Post:代表一个 Hacker News 内容条目,包含标题、链接等字段,是一个典型的数据类,主要用来衔接程序的数据抓取与文件写入行为;
  • HNTopPostsSpider:抓取 Hacker News 内容的爬虫类,包含抓取页面、解析、写入结果等行为,是完成主要工作的类;

SRP 认为:一个类应该仅有一个被修改的理由,换句话说,每个类都应该只承担一种职责。在上面的爬虫脚本里,你可以轻易找到两个需要修改 HNTopPostsSpider 类的理由:

  • Hacker News 网站的程序员突然更新了页面样式,旧 XPath 解析算法无法正常解析新页面,因此需要修改 fetch() 方法里的解析逻辑;
  • 程序的用户觉得纯文本格式不好看,想要改成 Markdown 样式,因此需要修改 write_to_file() 方法里的输出逻辑;

违反 SRP 的坏处:

  • 假如某个类违反了 SRP,我们就会经常出于某种原因去修改它,而这很可能会导致不同功能之间相互影响;
  • 单个类承担的职责越多,就意味着这个类越复杂、越难维护;
  • 违反 SRP 的类也很难复用;

解决办法有很多,其中最传统的就是把大类拆分为小类。为了让 HNTopPostsSpider 类的职责变得更纯粹,我把其中与写入文件相关的内容拆了出去,形成了一个新的类 PostsWriter:

1
2
3
4
5
6
7
8
9
10
11
12
class PostsWriter:
def __init__(self, fp: TextIO, 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() 方法。这样修改以后,HNTopPostsSpider 类和 PostsWriter 类都符合了 SRP,由于现在两个类各自只负责一件事,需要一个新角色把它们的工作串联起来:

1
2
3
4
5
def get_hn_top_posts(fp: Optional[TextIO] = None):
dest_fp = fp or sys.stdout
crawler = HNTopPostsSpider()
writer = PostsWriter(dest_fp, title='Top news on HN')
writer.write(list(crawler.fetch()))

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

OCP:开放关闭原则

Software entities (modules, classes, functions, etc.) should be open for extension, but closed for modification.

当前版本的脚本会不分来源地把热门条目都抓取回来,但其实我只对那些来自特定站点(比如 GitHub、Bloomberg)的内容感兴趣。因此,我需要修改 HNTopPostsSpider 类的代码来对结果进行过滤:

1
2
3
4
5
6
7
8
9
10
11
12
def fetch(self) -> Iterable[Post]:
...
counter = 0
for item in items:
if counter >= self.limit:
break
...
link = node_title.get('href')
parsed_link = parse.urlparse(link)
if parsed_link.netloc in ('github.com', 'bloomberg.com'):
counter += 1
yield Post(...)

OCP 认为:类应该对扩展开放,对修改关闭。现在的代码明显违反了 OCP,因为我必须修改类代码,才能调整域名过滤条件,第一个解决办法是使用继承。要做到有效地扩展,关键点在于找到父类中不稳定、会变动的内容,只有将这部分变化封装成方法(或属性),子类才能通过继承重写这部分行为。

在目前的需求场景下,HNTopPostsSpider 类里会变动的不稳定逻辑,其实就是“用户对条目是否感兴趣”部分,我们可以将这部分逻辑抽出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def fetch(self) -> Iterable[Post]:
...
counter = 0
for item in items:
if counter >= self.limit:
break
...
link = node_title.get('href')
post = Post(...)
if self.interested_in_post(post):
counter += 1
yield post

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

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

1
2
3
4
class GithubNBloombergHNTopPostsSpider(HNTopPostsSpider):
def interested_in_post(self, post: Post) -> bool:
parsed_link = parse.urlparse(post.link)
return parsed_link.netloc in ('github.com', 'bloomberg.com')

除了继承外,我们还可以采用组合(Composition),更具体地说,使用基于组合思想的依赖注入(Dependency Injection)技术。与继承不同,依赖注入允许我们在创建对象时,将业务逻辑中易变的部分(常被称为算法),通过初始化参数注入对象里,最终利用多态特性达到不改代码来扩展类的效果。

在这个脚本里,“条件过滤算法”是业务逻辑里的易变部分,要实现依赖注入,我们需要先对过滤算法建模:

1
2
3
4
5
6
from abc import ABC, abstractmethod

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

随后,为了实现脚本的原始逻辑:不过滤任何条目,我们创建一个继承该抽象类的默认算法类 DefaultPostFilter;要实现依赖注入,HNTopPostsSpider 类也需要做一些调整,它必须在初始化时接收一个名为 post_filter 的结果过滤器对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class DefaultPostFilter(PostFilter):
def validate(self, post: Post) -> bool:
return True

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

def fetch(self) -> Iterable[Post]:
...
counter = 0
for item in items:
if counter >= self.limit:
break
...
post = Post(...)
if self.post_filter.validate(post):
counter += 1
yield post

假如需求发生了变化,需要修改当前的过滤逻辑,那么我只要创建一个新的 PostFilter 类即可:

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

我们必须编写一个抽象类,以此满足类型注解的需求,类型注解会让 Python 更接近静态语言。启用类型注解,你就必须时刻寻找那些能作为注解的实体类型,类型注解会强制我们把大脑里的隐式接口和协议显式地表达出来。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class HNTopPostsSpider:
...
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:
if counter >= self.limit:
break
...
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

之后,每当我要调整过滤站点时,只要修改 hosts 列表即可。同前面的继承与依赖注入相比,使用数据驱动的代码明显更简洁,因为它不需要定义任何额外的类。但数据驱动也有一个缺点:它的可定制性不如其他两种方式,比如,假如我想以链接是否以某个字符串结尾来进行过滤,现在的代码就做不到。

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

LSP:里式替换原则

If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program.

LSP 认为:所有子类(派生类)对象应该可以任意替代父类(基类)对象使用,且不会破坏程序原本的功能。假设我在开发一个简单的网站,网站支持用户注册与登录功能,并且支持批量停用用户:

1
2
3
4
5
6
7
8
9
10
class User(Model):
...

def deactivate(self):
self.is_active = False
self.save()

def deactivate_users(users: Iterable[User]):
for user in users:
user.deactivate()

随着网站的功能变得越来越丰富,我需要给系统增加一些新的用户类型:站点管理员。这是一类特殊的用户,比普通用户多一些额外的管理类属性:

1
2
3
4
5
class Admin(User):
...

def deactivate(self):
raise RuntimeError('admin can not be deactivated!')

由于子类抛出了父类所不认识的异常类型,现在的代码并不满足 LSP,因为在 deactive_users() 函数看来,子类 Admin 对象根本无法替代父类 User 对象。一个常见但错误的解决办法:

1
2
3
4
5
6
7
def deactivate_users(users: Iterable[User]):
for user in users:
if isinstance(user, Admin):
logger.info(f'skip deactivating admin user {user.username}')
continue

user.deactivate()

假如以后网站有了更多继承 User 类的新用户类型,比如 VIP 用户、员工用户等,而它们也都不支持停用操作,那在现在的代码结构下,我就得不断调整 deactive_users() 函数,来适配这些新的用户类型。“子类对象可以替换父类”的“子类”指的并不是某个具体的子类,而是未来可能出现的任意一个子类。因此,通过增加一些针对性的类型判断,试图让程序符合 LSP 的做法完全行不通。

要让子类符合 LSP,我们必须让用户类 User 的“不支持停用”特性变得更显式,最好将其设计到父类协议里去,而不是让子类随心所欲地抛出异常。为此,我们至少可以做两件事:

  • 创建自定义异常类:

    1
    2
    class DeactivationNotSupported(Exception):
    """当用户不支持停用时抛出"""
  • 在父类 User 和子类 Admin 的方法文档里,增加与抛出异常相关的说明:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    class User(Model):
    ...

    def deactivate(self):
    """停用当前用户

    :raises: 当用户不支持被停用时,抛出 DeactivationNotSupported 异常
    """
    ...

    class Admin(User):
    ...

    def deactivate(self):
    """停用当前用户

    :raises: 当用户不支持被停用时,抛出 DeactivationNotSupported 异常
    """
    raise DeactivationNotSupported('admin can not be deactivated')

这样调整后,当其他人要编写任何使用 User 的代码时,都可以针对这个异常进行恰当的处理。比如,我可以调整 deactive_users() 方法,让它在每次调用 deactive() 时都显式地捕获异常:

1
2
3
4
5
6
7
8
def deactivate_users(users: Iterable[User]):
for user in users:
try:
user.deactivate()
except DeactivationNotSupported:
logger.info(
f'user {user.username} does not allow deactivating, skip.'
)

现在,我在类上添加一个新的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class User(Model):
...

def list_related_posts(self) -> List[int]:
return [
post.id
for post in session.query(Post).filter(username=self.username)
]

class Admin(User):
...

def list_related_posts(self) -> Iterable[int]:
for post in session.query(Post).all():
yield post.id

由于子类的方法返回值类型与父类不同,并且该类型不是父类返回值类型的子类,上面的代码违反了 LSP。假如我把之前两个类的方法返回值调换一下,这样的设计就完全符合里氏替换原则。

除此之外,方法参数也会违反 LSP——子类的方法参数与父类不同,并且参数要求没有变得更宽松(可选参数),同名参数没有更抽象。以下是一个错误示例:

1
2
3
4
5
6
7
class User(Model):
def list_related_posts(self, type: int) -> List[int]:
...

class Admin(User):
def list_related_posts(self, include_hidden: bool) -> List[int]:
...

当子类方法参数与父类不一致时,有些特殊情况其实仍然可以满足 LSP:

  • 子类方法可以接收比父类更多的参数,只要保证这些新增参数是可选的:

    1
    2
    3
    4
    5
    6
    7
    class User(Model):
    def list_related_posts(self) -> List[int]:
    ...

    class Admin(User):
    def list_related_posts(self, include_hidden: bool = False) -> List[int]:
    ...
  • 子类与父类参数一致,但子类的参数类型比父类的更抽象:

    1
    2
    3
    4
    5
    6
    7
    class User(Model):
    def list_related_posts(self, titles=List[str]) -> List[int]:
    ...

    class Admin(User):
    def list_related_posts(self, titles=Iterable[str]) -> List[int]:
    ...

在面向对象领域,当我们针对某个类型编写代码时,其实并不知道这个类型未来会派生出多少千奇百怪的子类型,我们只能根据当前看到的基类,尝试编写适合于未来子类的代码。LSP 能促使我们设计出更合理的继承关系,将多态的潜能更好地激发出来

DIP:依赖倒置原则

High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions.

不论多复杂的程序,都是由一个个模块组合而成的。当你告诉别人你正在写一个很复杂的程序时,你其实并不是直接在写那个程序,而是在逐个完成它的模块,最后用这些模块组成程序。在用模块组成程序的过程中,模块间自然产生了依赖关系,DIP 认为:高层模块不应该依赖底层模块,二者都应该依赖抽象

在 Hacker News 上,每个由用户提交的条目后面都跟着它的来源域名。为了统计哪些站点在 Hacker News 上最受欢迎,我想编写一个脚本,用它来分组统计每个来源站点的条目数量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class SiteSourceGrouper:
def __init__(self, url: str):
self.url = url

def get_groups(self) -> Dict[str, int]:
resp = requests.get(self.url)
html = etree.HTML(resp.text)
elems = html.xpath('//table[@class="itemlist"]//span[@class="sitestr"]')

groups = Counter()
for elem in elems:
groups.update([elem.text])
return groups

def main():
groups = SiteSourceGrouper("https://news.ycombinator.com/").get_groups()
for key, value in groups.most_common(3):
print(f'Site: {key} | Count: {value}')

if __name__ == '__main__':
main()

从层级上来说,SiteSourceGrouper 是高层模块,requests 和 lxml 是低层模块,依赖关系是正向的。为了测试程序的正确性,我为脚本写了一些单元测试:

1
2
3
4
def test_grouper_returning_valid_type():
grouper = SiteSourceGrouperO('https://news.ycombinator.com/')
result = grouper.get_groups()
assert isinstance(result, Counter), "groups should be Counter instance"

在本地开发时,这个测试用例可以正常执行,没有任何问题。但当我提交了测试代码,想在 CI 服务器上自动执行测试时,却发现根本无法完成测试。这是因为 SiteSourceGrouper 的执行链路依赖 requests 模块和网络条件,这严格限制了单元测试的执行环境,而 CI 环境根本就不能访问外网。

mock 是测试领域的一个专有名词,代表一类特殊的测试假对象。在 Python 里,单元测试模块 unittest 为我们提供了强大的 mock 子模块,里面有许多和 mock 技术有关的工具,如下所示:

  • Mock: mock 主类型,Mock() 对象被调用后不执行任何逻辑,但是会记录被调用的情况——包括次数、参数等;
  • MagicMock: 在 Mock 类的基础上追加了对魔法方法的支持,是 patch() 函数所使用的默认类型;
  • path(): 补丁函数,使用时需要指定待替换的对象,默认使用一个 MagicMock() 替换原始对象,可当作上下文管理器或装饰器使用;

通过 mock 技术,我们最终让单元测试不再依赖网络环境,可以成功地在 CI 环境中执行:

1
2
3
4
5
6
7
8
9
10
from unittest import mock

@mock.patch('hn_site_grouper.requests.get')
def test_grouper_returning_valid_type(mocked_get):
with open('static_hn.html', 'r') as fp:
mocked_get.return_value.text = fp.read()

grouper = SiteSourceGrouperO('https://news.ycombinator.com/')
result = grouper.get_groups()
assert isinstance(result, Counter), "groups should be Counter instance"

当我们编写单元测试时,有一条非常重要的指导原则:测试程序的行为,而不是测试具体实现。它的意思是,好的单元测试应该只关心被测试对象功能是否正常,是否能做好它所宣称的事情,而不应该关心被测试对象内部的具体实现是什么样的。正因为如此,mock 应该总是被当作一种应急的技术,而不是一种低成本、让单元测试能快速开展的手段。大多数情况下,假如你的单元测试代码里有太多 mock,往往代表你的程序设计得不够合理,需要改进。

DIP 里的抽象特指编程语言的一类特殊对象,这类对象只声明一些公开的 API,并不提供任何具体实现。比如,在 Java 中,接口就是一种抽象;而在 Python 里,有一个和接口非常类似的东西——抽象类。设计抽象是 DIP 里最重要的一步,其主要任务是确定这个抽象的职责与边界,在上面的脚本里,高层模块主要依赖 requests 模块做了两件事:

  • 通过 requests.get() 获取响应 response 对象;
  • 利用 response.text 获取响应文本;

可以看出,这个依赖关系的主要目的是获取 Hacker News 的页面文本。因此,我可以创建一个名为 HNWebPage 的抽象,让它承担“提供页面文本”的职责:

1
2
3
4
5
6
from abc import ABC, abstractmethod

class HNWebPage(ABC):
@abstractmethod
def get_text(self) -> str:
raise NotImplementedError()

定义好抽象后,接下来分别让高层模块和低层模块与抽象产生依赖关系。低层模块与抽象间的依赖关系表现为,它会提供抽象的具体实现:

1
2
3
4
5
6
7
class RemoteHNWebPage(HNWebPage):
def __init__(self, url: str):
self.url = url

def get_text(self) -> str:
resp = requests.get(self.url)
return resp.text

接下来,我们需要调整高层模块 SiteSourceGrouper 类的代码:

1
2
3
4
5
6
7
class SiteSourceGrouper:
def __init__(self, page: HNWebPage):
self.page = page

def get_groups(self) -> Dict[str, int]:
html = etree.HTML(self.page.get_text())
...

为了满足单元测试的无网络需求,基于 HNWebPage 抽象类,我可以实现一个不依赖网络的新类型 LocalHNWebPage:

1
2
3
4
5
6
7
class LocalHNWebPage(HNWebPage):
def __init__(self, path: str):
self.path = path

def get_text(self) -> str:
with open(self.path, 'r') as fp:
return fp.read()

单元测试代码也可以进行相应的调整:

1
2
3
4
5
def test_grouper_from_local():
page = LocalHNWebPage(path="./static_hn.html")
grouper = SiteSourceGrouper(page)
result = grouper.get_groups()
assert isinstance(result, Counter), "groups should be Counter instance"

抽象的好处显而易见:它解耦了模块间的依赖关系,让代码变得更灵活。但抽象同时也带来了额外的编码与理解成本,所以,了解何时不抽象与何时抽象同样重要。只有对代码中那些容易变化的部分进行抽象,才能获得最大收益。

ISP:接口隔离原则

Clients should not be forced to depend upon interfaces that they do not use.

接口是编程语言里的一类特殊对象,它包含一些公开的抽象协议,可以用来构建模块间的依赖关系。在不同的编程语言里,接口有不同的表现形态,在 Python 中,接口可以是抽象类、Protocal,也可以是鸭子类型里的某个隐式概念。

ISP 认为:调用方不应该依赖任何它不使用的方法。以统计 Hacker News 页面条目为例:

  • 调用方:SiteSourceGrouper;
  • 接口:HNWebPage;
  • 依赖关系:调用接口方法 get_text() 获取页面文本;

现在,我想开发一个新功能:定期对 Hacker News 首页内容进行归档,观察热点新闻在不同时间点的变化规律。因此,除了页面文本内容外,我还需要获取页面大小、生成时间等额外信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from abc import abstractmethod, ABC

class HNWebPage(ABC):
@abstractmethod
def get_text(self) -> str:
raise NotImplementedError()

@abstractmethod
def get_size(self) -> int:
raise NotImplementedError()

@abstractmethod
def get_generated_at(self) -> datetime.datetime:
raise NotImplementedError()

对 HNWebPage 接口的盲目扩展暴露出一个问题:更丰富的接口协议,意味着更高的实现成本,也更容易给实现方带来麻烦:

  • SiteSourceGrouper 类依赖了 HNWebPage,但是并不使用后者的 get_size()、get_generated_at() 方法;
  • LocalHNWebPage 类为了实现 HNWebPage 抽象,需要退化 get_generated_at() 方法;

在设计接口时有一个简单的技巧:让调用方来驱动协议设计。在现在的程序里,根据这两个调用方的需求,我可以把 HNWebPage 分离成两个不同的抽象类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from abc import ABC, abstractmethod

class ContentOnlyHNWebPage(ABC):
@abstractmethod
def get_text(self) -> str:
raise NotImplementedError()

class HNWebPage(ABC):
@abstractmethod
def get_text(self) -> str:
raise NotImplementedError()

@abstractmethod
def get_size(self) -> int:
raise NotImplementedError()

@abstractmethod
def get_generated_at(self) -> datetime.datetime:
raise NotImplementedError()

当你认识到 ISP 带来的种种好处后,很自然地会养成写小类、小接口的习惯。在现实世界里,其实已经有很多小而精的接口设计可供参考,比如,Python 的 collections.abc 模块里面有非常多的小接口;Go 语言标准库里的 Reader 和 Writer 接口。