开发大型项目

数据模型与描述符

在 Python 中,数据模型(Data Model)是一个非常重要的概念,假如把 Python 语言看作一个框架,数据模型就描述了框架如何工作,创建怎样的对象才能更好地融入 Python 这个框架。所有与数据模型有关的方法,基本都是以双下划线 __ 开头和结尾,它们通常被称为魔法方法(Magic Method)。

字符串魔法方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Person:
def __init__(self, name, age, favorite_color):
self.name = name
self.age = age
self.favorite_color = favorite_color

def __str__(self):
return self.name

def __repr__(self):
# 变量名后的 !r 表示在渲染字符串模版时,程序会优先使用 repr() 而非 str() 的结果
return '{cls_name}(name={name!r}, age={age!r}, favorite_color={color!r})'.format(
cls_name=self.__class__.__name__,
name=self.name,
age=self.age,
color=self.favorite_color,
)

def __format__(self, format_spec):
if format_spec == 'verbose':
return f'{self.name}({self.age})[{self.favorite_color}]'
elif format_spec == 'simple':
return f'{self.name}({self.age})'
return self.name

__str__

使用 __str__ 方法,可以定义对象的字符串值。这种场景下的字符串注重可读性,格式应当对用户友好:

1
2
3
>>> p = Person('ethan', 18, 'black')
>>> print(p)
ethan

__repr__

使用 __repr__ 方法,可以定义对象对调试友好的详细字符串值。这种场景下的字符串注重内容的完整性

1
2
3
>>> p = Person('ethan', 18, 'black')
>>> p
Person(name='ethan', age=18, favorite_color='black')

__format__

使用 __format__ 方法,可以在对象用于字符串模版渲染时,提供多种字符串值:

1
2
3
4
5
>>> p = Person('ethan', 18, 'black')
>>> print(f'{p:verbose}')
ethan(18)[black]
>>> print(f'{p:simple}')
ethan(18)

比较运算符重载

与比较运算符相关的魔法方法共 6 个,如果使用 @total_ordering 装饰一个类,那么在重载类的比较运算符时,你只要先实现 __eq__ 方法,然后在 __lt__、__le__、__gt__、__ge__ 四个方法里随意挑一个实现即可,@total_ordering 会帮你自动补全剩下的所有方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from functools import total_ordering

@total_ordering
class Square:
def __init__(self, length):
self.length = length

def area(self):
return self.length ** 2

def __eq__(self, other):
if isinstance(other, self.__class__):
return self.length == other.length
return False

def __lt__(self, other):
if isinstance(other, self.__class__):
return self.length < other.length
return NotImplemented

描述符

描述符(Descriptor)是 Python 对象模型里的一种特殊协议,任何一个实现了 __get__、__set__ 或 __delete__ 的类,都可以称为描述符类,它的实例则叫作描述符对象。

按照实现方式的不同,描述符可以分为两大类:

  • 非数据描述符:只实现了 __get__ 方法的描述符,比如实例方法、类方法、静态方法,你可以轻易覆盖它们的行为;
  • 数据描述符:实现了 __set__ 或 __delete__ 其中任何一个方法的描述符,比如属性装饰器,你无法直接通过重写修改它的状态;

无描述符时,实现属性校验功能

使用属性装饰器最大的缺点是——很难复用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Person:
def __init__(self, name, age):
self.name = name
self.age = age

@property
def age(self):
return self._age

@age.setter
def age(self, value):
try:
value = int(value)
except (TypeError, ValueError):
raise ValueError('value is not a valid integer!')

if not (0 <= value <= 150):
raise ValueError('value must between 0 and 150!')
self._age = value

用描述符实现属性校验功能

为了提供更高的可复用性,这次我们在年龄字段的基础上抽象出一个支持校验功能的整型描述符类型:

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
class IntegerField:
def __init__(self, min_value, max_value):
self.min_value = min_value
self.max_value = max_value

def __set_name__(self, owner, name):
# 将绑定属性名保存在描述符对象中
self._name = name

