乍听之下,不无道理;仔细揣摩,胡说八道

0%

序言

众所周知,Python 支持向函数传递关键字参数。比如 Python 的内置函数max就接受名为key的关键字参数,以决定如何获取比较两个参数时的依据

1
max({'v': 1}, {'v': 3}, {'v': 2}, key=lambda o: o['v'])  # 返回值为{'v': 3}

自定义一个运用了关键字参数特性的函数当然也不在话下。例如模仿一下 Common Lisp 中的函数string-equal

1
2
3
4
5
6
7
8
9
10
def string_equal(string1, string2, *, start1=None, end1=None, start2=None, end2=None):
if not start1:
start1 = 0
if not end1:
end1 = len(string1) - 1
if not start2:
start2 = 0
if not end2:
end2 = len(string2) - 1
return string1[start1:end1 + 1] == string2[start2:end2 + 1]

再以关键字参数的形式向它传参

1
string_equal("Hello, world!", "ello", start1=1, end1=4)  # 返回值为True

秉承 Python 之禅中的There should be one-- and preferably only one --obvious way to do it.理念, 我甚至可以花里胡哨地、用关键字参数的语法向string1string2传参

1
string_equal(string1='Goodbye, world!', string2='ello')  # 返回值为False

但瑜不掩瑕,Python 的关键字参数也有其不足。

Python 的不足

Python 的关键字参数特性的缺点在于,同一个参数无法同时以:

  1. 具有自身的参数名,以及;
  2. 可以从**kwargs中取得,

两种形态存在于参数列表中。

举个例子,我们都知道 Python 有一个知名的第三方库叫做 requests,提供了用于开发爬虫牢底坐穿的发起 HTTP 请求的功能。它的类requests.Session的实例方法request有着让人忍不住运用 Long Parameter List 对其重构的、长达 16 个参数的参数列表。(你可以移步request方法的文档观摩)

为了便于使用,requests 的作者贴心地提供了requests.request,这样只需要一次简单的函数调用即可

1
requests.request('GET', 'http://example.com')

requests.request函数支持与requests.Session#request(请允许我借用 Ruby 对于实例方法的写法)相同的参数列表,这一切都是通过在参数列表中声明**kwargs变量,并在函数体中用相同的语法向后者传参来实现的。(你可以移步request 函数的源代码观摩)

这样的缺陷在于,requests.request函数的参数列表丢失了大量的信息。要想知道使用者能往kwargs中传入什么参数,必须:

  1. 先知道requests.request是如何往requests.Session#request中传参的——将kwargs完全展开传入是最简单的情况;
  2. 再查看requests.Session#request的参数列表中排除掉methodurl的部分剩下哪些参数。

如果想在requests.request的参数列表中使用参数自身的名字(例如paramsdatajson等),那么调用requests.Session#request则变得繁琐起来,不得不写成

1
2
with sessions.Session() as session:
return session.request(method=method, url=url, params=params, data=data, json=data, **kwargs)

的形式——果然人类的本质是复读机。

一个优雅的解决方案,可以参考隔壁的 Common Lisp。

Common Lisp 的优越性

Common Lisp 第一次面世是在1984年,比 Python 的1991年要足足早了7年。但据悉,Python 的关键字参数特性借鉴自 Modula-3,而不是万物起源的 Lisp。Common Lisp 中的关键字参数特性与 Python 有诸多不同。例如,根据 Python 官方手册中的说法,**kwargs中只有多出来的关键字参数

If the form “**identifier” is present, it is initialized to a new ordered mapping receiving any excess keyword arguments

而在 Common Lisp 中,与**kwargs对应的是&rest args,它必须放置在关键字参数之前(即左边),并且根据 CLHS 中《A specifier for a rest parameter》的说法,args中含有所有未经处理的参数——也包含了位于其后的关键字参数

1
2
3
4
(defun foobar (&rest args &key k1 k2)
(list args k1 k2))

(foobar :k1 1 :k2 3) ;; 返回值为((:K1 1 :K2 3) 1 3)

如果我还有另一个函数与foobar有着相似的参数列表,那么也可以轻松将所有参数传递给它

