对象注解属性的最佳实践
**********************

作者:
   Larry Hastings


摘要
^^^^

本文意在汇聚对象的注解字典用法的最佳实践。 如果 Python 代码会去查看
Python 对象的 "__annotations__" 属性，建议遵循以下准则。

本文分为四个部分：在 Python 3.10 以上版本中访问对象注解的最佳实践、在
Python 3.9 以上版本中访问对象注解的最佳实践、适用于任何 Python 版本的
其他 "`__annotations__" 最佳实践、"__annotations__" 的特别之处。

请注意，本文是专门介绍 "__annotations__" 的，而不是介绍注解的用法。若
要了解“类型提示”的使用信息，请参阅 "typing" 模块。


在 Python 3.10 以上版本中访问对象的注解字典
===========================================

   Python 3.10 在标准库中加入了一个新函数："inspect.get_annotations()"
   。在 Python 3.10 以上的版本中，调用该函数就是访问对象注解字典的最佳
   做法。该函数还可以“解析”字符串形式的注解。

   有时会因为某些原因看不到 "inspect.get_annotations()" ，也可以直接访
   问 "__annotations__" 数据成员。这方面的最佳实践在 Python 3.10 中也
   发生了变化：从 Python 3.10 开始，Python 函数、类和模块的
   "o.__annotations__" *保证* 可用。如果确定是要查看这三种对象，只要利
   用 "o.__annotations__" 读取对象的注释字典即可。

   不过其他类型的可调用对象可能就没有定义 "__annotations__" 属性，比如
   由 "functools.partial()" 创建的可调用对象。当访问某个未知对象的
   "__annotations__" 时，Python 3.10 以上版本的最佳做法是带三个参数去
   调用 "getattr()" ，比如 "getattr(o, '__annotations__', None)"。

   在 Python 3.10 之前，在一个未定义标注但具有定义了标注的父类的类上访
   问 "__annotations__" 将返回父类的 "__annotations__"。 在 Python
   3.10 和更新的版本中，子类的标注将改为一个空字典。


在 Python 3.9 及更早的版本中访问对象的注解字典
==============================================

   在 Python 3.9 及之前的版本中，访问对象的注解字典要比新版本中复杂得
   多。这个是 Python 低版本的一个设计缺陷，特别是访问类的注解时。

   要访问其他对象——函数、可调用对象和模块——的注释字典，最佳做法与 3.10
   版本相同，假定不想调用 "inspect.get_annotations()"：你应该用三个参
   数调用 "getattr()" ，以访问对象的 "__annotations__" 属性。

   不幸的是，对于类而言，这并不是最佳做法。因为 "`__annotations__" 是
   类的可选属性，并且类可以从基类继承属性，访问某个类的
   "__annotations__" 属性可能会无意间返回 *基类* 的注解数据。例如：

      class Base:
          a: int = 3
          b: str = 'abc'

      class Derived(Base):
          pass

      print(Derived.__annotations__)

   如此会打印出 "Base" 的注解字典，而非 "Derived" 的。

   若要查看的对象是个类（"isinstance(o, type)"），代码不得不另辟蹊径。
   这时的最佳做法依赖于 Python 3.9 及之前版本的一处细节：若某个类定义
   了注解，则会存放于字典 "__dict__" 中。由于类不一定会定义注解，最好
   的做法是在类的 dict 上调用 "get" 方法。

   综上所述，下面给出一些示例代码，可以在 Python 3.9 及之前版本安全地
   访问任意对象的 "__annotations__" 属性：

      if isinstance(o, type):
          ann = o.__dict__.get('__annotations__', None)
      else:
          ann = getattr(o, '__annotations__', None)

   运行之后，"ann" 应为一个字典对象或 "None"。建议在继续之前，先用
   "isinstance()" 再次检查 "ann" 的类型。

   请注意，有些特殊的或畸形的类型对象可能没有 "__dict__" 属性，为了以
   防万一，可能还需要用 "getattr()" 来访问 "__dict__"。


