Published on

友好的 Python:面向对象接口

Reading Time

11 min read

Photo by Ryland Dean on Unsplash
Photo by Ryland Dean on Unsplash

前言

很久没更新了,写这篇文章是因为受了高天直播 Code Review的启发,深刻感觉到 Python 的灵活和强大,导致了实现同样的功能不同的人会写出完全不一样的代码。Python 语法糖有很多,如何把握「甜度」?过犹不及,我就本人的口味来细说一下。

免责声明,本文有关代码好坏的论断纯属个人喜好,总结的规律均为信口开河,若要争论个高下大可不必。

写个配置类

小 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)

这种方法在我看来,有个最大的问题,就是传入它的参数**并不总是生效:**你传了 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),则可以用函数,否则如果这个对象是某个辅助对象,比如 ConnectionConfig,则适合用 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 = "CONFIG_" + name.upper()

    def __get__(self, instance, owner):
        if instance is None:
            return self
        value = instance._data[self.name] or os.getenv(self.env_name)
        return self.validate_func(instance, value)

用起来更是相当酷炫:


class Settings:

    db_user = ConfigItem()

    @ConfigItem
    def db_password(self, value):
        if len(value) < 8:
            raise ValueError("Password must be at least 8 characters")
        return value

再细看一眼,是不是特别像一个 ORM 了,接着扩展下去,这其实就是一个 ORM 或者类似 pydantic 的轮子的雏形。

Footnotes

  1. 当然硬要用 @singledispatch 去做重载也不是不行,但你真的要这样?

  2. 为了说明问题,暂时去掉了 @dataclass,用最朴素的 __init__ 实现。

Share: