跳转至

全局对象模式

原文:The Global Object Pattern

像其他几种脚本语言一样,Python 将每个模块的外层解析为普通代码。未加边框的赋值语句、表达式、甚至循环和条件语句都将在模块被导入时执行。这为用常量和数据结构来补充模块的类和函数提供了一个很好的机会,调用者会发现它很有用--但也提供了危险的诱惑:可变的全局对象会把远处的代码耦合起来,而 I/O 操作会带来导入时间的费用和副作用。

每个 Python 模块都是一个独立的命名空间。像 json 这样的模块可以提供一个 loads() 函数,而不会与 pickle 模块中定义的完全不同的 loads() 函数发生冲突、替换或覆盖。

独立的命名空间对于使编程语言具有可操作性至关重要。如果 Python 模块不是独立的名字空间,你将无法通过把注意力集中在你面前的模块上来阅读或编写 Python 代码 -- 一行代码可能会使用,或意外地与标准库中其他地方定义的名字或你安装的第三方模块冲突。如果一个第三方模块的新版本定义了一个新的全局,并与你的模块发生冲突,那么升级该模块可能会破坏你的整个程序。被迫在没有命名空间的语言中编码的程序员很快就会发现自己在全局名称中添加了前缀、后缀和额外的标点符号,以防止它们发生冲突。

当然,每个函数和类都是一个对象(在 Python 中,所有东西都是一个对象),模块全局模式更具体地指的是 在模块的全局级别上被赋予名字的普通对象实例。

有两种模式使用了模块全局,但它们的重要性足以让它们有自己的文章。

  • Prebound Methods 是在一个模块构建一个对象时产生的,然后将该对象的一个或多个绑定方法分配给模块全局级别的名称。这些名字可以在以后用来调用这些方法,而不需要去找对象本身。
  • 虽然一个 Sentinel Object 不一定要生活在模块的全局命名空间中,一些 Sentinel Object (哨兵对象)被定义为类的属性,而另一些则是私有的,生活在一个闭包中,但许多哨兵,无论是在标准库中还是在其他地方,都被定义为模块全局并被访问。

本文将介绍其他一些常见的情况。

常量模式

模块经常将有用的数字、字符串和其他值分配给其全局范围内的名字。标准库包括许多这样的赋值,我们可以从中摘录几个例子。

January = 1                   # calendar.py
WARNING = 30                  # logging.py
MAX_INTERPOLATION_DEPTH = 10  # configparser.py
SSL_HANDSHAKE_TIMEOUT = 60.0  # asyncio.constants.py
TICK = "'"                    # email.utils.py
CRLF = "\r\n"                 # smtplib.py
记住,这些是 "常量",只是在对象本身是不可改变的意义上。名称仍然可以被重新分配。

import calendar
calendar.January = 13
print(calendar.January) # 13
或被删除,对于这个问题。
del calendar.January
print(calendar.January)
Traceback (most recent call last):
  ...
AttributeError: module 'calendar' has no attribute 'January'

除了整数、浮点数和字符串之外,常量还包括不可变的容器,如 tuplesfrozen sets

all_errors = (Error, OSError, EOFError)  # ftplib.py
bytes_types = (bytes, bytearray)         # pickle.py
DIGITS = frozenset("0123456789")         # sre_parse.py

更专门的不可变数据类型也可以作为常量。

_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)  # datetime

在极少数情况下,代码中明显不打算修改的模块全局还是会使用可变数据结构。在 frozenset 发明之前的代码中,普通的可变集很常见。今天仍在使用字典,因为,不幸的是,标准库没有提供冻结字典。

# socket.py
_blocking_errnos = { EAGAIN, EWOULDBLOCK }

# locale.py
windows_locale = {
  0x0436: "af_ZA", # Afrikaans
  0x041c: "sq_AL", # Albanian
  0x0484: "gsw_FR",# Alsatian - France
  ...
  0x0435: "zu_ZA", # Zulu
}

常量通常是作为重构引入的:程序员注意到相同的值 60.0反复出现在他们的代码中,因此为该值引入了一个常量SSL_HANDSHAKE_TIMEOUT。现在,每次使用这个名字都会导致在全局范围内进行搜索的轻微代价,但这被一些优势所平衡。常量的名称现在记录了值的含义,提高了代码的可读性。常量的赋值语句现在提供了一个单一的位置,将来可以在那里编辑这个值,而不需要在代码中寻找每个使用 60.0 的地方。

这些优点是非常重要的,有时甚至会为一个只使用一次的值引入常量,将一个隐藏在代码深处的字词作为一个全局值提升到可见的位置。