解析字符串形式的注解
====================

   有时注释可能会被“字符串化”，解析这些字符串可以求得其所代表的 Python
   值，最好是调用 "inspect.get_annotations()" 来完成这项工作。

   如果是 Python 3.9 及之前的版本，或者由于某种原因无法使用
   "inspect.get_annotations()" ，那就需要重现其代码逻辑。建议查看一下
   当前 Python 版本中 "inspect.get_annotations()" 的实现代码，并遵照实
   现。

   简而言之，假设要对任一对象解析其字符串化的注释 "o" ：

   * 如果 "o" 是个模块，在调用 "eval()" 时，"o.__dict__"  可视为
     "globals" 。

   * 如果 "o" 是一个类，在调用 "eval()" 时，
     "sys.modules[o.__module__].__dict__" 视作  "globals"，
     "dict(vars(o))" 视作 "locals" 。

   * 如果 "o" 是一个用 "functools.update_wrapper()" 、
     "functools.wraps()" 或 "functools.partial()" 封装的可调用对象，可
     酌情访问 "o.__wrapped__" 或 "o.func" 进行反复解包，直到你找到未经
     封装的根函数。

   * 如果 "o" 是个可调用对象（但不是一个类），在调用 "eval()" 时，
     "o.__dict__"  可视为 "globals" 。

   但并不是所有注解字符串都可以通过 "eval()" 成功地转化为 Python 值。
   理论上，注解字符串中可以包含任何合法字符串，确实有一些类型提示的场
   合，需要用到特殊的 *无法* 被解析的字符串来作注解。比如：

   * 在 Python 支持 **PEP 604** 的联合类型 "|" (Python 3.10) 之前使用
     它。

   * 运行时用不到的定义，只在 "typing.TYPE_CHECKING" 为 True 时才会导
     入。

   如果 "eval()" 试图求值，将会失败并触发异常。因此，当要设计一个可采
   用注解的库 API ，建议只在调用方显式请求的时才对字符串求值。


任何版本 Python 中使用 "__annotations__" 的最佳实践
===================================================

   * 应避免直接给对象的 "__annotations__" 成员赋值。请让 Python 来管理
     "__annotations__"。

   * 如果直接给某对象的 "__annotations__" 成员赋值，应该确保设成一个
     "dict" 对象。

   * 如果直接访问某个对象的 "__annotations__" 成员，在解析其值之前，应
     先确认其为字典类型。

   * 应避免修改 "__annotations__" 字典。

   * 应避免删除对象的 "__annotations__" 属性。


"__annotations__" 的坑
======================

   在 Python 3 的所有版本中，如果对象没有定义注解，函数对象就会直接创
   建一个注解字典对象。用 "del fn.__annotations__" 可删除
   "__annotations__" 属性，但如果后续再访问 "fn.__annotations__"，该对
   象将新建一个空的字典对象，用于存放并返回注解。在函数直接创建注解字
   典前，删除注解操作会抛出 "AttributeError" 异常；连续两次调用 "del
   fn.__annotations__" 一定会抛出一次 "AttributeError" 异常。

   以上同样适用于 Python 3.10 以上版本中的类和模块对象。

   所有版本的 Python 3 中，均可将函数对象的 "__annotations__" 设为
   "None"。但后续用 "fn.__annotations__" 访问该对象的注解时，会像本节
   第一段所述那样，直接创建一个空字典。但在任何 Python 版本中，模块和
   类均非如此，他们允许将 "__annotations__" 设为任意 Python 值，并且会
   留存所设值。

   如果 Python 会对注解作字符串化处理（用 "from __future__ import
   annotations" ），并且注解本身就是一个字符串，那么将会为其加上引号。
   实际效果就是，注解加了 *两次* 引号。例如：

      from __future__ import annotations
      def foo(a: "str"): pass

      print(foo.__annotations__)

   这会打印出 "{'a': "'str'"}"。这不应算是个“坑”；只是因为可能会让人吃
   惊，所以才提一下。
