跳转到内容

封装变量

封装变量(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_owner
owner["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,都是在告诉别的模块“别直接碰”。

重构前四个模块直接读写同一个字典;重构后读经过 get_default_owner 拿到副本,写只经过 set_default_owner

  1. 写一对读写函数,先原样返回和赋值,不复制。
  2. 把每一处直接读变量的代码改成调用读函数,每一处直接赋值改成调用写函数。每改一处跑一次测试。
  3. 把变量设为模块私有(Python 加下划线前缀,TS 去掉 export),确认没有遗漏的直接访问。
  4. 跑测试。
  5. 如果变量是字典、列表这类可变对象,让读函数返回副本,写函数存入副本,再跑测试。这一步防止调用方拿到引用后在外面改。

封装变量:把对一个变量的所有访问收口到一对函数里,变量本身变成私有。它的价值在于给数据的访问留一个可以放逻辑的地方:校验、记录、延迟计算、返回副本。变量被越多地方使用,封装的回报越大;一个函数里的局部变量不需要封装。

它常用在三种场合:模块级或全局的可变数据;类里被外部直接读写的字段;准备搬移一个被广泛使用的字段之前,先封装,再搬,调用方不用动。

第三种场合值得多说一句。假设 default_owner 以后要从模块变量改成从数据库读。没封装时,几十处 default_owner["last"] 都得改成数据库调用;封装之后只改 get_default_owner 一个函数的内部,调用方一行不动。封装给了数据一层“可以换掉实现”的壳,这是它比“把变量藏起来”更大的价值。

Python 没有真正的私有变量,下划线前缀只是约定,别的模块仍然能 from owners import _default_owner。这个约定在实践里足够:评审时看到有人导入下划线开头的名字,就知道该拦。TS 里去掉 export 则是硬性的,别的模块导不到它。

很多应用把配置做成一个模块级的全局字典,这种写法学自 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" 都在设置时被拦住,而不是在几百行之后某个读它的地方悄悄出错。

不可变的值不需要封装:一个常量列表没人能改它,直接暴露就好。只在一个函数里用到的变量也不需要,封装的回报来自访问点的数量,一个访问点没有回报。返回副本有代价,变量是很大的结构、读得又非常频繁时,复制可能被测量出来;这时可以返回只读视图(Python 的 MappingProxyType,TS 的 Readonly 类型)代替复制。

  • 被多处读写的变量,用一对读写函数包住,变量本身设为私有。
  • 读函数返回副本,调用方就改不坏原值;写函数是校验和日志的唯一落脚点。
  • 先用原样读写的函数替换所有访问点,测试通过后再加复制和校验。
  • 局部变量和不可变的常量不需要封装。