[博客翻译]别让字典毁了你的代码——Python编程中的替代方案


原文地址:https://roman.pt/posts/dont-let-dicts-spoil-your-code/


Balance

你的简单原型或临时脚本多久会变成完整的应用程序?

有机代码增长的简洁性有一面不利:变得难以维护。字典作为主要数据结构的泛滥是你的代码中技术债务的明显信号。幸运的是,现代 Python 提供了许多可行的替代方案来取代普通字典。

字典有什么问题?

字典不透明

接受字典的函数扩展和修改起来非常困难。通常,要更改接收字典的函数,你必须手动追溯调用路径回到该字典的创建点。通常存在多个调用路径,并且如果程序没有计划地增长,字典结构可能有不一致之处。

字典是可变的

为了适应特定工作流程而修改字典值是很诱人的,程序员经常滥用这种功能。原地修改可以有不同叫法:预处理、填充、充实、数据整理等。结果都是相同的。这种操作妨碍了你的数据结构,并让其依赖于你的应用程序的工作流程。

字典不仅允许你修改它们的数据,还允许你随意修改对象的结构。你可以添加或删除字段或改变类型。这样做是对你数据的最大伤害。

将字典视为线上传输格式

字典出现在代码中的常见来源是从 JSON 反序列化。例如,从第三方 API 响应中得到。

>>> requests.get("https://api.github.com/repos/imankulov/empty").json()
{'id': 297081773,
 'node_id': 'MDEwOlJlcG9zaXRvcnkyOTcwODE3NzM=',
 'name': 'empty',
 'full_name': 'imankulov/empty',
 'private': False,
...
}

API 返回的一个字典。

养成一种习惯,将字典视为“线上传输格式”并立即将其转换为提供语义的数据结构。

使用序列化器和反序列化器在传输格式与内部表示之间转换

使用序列化器和反序列化器在传输格式与内部表示之间转换

实现方法很简单。

  • 定义你的领域模型。领域模型仅仅是应用程序中的一个类。
  • 在获取时同时进行反序列化。

在 Domain-Driven Design (领域驱动设计) 中,这种模式称为防腐层。除了提供语义清晰度外,领域模型还提供了一个自然的层次,隔离了外部架构与你应用程序的业务逻辑。

从 GitHub 检索仓库信息的两个函数实现:

返回字典

import requests

def get_repo(repo_name: str):
    """根据名称返回仓库信息。"""
    return requests.get(f"https://api.github.com/repos/{repo_name}").json()

函数的输出是不透明的并且过于冗长。格式是在你的代码之外定义的。

>>> get_repo("imankulov/empty")
{'id': 297081773,
 'node_id': 'MDEwOlJlcG9zaXRvcnkyOTcwODE3NzM=',
 'name': 'empty',
 'full_name': 'imankulov/empty',
 'private': False,
 # 数十行不必要的属性、URL 等。
 # ...
}

返回领域模型

class GitHubRepo:
    """GitHub 仓库。"""
    def __init__(self, owner: str, name: str, description: str):
        self.owner = owner
        self.name = name
        self.description = description

    def full_name(self) -> str:
        """获取仓库全名。"""
        return f"{self.owner}/{self.name}"

def get_repo(repo_name: str) -> GitHubRepo:
    """根据名称返回仓库信息。"""
    data = requests.get(f"https://api.github.com/repos/{repo_name}").json()
    return GitHubRepo(data["owner"]["login"], data["name"], data["description"])
>>> get_repo("imankulov/empty")
<GitHubRepo at 0x103023520>

尽管下面的示例包含更多代码,但相比于前一个示例,在维护和扩展代码库时这一个更好。

让我们来看看两者之间的区别。

  • 数据结构明确且我们可以详细记录它。
  • 类还有一个 full_name() 方法实现了一些类特定的业务逻辑。与字典不同,数据模型允许你将代码和数据一起存放。
  • GitHub API 的依赖被隔离在 get_repo() 函数中。 GitHubRepo 对象不需要知道任何关于外部 API 和对象如何创建的信息。通过这种方式,你可以独立于模型修改反序列化器或添加新方法来创建对象:如来自 pytest 固定装置,GraphQL API,本地缓存等。

☝️ 如果不需要,请忽略来自 API 的字段。仅保留你需要的字段。

在许多情况下,你可以也应该忽略来自 API 的大多数字段,仅添加应用程序使用的字段。不仅复制字段是浪费时间,还会使类结构变得僵硬,使得难以适应业务逻辑的变化或支持新版本的 API。从测试的角度来看,较少的字段意味着实例化对象时更少的麻烦。

流程化模型创建

包装字典需要创建大量类。你可以通过使用帮助你“创建更优秀类”的库简化工作。

使用 dataclasses 创建模型

