封装集合
封装集合(Encapsulate Collection)不再把类里的列表直接交出去,而是提供添加、删除的方法,读取时返回副本或只读视图。 它解决的痛是:外部代码拿到列表就能随便增删,类自己的规则管不住它。
一个培训平台的学员类 Person 有一个课程列表。选课页面、管理后台、批量导入脚本都能拿到这个列表。运营新定了一条规则:每人最多同时选 5 门课。
"""学员选课:重构前。"""
class Person: def __init__(self, name: str) -> None: self.name = name self.courses: list[str] = []
if __name__ == "__main__": p = Person("小林") p.courses.append("Python 入门") p.courses.append("SQL 基础") p.courses.remove("SQL 基础") print(p.courses)
# 输出:# ['Python 入门']// 学员选课:重构前。class Person { courses: string[] = []; constructor(readonly name: string) {}}
const p = new Person("小林");p.courses.push("Python 入门");p.courses.push("SQL 基础");p.courses.splice(p.courses.indexOf("SQL 基础"), 1);console.log(JSON.stringify(p.courses));
// 输出:// ["Python 入门"]“最多 5 门”这条规则该写在哪?Person 管不住,因为三个模块都直接 p.courses.append(...),类根本不知道有人加了课。只能在三个模块里各写一遍检查,漏一处就有人选了第 6 门。更隐蔽的是,即使类提供了一个 set_courses(new_list) 的方法并在里面检查,调用方拿到 p.courses 之后照样可以 append,因为拿到的是同一个列表对象。
这是可变数据:集合被谁改、什么时候改,类一无所知。
"""学员选课:重构后。"""
MAX_COURSES = 5
class Person: def __init__(self, name: str) -> None: self.name = name self._courses: list[str] = []
@property def courses(self) -> list[str]: return list(self._courses)
def add_course(self, course: str) -> None: if len(self._courses) >= MAX_COURSES: raise ValueError(f"最多同时选 {MAX_COURSES} 门课") self._courses.append(course)
def remove_course(self, course: str) -> None: self._courses.remove(course)
if __name__ == "__main__": p = Person("小林") p.add_course("Python 入门") p.add_course("SQL 基础") p.remove_course("SQL 基础") print(p.courses)
# 输出:# ['Python 入门']// 学员选课:重构后。const MAX_COURSES = 5;
class Person { private _courses: string[] = []; constructor(readonly name: string) {}
get courses(): readonly string[] { return [...this._courses]; }
addCourse(course: string): void { if (this._courses.length >= MAX_COURSES) throw new Error(`最多同时选 ${MAX_COURSES} 门课`); this._courses.push(course); }
removeCourse(course: string): void { const i = this._courses.indexOf(course); if (i < 0) throw new Error(`没有选这门课:${course}`); this._courses.splice(i, 1); }}
const p = new Person("小林");p.addCourse("Python 入门");p.addCourse("SQL 基础");p.removeCourse("SQL 基础");console.log(JSON.stringify(p.courses));
// 输出:// ["Python 入门"]“最多 5 门”只写在 add_course 一处。courses 返回副本,调用方往副本里加东西不影响类;TS 的返回类型写成 readonly string[],push 在编译时就会报错,比运行时更早发现。
怎么一步步做
Section titled “怎么一步步做”- 如果集合字段还没封装,先做封装变量(第 8 章),给它一个读属性。
- 在类上加
add_course和remove_course。跑测试。 - 找到所有直接修改集合的地方(
append、remove、push、splice、下标赋值),逐个改成调用新方法,每改一处跑一次测试。 - 让读属性返回副本(Python 用
list(...),TS 用展开运算符或slice()),TS 再把返回类型标成readonly。跑测试。此时漏改的写操作会在 TS 里编译失败,在 Python 里变成“改了副本、没效果”,有测试覆盖的话会被抓到。 - 把规则加进
add_course。
字典和集合也一样要封装。用户的权限如果是一个公开的 set,任何模块都能 user.permissions.add("admin"),提权就是一行代码的事。提供 grant 和 revoke 两个方法,并在里面记审计日志,权限的每一次变化都有迹可查。
Python 里返回副本时,可以考虑返回 tuple(self._courses) 而不是 list(...):元组没有 append,调用方误以为能改它时会立刻得到一个错误,而不是悄悄改了一份没人用的副本。
定义与适用场景
Section titled “定义与适用场景”封装集合:类不直接暴露内部集合的引用,而是提供修改集合的方法,读取时返回副本或只读视图。凡是一个类持有列表、字典、集合,而外部需要修改它们的地方,都该考虑:购物车里的商品、用户的角色、文档的标签、事件的监听器。
返回副本还是只读视图?副本最安全,代价是每次读都复制一次;集合很大、读得很频繁时,复制的开销才值得考虑:字典可以返回 types.MappingProxyType 这种不复制的只读视图,列表没有现成的只读视图,可以让类实现 __iter__ 和 __len__ 而不交出列表(见下文),TS 只靠 readonly 类型(运行时仍能被强转后修改,但正常代码不会这么写)。
真实系统里的例子
Section titled “真实系统里的例子”Node 的 EventEmitter
Section titled “Node 的 EventEmitter”Node 的 EventEmitter 内部有一张“事件名到监听器数组”的表,但它从不把数组交出去,只提供 on、off、emit;listeners(name) 返回的是副本。下面是它的最小复刻,最后一行证明改副本不影响内部。
// 模拟 Node 的 EventEmitter:监听器数组不对外暴露。type Listener = (payload: string) => void;
class Emitter { private readonly table = new Map<string, Listener[]>();
on(event: string, fn: Listener): void { this.table.set(event, [...(this.table.get(event) ?? []), fn]); }
off(event: string, fn: Listener): void { this.table.set(event, (this.table.get(event) ?? []).filter((f) => f !== fn)); }
emit(event: string, payload: string): void { for (const fn of this.table.get(event) ?? []) fn(payload); }
listeners(event: string): Listener[] { return [...(this.table.get(event) ?? [])]; }}
const bus = new Emitter();const log = (msg: string) => console.log(`收到:${msg}`);bus.on("order", log);bus.emit("order", "A100");bus.listeners("order").length = 0;bus.emit("order", "A101");bus.off("order", log);bus.emit("order", "A102");console.log(bus.listeners("order").length);
// 输出:// 收到:A100// 收到:A101// 0bus.listeners("order").length = 0 清空的只是副本,所以 A101 照样收到;真正的移除只能走 off。
有时调用方只需要遍历和计数,不需要拿到列表本身。这时可以让类直接支持遍历:Python 实现 __iter__ 和 __len__,TS 实现 [Symbol.iterator],调用方写 for course in person、len(person),类内部用什么数据结构存课程完全不对外暴露,将来从列表换成别的结构也不影响任何调用方。
什么时候别用
Section titled “什么时候别用”集合只在类内部使用、从不交出去,就不需要这套方法。一个函数内部临时建的列表也不需要。集合本身是不可变的(Python 的 tuple、frozenset),读出去也改不了,直接暴露就行。
封装集合常常和封装记录(第 13 章)一起出现:记录里的列表字段,正是最容易被外部直接修改的地方。先封装记录,再给其中的集合字段配上增删方法,一个类对自己数据的控制才算完整。如果集合的元素本身也是可变对象,返回副本只保护了列表的结构,元素仍能被改,这时还要考虑把元素做成不可变的值对象(第 33 章)。
- 不把内部列表的引用交出去,外部只能通过
add、remove方法修改。 - 读属性返回副本或只读类型,改副本不影响类。
- 集合上的规则(上限、去重、排序)都写进修改方法,只写一处。
- TS 的
readonly数组类型让漏改的写操作在编译时就暴露出来。