def __get__(self, instance, owner=None):
if not instance:
return self
# 在数据存取时,使用动态的 self._name
return instance.__dict__[self._name]

def __set__(self, instance, value):
value = self._validate_value(value)
instance.__dict__[self._name] = value

def _validate_value(self, value):
try:
value = int(value)
except (TypeError, ValueError):
raise ValueError(f'{self._name} is not a valid integer!')

if not (self.min_value <= value <= self.max_value):
raise ValueError(f'{self._name} must between {self.min_value} and {self.max_value}!')
return value

编程建议

每当你想要重写 __hash__ 方法时,一定要保证方法产生的哈希值是稳定的,不会随着对象状态而改变:

  • 对象不可变,不允许任何修改,比如定义 dataclass 时指定的 frozen=True;
  • 至少保证,被卷入哈希值计算的条件不会改变;

虽然数据模型能帮我们写出更 Pythonic 的代码,但切勿过度推崇:

1
2
3
4
5
6
7
8
9
class Events:
def __init__(self, events):
self.events = events

def is_empty(self):
return not bool(self.events)

def list_events_by_range(self, start, end):
return self.events[start:end]
1
2
3
4
5
6
7
8
9
class Events:
def __init__(self, events):
self.events = events

def __len__(self):
return len(self.events)

def __getitem__(self, index):
return self.events[index]

__del__ 方法不是在执行 del 语句时被触发,而是在对象被作为垃圾回收时被触发。换句话说,del 让对象的引用计数减 1,但只有当引用计数降为 0 时,它才会马上被 Python 解释器回收,不要使用 __del__ 来做任何自动化的资源回收工作。

开发大型项目

Python 的自由感体现在,它既可以用来写一些快糙猛的小脚本,同时也能用它来做一些真正的大项目,解决一些更为复杂的问题。在经历了多年发展后,如今的 Python 有着成熟的打包机制、强大的工具链以及繁荣的第三方生态,无数企业乐于用 Python 来开发重要项目。

常用工具介绍

在多人参与的大型项目里,最基本的一件事就是让所有人的代码风格保持一致,整洁得就像是出自同一人之手。

flake8

Python 有一份官方代码风格指南:PEP 8,但在开发项目时,光有一套纸面上的规范是不够的。纸面规范只适合阅读,无法用来快速检索真实代码是否符合规范,只有通过 Linter 才能最大地发挥 PEP 8 的作用。

Linter 指一类特殊的代码静态分析工具,专门用来找出代码里的格式问题、语法问题等,帮助提升代码质量。

flake8 的主要检查能力是由它所集成的其他工具所提供的:

  • pycodestyle:PEP 8 检查工具;
  • pyflakes:更专注于检查代码的正确性,比如语法错误、变量名未定义等;
  • mccabe:扫描代码的圈复杂度;

此外,flake8 还通过插件机制提供了强大的定制能力,可谓 Python 代码检查领域的一把瑞士军刀,非常值得在项目中使用。

isort

PEP 8 认为,一个源码文件的所有 import 语句,都应该依照以下规则分为三组:

  1. 导入 Python 标准库包的 import 语句;
  2. 导入相关联的第三方包的 import 语句;
  3. 与当前应用(或当前库)相关的 import 语句;

有了 isort 以后,你在调整 import 语句时可以变得随心所欲,只需负责一些简单的编辑工作,isort 会帮你搞定剩下的所有事情。

black

虽然 PEP 8 规范为许多代码风格问题提供了标准答案,但这份答案其实非常宏观,在许多细节要求上并不严格。在许多场景中,同一段代码在符合 PEP 8 规范的前提下,可以写成好几种风格。作为一个代码格式化工具,black 最大的特点就在于它的不可配置性:

The uncompromising Python code formatter.
-- GitHub

black 能让我们不用在各种编码风格间纠结,能有效解决许多问题,整体来看,在大型项目中引入 black,利远大于弊。

pre-commit