一些程序员把常量分配放在靠近使用它们的代码的地方;另一些程序员则把所有常量放在文件的顶部。除非常量被放在离代码很近的地方,以至于人类读者总是能看到它,否则把常量放在模块的顶部会更友好,便于那些还没有把编辑器配置为支持跳转定义的读者参考。

另一种常量不是向内的,针对模块本身的代码,而是向外的,作为模块所宣传的 API 的一部分。像 logging 模块中的WARNING 这样的常量为调用者提供了常量的优势:代码将更加可读,常量的值可以在以后调整,而不需要每个调用者编辑他们的代码。

你可能会想,一个供模块自己使用,但不供调用者使用的常量,总是以下划线开始,以标明它的私有。但是 Python 程序员在标记常量为私有时并不一致,也许是因为需要永远保留一个常量,因为调用者可能已经决定开始使用它,这比让一个辅助函数或类的 API 永远被锁定的代价要小。

计算导入时间

有时引入常量是为了提高效率,以避免每次调用代码时重新计算一个值。例如,尽管在所有的现代 Python 实现中,涉及字面数字的数学运算实际上已经被优化掉了,但开发者仍然经常感到更舒服,通过把结果分配给一个模块的全局,明确表示数学运算应该在导入时完成。

# zipfile.py
ZIP_FILECOUNT_LIMIT = (1 << 16) - 1
当数学表达式很复杂时,指定一个名字也能增强代码的可读性。

再举个例子,存在一些特殊的浮点值,它们不能在 Python 中写成字面意义;它们只能通过向 float 类型传递一个字符串来生成。为了避免每次需要这样的值时都用 'nan''inf'来调用 float() ,模块通常只建立一次这样的值作为模块的 globals

# encoder.py
INFINITY = float('inf')
常量也可以捕获条件的结果,以避免在每次需要数值时重新评估它——当然,只要条件在程序运行时不会改变。

# shutil.py
COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 16 * 1024
我最喜欢的标准库中的计算常量的例子是types模块。我一直以为它是在C语言中实现的,以获得对FunctionTypeLambdaType等由语言实现本身定义的内置类型对象的特殊访问。

事实证明?我错了。types 模块是用普通的 Python 编写的

没有对语言内部的任何特殊访问,它所做的和其他人一样,都是为了了解函数的类型。它创建了一个函数,然后询问它的类型。

# types.py
def _f(): pass
FunctionType = type(_f)

一方面,这使得 types 模块看起来几乎是多余的,你总是可以用同样的技巧来发现 FunctionType。但另一方面,从 types 中导入它让常量模式的两个主要优点都得到了体现:代码变得更易读,因为 FunctionType 在任何地方都有相同的名字,而且更有效率,因为无论一个大系统中有多少模块可能使用它,常量都只需要计算一次。

Dunder 常量

在模块全局层面定义的常量的一个特例是 "dunder "常量,其名称 以双下划线开始和结束,如:__name____file__

有几个模块全局的dunder常量是由语言本身设置的。对于官方列表,请在 Python Reference 关于标准类型层次结构的章节中寻找 "模块 "子标题。最常遇到的两个是 __name__,程序需要检查它,因为 Python 可怕的设计决定给从命令行调用的模块分配一个假名字'__main__',以及 __file__,模块的 Python 文件本身的完整文件系统路径 ,它几乎被普遍用来寻找包含在软件包中的数据文件,尽管现在官方的建议是使用 pkgutil.get_data() 来代替。

here = os.path.dirname(__file__)

除了语言运行时设置的 dunder 常量之外,如果一个模块选择设置,还有一个Python认可的常量:如果 __all__ 被分配了一连串的标识符,那么只有这些名字会被导入到另一个 from ... import * 的模块。你可能已经预料到 __all__ 会随着 import *获得反模式的声誉而变得不那么流行,但它却获得了快乐的第二职业,限制了自动文档引擎如 Sphinx autodoc 模块所包含的符号列表。

尽管大多数模块从未打算修改 __all__,但它们还是莫名其妙地将其指定为一个 Python 列表。使用一个元组更为优雅。

除了这些官方的错误常量,一些模块,尽管很多人觉得错误的名字不吸引人,还沉迷于创建更多的错误。像 __author____version__ 这样的名字的赋值散布在标准库和其他地方。虽然它们的出现并不一致,以至于工具可以假定它们的存在,但偶尔的读者可能会发现它们是有信息的,而且它们比官方的包元数据更容易得到。

请注意,即使在标准库中,关于__author__应该是什么类型,似乎也没有达成一致。

