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 安全。

十、几个必须记住的点

十一、可迁移的套路

  1. 按名字找函数优先用注册表字典(名字 → callable),白名单天然安全可扩展;if 链和裸 getattr 退居次选。
  2. 类内动态派发用 getattr(self, "handle_" + name),但外部输入先映射成白名单 key 再 getattr。
  3. 拿到自己的类用 self.__class__ / cls(classmethod)做工厂、克隆、按类型派发。
  4. 跨进程只能传名字 / 路径字符串,落地端用注册表或 importlib 还原成真实 callable;改名、移模块都会断链。
  5. 任何字符串驱动执行都是注入面:不可信来源只允许命中白名单。

小结

这一门讲了七种生产级写法:ContextVar 管请求作用域、单例和 frozen dataclass 管唯一与不可变、模板方法抽生命周期、闭包与延迟导入管临时状态与重资源、声明式鉴权与中间件管横切、Pydantic 与注册表管数据与类型增长,最后一章讲 Python 怎么把函数当数据、按名字在运行时找回并调用。它们共同的取向是:让语言 / 框架机制替你保证正确性,而不是靠人肉小心。

↓ 复习入口:课程导读

进入 keel 阅读