KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
06 · 动态分发与反射:把方法存成字符串,运行时再映射回真实 callable — keel 龙骨
问题:配置或消息队列里往往只有任务名的字符串(如 "export")。运行时怎么安全、可扩展地把它对应到真正的函数?为什么 if name == "export": ... 越写越烂?这一章讲 Python 怎么把函数当数据、按名字在运行时找回并调用——注册表、getattr、self.class、importlib、inspect——也就是你说的「方法存成字符串、启用时再对应回真方法」。
问题:配置或消息队列里往往只有任务名的字符串(如
"export")。运行时怎么安全、可扩展地把它对应到真正的函数?为什么if name == "export": ...越写越烂?这一章讲 Python 怎么把函数当数据、按名字在运行时找回并调用——注册表、getattr、self.__class__、importlib、inspect——也就是你说的「方法存成字符串、启用时再对应回真方法」。
一、起点:字符串 → 函数,别用 if 链
配置 / 队列里只有字符串任务名。最直觉的写法是 if 链:
def run_task(name, payload):
if name == "export":
do_export(payload)
elif name == "import":
do_import(payload)
else:
raise ValueError(f"unknown task: {name}")
每加一种任务就改一次这个函数,所有分支挤在一起,类型增长时必然膨胀。
注意:未知任务名不要 silently 忽略或落到 else 什么都不做——生产环境里任务悄悄不执行,比报错更难查。
解法:Python 里函数是一等公民,可以像数据一样存进字典:
def do_export(payload): ...
def do_import(payload): ...
HANDLERS: dict[str, callable] = {
"export": do_export,
"import": do_import,
}
def run_task(name, payload):
handler = HANDLERS.get(name) # 按字符串找回真实函数
if handler is None:
raise ValueError(f"unknown task: {name}")
return handler(payload)
这就是「方法存成字符串、运行时映射回方法」的标准形态:名字(字符串)→ 真实 callable。新增任务 = 在字典加一行,函数本体和分发逻辑解耦。
坑:HANDLERS[name] 在未知 key 上抛 KeyError;用 .get(name) 拿 None 再判空,错误更可控。
二、getattr:按字符串在对象上找回方法
函数常是某个对象的方法。用 getattr 按字符串在对象上找回:
class TaskRunner:
def handle_export(self, payload): ...
def dispatch(self, action: str, payload):
handler = getattr(self, f"handle_{action}", None) # "export" -> "handle_export"
if handler is None or not callable(handler):
raise ValueError(f"no handler: {action}")
return handler(payload)
TaskRunner().dispatch("export", p) 内部把字符串 "export" 拼成 "handle_export",再 getattr 找回绑定方法并调用。这就是 handle 模式:方法名以字符串形式存在(配置、消息、API 字段),运行时再定位到真实方法。
坑一:getattr 返回的不一定是方法,也可能是数据属性。必须先 callable() 判断,否则对一个数调用会 TypeError。
安全:对不可信来源(外部输入、消息体)用 getattr(obj, raw_input) 极危险——它能摸到 __class__、__globals__、__subclasses__ 这类属性,等于给对方任意代码执行入口。外部字符串必须先映射到白名单 key(如上面的 f"handle_{action}" 且 action 限定取值),绝不直接 getattr(obj, raw_input)。
三、拿到自己的类:self.class / type(self) / cls
在实例或类方法里,运行期可以拿到「自己属于哪个类」:
class Plugin:
def clone(self):
return self.__class__() # 造一个同类型的全新实例
@classmethod
def from_config(cls, cfg: dict):
return cls(**cfg) # cls 是真实子类
type(self) 与 self.__class__ 通常等价。cls 在 classmethod 里永远是「实际被调用的类」,连父类方法里也是子类——所以 from_config 既能造 Plugin 也能造任意子类。
延伸:Base.__subclasses__() 能在运行期枚举所有已注册的子类,插件系统常靠它自动发现所有实现。
四、模块级动态查找:globals() / locals()
本模块顶层函数都在 globals() 这个字典里,按名字字符串即可找回:
def cmd_export(): ...
def run(name):
fn = globals().get(f"cmd_{name}")
if callable(fn):
return fn()
globals() 是整个模块的命名空间,任何人都可能覆盖里面的名字,也可被误映射到无关函数;可控性不如显式注册表。小脚本可用,正式代码优先注册表。
五、跨模块 / 插件式:importlib + getattr
handler 在别的模块、连模块名都来自配置时,用 importlib 按字符串导入模块,再 getattr 取函数:
import importlib
def load_handler(module_path: str, attr: str):
module = importlib.import_module(module_path)
fn = getattr(module, attr, None)
if not callable(fn):
raise ValueError(f"{module_path}.{attr} 不是可调用的")
return fn
模块路径来自不可信配置时,importlib.import_module 能导入任意模块——必须白名单限定前缀(如只允许 plugins. 开头),否则是代码执行漏洞。
六、inspect:运行期取元信息(签名、是不是函数 / 类)
import inspect
def adapter(fn, *args, **kwargs):
sig = inspect.signature(fn)
picked = {k: v for k, v in kwargs.items() if k in sig.parameters} # 只传 fn 接受的参数
return fn(*args, **picked)
inspect.isfunction(do_export) # True
inspect.isclass(Plugin) # True
靠 inspect 猜参数再调用,比显式契约脆弱——函数一改签名,适配层就 silently 错位。适合框架 / 适配层,不适合业务主路径。
七、把「调用某方法」变成可传递的 callable:operator.methodcaller
from operator import methodcaller
upper = methodcaller("upper")
upper("ab") # "AB",等效 "ab".upper()
sorted(names, key=methodcaller("lower")) # 不区分大小写排序
它是 getattr + 调用的封装,常用于把方法名当数据传给 map、sorted 等。
八、进阶:运行期造类 / 动态挂方法(慎用)
Dynamic = type("Dynamic", (Plugin,), {"run": lambda self: "ok"})
def extra(self): return "extra"
Plugin.extra = extra # setattr 挂方法(monkey-patch)
这种写法有全局副作用——影响所有实例、难测试、破坏类型检查。只在插件扩展、测试隔离、框架 bootstrap 这类受控场景用,业务主路径别碰。
九、几种方式怎么选
| 方式 | 适用 | 安全 / 可控 | 备注 |
|---|---|---|---|
注册表 dict[str, callable] |
跨对象 / 模块的有限集合 | 高(白名单天然) | 外部输入首选 |
getattr(obj, name) |
同对象上按名找方法 | 中(能摸到危险属性) | 内部可信、配合前缀约定 |
globals() / locals() |
本模块内按名找 | 低(易污染) | 小脚本可用 |
importlib + getattr |
按配置加载插件 | 中(需校验模块路径) | 框架 / 插件化 |
type() / setattr |
动态造类 / 挂方法 | 低(全局副作用) | 高级、慎用 |
结论:函数是一等公民,名字是字符串,注册表字典 name: callable 就是「存成字符串、运行时映射回方法」的标准解法——比 if 链可扩展、比裸 getattr / globals 安全。
十、几个必须记住的点
- 序列化边界:函数 / 方法本身不能 JSON 序列化,能序列化的只有它的名字(或 import 路径)。所以「存名字、查表还原」是跨进程分发的必然形态,但也要求名字稳定可还原——改名或移动模块 = 断链。
- 可维护性:动态分发让 IDE 跳转、静态类型、重构全部失效。缓解:注册表集中定义 + 给 callable 标类型注解 + 测试覆盖每种 key。
- 安全:任何「字符串驱动执行」都是注入面。不可信来源一律白名单,绝不直接
globals()[name]()或对不可信字符串getattr(obj, name)。
十一、可迁移的套路
- 按名字找函数优先用注册表字典(名字 → callable),白名单天然安全可扩展;if 链和裸
getattr退居次选。 - 类内动态派发用
getattr(self, "handle_" + name),但外部输入先映射成白名单 key 再 getattr。 - 拿到自己的类用
self.__class__/cls(classmethod)做工厂、克隆、按类型派发。 - 跨进程只能传名字 / 路径字符串,落地端用注册表或
importlib还原成真实 callable;改名、移模块都会断链。 - 任何字符串驱动执行都是注入面:不可信来源只允许命中白名单。
小结
这一门讲了七种生产级写法:ContextVar 管请求作用域、单例和 frozen dataclass 管唯一与不可变、模板方法抽生命周期、闭包与延迟导入管临时状态与重资源、声明式鉴权与中间件管横切、Pydantic 与注册表管数据与类型增长,最后一章讲 Python 怎么把函数当数据、按名字在运行时找回并调用。它们共同的取向是:让语言 / 框架机制替你保证正确性,而不是靠人肉小心。
↓ 复习入口:课程导读