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

作者:
   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'"}"。这不应算是个“坑”；只是因为可能会让人吃惊
，所以才提一下。