1
2
3
4
5
6
(defun foobaz (a &rest args &key k1 k2)
(declare (ignorable k1 k2))
(cons a
(apply #'foobar args)))

(foobaz 1 :k1 2 :k2 3) ;; 返回值为(1 (:K1 2 :K2 3) 2 3)

甚至于,即使在foobaz中支持的关键字参数比foobar要多,也能轻松地处理,因为 Common Lisp 支持向被调用的函数传入一个特殊的关键字参数:allow-other-keys即可

1
2
3
4
5
6
7
(defun foobaz (a &rest args &key k1 k2 my-key)
(declare (ignorable k1 k2))
(format t "my-key is ~S~%" my-key)
(cons a
(apply #'foobar :allow-other-keys t args)))

(foobaz 1 :k1 2 :k2 3 :my-key 4) ;; 打印my-key is 4,并返回(1 (:ALLOW-OTHER-KEYS T :K1 2 :K2 3 :MY-KEY 4) 2 3)

回到 HTTP 客户端的例子。在 Common Lisp 中我一般用drakma这个第三方库来发起 HTTP 请求,它导出了一个http-request函数,用法与requests.request差不多

1
(drakma:http-request "http://example.com" :method :get)

如果我想要基于它来封装一个便捷地发出 GET 请求的函数http-get的话,可以这样写

1
2
(defun http-get (uri &rest args)
(apply #'drakma:http-request uri :method :get args))

如果我希望在http-get的参数列表中直接暴露出一部分http-request支持的关键字参数的话,可以这样写

1
2
3
(defun http-get (uri &rest args &key content)
(declare (ignorable content))
(apply #'drakma:http-request uri :method :get args))

更进一步,如果我想在http-get中支持解析Content-Typeapplication/json的响应结果的话,还可以这样写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(ql:quickload 'jonathan)
(ql:quickload 'str)
(defun http-get (uri &rest args &key content (decode-json t))
;; http-request并不支持decode-json这个参数,但依然可以将整个args传给它。
(declare (ignorable content))
(multiple-value-bind (bytes code headers)
(apply #'drakma:http-request uri
:allow-other-keys t
:method :get
args)
(declare (ignorable code))
(let ((content-type (cdr (assoc :content-type headers)))
(text (flexi-streams:octets-to-string bytes)))
(if (and decode-json
(str:starts-with-p "application/json" content-type))
(jonathan:parse text)
text))))

不愧是Dio Common Lisp,轻易就做到了我们做不到的事情。

题外话

曾几何时,Python 程序员还会津津乐道于 Python 之禅中的There should be one-- and preferably only one --obvious way to do it.,但其实 Python 光是在定义一个函数的参数方面就有五花八门的写法了。甚至在写这篇文章的过程中,我才知道原来 Python 的参数列表中可以通过写上/来使其左侧的参数都成为 positional-only 的参数。

1
2
3
4
5
6
def foo1(a, b): pass
def foo2(a, /, b): pass


foo1(a=1, b=2)
foo2(a=1, b=2) # 会抛出异常,因为a只能按位置来传参。

序言

或许是为了显摆,也或许是虚心学习,总之我在去年年初花了大约两个月读完了《架构整洁之道》。但读过后也仅仅就是读了而已,尽管书中描绘了一个名为整洁架构的软件架构,但我并没有理解并应用到实际的开发中去。书中的诸多理念最终都蛰伏在了我的脑海深处。

今年年初的时候我换了工作。新的单位给每人都配备了办公用的电脑,从此我也不用背着2公斤重的MacBook Pro通勤了。美中不足的地方是,我和cuckoo之间的联系被斩断了,因为cuckoo是个单机程序,要在私人电脑和办公电脑上各装一份太不方便了。于是乎,我决定开两个新的项目,将cuckoo拆分为客户端和服务端两部分。

正好,这给了我在实际的项目中践行整洁架构的机会。

什么是整洁架构

不像数学领域的概念往往有一个精确的定义,书中甚至没有道出整洁架构是什么。相对的,只有一副引人入胜的架构示意图(图片摘自作者博客的这篇文章

在作者的文章中,对图中的四个层次给出了响应的解释:

  • Entities封装了企业范围内的业务规则。如果你没有经营一个企业,仅仅是开发一款应用,那么Entities就是应用的业务对象,它们封装了应用内最通用、上层的规则。
  • Use Cases包含了与应用相关的业务规则。它封装并实现了系统的所有用例。
  • 这一层负责将最方便entities和use cases的数据转换为最方便外部系统使用的格式。在这一层以内都是抽象的,对外界诸如MVC、GUI、数据库等均是无感知的。此外,这一层也负责与外部服务通信。
  • Frameworks & Drivers,顾名思义,这一层包含了与框架相关的代码,或者像C语言中的main函数这样的入口函数代码;

如何应用整洁架构

实际项目的例子

前文提到,为了满足新需求,我需要将cuckoo改造为C/S模型。但比起缓缓地将cuckoo拆解为两部分,我更乐于大刀阔斧地从头开发开发这两个程序,于是便诞生了:

  • 服务端程序为nest,负责管理任务、计划等实体对象,并提供基于HTTP协议的API;
  • 客户端程序为fledgling,负责与nest通信,并在客户机上触发通知(如macOS的右上角弹出通知)。

它们都是我依照自己对整洁架构的理解来编写的。

从架构理念到具体决策

正如REST仅仅是一种软件结构风格而不是具体的设计指南一样,整洁架构也并没有规定示意图中的分层结构该如何运用一门语言的特性来实现,这需要开发者自己去摸索。下文我给出自己在nestfledgling项目中的做法。

如何安排代码目录结构

在程序的代码结构中,最接近于架构示意图的分层架构的,当属代码仓库的目录结构了。模仿整洁架构中的四层结构,我在nest中也安排了相似的目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(venv) ➜  nest git:(master) tree -I '__pycache__' -d ./nest
./nest
├── app
│   ├── entity
│   └── use_case
├── cli
│   ├── command
│   └── config
├── infra
├── repository
│   └── DDL
└── web
├── config
├── controller
└── presenter

13 directories

nest/app/entity/目录

nest/app/entity/目录下的各个文件分别定义了系统中的各个实体类型

1
2
(venv) ➜  nest git:(master) ls nest/app/entity
__pycache__ certificate.py location.py plan.py task.py user.py

例如:

  • task.py中定义了类Task,表示一个任务;
  • plan.py中定义了类Plan,表示任务的一次触发计划,等等。

entity/目录下的各个文件中,还定义了管理各种实体对象生命期的仓库对象,例如:

  • task.py中定义了类ITaskRepository,它负责增(add方法)删(clearremove方法)查(findfind_by_id方法)改(同样是add方法)任务对象;
  • plan.py中定义了类IPlanRepository,同样能够增(add方法)删(clearremove方法)查(find_as_queuefind_by_idfind_by_task_id方法)改(同样是add方法)计划对象,等等。

实体类型都是充血模型,它们实现了系统核心的业务规则,例如:

  • Plan有方法is_repeated用于检查是否为重复性任务;
  • 有方法is_visible用于检查该计划在当前时间是否可见;
  • 有方法rebirth用于生成一个新的、下一次触发的计划,等等。

这个目录下的内容相当于整洁架构中的Entities层。

nest/app/use_case/目录

nest/app/use_case/目录下的各个文件分别定义了系统所提供的功能

1
2
3
(venv) ➜  nest git:(master) ls nest/app/use_case
__init__.py authenticate.py change_task.py create_plan.py delete_plan.py get_location.py get_task.py list_plan.py login.py registration.py
__pycache__ change_plan.py create_location.py create_task.py delete_task.py get_plan.py list_location.py list_task.py pop_plan.py

例如:

  • authenticate.py定义了系统如何认证发送当前请求的用户;
  • change_task.py定义了系统如何修改一个任务对象,等等。

每一个处于该目录下的文件,只会依赖nest/app/entity/中的代码,并且它们都是抽象的。例如,authenticate.py中的类AuthenticateUseCase的构造方法中,要求其:

  • 参数certificate_repository必须是类ICertificateRepository或其子类的实例;
  • 参数params必须是类IParams或其子类的实例。

然而ICertificateRepositoryIParams其实都是抽象基类ABC的子类,并且它们都有被装饰器abstractmethod装饰的抽象方法,因此并不能直接实例化。

该目录相当于整洁架构中的Use Cases层。

其它目录

顾名思义,cliweb目录分别是与命令行程序、基于HTTP的API相关的代码,它们实现了处理来自命令行和HTTP协议的输入,以及打印到终端和返回HTTP响应的功能。repository目录下的各个文件实现了entity目录中各个抽象的仓库类的具体子类

1
2
(venv) ➜  nest git:(master) ls nest/repository
DDL __init__.py __pycache__ certificate.py db_operation.py location.py plan.py task.py user.py

例如:

  • certificate.py中实现了entity/目录下的同名文件中的抽象类ICertificateRepository——一个基于内存的子类MemoryCertificateRepository,以及一个基于Redis的子类RedisCertificateRepository
  • location.py中实现了entity/目录下的同名文件中的抽象类ILocationRepository——基于MySQL的子类DatabaseLocationRepository,等等。

需要注意的是,除了app外的这些目录,并不能与整洁架构示意图中的外面两层严格对应起来。例如,尽管cliweb的名字一下子就让人认为它们处于Frameworks & Drivers层,但web/presenter/目录下的内容其实与框架并无联系。反倒是从命名上看处于Interface Adapters层的web/controller/目录,其中的代码依赖于Flask框架。

如何往Use Cases层传入数据

在鲍勃大叔的文章中,提到了关于如何在层之间传递数据的原则

Typically the data that crosses the boundaries is simple data structures. You can use basic structs or simple Data Transfer objects if you like. Or the data can simply be arguments in function calls. Or you can pack it into a hashmap, or construct it into an object.

nest/app/use_case/目录下的所有用例采用的都是这里提到的construct it into an object的方式。以create_task.py为例:

1
2
3
4
5
6
7
8
9
10
11
12
class IParams(ABC):
@abstractmethod
def get_brief(self) -> str:
pass

@abstractmethod
def get_keywords(self) -> List[str]:
pass

@abstractmethod
def get_user_id(self) -> int:
pass
  • 用内置模块abc中的抽象基类ABC、装饰器abstractmethod,以及类CreateTaskUseCase中的assert一起模拟类似Java中的interface的效果;
  • 用方法而不是成员变量来获取不同的输入参数:
    • get_brief获取任务的简述;
    • get_keywords获取关键字列表;
    • get_user_id获取创建该任务的用户的ID。

聪明的盲生已经发现了华点:明明只需要在类CreateTaskUseCase的构造方法中定义briefkeywords,以及user_id三个参数即可,为什么要用方法这么麻烦呢?答案是因为方法更灵活。

当你采用构造方法参数的方案时,本质上是立了一个假设:

  1. 在所有惯性系中,物理定律有相同的表达形式先完成所有参数的获取;
  2. 再执行用例中的业务逻辑。

如果是一个基于HTTP协议的API,那么这个假设是成立的——用户在客户端发送的HTTP请求到达服务端后,便无法再补充参数了。但有一种场景,用户能够在用例执行业务逻辑的过程中,持续地与应用交互,那便是命令行程序。

我在fledgling项目中给了一个用户在用例执行过程中,交互式地输入的例子。在文件fledgling/app/use_case/delete_task.py中,实现了删除指定任务的用例。它要求输入两个参数

1
2
3
4
5
6
7
8
9
class IParams(ABC):
@abstractmethod
def get_confirmation(self) -> bool:
"""获取用户是否要删除该任务的确认。"""
pass

@abstractmethod
def get_task_id(self) -> int:
pass

在文件fledgling/cli/command/delete_task.py中实现了IParams类的命令行形态。当没有从命令行参数中获取到任务的ID时,便会使用第三方库PyInquirer询问用户输入任务ID,并进一步确认

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
class Params(IParams):
def __init__(self, *, task_id: Optional[int]):
self.task_id = task_id

def get_confirmation(self) -> bool:
if self.task_id:
return True
questions = [
{
'message': '确定删除该任务',
'name': 'confirmation',
'type': 'confirm',
}
]
answers = prompt(questions)
return answers['confirmation']

def get_task_id(self) -> int:
if self.task_id:
return self.task_id
questions = [
{
'message': '输入要删除的任务的ID',
'name': 'task_id',
'type': 'input',
}
]
answers = prompt(questions)
return answers['task_id']

而这一切煮不在乎DeleteTaskUseCase并不会感知到,它独立于用户界面。

在哪一层维护业务规则

在《架构整洁之道》第20章中,鲍勃大叔给出了业务规则的定义

Strictly speaking, business rules are rules or procedures that make or save
the business money. Very strictly speaking, these rules would make or save the business money, irrespective of whether they were implemented on a computer. They would make or save money even if they were executed manually.

业务规则往往不是独立存在的,它们需要作用在一些数据上

Critical Business Rules usually require some data to work with. For example, our loan requires a loan balance, an interest rate, and a payment schedule.

而整洁架构中的实体就是包含了一部分业务规则及其操作的数据的对象。以nest中的计划实体为例,在类Plan中包含了几种业务规则——尽管这些规则不能为我赚钱或者省钱:

  • 一个计划的持续时长(如果有的话)不会是负的秒数——由duration的setter保障;
  • 周期性计划必须指定周期——由new方法维护;
  • 一个计划是重复的,当且仅当它有指定重复类型——由is_repeated方法维护;
  • 一个计划是可见的,当且仅当它:
    • 要么没有指定可见的小时,要么当且时间处于指定的小时中,并且;
    • 要么没有指定星期几可见,要么今天是指定的weekday——由is_visible方法维护。

但在整洁架构的示意图中,Use Cases层也是有维护规则的,它维护的是应用的业务规则(Application Business Rules)。与Entities层所维护的业务规则不同,Use Cases层的业务规则取决于应用提供的功能。例如,在nest项目修改一个计划的用例ChangePlanUseCase类的方法run中,会:

  1. 检查指定的计划是否存在——显然,实体没法检查自己是否存在;
  2. 检查计划是否能被修改;
  3. 检查新的地点的ID是否指向真实存在的地点对象——显然,Plan对象不会去检查Location存在与否;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 文件nest/app/use_case/change_plan.py
class ChangePlanUseCase:
# 省略__init__的定义
def run(self):
# 省略一些无关要紧的代码
params = self.params
plan_id = params.get_plan_id()
plan = self.plan_repository.find_by_id(plan_id)
if plan is None: # 上面第1点
raise PlanNotFoundError(plan_id=plan_id)
if not plan.is_changeable(): # 上面第2点
raise UnchangeableError()

found, location_id = params.get_location_id()
if found:
if location_id:
location = self.location_repository.get(id_=location_id)
if not location: # 上面第3点
raise LocationNotFoundError(location_id=location_id)
plan.location_id = location_id

聪明的你一定发现了:is_changeable为什么不作为Enterpries Business Rules,在Plan对象内自行检查呢?答案是因为这样写更简单。

试想一下,如果要让Plan自己禁止在is_changeableFalse时被修改,那么必须:

  • 先为所有可修改的属性设置setter;
  • 在每一个setter中都调用is_changeable进行检查。

之所以要这么做,是因为一个实体对象(在这里是指Plan的实例对象)是外部的时间流动是无感知的。它不知道外层(此处是Use Cases层)会调用哪一个方法,调用哪一个方法。因此,要想保持“终止状态的计划不能修改”,就必须在每一处setter都检查。

与之相反,在用例中有编排,因此它可以感知时间的流动。用例可以让Planis_changeable方法在其它任何方法之前被调用,因此免除了繁琐地在每一个setter中检查is_changeable的必要。

如何获取Use Cases层的处理结果

正如往Use Cases层中输入参数可以采用:

  1. 直接在__init__中传入对应类型的参数,或;
  2. __init__中传入一个能根据方法提取参数的对象。

两种方案一样,获取Use Cases层的计算结果同样有两种方案:

  1. 获取run方法的返回值,捕捉它的异常,或;
  2. __init__中传入一个能够接受不同结果并处理的对象。

nest这样的仅仅提供HTTP API的应用中,第1种方案便已经足够了。例如,在文件nest/web/controller/create_plan.py中,类CreatePlanUseCaserun方法的返回值为创建的计划对象,如果run调用成功,这个controller会借助于PlanPresenter,将计划对象转换为JSON对象格式的字符串,返回给调用方;如果调用失败,那么controller中也会捕捉异常(如InvalidRepeatTypeError)并以另一种格式返回给调用方。

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
def create_plan(certificate_repository, repository_factory):
# 省略了不必要的代码
params = HTTPParams()
use_case = CreatePlanUseCase(
location_repository=repository_factory.location(),
params=params,
plan_repository=repository_factory.plan(),
task_repository=repository_factory.task(),
)
try:
plan = use_case.run()
presenter = PlanPresenter(plan=plan)
return { # 成功的情形
'error': None,
'result': presenter.format(),
'status': 'success',
}, 201
except InvalidRepeatTypeError as e: # 失败的情形
return {
'error': {
'message': '不支持的重复类型:{}'.format(e.repeat_type),
},
'result': None,
'status': 'failure',
}, 422

如果想要更高的灵活性并且也有施展的空间,那么可以考虑第2种方案。例如fledgling项目中文件fledgling/app/use_case/list_plan.py中,就定义了一个接口IPresenter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class IPresenter(ABC):
@abstractmethod
def on_find_location(self):
pass

@abstractmethod
def on_find_task(self):
pass

@abstractmethod
def on_invalid_location(self, *, error: InvalidLocationError):
pass

@abstractmethod
def show_plans(self, *, count: int, plans: List[Plan]):
pass

并且在用例的执行过程中,会多次向self.presenter传递数据

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
47
48
49
50
51
class ListPlanUseCase:
# 省略__init__方法
def run(self):
location_id = None
location_name = self.params.get_location_name()
no_location = self.params.get_no_location()
if not no_location and location_name is not None:
locations = self.location_repository.find(name=location_name)
if len(locations) == 0:
self.presenter.on_invalid_location(error=InvalidLocationError(name=location_name)) # 第1次,触发无效地点的错误
return

location_id = locations[0].id

page = self.params.get_page()
per_page = self.params.get_per_page()
criteria = {
'page': page,
'per_page': per_page,
}
if location_id is not None:
criteria['location_id'] = location_id
plans, count = self.plan_repository.list(**criteria)
location_ids = [plan.location_id for plan in plans]
self.presenter.on_find_location() # 第2次交互,通知presenter开始查找地点的事件
locations = self.location_repository.find(
ids=location_ids,
page=1,
per_page=len(location_ids),
)
task_ids = [plan.task_id for plan in plans]
self.presenter.on_find_task() # 第3次交互,通知presenter开始查找任务的事件
tasks = self.task_repository.list(
page=1,
per_page=len(task_ids),
task_ids=task_ids,
)
for plan in plans:
location_id = plan.location_id
location = [location for location in locations if location.id == location_id][0]
plan.location = location
task_id = plan.task_id
task = [task for task in tasks if task.id == task_id][0]
plan.task = task

# 第4次,也是最后一次,传入用例的处理结果
self.presenter.show_plans(
count=count,
plans=plans,
)
return

在构造方法中注入presenter的缺点在于用例的run方法中需要显式地return,否则用例会继续执行下去。

Python语言特性的运用

模拟接口——abstractmethodv.s.NotImplementedError

整洁架构的每一层都只会依赖于内层,而内层又对外层一无所知,负责解耦两者的便是编程语言的接口特性。但Python并不像Java那般有interface关键字,因此我利用它的其它一系列特性来模拟出接口:

  • class代替interface,这些类继承自内置模块abc的抽象基类ABC
  • 除此之外,这些类中的方法还用同一模块中的abstractmethod装饰,使它们必须由该类的子类全部定义;
  • 在使用这个接口的位置(例如Use Cases层)用断言assert约束输入参数的类型。

nest中的大部分需要接口的位置我都是用这种手法来做的,但这种方式会给编写单元测试用例带来一些不便:

  1. 因为代码中用assert来检查参数类型,导致传入的参数只能是这个接口或其子类的实例;
  2. 因为接口类继承自ABC,所以必须定义所有被abstractmethod装饰的方法,否则在实例化时就会抛出异常。

例如,在nest项目的文件tests/use_case/task/test_list.py中,作为白盒测试的人员,我确切地知道类ListTaskUseCaserun方法只会调用它的task_repositoryfind方法,但在类MockTaskRepository中依然不得不定义基类的每一个方法——尽管它们只有一行pass语句。

如果愿意放弃一点点的严谨性,那么可以弱化一下上面的接口方案:

  1. 不使用abstractmethod,而是在本应为抽象方法的方法中只留下一句raise NotImplementedError
  2. 不使用assert检查类型,而是在参数中写上type hint。

有了第1点,那么在测试用例中就不需要为测试路径上不会调用的方法写多余的定义了。而有了第2点,也就不需要为测试路径上不会引用的属性创建对象了,大可直接传入一个None。选择哪一种都无妨,取决于开发者或团队的口味。

金坷垃整洁架构的好处都有啥

在《架构整洁之道》的第20章,作者给出了整洁架构的五种优秀特性:

  • 独立于框架。例如,我可以花不是很大的力气,将nestFlask迁移到Bottle上,尽管并不会无缘无故或频繁地这么做;
  • 容易测试。例如,在nest项目的目录tests/use_case下的测试用例不需要有任何外部系统的依赖就可以编写并运行;
  • 独立于用户界面。例如,在nest项目中同一个用例RegistrationUseCase就有HTTP API和命令行两种用户界面:
    • 在文件nest/web/controller/registration.py中是HTTP API形态;
    • 在文件nest/cli/command/register.py中则是命令行形态。
  • 独立于数据库。例如,就像更换Web框架一样,我也可以从MySQL迁移到PostgreSQL中,这对于EntitiesUse Cases层的代码而言别无二致;
  • 独立于外部系统。例如,在fledgling项目中,尽管也定义了一个接口ITaskRepository,但不同于nest中基于数据库的实现子类DatabaseTaskRepository,在fledgling中实现的是基于网络传输的类TaskRepository。但究竟是基于单机数据库,还是身处一个分布式系统(C/S模型)中,EntitiesUse Cases层对此是无感知的。

甘瓜苦蒂——整洁架构的不足

渗入内层的I/O

忆往昔峥嵘岁月稠在Python的语言标准的Comparisions章节中提到

Also unlike C, expressions like a < b < c have the interpretation that is conventional in mathematics

也就是说,在C语言中要写成a < b && b < c的表达式,在Python中可以写成a < b < c。并且,标准中还提到

Comparisons can be chained arbitrarily, e.g., x < y <= z is equivalent to x < y and y <= z, except that y is evaluated only once (but in both cases z is not evaluated at all when x < y is found to be false).

一般将这种性质成为短路。因此,像2 < 1 < (1 / 0)这样的表达式在Python中不会引发异常,而是返回False

Python的小于号能拥有短路特性,是因为它并非一个普通函数,而是有语言层面加持的操作符。而在Common Lisp(下称CL)中,小于号仅仅是一个普通函数,就像Haskell中的小于号也是一个函数一般。不同的是,CL的小于号能接受多于两个的参数

1
(< 1 2 3 -1) ; 结果为NIL

但它并没有短路特性

1
(< 1 2 3 -1 (/ 1 0)) ; 引发名为DIVISION-BY-ZERO的错误

要想模拟出具有短路特性的小于号,必须借助于宏的力量。

想生成什么样的代码

要想写出一个宏,必须先设想出它的语法,以及它会展开成什么样的代码。姑且为这个宏起名为less-than,它的语法应当为

1
2
3
(defmacro less-than (form &rest more-forms)
; TBC
)

至于它的展开结果可以有多种选择。例如,可以(less-than 2 1 (/ 1 0))展开为自身具有短路特性的and形式

1
(and (< 2 1) (< 1 (/ 1 0)))

但就像在C语言中用宏朴素地实现计算二者最大值的MAX宏一样,上面的展开方式在一些情况下会招致重复求值

1
(less-than 1 (progn (print 'hello) 2) 3)

因此,起码要展开为andlet的搭配

1
2
3
4
5
(let ((g917 1)
(g918 (progn (print 'hello) 2)))
(and (< g917 g918)
(let ((g919 3))
(< g918 g919))))

要想展开为这种结构,可以如这般实现less-than

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(defmacro less-than (form &rest more-forms)
(labels ((aux (lhs forms)
"LHS表示紧接着下一次要比较的、小于号的左操作数。"
(unless forms
(return-from aux))
(let* ((rhs (gensym))
(rv (aux rhs (rest forms))))
(if rv
`(let ((,rhs ,(first forms)))
(and (< ,lhs ,rhs)
,rv))
`(< ,lhs ,(first forms))))))
(cond ((null more-forms)
`(< ,form))
(t
(let ((lhs (gensym)))
`(let ((,lhs ,form))
,(aux lhs more-forms)))))))

用上面的输入验证一下是否会导致重复求值

1
2
3
4
5
CL-USER> (macroexpand-1 '(less-than 1 (progn (print 'hello) 2) 3))
(LET ((#:G942 1))
(LET ((#:G943 (PROGN (PRINT 'HELLO) 2)))
(AND (< #:G942 #:G943) (< #:G943 3))))
T

优化一下

显然less-than可以优化,只需要简单地运用递归的技巧即可

1
2
3
4
5
6
7
8
9
10
(defmacro less-than (form &rest more-forms)
(cond ((<= (length more-forms) 1)
`(< ,form ,@more-forms))
(t
(let ((lhs (gensym))
(rhs (gensym)))
`(let ((,lhs ,form)
(,rhs ,(first more-forms)))
(and (< ,lhs ,rhs)
(less-than ,rhs ,@(rest more-forms))))))))

展开后的代码简短得多

1
2
3
4
CL-USER> (macroexpand-1 '(less-than 1 (progn (print 'hello) 2) 3))
(LET ((#:G955 1) (#:G956 (PROGN (PRINT 'HELLO) 2)))
(AND (< #:G955 #:G956) (LESS-THAN #:G956 3)))
T

“实战Elisp”系列旨在讲述我使用Elisp定制Emacs的经验,抛砖引玉,还请广大Emacs同好不吝赐教——如果真的有广大Emacs用户的话,哈哈哈。

Emacs的org-mode用的是一门叫Org的标记语言,正如大部分的标记语言那样,它也支持无序列表和检查清单——前者以- (一个连字符、一个空格)为前缀,后者以- [ ] - [x] 为前缀(比无序列表多了一对方括号及中间的字母x

此外,org-mode还为编辑这两种列表提供了快速插入新一行的快捷键M-RET(即按住alt键并按下回车键)。如果光标位于无序列表中,那么新的一行将会自动插入- 前缀。遗憾的是,如果光标位于检查清单中,那么新一行并没有自动插入一对方括号

每次都要手动敲入[ ] 还挺繁琐的。好在这是Emacs,它是可扩展的、可定制的。只需敲几行代码,就可以让Emacs代劳输入方括号了。

Emacs的AOP特性——advice-add

借助Emacs的describe-key功能,可以知道在一个org-mode的文件中按下M-RET时,Emacs会调用到函数org-insert-item上。要想让M-RET实现自动追加方括号的效果,马上可以想到简单粗暴的办法:

  • 定义一个新的函数,并将M-RET绑定到它身上;
  • 重新定义org-insert-item函数,使其追加方括号;

但不管是上述的哪一种,都需要连带着重新实现插入连字符、空格前缀的已有功能。有一种更温和的办法可以在现有的org-insert-item的基础上扩展它的行为,那就是Emacs的advice特性。

advice是面向切面编程范式的一种,使用Emacs的advice-add函数,可以在一个普通的函数被调用前或被调用后捎带做一些事情——比如追加一对方括号。对于这两个时机,分别可以直接用advice-add:before:after来实现,但用在这里都不合适,因为:

  • 检测是否位于检查清单中,需要在调用org-insert-item前做;
  • 追加一对方括号,则需要在org-insert-item之后做。

因此,正确的做法是使用:around来修饰原始的org-insert-item函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
(cl-defun lt-around-org-insert-item (oldfunction &rest args)
"在调用了org-insert-item后识时务地追加 [ ]这样的内容。"
(let ((is-checkbox nil)
(line (buffer-substring-no-properties (line-beginning-position) (line-end-position))))
;; 检查当前行是否为checkbox
(when (string-match-p "- \\[.\\]" line)
(setf is-checkbox t))
;; 继续使用原来的org-insert-item插入文本
(apply oldfunction args)
;; 决定要不要追加“ [ ]”字符串
(when is-checkbox
(insert "[ ] "))))

(advice-add 'org-insert-item :around #'lt-around-org-insert-item)

这下子,M-RET对检查清单也一视同仁了

Common Lisp的method combination

advice-add:after:around,以及:before在Common Lisp中有着完全同名的等价物,只不过不是用一个叫advice-add的函数,而是喂给一个叫defmethod的宏。举个例子,用defmethod可以定义出一个多态的len函数,对不同类型的入参执行不同的逻辑

1
2
3
4
5
6
7
(defgeneric len (x))

(defmethod len ((x string))
(length x))

(defmethod len ((x hash-table))
(hash-table-count x))

然后为其中参数类型为字符串的特化版本定义对应的:after:around,以及:before修饰过的方法

1
2
3
4
5
6
7
8
9
10
11
(defmethod len :after ((x string))
(format t "after len~%"))

(defmethod len :around ((x string))
(format t "around中调用len前~%")
(prog1
(call-next-method)
(format t "around中调用len后~%")))

(defmethod len :before ((x string))
(format t "before len~%"))

这一系列方法的调用规则为:

  1. 先调用:around修饰的方法;
  2. 由于上述方法中调用了call-next-method,因此再调用:before修饰的方法;
  3. 调用不加修饰的方法(在CL中这称为primary方法);
  4. 再调用:after修饰的方法;
  5. 最后,又回到了:around中调用call-next-method的位置。

咋看之下,Emacs的advice-add支持的修饰符要多得多,实则不然。在CL中,:after:around,以及:before同属于一个名为standardmethod combination,而CL还内置了其它的method combination。在《Other method combinations》一节中,作者演示了prognlist的例子。

如果想要模拟Emacs的advice-add所支持的其它修饰符,那么就必须定义新的method combination了。

可编程的编程语言——define-method-combination

曾经我以为,defmethod只能接受:after:around,以及:before,认为这三个修饰符是必须在语言一级支持的特性。直到有一天我闯入了LispWorks的define-method-combination词条中,才发现它们也是三个平凡的修饰符而已。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(define-method-combination standard ()
((around (:around))
(before (:before))
(primary () :required t)
(after (:after)))
(flet ((call-methods (methods)
(mapcar #'(lambda (method)
`(call-method ,method))
methods)))
(let ((form (if (or before after (rest primary))
`(multiple-value-prog1
(progn ,@(call-methods before)
(call-method ,(first primary)
,(rest primary)))
,@(call-methods (reverse after)))
`(call-method ,(first primary)))))
(if around
`(call-method ,(first around)
(,@(rest around)
(make-method ,form)))
form))))

秉持“柿子要挑软的捏”的原则,让我来尝试模拟出advice-add:after-while:before-while的效果吧。

:after-while:before-while的效果还是很容易理解的

Call function after the old function and only if the old function returned non-nil.

Call function before the old function and don’t call the old function if function returns nil.

因此,由define-method-combination生成的form中(犹记得伞哥在《PCL》中将它翻译为形式),势必要:

  • 检查是否有被:before-while修饰的方法;
  • 如果有,检查调用了被:before-while修饰的方法后的返回值是否为NIL
  • 如果没有,或者被:before-while修饰的方法的返回值为非NIL,便调用primary方法;
  • 如果有被:after-while修饰的方法,并且primary方法的返回值不为NIL,就调用这些方法;
  • 返回primary方法的返回值。

为了简单起见,尽管after-whilebefore-while变量指向的是多个“可调用”的方法,但这里只调用“最具体”的一个。

给这个新的method combination取名为emacs-advice,其具体实现已是水到渠成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(define-method-combination emacs-advice ()
((after-while (:after-while))
(before-while (:before-while))
(primary () :required t))
(let ((after-while-fn (first after-while))
(before-while-fn (first before-while))
(result (gensym)))
`(let ((,result (when ,before-while-fn
(call-method ,before-while-fn))))
(when (or (null ,before-while-fn)
,result)
(let ((,result (call-method ,(first primary))))
(when (and ,result ,after-while-fn)
(call-method ,after-while-fn))
,result)))))

call-method(以及它的搭档make-method)是专门用于在define-method-combination中调用传入的方法的宏。

用一系列foobar方法来验证一番

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(defgeneric foobar (x)
(:method-combination emacs-advice))

(defmethod foobar (x)
'hello)

(defmethod foobar :after-while (x)
(declare (ignorable x))
(format t "for side effect~%"))

(defmethod foobar :before-while (x)
(evenp x))

(foobar 1) ;; 返回NIL
(foobar 2) ;; 打印“fo side effect”,并返回HELLO

后记

尽管我对CL赏识有加,但越是琢磨define-method-combination,就越会发现编程语言的能力是有极限的,除非超越编程语言。比如Emacs的advice-add所支持的:filter-args:filter-return就无法用define-method-combination优雅地实现出来——并不是完全不行,只不过需要将它们合并在由:around修饰的方法之中。

准备过互联网公司的服务端岗位面试的人,对于二叉树的三种遍历方式想必是如数家珍。假设以类BinaryTree定义一棵二叉树

1
2
3
4
5
class BinaryTree:
def __init__(self, left, right, value):
self.left = left
self.right = right
self.value = value

实现一个前序遍历的算法便是信手拈来的事情

1
2
3
4
5
6
7
def preorder_traversal(tree, func):
"""前序遍历二叉树的每个节点。"""
if tree is None:
return
func(tree.value)
preorder_traversal(tree.left, func)
preorder_traversal(tree.right, func)

随着行业曲率的增大,要求写出不使用递归的版本也没什么过分的

1
2
3
4
5
6
7
8
9
def iterative_preorder_traversal(tree, func):
nodes = [tree]
while len(nodes) > 0:
node = nodes.pop()
func(node)
if node.left is not None:
nodes.append(node.right)
if node.left is not None:
nodes.append(node.left)

一直以来,我觉得这种用一个显式的栈来代替递归过程中隐式的栈的做法就是镜花水月。但最近却找到了它的一个用武之地——用于实现iterator

iterator是个啥?

这年头,iterator已经不是什么新鲜事物了,许多语言中都有支持,维基百科上有一份清单列出了比较知名的语言的iterator特性。按照Python官方的术语表中的定义iterator表示一个数据流,反复调用其__next__方法可以一个接一个地返回流中的下一项数据。将内置函数iter作用于liststrtuple类型的对象,可以获得相应的迭代器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ cat get_iter.py
# -*- coding: utf8 -*-
if __name__ == '__main__':
values = [
[1, 2, 3],
'Hello, world!',
(True, None),
]
for v in values:
print('type of iter({}) is {}'.format(v, type(iter(v))))
$ python get_iter.py
type of iter([1, 2, 3]) is <class 'list_iterator'>
type of iter(Hello, world!) is <class 'str_iterator'>
type of iter((True, None)) is <class 'tuple_iterator'>

写一个前序遍历的iterator

一个iterator对象必须要实现__iter____next__方法:

  • __iter__只需要返回iterator对象自身即可;
  • 顾名思义,__next__负责返回下一个元素。

仔细观察一下前文中的iterative_preorder_traversal函数可以看出:

  • nodes = [tree]属于初始化逻辑;
  • len(nodes) > 0用于判断是应当抛出StopIteration,还是应当继续返回下一个值(nodes.pop());
  • 最后四行就是负责填充nodes,好让它可以在下一次调用__next__的时候有值可以返回的。

到这里,iterator的具体实现代码已经呼之欲出了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class BinaryTreePreorderIterator:
def __init__(self, root):
nodes = []
if root is not None:
nodes.append(root)
self.nodes = nodes

def __iter__(self):
return self

def __next__(self):
if len(self.nodes) == 0:
raise StopIteration
node = self.nodes.pop()
if node.right is not None:
self.nodes.append(node.right)
if node.left is not None:
self.nodes.append(node.left)
return node.value

构造一棵这样的满二叉树

BinaryTreePreorderIterator可以正确地打印出每一个节点的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if __name__ == '__main__':
tree = BinaryTree(
BinaryTree(
BinaryTree(None, None, 1),
BinaryTree(None, None, 3),
2,
),
BinaryTree(
BinaryTree(None, None, 5),
BinaryTree(None, None, 7),
6,
),
4,
)
for n in BinaryTreePreorderIterator(tree):
print('{}\t'.format(n), end='')
# 打印内容为:4 2 1 3 6 5 7

iterator的优势

显然,iterator比起preorder_traversal更为灵活——很容易在for-in循环内添加各种各样的控制逻辑:用continue跳过一些值,或者用break提前结束遍历过程。这些在函数preorder_traversal中做起来会比较别扭。

聪明的你应该已经发现了,大可不必将preorder_traversal拆解到一个构造方法和一个__next__方法中。用generator写起来明明更加直观

1
2
3
4
5
6
7
8
9
10
def preorder_generator(tree):
"""返回一个能够以前序遍历的次序遍历二叉树节点的generator。"""
nodes = [tree]
while len(nodes) > 0:
node = nodes.pop()
yield node.value
if node.left is not None:
nodes.append(node.right)
if node.left is not None:
nodes.append(node.left)

但是,很多语言并不支持generator。与之相比,iterator要亲民得多,更容易移植。例如,即使是Common Lisp这种一穷二白的语言,也可以实现和Python的iterator以及for类似的效果

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
47
48
49
50
51
52
53
54
55
56
57
58
59
(in-package #:cl-user)

(defpackage #:com.liutos.binary-tree
(:use #:cl))

(in-package #:com.liutos.binary-tree)

(defclass preorder-iterator ()
((nodes
:initform nil)
(tree
:initarg :tree))
(:documentation "前序遍历二叉树的迭代器"))

(defmethod initialize-instance :after ((instance preorder-iterator) &key)
(with-slots (nodes tree)
instance
(when tree
(push tree nodes))))

(defgeneric next (iterator)
(:documentation "返回迭代器的下一个值。"))

(define-condition stop-iteration (error)
()
(:documentation "Python中StopIteration异常的等价物。"))

(defmethod next ((iterator preorder-iterator))
(with-slots (nodes) iterator
(when (null nodes)
(error 'stop-iteration))

(let ((node (pop nodes)))
;; 一个节点的结构为:(值 左子树 右子树)
(when (third node)
(push (third node) nodes))
(when (second node)
(push (second node) nodes))
(first node))))

(defmacro for-in (var iterator &body forms)
"将iterator中的值逐个绑定到变量var上,并执行forms中的表达式。"
(let ((iter (gensym)))
`(let ((,iter ,iterator))
(handler-case
(loop
(let ((,var (next ,iter)))
,@forms))
(stop-iteration (c)
(declare (ignorable c)))))))

(defparameter *tree*
'(4 (2 (1 nil nil) (3 nil nil)) (6 (5 nil nil) (7 nil nil))))

(defun test-preorder-iterator ()
"测试前序遍历迭代器。"
(for-in n (make-instance 'preorder-iterator
:tree *tree*)
(format t "~D~C" n #\Tab)))

后记

中序遍历和后序遍历也可以写成迭代器,证明略。