只是安装好工具,再偶尔手动执行那么一两次是远远不够的,要最大地发挥工具的能力,你必须让它们融入所有人的开发流程里。Git 有个特殊的钩子功能,它允许你给每个仓库配置一些钩子程序(Hook),之后每当你进行特定的 Git 操作时,这些钩子程序就会执行。

pre-commit 是一个专门用于预提交阶段的工具,要使用它,你需要先创建一个配置文件 .pre-commit-config.yaml,由于它与项目源码存放在一起,都在代码仓库中,因此项目的所有开发者天然共享 pre-commit 的插件配置,每个人不用单独维护各自的配置。

mypy

在现实世界里,我们写的程序里的许多 bug 都和类型系统息息相关。mypy 是最为流行的静态类型检查工具,在大型项目中,类型注解与 mypy 的组合能大大提高项目代码的可读性与正确性。mypy 让动态类型的 Pyhon 拥有了部分静态类型语言才有的能力,值得在大型项目中推广使用。

单元测试简介

根据关注点的不同,自动化测试可以分为不同的类型,比如 UI 测试、集成测试、单元测试等:

作为一名程序员,编写单元测试其实是一项收益极高的工作,它不光能让你更容易发现代码里的问题,还能驱使你写出更具扩展性的好代码。

unittest

在 Python 里编写单元测试,最正统的方式是使用 unittest 模块,它是标准库里的单元测试模块,使用方便、无须额外安装。用 unittest 编写测试的第一步,是创建一个继承 unittest.TestCase 的子类,然后编写许多以 test 开头的测试方法;在方法内部,通过调用一些以 assert 开头的方法来进行测试断言。

通过定义 setUp() 和 tearDown() 方法,你可以让程序在执行每个测试方法的前后,运行额外的代码逻辑。unittest 模块在最初实现时,大量参考了 Java 语言的单元测试框架 JUnit,因此,它的许多奇怪设计其实是 Java 化的表现,比如只能用类来定义测试用例,又比如方法都采用驼峰命名法等。

pytest

pytest 是一个开源的第三方单元测试框架,它功能更多、设计更复杂、上手难度也更高,但 pytest 的最大优势在于,它把 Python 的一些惯用写法与单元测试很好地融合了起来:

  • 不必用一个 TestCase 类来定义测试用例,用一个以 test 开头的普通函数也行;
  • 不需要调用任何特殊的 assert{xx}() 方法,只要写一条原生的断言语句 assert {expression} 就好;
parametrize

在单元测试领域,有一种常用的编写测试代码的技术:表驱动测试(Table-driven Testing),它鼓励你将不同测试用例间的差异点抽象出来,提炼成一张包含多份输入参数、期望结果的数据表,以此驱动测试执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pytest
from string_utils import string_upper

@pytest.mark.parametrize(
's, expected',
[
('foo', 'FOO'),
('', ''),
('foo BAR', 'FOO BAR'),
],
)

def test_string_upper(s, expected):
assert string_upper(s) == expected
fixture

在编写单元测试时,我们常常需要重复用到一些东西,这类被许多单元测试依赖、需要重复使用的对象,常被称为 fixture:

1
2
3
4
5
6
7
@pytest.fixture
def random_token():
token_l = []
char_pool = string.ascii_lowercase + string.digits
for _ in range(32):
token_l.append(random.choice(char_pool))
return ''.join(token_l)

假如你在 fixture 函数中使用 yield 关键字,把它变成一个生成器函数,那么就能为 fixture 增加额外的清理逻辑

1
2
3
4
5
@pytest.fixture
def db_connection():
conn = create_db_conn()
yield conn
conn.close()

除了作为函数参数,被主动注入测试方法中以外,pytest 的 fixture 还有另外一种触发方式——自动执行,非常适合用来做一些测试准备与事后清理工作:

1
2
3
4
5
6
7
8
@pytest.fixture(autouse=True)
def prepare_data():
# 在测试开始前,创建两个用户
User.objects.create(...)
User.objects.create(...)
yield
# 在测试结束时,销毁所有用户
User.objects.all().delete()