# inspect.py
__author__ = ('Ka-Ping Yee <ping@lfw.org>',
              'Yury Selivanov <yselivanov@sprymix.com>')
为什么不是authorversion,而是没有 dunders 呢?早期的读者可能误解了 dunders,它真正的意思是 "对 Python 语言运行时间的特殊性",它模糊地表明一个值是模块元数据而不是模块代码。一些标准库模块确实提供了不带 dunders 的版本,但甚至没有就大写字母达成一致。
VERSION = "1.3.0"  # etree/ElementTree.py
version = "0.20"   # sax/expatreader.py
version = "0.9.0"  # tarfile.py

为了避免围绕这些非正式和临时的元数据约定的不一致,一个期望用 pip 安装的包可以直接从 Python 包安装系统中了解其他已安装包的名称和版本。更多的信息可以在 setuptools 文档中的 pkg_resources 模块中找到。

全局对象模式

在成熟的全局对象模式中,就像在常量模式中一样,一个模块在导入时实例化一个对象,并在模块的全局范围内给它分配一个名字。但这个对象并不是简单地作为数据使用;它不仅仅是一个整数、字符串或数据结构。相反,该对象是为了它所提供的方法而提供的,为了它所能执行的动作。

最简单的全局对象是不可改变的。一个常见的例子是编译的正则表达式,这里有几个来自标准库的例子。

escapesre = re.compile(r'[\\"]')       # email/utils.py
magic_check = re.compile('([*?[])')    # glob.py
commentclose = re.compile(r'--\s*>')   # html/parser.py
HAS_UTF8 = re.compile(b'[\x80-\xff]')  # json/encoder.py

将正则表达式编译为模块全局是更普遍的全局对象模式的一个好例子。它实现了一种优雅而安全的费用转移,从程序运行时间的后期转移到导入时间。其代价是。

  • 导入模块的成本增加了编译正则表达式的成本(加上将其分配给全局名称的微小成本)。
  • 现在,导入时间成本由每个导入模块的程序承担。即使一个程序没有碰巧调用任何使用上面显示的 HAS_UTF8 正则表达式的代码,只要它导入 json 模块,就会产生编译它的费用。(情节曲折:在 Python 3 中,这个模式甚至在模块中都不再使用了!但它的名字并没有被标记为私有。但是它的名字没有用前导下划线标记为私有,所以我想删除它是不安全的--每一次import json 都要永远支付它的费用?)
  • 但是那些事实上需要使用正则表达式的函数和方法将不再为其编译产生重复的成本。编译后的正则表达式可以立即开始扫描一个字符串 如果正则表达式被频繁使用,比如在像解析这样的高成本操作的内循环中,那么节省的费用就会很可观。
  • 与本地使用的正则表达式在更大的表达式中匿名使用相比,全局名称将使调用的代码更具可读性。(如果可读性是唯一的关注点,记住你可以将正则表达式的字符串定义为全局,但跳过在模块级编译它的成本)。

顺便说一下,如果你把正则表达式移到类属性中,而不是把它一直移到全局范围,那么这一系列的权衡也是一样的。当我最终能写到 Python 和类时,我会从这里链接到关于类属性的进一步思考。

可变全局对象

但是,全局对象是可变的,怎么办?

当它们包裹着系统资源时,是最容易被证明的,因为这些资源从本质上来说也是操作系统进程的全局资源。标准库中的一个例子是 environ 对象,它给你的 Python 程序提供了 "environment"--提供你的时区、终端类型等的文本键和值,它是从你的 Python 程序的父进程中传递过来的。

现在,你的程序是否真的应该在运行时向它的环境中写入新的值,这一点是可以争论的。如果你正在启动一个需要调整环境变量的 subprocess,子进程例程会提供一个 env 参数。但是如果代码确实需要操作这个全局资源,那么由一个相应的全局 Python 对象来调解这种访问是合理的。

# os.py
environ = _createenviron()

通过这个全局对象,Python程序中的各种例程,也许还有线程,协调它们对这个全进程资源的访问。

import os
os.environ['TERM'] = 'xterm'
任何改变将立即被读取该环境键的程序的任何其他部分看到。

>>> os.environ['TERM']
'xterm'

