封装变量
封装变量(Encapsulate Variable)用一对读写函数包住一个被多处共享的变量,所有访问都经过这对函数。 它解决的痛是:一个变量谁都能直接改,出了问题找不到是谁在哪里改的。
小郑负责一个工单系统。每张新工单都有一个默认负责人,存在模块顶层的一个字典 default_owner 里。派单、提醒、报表三个模块都直接读它,运营后台可以改它。
"""默认负责人:重构前。"""
default_owner = {"first": "三", "last": "张"}
def assign(ticket: dict) -> str: return f"工单 {ticket['id']} 的负责人:{default_owner['last']}{default_owner['first']}"
if __name__ == "__main__": print(assign({"id": 1})) default_owner = {"first": "四", "last": "李"} print(assign({"id": 2}))
# 输出:# 工单 1 的负责人:张三# 工单 2 的负责人:李四// 默认负责人:重构前。export let defaultOwner = { first: "三", last: "张" };
export function assign(ticket: { id: number }): string { return `工单 ${ticket.id} 的负责人:${defaultOwner.last}${defaultOwner.first}`;}
console.log(assign({ id: 1 }));defaultOwner = { first: "四", last: "李" };console.log(assign({ id: 2 }));
// 输出:// 工单 1 的负责人:张三// 工单 2 的负责人:李四某天报表模块的同事写了这么两行,想拿一份负责人信息改个格式用:
owner = default_ownerowner["first"] = owner["first"] + "(代)"他以为 owner 是一份副本,其实是同一个字典。从这一刻起,派单模块看到的默认负责人名字就变了,而派单模块的代码一行没改。排查的人要在整个代码库里搜 default_owner,逐个看哪一处可能改了它。
如果现在要加一个需求:默认负责人改动时记一条审计日志。没有办法,因为改动可以发生在任何地方,没有一个地方能拦住它。
这是全局数据加可变数据:一个到处可见、又随时可改的值。
"""默认负责人:重构后。"""
_default_owner = {"first": "三", "last": "张"}
def get_default_owner() -> dict: return dict(_default_owner)
def set_default_owner(owner: dict) -> None: global _default_owner _default_owner = dict(owner)
def assign(ticket: dict) -> str: owner = get_default_owner() return f"工单 {ticket['id']} 的负责人:{owner['last']}{owner['first']}"
if __name__ == "__main__": print(assign({"id": 1})) set_default_owner({"first": "四", "last": "李"}) print(assign({"id": 2}))
# 输出:# 工单 1 的负责人:张三# 工单 2 的负责人:李四// 默认负责人:重构后。interface Owner { first: string; last: string;}
let defaultOwner: Owner = { first: "三", last: "张" };
export function getDefaultOwner(): Owner { return { ...defaultOwner };}
export function setDefaultOwner(owner: Owner): void { defaultOwner = { ...owner };}
export function assign(ticket: { id: number }): string { const owner = getDefaultOwner(); return `工单 ${ticket.id} 的负责人:${owner.last}${owner.first}`;}
console.log(assign({ id: 1 }));setDefaultOwner({ first: "四", last: "李" });console.log(assign({ id: 2 }));
// 输出:// 工单 1 的负责人:张三// 工单 2 的负责人:李四报表同事那两行现在是安全的:get_default_owner() 返回的是副本,改它不影响别人。审计日志有了唯一的落脚点:写在 set_default_owner 里,所有改动都经过它。Python 里变量名前加下划线、TS 里不再 export,都是在告诉别的模块“别直接碰”。
怎么一步步做
Section titled “怎么一步步做”- 写一对读写函数,先原样返回和赋值,不复制。
- 把每一处直接读变量的代码改成调用读函数,每一处直接赋值改成调用写函数。每改一处跑一次测试。
- 把变量设为模块私有(Python 加下划线前缀,TS 去掉
export),确认没有遗漏的直接访问。 - 跑测试。
- 如果变量是字典、列表这类可变对象,让读函数返回副本,写函数存入副本,再跑测试。这一步防止调用方拿到引用后在外面改。
定义与适用场景
Section titled “定义与适用场景”封装变量:把对一个变量的所有访问收口到一对函数里,变量本身变成私有。它的价值在于给数据的访问留一个可以放逻辑的地方:校验、记录、延迟计算、返回副本。变量被越多地方使用,封装的回报越大;一个函数里的局部变量不需要封装。
它常用在三种场合:模块级或全局的可变数据;类里被外部直接读写的字段;准备搬移一个被广泛使用的字段之前,先封装,再搬,调用方不用动。
第三种场合值得多说一句。假设 default_owner 以后要从模块变量改成从数据库读。没封装时,几十处 default_owner["last"] 都得改成数据库调用;封装之后只改 get_default_owner 一个函数的内部,调用方一行不动。封装给了数据一层“可以换掉实现”的壳,这是它比“把变量藏起来”更大的价值。
Python 没有真正的私有变量,下划线前缀只是约定,别的模块仍然能 from owners import _default_owner。这个约定在实践里足够:评审时看到有人导入下划线开头的名字,就知道该拦。TS 里去掉 export 则是硬性的,别的模块导不到它。
真实系统里的例子
Section titled “真实系统里的例子”很多应用把配置做成一个模块级的全局字典,这种写法学自 Django 的 settings 模块(Django 自己的 settings 对象用属性访问,拼错名字会报错,比裸字典多走了一步)。直接暴露字典的问题是拼错键名不会报错、赋错类型也不会报错。封装成读写函数之后,这两种错误都能在访问时抓住。
"""模拟应用配置模块:读写都经过函数,可以校验。"""
_settings: dict = {"DEBUG": False, "PAGE_SIZE": 20}
def get_setting(name: str): if name not in _settings: raise KeyError(f"未知配置项 {name}") return _settings[name]
def set_setting(name: str, value) -> None: current = get_setting(name) if type(value) is not type(current): raise TypeError(f"{name} 应为 {type(current).__name__},收到 {type(value).__name__}") _settings[name] = value
if __name__ == "__main__": set_setting("PAGE_SIZE", 50) print(get_setting("PAGE_SIZE")) for name, value in [("PAGE_SZIE", 10), ("DEBUG", "yes")]: try: set_setting(name, value) except (KeyError, TypeError) as error: print(error)
# 输出:# 50# '未知配置项 PAGE_SZIE'# DEBUG 应为 bool,收到 str拼错的 PAGE_SZIE 和类型不对的 "yes" 都在设置时被拦住,而不是在几百行之后某个读它的地方悄悄出错。
什么时候别用
Section titled “什么时候别用”不可变的值不需要封装:一个常量列表没人能改它,直接暴露就好。只在一个函数里用到的变量也不需要,封装的回报来自访问点的数量,一个访问点没有回报。返回副本有代价,变量是很大的结构、读得又非常频繁时,复制可能被测量出来;这时可以返回只读视图(Python 的 MappingProxyType,TS 的 Readonly 类型)代替复制。
- 被多处读写的变量,用一对读写函数包住,变量本身设为私有。
- 读函数返回副本,调用方就改不坏原值;写函数是校验和日志的唯一落脚点。
- 先用原样读写的函数替换所有访问点,测试通过后再加复制和校验。
- 局部变量和不可变的常量不需要封装。