pytest 里的 fixture 可以使用五种作用域(Scope),根据作用域的不同,被缓存的 fixture 结果会在不同的时机被销毁:

  • function:默认作用域,结果会在每个测试函数结束后销毁;
  • class:结果会在执行完类里的所有测试方法后销毁;
  • module:结果会在执行完整个模块的所有测试后销毁;
  • package:结果会在执行完整个包的所有测试后销毁;
  • session:结果会在测试会话(也就是一次完整的 pytest 执行过程)结束后销毁;

编程建议

虽然好像人人都认为单元测试很有用,但在实际工作中,有完善单元测试的项目仍然是极其罕见的。

写单元测试不是浪费时间

虽然不写单元测试看上去节约了一丁点儿时间,但有问题的代码上线后,你会花费更多的时间去定位、去处理这个 bug。缺少单元测试的帮助,你需要耐心找到改动可能会影响到的每个模块,手动验证它们是否正常工作,所有这些事所花费的时间,足够你写好几十遍单元测试。

在有着完善单元测试的模块里,重构是件轻松惬意的事情。在重构时,可以按照任何你想要的方式随意调整和优化旧代码,每次调整后,只要重新运行一遍测试用例,几秒钟之内就能得到完善和准确的反馈。

不要总想着补测试

单元测试不光能验证程序的正确性,还能极大地帮助你改进代码设计,但这种帮助有一个前提,那就是你必须在编写代码的同时编写单元测试。当开发功能与编写测试同步进行时,你会来回切换自己的角色,分别作为代码的设计者和使用者,不断从代码里找出问题,调整设计。经过多次调整与打磨后,你的代码会变得更好、更具扩展性。

测试代码并不比普通代码地位低,选择事后补测试,你其实丢掉了用测试驱动开发(Test-Driven Development)的机会:

  1. 写测试用例(哪怕测试用例引用的模块根本不存在);
  2. 执行测试用例,让其失败;
  3. 编写最简单的代码(此时只关心实现功能,不关心代码整洁度);
  4. 执行测试用例,让测试通过;
  5. 重构代码,删除重复内容,让代码变得更整洁;
  6. 执行测试用例,验证重构;
  7. 重复整个过程;

难测试的代码就是烂代码

在不写单元测试时,烂代码就已经是烂代码了,只是我们没有很好地意识到这一点。因此,每当你发现很难为代码编写测试时,就应该意识到代码设计可能存在问题,需要努力调整设计,让代码变得更容易测试。单元测试是评估代码质量的标尺,每当你写好一段代码,都能清楚地知道到底写得好还是坏,因为单元测试不会说谎。

像应用代码一样对待测试代码

人们在对待应用代码和测试代码的态度上,常常有一些微妙的区别:

  • 对重复代码的容忍程度:在测试代码里,出现 10 行重复代码是件稀松平常的事情,人们甚至能容忍更长的重复代码段;
  • 对代码执行效率的重视程度:假如有人在测试代码里偶然引入了一个效率低下的 fixture,导致整个测试的执行耗时突然增加了 30%,似乎也不是什么大事儿,极少会有人关心;
  • 对重构的态度:我们很少对测试代码做重构工作,除非某个旧测试用例突然坏掉了,否则我们绝不去动它;

但这样其实是不对的,如果对测试代码缺少必要的重视,那么它就会慢慢腐烂。当它最终变得不堪入目,执行耗时以小时计时,人们就会从心理上开始排斥编写测试,也不愿意执行测试。

避免教条主义

要更好地实践单元测试,你要做的第一件事就是摒弃教条主义,脚踏实地,不断寻求最适合当前项目的测试方案:

  • 单元测试里的单元其实并不严格地指某个方法、函数,而是指软件模块的一个行为单元,或者说功能单元;
  • 某个测试用例应该算作集成测试还是单元测试并不重要,所有的自动化测试只要能满足:快、用例间互相隔离、没有副作用,这样就够了;