通过一个独特的全局对象来耦合代码库的不同部分,甚至是不同库的不相关部分,其问题是众所周知的。

  • 以前独立的测试突然通过全局对象耦合起来,不能再安全地并行运行了。如果一个测试对 environ['PATH'] 做了一个临时赋值,而另一个测试刚刚启动了一个带有子进程的二进制文件,那么这个二进制文件将继承 $PATH 的测试值--可能会导致一个错误。
  • 有时你可以通过锁来序列化对全局对象的访问。但是,除非你对你的代码使用的所有库进行彻底的审计,并在升级到新版本时继续审计,否则甚至很难知道哪些测试调用的代码最终会触及特定的全局对象,如environ
  • 即使是串行运行的测试,而不是并行的,如果一个测试不能在下一个测试运行之前将 environ 恢复到原来的状态,那么现在也会出现耦合的情况。诚然,这可以通过拆分程序或自动恢复状态的模拟来缓解。但是,除非每一个测试都非常谨慎,否则你的测试套件仍然会受到异常的影响,这些异常取决于随机的测试顺序,或者取决于前一个测试是否成功或提前退出。
  • 这些危险不仅困扰着测试,而且也困扰着生产运行。即使你的应用程序不启动多线程,也会出现令人惊讶的情况,即重构后的代码会在另一个正在转换其状态的例程中调用对environ 执行的操作。

标准库有更多的可变全局模式的例子--公共全局和私有全局都在其模块里。有些对应的是系统层面上的独特资源。

# Lib/multiprocessing/process.py
_current_process = _MainProcess()
_process_counter = itertools.count(1)

另一些人没有对应外部资源,而是作为单一的协调点,为全过程的活动(如记录)服务。

# Lib/logging/__init__.py
root = RootLogger(WARNING)

第三方库可以提供几十个例子,从全局HTTP线程池和数据库连接到请求处理程序、库插件和第三方编解码器的注册表。但在每一种情况下,Mutable Global都会对上面列出的所有危险进行求偿,以换取将资源放在每个模块都能到达的地方的便利。

我的建议是,在你能做到的范围内,编写接受参数并返回由参数计算的值的代码。如果做不到这一点,可以尝试将数据库连接或开放的套接字传递给需要与外部世界交互的代码。对于那些发现自己被搁置在所需资源之外的代码来说,这是一个折中的办法,它可以诉诸于访问全局。

当然,Python 的荣耀在于它通常可以使反模式和妥协在代码中读得相当优雅。在模块的全局层次上的赋值语句和其它赋值语句一样容易编写和阅读,而且调用者可以通过与函数和类相同的导入语句访问 Mutable Global。

I/O 导入时间

许多最糟糕的全局对象是那些在导入时执行文件或网络I/O的对象。它们不仅给每个需要该模块的库、脚本和测试带来了I/O的成本,而且在文件或网络不可用的情况下还会使它们面临失败。

库的作者有一种不幸的倾向,就是做出 "文件 /etc/hosts 总是存在的 "这样的假设,而事实上,他们不可能提前知道他们的代码有一天会面临的所有奇特的环境--也许一个小小的嵌入式系统实际上缺乏这个文件;也许一个持续集成环境正在旋转的容器根本就没有任何网络配置。

即使面对这种可能性,模块作者仍可能试图为他们的导入时间I/O进行辩护。"但是将I/O延迟到导入时间之后,只是推迟了不可避免的事情--如果系统没有 /etc/hosts ,那么用户以后也会得到完全相同的异常。" 这个借口的尝试揭示了三个误解。

  1. 导入时的错误比运行时的错误要严重得多。记住,在你的包被导入的那一刻,程序的主例程可能还没有开始运行--调用者通常还在他们文件顶部的import 语句堆中。他们可能还没有设置日志,也没有进入他们的应用程序的主try...except 块来捕捉和报告故障,所以导入过程中的任何错误可能会直接打印到标准输出,而不是得到正确的报告。
  2. 应用程序的编写通常是为了在某些操作失败后仍能生存,以便在紧急情况下仍能执行其他功能。即使需要你的库的功能现在会遇到异常,应用程序可能还有许多其他功能可以继续提供--或者可以,如果你没有在导入时用异常杀死它的话。
  3. 最后,库的作者需要记住,导入他们的库的 Python 程序可能根本就不会使用它!不要以为你的代码已经导入了库,就可以认为你的库已经有了。不要以为你的代码被导入了就会被使用。有很多情况下,一个模块被偶然地导入,作为更多模块的依赖,但从未被调用。通过在导入时执行I/O,你可能会给数百个程序和测试带来费用和风险,而这些程序和测试甚至不需要或不关心你的网络端口、连接池或开放文件。

出于所有这些原因,你的全局对象最好等到第一次被调用时再打开文件和创建套接字--因为在第一次调用时,库才知道主程序已经启动并运行,并知道在这个特定的程序运行中肯定需要它的服务。

我承认,当我的软件包需要加载一个嵌入软件包本身的小数据文件时,我有时确实会打破这个规则。