一个*类型(type)*就是一组值。 int 类型是所有整数构成的集合。 你自己定义的类型,比如从一到十的评分,则是一个更小的集合: 也就是允许出现的那些值。 从历史上看,程序员并不擅长确保对象始终待在这个集合之内。 构造出非法状态的对象,或者把一个对象修改到非法状态,都太容易了。 于是防御性检查开始散布到整个代码库中。
本章展示一种更好的方法,它建立在冻结的数据类(frozen data class)之上。 你只在构造时验证一次值,然后将对象冻结,使它无法再改变。 此后,类型本身就能保证每个对象都是合法值。 收到这种对象的代码不必再次检查它。 本章材料来自我的 PyCon 2022 演讲 Making Data Classes Work for You。
下面的 check() 函数会贯穿本章。 它会抛出 TypeFailure, 这是一个自定义异常,表示某个值落在该类型允许集合之外:
# validation.py
from dataclasses import dataclass
@dataclass(eq=False)
class TypeFailure(ValueError):
subject: str
reason: str = ""
def __str__(self) -> str:
return f"{self.subject}{self.reason}".rstrip()
def check(condition: bool, subject: str,
reason: str = "") -> None:
if not condition:
raise TypeFailure(subject, reason)
异常和其他对象一样,也是一个值,而它携带的值理应有名字。 subject 是调用方表示出来的被拒绝值,例如 Stars(11)。 reason 则在名字本身不足以说明问题时解释拒绝原因, 例如 needs an @。 异常处理器可以直接读取 e.subject 和 e.reason,而不必从异常文本里解析它们。
eq=False 会关闭自动生成的 __eq__(),这么做有两个原因。 一个定义了 __eq__() 的数据类会把 __hash__ 设为 None (数据类里会在 Messenger 上看到这一点), 而如果你把不可哈希的异常放进 set,就会踩坑。 异常应该按身份比较。 两个携带同样文本的失败,仍然是两个不同的失败对象。
假设一个“星级”评分是从一到十的整数。 如果用 int 表示它,那么没有任何东西能阻止调用方传入十一, 甚至负一。 为了避免这种情况,每个接收评分的函数都必须检查它:
# stars_unchecked.py
# An int as a 1-10 rating must be re-checked everywhere
from validation import check
def f1(stars: int) -> int:
# Check the argument here...
check(1 <= stars <= 10, f"f1({stars})")
return stars + 5
def f2(stars: int) -> int:
# ...and again in every other function
check(1 <= stars <= 10, f"f2({stars})")
return stars * 5
def f3(stars: int) -> int: # The check is missing here
return stars * 100
rating = 6
print(f1(rating))
#: 11
print(f2(rating))
#: 30
print(f3(11))
#: 1100
每个函数都重复了相同的检查,而且这种检查很容易被忘掉, 类型系统也帮不上忙。 int 注解表达的是“任意整数”,这并不是你真正想表达的含义。 检查参数也完全不能保证返回值合法:f1(6) 返回 11, 而 11 并不是合法评分。 f3() 展示的就是忘记检查时会发生什么。 11 不是合法评分,但没有任何东西对此提出异议: 不是类型注解,不是类型检查器,运行中的程序也不是。 结果就是,一个任何合法评分都不可能产生的数字, 却像完全正常一样继续向后传递。
面向对象的回答通常是:用一个类把这个值包起来,并在构造函数中验证它。 这确实更好,但还没有彻底解决问题。 值仍然是可变的,所以每个修改它的方法都必须重新验证, 而且一个方法还可能在执行的中间步骤把对象留在非法状态:
# stars_class.py
from validation import TypeFailure, check
class Stars:
def __init__(self, number: int) -> None:
self._number = number # Private by convention
self._validate()
def _validate(self) -> None:
check(1 <= self._number <= 10,
f"Stars({self._number})")
@property
# No setter: blocks external mutation
def number(self) -> int:
return self._number
def __str__(self) -> str:
return f"Stars({self._number})"
def f1(self) -> int:
self._number = self._number + 5
self._validate() # Postcondition
return self._number
if __name__ == "__main__":
rating = Stars(4)
print(rating)
print(rating.f1())
damaged = Stars(8)
try:
damaged.f1()
except TypeFailure as e:
print(f"TypeFailure:{e}")
print(damaged)
#: Stars(4)
#: 9
#: TypeFailure: Stars(13)
#: Stars(13)
只读的 @property 可以阻止用户直接给 number 赋值, 但对象内部仍然会修改 _number, 所以 f1() 在返回之前必须重新检查结果。 这个检查发生在修改之后,而不是用来阻止修改: Stars(8).f1() 会先把 _number 设成 13,然后抛出 TypeFailure, 对象此后依旧保存着那个非法值。 捕获异常并不会撤销已经造成的破坏。 在进入函数时检查参数、离开函数时检查结果, 并要求一个类不变量(class invariant)在调用之间始终成立, 这种实践叫作契约式设计(Design by Contract,DbC)。 f1() 没有接收参数,因此这里只有后置条件。 如果某个方法还接收第二个评分,那么它同样需要针对那个评分的前置条件。 DbC 的问题在于,契约会散布到每一个会触碰该值的方法中。 本章要替换掉的正是其中的“不变量”部分。 _validate() 声明了这个不变量,而每个修改方法都必须记得调用它。 这和之前到处散落检查本质上是同一件事,只不过把检查挪进了类里。 类封装了这个值。 但它并没有把这个值约束在合法值集合中。
*数据类(data class)*消除了“只负责保存数据的类”中大量样板代码。 @dataclass 装饰器会根据你声明的字段自动生成 __init__()、__repr__() 和 __eq__():
# messenger.py
from dataclasses import dataclass
@dataclass
class Messenger:
name: str
number: int
depth: float = 0.0 # Default value
元编程中构建的检查辅助函数 display_object() 可以展示 @dataclass 自动生成了什么: