简介:《Python Object-Oriented Programming》第四版是 Packt 于 2021 年推出、Steven F. Lott 与 Dusty Phillips 合著的英文原版电子书,面向希望系统掌握 Python 面向对象编程并构建健壮可维护应用与库的开发者。全书从封装、继承、多态等基础出发,结合类、实例、属性与方法讲解 OOP 设计,并延伸至模块化组织、异常处理、单元测试、文档编写、版本控制、CI/CD 与依赖管理等工程实践;既可用于初学者搭建知识框架,也适合有经验的程序员查漏补缺。书中还强调以清晰接口隐藏实现细节、用测试验证对象行为,并讨论包结构、可扩展类层次与第三方依赖治理,便于把 OOP 原则落到真实项目。资源包内含 1 个 PDF 文件,大小约 6.51MB,单文件结构完整,便于离线阅读、全文检索与按章节查阅。目前已有 43 人学习,可作为日常开发、代码重构与 Python 库维护时的参考手册。
1. 从一份 14 章的书稿说起:Python 的 OOP 到底难在哪
翻这份第四版的人,十有八九是从继承那一章开始看,然后停在抽象基类。我的建议反过来:先把第 4 章「Expecting the Unexpected」和第 5 章「When to Use Object-Oriented Programming」读掉,这两章回答的是「这段逻辑到底该不该写成类」,而不是「类怎么写」。全书 14 章,前半本讲类定义、继承、异常、抽象基类和内置数据结构,中间讲迭代器与生成器,后半本摊开 11 个设计模式,最后两章落在 pytest 测试与线程/进程/asyncio 三套并发模型。它不适合当语法手册查,适合当设计评审时的对照清单,尤其适合已经能写脚本、但写出来的东西一改就崩的从业者。下面按「能跑起来」的顺序拆:先把类定义和模块边界写对,再把继承和抽象基类用到位,最后用 pytest 把行为固定可验证。
2. 类定义落地:类型提示、初始化与模块边界
这一章挑的是书里第 2 章到第 3 章的内容,但落到一个具体场景:写一个极简的样本分类小工具,一个Sample承载特征向量与标签,一个Classifier负责判定。选择这个小场景是因为它足够小、能一次讲透,又足够真,几乎每个处理数据的项目里都有同构的东西。下面每一步都给可抄的代码,参数和取舍放在代码后面说。
2.1 类型提示先行的类骨架
先把类型提示当设计工具用,而不是当装饰。书里第 2 章开篇就讲 type hints,重点不在语法,而在于它会逼你回答「这个方法允许接受什么、返回什么」。
from __future__ import annotations # 3.7/3.8 也能写内置泛型下标 from typing import Iterable, Protocol class Classifier(Protocol): """结构化类型:不要求显式继承,只要求具备 classify 方法""" def classify(self, features: tuple[float, ...]) -> str: ... class Sample: __slots__ = ("_features", "_label") def __init__(self, features: Iterable[float], label: str) -> None: # 入口处做一次固化:外部传生成器/列表都能接受,内部只保存不可变 tuple self._features = tuple(float(f) for f in features) self._label = label @property def features(self) -> tuple[float, ...]: return self._features @property def label(self) -> str: return self._label def __repr__(self) -> str: return f"Sample(features={self._features!r}, label={self._label!r})"from __future__ import annotations的作用是把注解延迟成字符串求值,这样在 3.8 上也能写tuple[float, ...],而不用退回Tuple[float, ...]。参数类型写Iterable[float]而不是list[float],是刻意放宽入口:调用方传生成器、传array.array、传 pandas 的 Series 都不会被类型检查器拦下,代价是构造函数里必须先tuple(...)固化一次。这个「宽进严存」的组合是书里反复强调的封装思路——内部状态一旦确定就不可变,外部再怎么改原对象都影响不到实例。__slots__在 5.3 会单独说坑,这里先记住它同时起到「禁止动态加属性」和「省内存」两个作用。
Protocol是这本书第 6 章讲抽象基类时的自然延伸:如果只需要「有某个方法」这一个约束,用 Protocol 比定义 ABC 再让实现类继承更轻,因为它是结构化类型,鸭子类型能被静态检查器识别。
2.2 构造函数里该做什么、不该做什么
书里第 2 章有一节专门讲初始化,核心结论很朴素:__init__只做赋值和轻量校验,别做 I/O、别做网络、别做重计算。反例长这样,很多人第一版就是这么写的:
class BadSample: def __init__(self, path: str) -> None: # 反例:构造即读盘,测试时必须先造文件 with open(path, encoding="utf-8") as f: self.rows = [line.split(",") for line in f]正解是把这类「从一个外部形态构造实例」的逻辑挪到类方法上,让构造函数保持纯粹:
from typing import Mapping class Sample: # ... 省略 2.1 的 __init__ 与 property @classmethod def from_csv_row(cls, row: Mapping[str, str]) -> "Sample": """从一个 CSV 字典行构造样本,字段缺失直接抛出 KeyError 语义的错误""" try: raw = row["features"] label = row["label"] except KeyError as exc: raise ValueError(f"缺少字段: {exc.args[0]}") from exc features = [float(x) for x in raw.split("|")] return cls(features, label)代码后的取舍:from_csv_row用Mapping[str, str]而不是dict,因为csv.DictReader返回的每一行本质就是映射;raise ... from exc保留原始异常链,排查时能看到是哪一列缺了。这一条在书里第 4 章会展开成完整的异常设计,但在这里先用上,因为构造函数和工厂方法的分工,本质上就是异常边界的分工。
提示:类方法构造器的命名建议统一,
from_*前缀是标准库和主流三方库里最常见的约定,看到名字就知道它不改变原有对象。
2.3 模块与包:绝对导入、__all__与循环导入
书里第 2 章后半段讲模块和包,这段内容容易被跳过,但它决定了一个库能不能被别人舒服地用。常见做法是把对外暴露的名字收在一个__init__.py里,内部实现藏在子模块:
# knn/__init__.py from knn.core import Classifier, Sample from knn.errors import InvalidSampleError __all__ = ["Classifier", "Sample", "InvalidSampleError"]__all__的实际作用是两件事:from knn import *只导出列表里的名字;以及给静态检查器和 IDE 一个明确的公共 API 清单。绝对导入优先于相对导入,原因是相对导入在模块被当作脚本直接运行时容易失效,而绝对导入只要包在sys.path上就稳定。
循环导入是另一个高频问题。典型场景是core.py需要errors.py里的异常,而errors.py的类型注解里又要引用core.py的类。解决办法是把只用于注解的引用塞进TYPE_CHECKING块:
# knn/errors.py from typing import TYPE_CHECKING if TYPE_CHECKING: # 只在类型检查时导入,运行时不会真的 import,避免循环 from knn.core import Sample class InvalidSampleError(ValueError): def __init__(self, sample: "Sample", reason: str) -> None: super().__init__(f"样本非法: {reason}") self.sample = sample self.reason = reasonTYPE_CHECKING在运行期恒为False,所以from knn.core import Sample这行不会执行,循环就断了。注意字符串形式的注解"Sample"必须保留引号,否则在不开from __future__ import annotations的模块里仍会求值。
2.4 组合还是继承:三个判定问题
书里第 1 章讲组合、第 3 章讲继承,但真正好用的是第 5 章给出的判定口径。我把它压成三个问题,每次写新类之前问一遍:
| 判定问题 | 倾向组合 | 倾向继承 |
|---|---|---|
| 新类型是不是旧类型的「一种」 | 否 | 是 |
| 是否需要复用父类的全部接口 | 只需要一两个方法 | 需要完整接口 |
| 父类改动时子类是否必须跟着改 | 否 | 是 |
表格里第二行最容易被误判。很多人继承list只为了用append和__iter__,结果父类的几十个方法全部进入公共 API,调用方可以随意sort()、clear(),破坏你精心维护的不变量。这种场景应该组合:内部持有一个list,只暴露你需要的那几个方法。第 3 章会讲到书里确实演示了继承list的做法,但那是在你需要「完整替换语义」的时候,不是日常默认选项。
3. 继承、MRO 与抽象基类:多态写对才有意义
继承是这本书里篇幅最重、也最容易写错的部分。书里第 3 章用了「菱形问题」一整节讲多继承,第 6 章又把抽象基类和运算符重载接上。这一章按「方法解析顺序 → 协作式构造 → 接口约束 → 扩展内置类型」四步走,每一步都对应一个真实会翻车的点。
3.1super()到底调了谁:把 C3 线性化手推一遍
super()不是「调用父类」,是「按 MRO 顺序调用下一个」。这两者的差别在多继承下就是灾难和正常的差别。先看一个最小例子:
class A: def ping(self) -> str: return "A" class B(A): def ping(self) -> str: return "B->" + super().ping() class C(A): def ping(self) -> str: return "C->" + super().ping() class D(B, C): def ping(self) -> str: return "D->" + super().ping() print([c.__name__ for c in D.__mro__]) # ['D', 'B', 'C', 'A', 'object'] print(D().ping()) # D->B->C->AMRO 的计算规则是 C3 线性化:每个类的结果由「自身 + 所有父类 MRO 的合并」构成,合并时保持每个父类内部的相对顺序,且父类永远排在其子类之后。D(B, C)的合并过程大致是:取B的 MRO[B, A, object]和C的 MRO[C, A, object],再与[B, C]合并,A因为同时出现在两个序列的尾部而被推迟,最终得到[D, B, C, A, object]。
这个结果解释了两件事。第一,D().ping()里B的super()拿到的是C,不是A,所以输出是D->B->C->A,C被串联进来了。第二,如果B和C的ping都不调super(),那么A的版本永远不会执行,链就断了。看到有人在多继承里写死A.ping(self)而不是super().ping(),基本可以判定这段代码在类层次变动时会静默出错。
排查 MRO 报错有个简单办法:TypeError: Cannot create a consistent method resolution order出现时,直接在 REPL 里把涉及的类逐个.__mro__打出来,找那个在两个父类 MRO 里顺序相反的类,通常是某个父类同时继承了另一个父类。
3.2 协作式多继承:参数要能一路传下去
方法链通了,构造参数却经常接不上。书里第 3 章专门有一节叫「Different sets of arguments」,讲的就是这个。稳妥写法是让每一层都接受**kwargs并原样往上传,自己的参数用关键字专用形式消费掉:
class Base: def __init__(self, **kwargs) -> None: super().__init__(**kwargs) # 一路穿透到 object,object 不接受多余参数 class WithLogging(Base): def __init__(self, *, logger=None, **kwargs) -> None: super().__init__(**kwargs) self.logger = logger class WithCache(Base): def __init__(self, *, cache_size: int = 128, **kwargs) -> None: super().__init__(**kwargs) self.cache_size = cache_size class Engine(WithLogging, WithCache): def __init__(self, *, k: int = 3, **kwargs) -> None: super().__init__(**kwargs) self.k = k print([c.__name__ for c in Engine.__mro__]) e = Engine(k=5, cache_size=64, logger=None) # 所有参数都能落到位关键在于Base.__init__必须接受**kwargs并且不做任何参数校验,否则链尾会炸出TypeError: object.__init__() takes exactly one argument。这个报错在 5.1 里会详细拆。参数上还有一个约定:混入类(Mixin)自己的参数一律用关键字专用(*分隔),避免位置参数在多层继承里错位。这条约定在团队协作里比任何文档都有效,因为位置参数一错位就不会报错,只会静默算错。
3.3 抽象基类:abc.ABC、collections.abc与协议约束
书里第 6 章把抽象基类讲得很细,从ABC到collections.abc再到自定义 ABC。实际项目里我一般分三种情况选:
- 需要「子类必须实现某方法,否则实例化即报错」:用
abc.ABC+@abstractmethod。 - 需要「按能力判断一个对象能不能用」:用
collections.abc里的Iterable、Sized、Sequence。 - 只需要静态类型约束、不想引入继承关系:用
typing.Protocol。
第一种的写法,顺手把模板方法模式也带上:
from abc import ABC, abstractmethod class BaseClassifier(ABC): @abstractmethod def classify(self, features: tuple[float, ...]) -> str: """子类必须实现:单个样本判定""" def classify_many(self, rows: list[tuple[float, ...]]) -> list[str]: # 模板方法:骨架写在这里,子类只填 classify return [self.classify(r) for r in rows]@abstractmethod的约束发生在实例化时,BaseClassifier()直接抛TypeError: Can't instantiate abstract class,这就是「失败提前」的价值——不用等到运行时某个分支才炸。classify_many是模板方法的典型写法:骨架在基类,变化点留给子类,子类不需要重复for循环也不会写漏。
collections.abc的用法更偏运行时判定。比如某个函数只要求参数能被迭代,注解写Iterable[Sample]就够,不必要求调用方继承任何东西;而在需要isinstance判断的旧代码里,isinstance(x, collections.abc.Sequence)会走__subclasshook__做结构化匹配,比isinstance(x, list)覆盖面宽得多——tuple、str、range都算Sequence。
3.4 扩展内置类型与运算符重载的边界
书里第 6 章有一节演示「用两个子列表扩展 list」,第 8 章又讲了__slots__与内存。这里给一个更常见的场景:一个不可变的二维向量,需要支持加法、相等和哈希。
from __future__ import annotations class Vector: __slots__ = ("x", "y") def __init__(self, x: float, y: float) -> None: self.x = x self.y = y def __add__(self, other: "Vector") -> "Vector": if not isinstance(other, Vector): return NotImplemented # 关键:返回 NotImplemented 而不是抛异常 return Vector(self.x + other.x, self.y + other.y) def __eq__(self, other: object) -> bool: if not isinstance(other, Vector): return NotImplemented return (self.x, self.y) == (other.x, other.y) def __hash__(self) -> int: return hash((self.x, self.y)) def __repr__(self) -> str: return f"Vector({self.x!r}, {self.y!r})"两个参数级细节值得说。__add__对不认识的类型返回NotImplemented而不是raise TypeError,这样 Python 会继续尝试右操作数的__radd__,最终才由解释器抛出标准的TypeError: unsupported operand type(s),错误信息更准确。__eq__一旦定义,Python 会自动把__hash__置为None,类就变成不可哈希,放进set或当dict键会直接报TypeError: unhashable type;要保住可哈希就必须显式重写__hash__,并且保证「相等的对象哈希值相同」。
4. 异常、属性与数据载体:三处最容易过度设计的地方
书里第 4 章讲异常、第 5 章讲 property、第 7 章讲数据结构,这三块有一个共同点:都能用,也都容易被用过头。这一章的任务是把「什么时候该用」的边界划出来,配上能直接抄的写法。
4.1 异常层次:项目根异常与捕获边界
自定义异常最常见的错误是每个错误都直接继承Exception,结果调用方没法写一个except兜住一整类问题。规范做法是先定义一个项目根异常,再按领域细分,同时混入一个内置语义类:
class KNNError(Exception): """项目根异常,所有本库异常都继承它""" class InvalidSampleError(KNNError, ValueError): """数据非法:既可按领域捕获,也符合内置 ValueError 语义""" class ModelNotFittedError(KNNError, RuntimeError): """调用顺序错误:先 fit 再 predict"""多继承在这里是安全且推荐的:KNNError提供领域归类,ValueError/RuntimeError提供与标准库一致的语义,调用方写except ValueError的老代码也能接住。捕获边界则遵循「在哪一层能处理就在哪一层捕获」——解析 CSV 的那一层把KeyError、ValueError转成InvalidSampleError,上层只管except KNNError。
抛异常时用raise NewError(...) from exc保留原因链,不要写raise NewError(...)把原始栈丢掉,也不要写except Exception: pass。前者让排查时能看到完整因果,后者是黑匣子,出了问题只能靠猜。
还有一条来自书里第 4 章的提醒:异常不该被当成正常控制流。用异常做「找不到就抛、找到就返回」的循环终止条件,在样本量大时性能会明显变差,因为每次抛出都要构造栈信息。常见做法是改用返回Optional或让函数返回一个「是否结束」的标志。
4.2 属性与描述符:property什么时候值得写
property的价值不在「把属性包一层」,而在于「赋值时需要校验或需要触发副作用」。书里第 5 章用超参数类做了完整演示,这里抽最小版本:
class Hyperparameter: def __init__(self) -> None: self._k = 3 @property def k(self) -> int: return self._k @k.setter def k(self, value: int) -> None: if not isinstance(value, int) or isinstance(value, bool): raise InvalidSampleError(None, f"k 必须是整数,收到 {type(value).__name__}") if value <= 0 or value % 2 == 0: raise InvalidSampleError(None, f"k 必须是正奇数,收到 {value}") self._k = value注意isinstance(value, bool)那一句:True是int的实例,不排除的话k=True会被当成 1 通过校验,这是血泪经验。校验只写一次,之后无论从构造函数、配置文件还是接口参数进来都走同一条路,这就是 property 最大的收益——把不变量收口到唯一入口。
只读属性则用@property不写 setter,配合 2.1 里的私有字段,外部拿得到、改不了。需要缓存计算结果时用functools.cached_property(3.8+),它第一次访问后把结果写进实例__dict__,后续直接命中;但如果类用了__slots__且没给该属性留槽位,cached_property会失效,这一点在没有类型检查的代码里很难发现。
4.3 数据载体选型:tuple、NamedTuple、dataclass、dict 怎么选
书里第 7 章把这几样摆在一起讲,是全书最实用的一节。汇总成一张表,按「可读性 / 可变性 / 可哈希 / 生态兼容」四个维度比:
| 载体 | 可读性 | 可变 | 可哈希 | 适合场景 |
|---|---|---|---|---|
tuple | 差(靠位置) | 否 | 是 | 临时返回值、内部热路径 |
typing.NamedTuple | 好(有字段名) | 否 | 是 | 需要当字典键的小记录 |
dataclass | 好 | 是 | 否(默认) | 有行为的领域对象 |
dataclass(frozen=True) | 好 | 否 | 是 | 配置、事件、值对象 |
dict | 中(键名靠约定) | 是 | 否 | 结构不固定、来源是 JSON |
落到代码上,值对象优先用冻结的 dataclass,注意slots=True是 3.10 才有的参数:
from dataclasses import dataclass, field @dataclass(frozen=True, slots=True) class SampleRecord: features: tuple[float, ...] label: str tags: tuple[str, ...] = field(default_factory=tuple) # 可变默认值必须用工厂 def __post_init__(self) -> None: if not self.features: raise InvalidSampleError(None, "features 不能为空")frozen=True同时解决了「误改」和「可哈希」两个问题,代价是__post_init__里只能用object.__setattr__修改字段,不能再走普通赋值。default_factory是必须记住的:直接写tags: tuple = ()对不可变类型没问题,但如果写list就会掉进 5.2 里那个共享可变默认值的坑。
5. 踩坑排查:5 条从报错栈里捞出来的记录
这一章全是「现象 → 原因 → 解决」。这几条都在真实项目里出现过,按出现频率从高到低排,每条都给出可复现的最小条件和修法。
5.1TypeError: object.__init__() takes exactly one argument
现象:多层继承的类一实例化就报这个错,但栈里看不出是哪一层的参数多了。最小复现:
class Base: def __init__(self) -> None: super().__init__() class Mixin(Base): def __init__(self, *, size: int) -> None: super().__init__() self.size = size Mixin(size=1) # TypeError: object.__init__() takes exactly one argument (the instance to initialize)原因:Base.__init__没有接受**kwargs,而Mixin已经把自己的参数消费掉了,super()一路穿透到object时没东西可传——真正的问题是Base这一层把链断掉了,而不是Mixin。多继承里只要任何一层构造函数签名不支持透传,链条就在那里终止。
解决:所有参与协作构造的层统一接受**kwargs并无条件透传,自己的参数用关键字专用消费。改完:
class Base: def __init__(self, **kwargs) -> None: super().__init__(**kwargs) # 尾端穿透到 object,kwargs 已为空排查技巧:从报错类开始,沿着__mro__逐层打印inspect.signature(cls.__init__),第一个不接受**kwargs的就是断点。
5.2 两个实例共享了同一份列表
现象:改a.items,b.items跟着变;或者 pytest 里前一个用例写入的数据出现在后一个用例里。
原因:可变默认值是类属性级别的一份对象,所有实例共享同一引用。这在 dataclass 里同样成立:
from dataclasses import dataclass @dataclass class Bad: items: list = [] # 所有实例共享同一个 list @dataclass class Good: items: list = None def __post_init__(self): if self.items is None: self.items = [] # 也能用,但不如 field(default_factory=list) 干净 from dataclasses import field @dataclass class Best: items: list = field(default_factory=list) # 每次实例化调用一次工厂解决:函数签名里用None哨兵再在函数体内赋值,dataclass 里一律用field(default_factory=...)。default_factory接收的是可调用对象,list、dict、set、tuple都可以直接传,带参数的用lambda或functools.partial。修完之后顺手在测试里加一条「两个实例的容器不是同一个对象」的断言,防止以后有人改回去。
5.3 加了__slots__内存却没降
现象:类上写了__slots__,sys.getsizeof(obj)或者obj.__dict__一看,__dict__还在,属性照样能动态添加。
原因:__slots__只在「当前类及其所有父类都没有__dict__」时才真正省内存。父类没定义__slots__,子类就继承了父类的__dict__,槽位声明形同虚设。
class Parent: # 没有 __slots__ → 实例带 __dict__ pass class Child(Parent): __slots__ = ("x",) # 无效:__dict__ 从 Parent 继承下来了 class Parent2: __slots__ = () class Child2(Parent2): __slots__ = ("x",) # 有效解决:沿继承链逐层补__slots__,基类至少写__slots__ = ()。另外两个副作用要知道:一是__slots__与多继承基本不兼容(两个非空__slots__的父类会冲突);二是定义了__slots__后不能再给实例加未声明的属性,很多依赖obj.xxx = ...动态赋值的库会因此炸掉。用之前先grep一下有没有人往实例上挂临时属性。
5.4 pytest 里 mock 了time.sleep,异步代码却没提速
现象:测试里monkeypatch.setattr(time, "sleep", lambda s: None),同步代码秒过,但跑asyncio的用例仍然卡满整个等待时间。
原因:协程里await asyncio.sleep(...)走的是事件循环的定时器,跟time.sleep完全无关,patch 错了对象。更要命的是,如果在协程里误写了同步的time.sleep,它会阻塞整个事件循环,所有并发任务一起停摆,表现出来就是「并发写了但一点没快」。
解决:测异步等待就 patchasyncio.sleep,或者干脆用依赖注入把等待函数传进去;定位阻塞则用asyncio的调试模式:
import asyncio async def main(): await asyncio.sleep(0.1) # PYTHONASYNCIODEBUG=1 或下面这种写法,会打印执行超过阈值的回调 asyncio.run(main(), debug=True)debug=True打开后,事件循环会把执行时间超过 100ms 的回调打出来,同步阻塞点一目了然。并发三选一(线程 / 进程 / asyncio)的判断标准也在这里:CPU 密集走进程池,I/O 密集走协程,需要调用只支持阻塞 API 的第三方库走线程池,三者混用时要特别注意把阻塞调用丢进run_in_executor。
5.5 自定义异常在子进程或跨进程序列化时炸掉
现象:主进程里好好的自定义异常,一进multiprocessing子进程就变成TypeError: __init__() missing 1 required positional argument,或者报pickle.PicklingError。
原因:异常对象跨进程序列化时依赖args重建,而重建过程会调用__init__(*args)。如果自定义__init__的签名跟Exception.args对不上,反序列化就会失败。__reduce__默认行为是(cls, self.args),而 2.3 里那个InvalidSampleError(sample, reason)的args只有reason一个(因为sample没传进super().__init__),重建时缺参数。
解决:两条路。一是让__init__的参数和args一致,把附加字段也塞进args;二是重写__reduce__显式指定重建函数:
class InvalidSampleError(ValueError): def __init__(self, sample, reason: str) -> None: super().__init__(reason) # args = (reason,) self.sample = sample self.reason = reason def __reduce__(self): # 反序列化时用同样的两个参数重建 return (self.__class__, (self.sample, self.reason))另外两条相关约束:异常类必须定义在可导入的模块顶层,定义在函数体内的类无法被 pickle;用spawn方式启动子进程时,入口必须放在if __name__ == "__main__":之下,否则子进程会重新执行模块顶层代码,在 Windows 上表现为无限递归创建进程。
6. 用 pytest 把书里的案例变成可回归的仓库
书里第 13 章讲测试,案例是给距离计算和超参数类写单测。落到自己的项目,我一般只保留三件套:一个 fixture 负责构造共享状态,一组parametrize覆盖边界,一条mypy --strict作为静态闸门。
import pytest from knn.core import Sample from knn.errors import InvalidSampleError @pytest.fixture def sample(): return Sample([1.0, 2.0], "a") @pytest.mark.parametrize("raw,expected", [ ("1.0|2.0", (1.0, 2.0)), ("1|2|3", (1.0, 2.0, 3.0)), (" 1.5 |2 ", (1.5, 2.0)), # 前后空格应被 float() 容忍 ]) def test_features_parsing(raw, expected): s = Sample([float(x) for x in raw.split("|")], "a") assert s.features == expected def test_sample_is_immutable(sample): with pytest.raises(AttributeError): sample.label = "b" # 只有 property 没有 setter,赋值应失败fixture 的作用是把「构造一个合法样本」这件事收成一处,后面所有用例共享;parametrize让每个边界独立成一条用例,失败时能直接看出是哪个输入挂了,而不是一个循环里跑到一半中断。第三组数据带空格,是因为真实 CSV 里空格永远存在,float()本身能处理,但很多人会先写strip()再写自定义解析,反而把1e-3这类科学计数法搞坏。
静态检查这一环,我在仓库里固定跑mypy --strict knn/,把disallow_untyped_defs之类的开关一次性拉满。第一次跑必然一片红,但类型提示补全之后,2.1 里那些Protocol、Iterable的收益才真正兑现——改签名的时候,所有不兼容的调用点会直接标红,不用等运行时。
| 检查项 | 工具 | 命令 | 通过标准 |
|---|---|---|---|
| 行为正确性 | pytest | python -m pytest -q | 全绿,无 skip |
| 类型一致性 | mypy | mypy --strict knn/ | 0 error |
| 覆盖盲区 | coverage | pytest --cov=knn --cov-fail-under=85 | 覆盖率不低于阈值 |
| 并发安全 | pytest +debug=True | 单独跑并发用例 | 无「回调执行超时」告警 |
最后说一个习惯。我早年在多继承上翻过一次车:一个混入类悄悄改了super()链的顺序,测试全过,上线后某个分支的参数被另一层消费掉了,日志里只剩一个语焉不详的TypeError。从那以后,我每次写超过两层的继承,都强制先在 REPL 里把__mro__打出来看一眼,再补一条断言 MRO 顺序的测试——顺序变了,测试立刻红,比事后翻栈快得多。希望帮到你。
本文还有配套的精品资源,点击获取