自 Python 3.7 版本起,Python 提供了 数据类

标准库中的 dataclasses 模块提供了装饰器和函数,可以自动为类添加生成的特殊方法,例如 __init__()__repr__()。因此,你编写的样板代码更少。

我将 dataclasses 用于小项目或脚本,以免引入额外的依赖。 GitHubRepo 模型使用 dataclasses 将看起来如下所示。

from dataclasses import dataclass

@dataclass(frozen=True)
class GitHubRepo:
    """GitHub 仓库。"""
    owner: str
    name: str
    description: str

    def full_name(self) -> str:
        """获取仓库全名。"""
        return f"{self.owner}/{self.name}"

当我创建数据类时,我的数据类几乎总是被定义为不可变的。相反,使用 dataclasses.replace() 来创建新的实例。只读属性给开发人员带来了安心感,他们在阅读和维护你的代码。

或者,使用 Pydantic 创建模型

最近,Pydantic 成为了我定义模型的首选工具,这是一个第三方数据验证库,比 dataclasses 强大得多。我特别喜欢它的序列化器和反序列化器、自动类型转换和自定义验证器。

序列化器简化了将记录存储到外部存储的过程,例如用于缓存。类型转换在将复杂的分层 JSON 转换为对象层次时特别有用。验证器对所有其他内容都有帮助。

使用 Pydantic,同样的模型可以如此定义。

from pydantic import BaseModel


class GitHubRepo(BaseModel):
    """GitHub 仓库。"""
    owner: str
    name: str
    description: str

    class Config:
        frozen = True

    def full_name(self) -> str:
        """获取仓库全名。"""
        return f"{self.owner}/{self.name}"

在线服务 jsontopydantic.com能帮我快速从第三方 API 创建 Pydantic 模型。我在服务中输入他们的文档中的响应示例,服务便会返回 Pydantic 模型。

jsontopydantic.com 将 Todoist API 响应转换为 Pydantic 模型
jsontopydantic.com 将 Todoist API 响应转换为 Pydantic 模型

在遗留代码库中标记字典为 TypeDict

Python 3.8 引入了所谓的 TypedDicts。在运行时,它们像普通的字典一样工作,但为开发者、类型验证器和集成开发环境提供额外的信息以描述其结构。

如果你遇到了大量字典的遗留代码,并且暂时无法重构所有代码,至少可以将这些字典注释为类型化的。

from typing import TypedDict


class GitHubRepo(TypedDict):
    """GitHub 仓库。"""
    owner: str
    name: str
    description: str

repo: GitHubRepo = {
    "owner": "imankulov",
    "name": "empty",
    "description": "An empty repository",
}

下面,我提供两张来自 PyCharm 的截图,以展示添加类型信息如何可以简化您的开发体验并防止错误。

PyCharm knows about the value type and provides autocomplete
PyCharm 知道值的类型并提供自动补全

PyCharm knows about the missing key and issues a warning
PyCharm 知道缺少的键并发出警告

对于键值存储,请将字典注释为映射

字典的一种合法用例是键值存储,在这种存储中所有值都有相同的类型,并且通过键来查找这些值。

colors = {
    "red": "#FF0000",
    "pink": "#FFC0CB",
    "purple": "#800080",
}

一个作为映射使用的字典。

在实例化或将此类字典传递给函数时,请考虑通过将变量类型注释为 Mapping 或 MutableMapping 来隐藏实现细节。一方面,这可能听起来像是过度设计。默认情况下字典是最常见的 MutableMapping 实现。另一方面,通过将变量注释为映射类型,您可以指定键和值的类型。此外,在 Mapping 类型的情况下,您发送了一个明确的消息,表明该对象应该是不可变的。

示例

我定义了一个颜色映射,并注释了一个函数。请注意函数是如何使用字典允许的操作但禁止 Mapping 实例使用这些操作的。

# file: colors.py
from typing import Mapping

colors: Mapping[str, str] = {
    "red": "#FF0000",
    "pink": "#FFC0CB",
    "purple": "#800080",
}

def add_yellow(colors: Mapping[str, str]):
    colors["yellow"] = "#FFFF00"

if __name__ == "__main__":
    add_yellow(colors)
    print(colors)

尽管类型错误,在运行时没有问题。

$ python colors.py
{'red': '#FF0000', 'pink': '#FFC0CB', 'purple': '#800080', 'yellow': '#FFFF00'}

为了验证有效性,我可以使用 mypy,它会引发一个错误。

$ mypy colors.py
colors.py:11: error: 不支持的索引赋值目标 ("Mapping[str, str]")
已找到 1 个错误(检查了 1 个源文件)

控制字典

密切关注您的字典。不要让它们控制您的应用程序。就像每种技术债务一样,您越晚引入适当的数据结构,转换就越复杂。