这是 “Python 工匠”系列的第 12 篇文章。[查看系列全部文章]html
Python 是一门支持多种编程风格的语言,面对相同的需求,拥有不一样背景的程序员可能会写出风格迥异的 Python 代码。好比一位习惯编写 C 语言的程序员,一般会定义一大堆函数来搞定全部事情,这是“过程式编程”的思想。而一位有 Java 背景的程序员则更倾向于设计许多个相互关联的类*(class)*,这是 “面向对象编程(后简称 OOP)”。node
虽然不一样的编程风格各有特色,没法直接比较。可是 OOP 思想在现代软件开发中起到的重要做用应该是毋庸置疑的。python
不少人在学习如何写好 OOP 代码时,会选择从那 23 种经典的“设计模式”开始。不过对于 Python 程序员来讲,我认为这并不是是一个最佳选择。git
Python 语言虽然拥有类、继承、多态等核心 OOP 特性,但和那些彻底基于 OOP 思想设计的编程语言*(好比 Java)*相比,它在 OOP 支持方面作了不少简化工做。好比它 没有严格的类私有成员,没有接口(Interface)对象 等。程序员
而与此同时,Python 灵活的函数对象、鸭子类型等许多动态特性又让一些在其余语言中很难作到的事情变得很是简单。这些语言间的差别共同致使了一个结果:不少经典的设计模式到了 Python 里,就丢失了那个“味道”,实用性也大打折扣。github
拿你们最熟悉的单例模式来讲。你能够花上一大把时间,来学习如何在 Python 中利用 __new__
方法或元类*(metaclass)*来实现单例设计模式,但最后你会发现,本身 95% 的需求均可以经过直接定义一个模块级全局变量来搞定。算法
因此,与具体化的 设计模式 相比,我以为一些更为抽象的 设计原则 适用性更广、更适合运用到 Python 开发工做中。而谈到关于 OOP 的设计原则,“SOLID” 是众多原则中最有名的一个。编程
著名的设计模式书籍《设计模式:可复用面向对象软件的基础》出版于 1994 年,距今已有超过 25 年的历史。而这篇文章的主角: “SOLID 设计原则”一样也并不年轻。小程序
早在 2000 年,Robert C. Martin 就在他的文章 "Design Principles and Design Patterns" 中整理并提出了 “SOLID” 设计原则的雏型,以后又在他的经典著做《敏捷软件开发 : 原则、模式与实践》中将其发扬光大。“SOLID” 由 5 个单词组合的首字母缩写组成,分别表明 5 条不一样的面向对象领域的设计原则。设计模式
在编写 OOP 代码时,若是遵循这 5 条设计原则,就更可能写出可扩展、易于修改的代码。相反,若是不断违反其中的一条或多条原则,那么很快你的代码就会变得不可扩展、难以维护。
接下来,让我用一个真实的 Python 代码样例来分别向你诠释这 5 条设计原则。
写在最前面的注意事项:
- “原则”不是“法律”,它只起到指导做用,并不是不能够违反
- “原则”的后两条与接口(Interface)有关,而 Python 没有接口,因此对这部分的诠释是个人我的理解,与原版可能略有出入
- 文章后面的内容含有大量代码,请作好心理准备 ☕️
- 为了加强代码的说明性,本文中的代码使用了 Python3 中的 类型注解特性
Hacker News(后简称 HN) 是一个在程序员圈子里很受欢迎的站点。在它的首页,有不少由用户提交后基于推荐算法排序的科技相关内容。
我常常会去上面看一些热门文章,但我以为每次打开浏览器访问有点麻烦。因此,我准备编写一个脚本,自动抓取 HN 首页 Top5 的新闻标题与连接,并用纯文本的方式写入到文件。方便本身用其余工具阅读。
编写爬虫几乎是 Python 天生的拿手好戏。利用 requests、lxml 等模块提供的好用功能,我能够轻松实现上面的需求。下面是我第一次编写好的代码:
import io
import sys
from typing import Generator
import requests
from lxml import etree
class Post:
"""HN(https://news.ycombinator.com/) 上的条目 :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:
"""抓取 HackerNews Top 内容条目 :param fp: 存储抓取结果的目标文件对象 :param limit: 限制条目数,默认为 5 """
ITEMS_URL = 'https://news.ycombinator.com/'
FILE_TITLE = 'Top news on HN'
def __init__(self, fp: io.TextIOBase, limit: int = 5):
self.fp = fp
self.limit = limit
def fetch(self) -> Generator[Post, None, None]:
"""从 HN 抓取 Top 内容 """
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 write_to_file(self):
"""以纯文本格式将 Top 内容写入文件 """
self.fp.write(f'# {self.FILE_TITLE}\n\n')
# enumerate 接收第二个参数,表示从这个数开始计数(默认为 0)
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 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()
复制代码
你能够把上面的代码称之为符合 OOP 风格的,由于在上面的代码里,我定义了两个类:
Post
:表示单个 HN 内容条目,其中定义了标题、连接等字段,是用来衔接“抓取”和“写入文件”两件事情的数据类HNTopPostsSpider
:抓取 HN 内容的爬虫类,其中定义了抓取页面、解析、写入结果的方法,是完成主要工做的类若是你本地的 Python 环境配置正常,那么能够尝试执行一下上面这段代码,它会输出下面这样的内容:
❯ python news_digester.py
> TOP 1: Show HN: NoAgeismInTech – Job board for companies fighting ageism in tech
> 分数:104 评论数:26
> 地址:https://noageismintech.com/
------
> TOP 2: Magic Leap sues former employee who founded the China-based Nreal for IP theft
> 分数:17 评论数:2
> 地址:https://www.bloomberg.com/news/articles/2019-06-18/secretive-magic-leap-says-ex-engineer-copied-headset-for-china
------
... ...
复制代码
这个脚本基于面向对象的方式编写*(换句话说,就是定义了一些 class 😒)*,能够知足个人需求。可是从设计的角度来看,它却违反了 SOLID 原则的第一条:“Single responsibility principle(单一职责原则)”,让咱们来看看是为何。
SOLID 设计原则里的第一个字母 S 来自于 “Single responsibility principle(单一职责原则)” 的首字母。这个原则认为:**“一个类应该仅仅只有一个被修改的理由。”**换句话说,每一个类都应该只有一种职责。
而在上面的代码中,HNTopPostsSpider
这个类违反了这个原则。由于咱们能够很容易的找到两个不一样的修改它的理由:
fetch
方法内的解析逻辑。write_to_file
方法内的输出逻辑。因此,HNTopPostsSpider
类违反了“单一职责原则”,由于它有着多个被修改的理由。而这背后的根本缘由是由于它承担着 “抓取帖子列表” 和 "将帖子列表写入文件" 这两种彻底不一样的职责。
若是某个类违反了“单一职责原则”,那意味着咱们常常会由于不一样的缘由去修改它。这可能会致使不一样功能之间相互影响。好比,可能我在某天调整了页面解析逻辑,却发现输出的文件格式也所有乱掉了。
另外,单个类承担的职责越多,意味着这个类的复杂度也就越高,它的维护成本也一样会水涨船高。违反“单一职责原则”的类一样也难以被复用,假如我有其余代码想复用 HNTopPostsSpider
类的抓取和解析逻辑,会发现我必需要提供一个莫名其妙的文件对象给它才行。
那么,要如何修改代码才能让它遵循“单一职责原则”呢?办法有不少,最传统的是:把大类拆分为小类。
为了让 HNTopPostsSpider
类的职责更纯粹,咱们能够把其中与“写入文件”相关的内容拆分出去做为一个新的类:
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')
# enumerate 接收第二个参数,表示从这个数开始计数(默认为 0)
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
类里,能够经过调用 PostsWriter
的方式来完成以前的工做:
class HNTopPostsSpider:
FILE_TITLE = 'Top news on HN'
<... 已省略 ...>
def write_to_file(self, fp: io.TextIOBase):
"""以纯文本格式将 Top 内容写入文件 实例化参数文件对象 fp 被挪到了 write_to_file 方法中 """
# 将文件写入逻辑托管给 PostsWriter 类处理
writer = PostsWriter(fp, title=self.FILE_TITLE)
writer.write(list(self.fetch()))
复制代码
经过这种方式,咱们让 HNTopPostsSpider
和 PostsWriter
类都各自知足了“单一职责原则”。我只会由于解析逻辑变更才去修改 HNTopPostsSpider
类,一样,修改 PostsWriter
类的缘由也只有调整输出格式一种。这两个类各自的修改能够单独进行而不会相互影响。
“单一职责原则”虽然是针对类说的,但其实它的适用范围能够超出类自己。好比在 Python 中,经过定义函数,一样也可让上面的代码符合单一职责原则。
咱们能够把“写入文件”的逻辑拆分为一个新的函数,由它来专门承担起将帖子列表写入文件的职责:
def write_posts_to_file(posts: List[Post], fp: io.TextIOBase, 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')
复制代码
而对于 HNTopPostsSpider
类来讲,改动能够更进一步。此次咱们能够直接删除其中和文件写入相关的全部代码。让它只负责一件事情:“获取帖子列表”。
class HNTopPostsSpider:
"""抓取 HackerNews Top 内容条目 :param limit: 限制条目数,默认为 5 """
ITEMS_URL = 'https://news.ycombinator.com/'
def __init__(self, limit: int = 5):
self.limit = limit
def fetch(self) -> Generator[Post, None, None]:
# <... 已省略 ...>
复制代码
相应的,类和函数的调用方 main
函数就须要稍做调整,它须要负责把 write_posts_to_file
函数和 HNTopPostsSpider
类之间协调起来,共同完成工做:
def main():
crawler = HNTopPostsSpider()
posts = list(crawler.fetch())
file_title = 'Top news on HN'
write_posts_to_file(posts, sys.stdout, file_title)
复制代码
将“文件写入”职责拆分为新函数是一个 Python 特点的解决方案,它虽然没有那么 OO*(面向对象)*,可是一样知足“单一职责原则”,并且在不少场景下更灵活与高效。
O 来自于 “Open–closed principle(开放-关闭原则)” 的首字母,它认为:“类应该对扩展开放,对修改封闭。”这是一个从字面上很难理解的原则,它一样有着另一种说法:“你应该能够在不修改某个类的前提下,扩展它的行为。”
这原则听上去有点让人犯迷糊,如何能作到不修改代码又改变行为呢?让我来举一个例子:你知道 Python 里的内置排序函数 sorted
吗?
若是咱们想对某个列表排序,能够直接调用 sorted
函数:
>>> l = [5, 3, 2, 4, 1]
>>> sorted(l)
[1, 2, 3, 4, 5]
复制代码
如今,假如咱们想改变 sorted
函数的排序逻辑。好比,让它使用全部元素对 3 取余后的结果来排序。咱们是否是须要去修改 sorted
函数的源码?固然不用,只须要在调用 sort
函数时,传入自定义的排序函数 key
参数就好了:
>>> l = [8, 1, 9]
# 按照元素对 3 的余数排序,能被 3 整除的 9 排在了最前面,随后是 1 和 8
>>> sorted(l, key=lambda i: i % 3)
[9, 1, 8]
复制代码
经过上面的例子,咱们能够认为:sorted
函数是一个符合“开放-关闭原则”的绝佳例子,由于它:
key
函数来扩展它的行为如今,让咱们回到爬虫小程序。在使用了一段时间以后,用户*(仍是我)*以为每次抓取到的内容有点不合口味。我其实只关注那些来自特定网站,好比 github 上的内容。因此我须要修改 HNTopPostsSpider
类的代码来对结果进行过滤:
class HNTopPostsSpider:
# <... 已省略 ...>
def fetch(self) -> Generator[Post, None, None]:
# <... 已省略 ...>
counter = 0
for item in items:
if counter >= self.limit:
break
# <... 已省略 ...>
link = node_title.get('href')
# 只关注来自 github.com 的内容
if 'github' in link.lower():
counter += 1
yield Post(... ...)
复制代码
完成修改后,让咱们来简单测试一下效果:
❯ 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
------
> TOP 2: Olivia: An open source chatbot build with a neural network in Go
> 分数:53 评论数:19
> 地址:https://github.com/olivia-ai/olivia
------
<... 已省略 ...>
复制代码
看上去新加的过滤代码起到了做用,如今只有连接中含有 github
的内容才会被写入到结果中。
可是,正如某位哲学家的名言所说:*“这世间惟一不变的,只有变化自己。”某天,用户(永远是我)*忽然以为,来自 bloomberg
的内容也都颇有意思,因此我想要把 bloomberg
也加入筛选关键字逻辑里。
这时咱们就会发现:如今的代码违反了"开放-关闭原则"。由于我必需要修改现有的 HNTopPostsSpider
类代码,调整那个 if 'github' in link.lower()
判断语句才能完成个人需求。
“开放-关闭原则”告诉咱们,类应该经过扩展而不是修改的方式改变本身的行为。那么我应该如何调整代码,让它能够遵循原则呢?
继承是面向对象理论中最重要的概念之一。它容许咱们在父类中定义好数据和方法,而后经过继承的方式让子类得到这些内容,并能够选择性的对其中一些进行重写,修改它的行为。
使用继承的方式来让类遵照“开放-关闭原则”的关键点在于:找到父类中会变更的部分,将其抽象成新的方法(或属性),最终容许新的子类来重写它以改变类的行为。
对于 HNTopPostsSpider
类来讲。首先,咱们须要找到其中会变更的那部分逻辑,也就是*“判断是否对条目感兴趣”*,而后将其抽象出来,定义为新的方法:
class HNTopPostsSpider:
# <... 已省略 ...>
def fetch(self) -> Generator[Post, None, None]:
# <... 已省略 ...>
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
的帖子,那么只须要定义一个继承于 HNTopPostsSpider
子类,而后重写父类的 interested_in_post
方法便可。
class GithubOnlyHNTopPostsSpider(HNTopPostsSpider):
"""只关心来自 Github 的内容 """
def interested_in_post(self, post: Post) -> bool:
return 'github' in post.link.lower()
def main():
# crawler = HNTopPostsSpider()
# 使用新的子类
crawler = GithubOnlyHNTopPostsSpider()
<... ...>
复制代码
假如咱们的兴趣发生了变化?不要紧,增长新的子类就行:
class GithubNBloomBergHNTopPostsSpider(HNTopPostsSpider):
"""只关系来自 Github/BloomBerg 的内容 """
def interested_in_post(self, post: Post) -> bool:
if 'github' in post.link.lower() \
or 'bloomberg' in post.link.lower():
return True
return False
复制代码
全部的这一切,都不须要修改本来的 HNTopPostsSpider
类的代码,只须要不断在它的基础上建立新的子类就能完成新需求。最终实现了对扩展开放、对改变关闭。
虽然类的继承特性很强大,但它并不是惟一办法,依赖注入(Dependency injection) 是解决这个问题的另外一种思路。与继承不一样,依赖注入容许咱们在类实例化时,经过参数将业务逻辑的变化点:帖子过滤算法 注入到类实例中。最终一样实现“开放-关闭原则”。
首先,咱们定义一个名为 PostFilter
的抽象类:
from abc import ABC, abstractmethod
class PostFilter(metaclass=ABCMeta):
"""抽象类:定义如何过滤帖子结果 """
@abstractmethod
def validate(self, post: Post) -> bool:
"""判断帖子是否应该被保留"""
复制代码
Hint:定义抽象类在 Python 的 OOP 中并非必须的,你也能够不定义它,直接从下面的 DefaultPostFilter 开始。
而后定义一个继承于该抽象类的默认 DefaultPostFilter
类,过滤逻辑为保留全部结果。以后再调整一下 HNTopPostsSpider
类的构造方法,让它接收一个名为 post_filter
的结果过滤器:
class DefaultPostFilter(PostFilter):
"""保留全部帖子 """
def validate(self, post: Post) -> bool:
return True
class HNTopPostsSpider:
"""抓取 HackerNews 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()
def fetch(self) -> Generator[Post, None, None]:
# <... 已省略 ...>
for item in items:
# <... 已省略 ...>
post = Post( ... ... )
# 使用测试方法来判断是否返回该帖子
if self.post_filter.validate(post):
counter += 1
yield post
复制代码
默认状况下,HNTopPostsSpider.fetch
会保留全部的结果。假如咱们想要定义本身的过滤算法,只要新建本身的 PostFilter
类便可,下面是两个分别过滤 GitHub 与 BloomBerg 的 PostFilter
类:
class GithubPostFilter(PostFilter):
def validate(self, post: Post) -> bool:
return 'github' in post.link.lower()
class GithubNBloomPostFilter(PostFilter):
def validate(self, post: Post) -> bool:
if 'github' in post.link.lower() or 'bloomberg' in post.link.lower():
return True
return False
复制代码
在 main()
函数中,我能够用不一样的 post_filter
参数来实例化 HNTopPostsSpider
类,最终知足不一样的过滤需求:
def main():
# crawler = HNTopPostsSpider()
# crawler = HNTopPostsSpider(post_filter=GithubPostFilter())
crawler = HNTopPostsSpider(post_filter=GithubNBloomPostFilter())
posts = list(crawler.fetch())
file_title = 'Top news on HN'
write_posts_to_file(posts, sys.stdout, file_title)
复制代码
与基于继承的方式同样,利用将“过滤算法”抽象为 PostFilter
类并以实例化参数的方式注入到 HNTopPostsSpider
中,咱们一样实现了“开放-关闭原则”。
在实现“开放-关闭”原则的众多手法中,除了继承与依赖注入外,还有一种常常被用到的方式:“数据驱动”。这个方式的核心思想在于:将常常变更的东西,彻底以数据的方式抽离出来。当需求变更时,只改动数据,代码逻辑保持不动。
它的原理与“依赖注入”有一些类似,一样是把变化的东西抽离到类外部。不一样的是,后者抽离的一般是类,而前者抽离的是数据。
为了让 HNTopPostsSpider
类的行为能够被数据驱动,咱们须要使其接收 filter_by_link_keywords
参数:
class HNTopPostsSpider:
"""抓取 HackerNews Top 内容条目 :param limit: 限制条目数,默认为 5 :param filter_by_link_keywords: 过滤结果的关键词列表,默认为 None 不过滤 """
ITEMS_URL = 'https://news.ycombinator.com/'
def __init__(self, limit: int = 5, filter_by_link_keywords: Optional[List[str]] = None):
self.limit = limit
self.filter_by_link_keywords = filter_by_link_keywords
def fetch(self) -> Generator[Post, None, None]:
# <... 已省略 ...>
for item in items:
# <... 已省略 ...>
post = Post( ... ... )
if self.filter_by_link_keywords is None:
counter += 1
yield post
# 当 link 中出现任意一个关键词时,返回结果
elif any(keyword in post.link for keyword in self.filter_by_link_keywords):
counter += 1
yield post
复制代码
调整了初始化参数后,还须要在 main
函数中定义 link_keywords
变量并将其传入到 HNTopPostsSpider
类的构造方法中,以后全部针对过滤关键词的调整都只须要修改这个列表便可,无需改动 HNTopPostsSpider
类的代码,一样知足了“开放-关闭原则”。
def main():
# link_keywords = None
link_keywords = [
'github.com',
'bloomberg.com'
]
crawler = HNTopPostsSpider(filter_by_link_keywords=link_keywords)
posts = list(crawler.fetch())
file_title = 'Top news on HN'
write_posts_to_file(posts, sys.stdout, file_title)
复制代码
与前面的继承和依赖注入方式相比,“数据驱动”的代码更简洁,不须要定义额外的类。但它一样也存在缺点:它的可定制性不如前面的两种方式。假如,我想要以“连接是否以某个字符串结尾”做为新的过滤条件,那么如今的数据驱动代码就有心无力了。
如何选择合适的方式来让代码符合“开放-关闭原则”,须要根据具体的需求和场景来判断。这也是一个没法一蹴而就、须要大量练习和经验积累的过程。
在这篇文章中,我经过一个具体的 Python 代码案例,向你描述了 “SOLID” 设计原则中的前两位成员:“单一职责原则” 与 “开放-关闭原则”。
这两个原则虽然看上去很简单,可是它们背后蕴藏了许多从好代码中提炼而来的智慧。它们的适用范围也不只仅局限在 OOP 中。一旦你深刻理解它们后,你可能会惊奇的在许多设计模式和框架中发现它们的影子*(好比这篇文章就出现了至少 3 种设计模式,你知道是哪些吗?)*。
让咱们最后再总结一下吧:
看完文章的你,有没有什么想吐槽的?请留言或者在 项目 Github Issues 告诉我吧。
系列其余文章: