友好的 Python:面向对象接口
前言 很久没更新了,写这篇文章是因为受了高天直播 Code Review的启发,深刻感觉到 Python 的灵活和强大,导致了实现同样的功能不同的人会写出完全不一样的代码。Python 语法糖有很多,如何把握「甜度」?过犹不及,我就本人的口味来细说一下。 <!-- more --> 免责声明,本文有关代码好坏的论断纯属个人喜好,总结的规律均为信口开河,若要争论个高下大可不必。 写个配置类 小 F 是个后端程序员,他接到一个需求,写一个配置类作为项目配置的模型。很常见的需求嘛,先不管 Pydantic 之类的现成方案,就假设要造轮子好了。要怎么写呢?小 F 略加思索,写出来了: @dataclasses.dataclass class Settings: db_user: str db_password: str db_host: str = 'localhost' db_port: int = 3306 ... # 省略一百个配置项 漂亮!用上了 dataclass,并提供了适当的默认值,小 F 还是很熟练嘛。 小 F:算作者识相,没有故意安排我做反面教材。 这个类要怎么使用呢? settinigs = Settings(db_user='root', db_password=os.getenv("DB_PASSWORD")) 多种初始化方式 但一个配置类,哪能总是从参数去指定呢?一般这些东西,要么从环境变量读进去,要么从一个 JSON 或 YAML 文件读入,要么是某种配置管理系统。现在要怎么设计这个 API 呢? 小 F 马上明白,这是构造函数重载啊,不过等下,Python 不支持重载[^1],所幸 Python 非常动态和灵活,__init__ 可以接受多种参数嘛[^2]: class Settings: ... def __init__(self, **kwargs, from_env=False, from_file=None): if from_env: self._load_from_env() elif from_file: self._load_from_file(from_file) else: for k, v in kwargs.items(): setattr(self, k, v) [^1]: 当然硬要用 @singledispatch 去做重载也不是不行,但你真的要这样? [^2]: 为了说明问题,暂时去掉了 @dataclass,用最朴素的 __init__ 实现。 这种方法在我看来,有个最大的问题,就是传入它的参数**并不总是生效:**你传了 from_env,那 from_file 会被忽略,你传了 from_file,那其他的 kwargs 会被忽略,这对使用者是相当不友好的,他们必须看文档才知道这几个参数优先级是怎样的。 小 F:你看你还是忍不住编排我了,我会这样写吗?然后他甩出来了另一个方案: class Settings: ... def load_from_env() -> Settings: ... def load_from_file(filename: str) -> Settings: ... 这个方案就修正了前述的问题:既然不同构造方法接受的参数不一致,那我专门暴露一个初始化函数就可以了。没错,这种方法也被广泛使用,比如 json.load() 和 json.loads(),不同的方法,接受不同的参数。但这种方法有一点小小的问题,要 import 的东西有点多,这里顶层就暴露了三个类和函数。小 F 的同事小 C 说,那这样可不可以: class Settings: ... def load_from_env(self) -> None: ... def load_from_file(self, file: str) -> None: ... # 使用 settings = Settings() settings.load_from_env() 我虽然在很多地方,包括之前公司的代码中看过这种写法,但我依然极其不推荐,原因是,如果 Settings 有一些必填参数,会在第一步实例化后得到一个不完全初始化的对象。正确的做法是不实例化,而直接改用 @classmethod: class Settings def __init__(self, ...): ... @classmethod def from_env(cls) -> Settings: ... @classmethod def from_file(cls, file: str) -> Settings: ... # 使用 settings = Settings.from_env() 这其实就是 Python 的构造方法重载,只是给构造方法起了不同的名字。这也是 classmethod 最主要的使用场景——使用某特殊方法构造一个实例,它们和 __init__/__new__ 方法的地位是等同的。 一般实践上,我们会用 __init__ 实现最 verbose(自定义空间最大)的构造方法,而用 classmethod 实现其他快捷的,可以从少数入参推断出全部参数的构造方法。而对于 classmethod 与普通函数的取舍,如果要构造的对象是整个包的主要导出对象(类似于 yaml, json),则可以用函数,否则如果这个对象是某个辅助对象,比如 Connection,Config,则适合用 classmethod,可以减少需要导入的成员。 多配置选择 现在如果要为生产、测试创建不同的配置,覆盖某种值,要怎么做呢?小 F 又说,继承呗: class ProductionSettings(Settings): ... class TestSettings(Settings): ... 假如我需要按一个 key(Production/Testing) 来选择配置,该如何做呢?最朴素的方法,建立一个 mapping: settings = {"production": ProductionSettings, "test": TestSettings} IT JUST WORKS. 可是小 F 看不顺眼,觉得维护这个 mapping 很费劲。他看了一些 Python 的进阶书(不是说书不好),学会了一些高端用法,他三下五除二,就改成了下面这样: class SettingsMeta(type): mapping = {} def __init__(cls, name, bases, attrs): super().__init__(name, bases, attrs) cls.mapping[re.match(r"(.+?)Settings$").group(1).lower()] = cls class Settings(metaclass=SettingsMeta): ... class ProductionSettings(Settings): ... # 使用 production_settings = SettingsMeta.mapping["production"] 元类!斯~斯~斯国以!除了这需要一点时间才能看懂在做什么外,这么写有什么问题呢?有,这里有抽象泄漏的问题:Settings 的子类,保存到了元类 SettingsMeta上,而这个元类是创建 Settings 的「类工厂」,这里就形成了循环:Settings -> SettingsMeta -> Settings。使用者不应该感知到元类的存在,也就不应该调用他上面的属性。小 F 又说,这个 mapping 属性可以在 Settings 上使用啊,这没错,但这问题更大了,所有子类都有这个 mapping,也就是说,下面这种用法是可以的: test_settings = ProductSettings.mapping["test"] 这就完全不 make sense 了。同之前引入 classmethod 解决不完全初始化的对象一样,我们应该从根本上杜绝存在这种诡异代码的可能性。 我们千万要警惕这种「炫技」的倾向,如果有多种实现方案,一定要选择最直截了当简单明白的方法。另一个原则是,你提供的东西,最好只提供刚好所需要的接口,而不暴露多余的接口。SettingsMeta 元类就是一个反例,其实你只需要用一个 mapping,它自己倒是自动更新了,却导致了所有配置类多了一个 mapping 属性(即使你换成用一个方法获取,或是用 __init_subclass__,也会污染到它的子类,这是这种方法的最大问题)。 所以说,高端的食材,不是,高端的语言特性往往需要在适当时候使用。其实,用一个额外的字典来存映射没什么不好,如果不想手写 mapping,也可以用注册机制,能更干净的达到效果。 减少重复(DRY) 本来打算结束,篇幅不够,那再加一个需求:让配置支持默认从环境变量中取值,并且更新环境变量能立刻生效。 这就需要每次读取值的时候都访问一次环境变量,这不就是 @property 的用武之地吗? class Settings: @property def db_url(self): return self._db_url or os.getenv("CONFIG_DB_URL") @property def db_password(self): return self._db_password or os.getenv("CONFIG_DB_PASSWORD") ... 这样一来,重复的代码就多起来了,要怎么 DRY 一下呢?这里又有不止一种方法了, 用 __getattr__ 吗? class Settings: def __getattr__(self, name): try: return os.environ["CONFIG_" + name.upper()] except KeyError: raise AttributeError(name) 这种方法,违背了一条 Python 之禅,也就是我引用最多最有意义的一条: Explicit is better than implicit. 你根本无法知道这个 Settings 到底支持多少个配置项,你只要设置 CONFIG_FOO,就能用 settings.foo 得到它的值,就算已经用了 AttributeError 防御不当使用,这威力也不必要地过大了。我推荐的方式,是用描述符: class ConfigItem: def __set_name__(self, owner, name): self.name = name self.env_name = "CONFIG_" + name.upper() def __get__(self, instance, owner): if instance is None: return self return instance._data[self.name] or os.getenv(self.env_name) # 使用 class Settings: db_url = ConfigItem() db_password = ConfigItem() ... 用描述符的最大好处,是他对补全很友好,而且可以加 type hint。后续如果要支持修改配置、值的校验、类型转换等,也很方便,比如接受一个校验函数: class ConfigItem: def __init__(self, validate_func=None): if validate_func is None: validate_func = lambda self, x: x self.validate_func = validate_func def __set_name__(self, owner, name): self.name = name self.env_name = "
以上内容来自博客订阅源,版权归原作者。关注作者后可以在 关注列表 第一时间查看作者文章。
登录后,加入这场讨论。
登录参与