Effective Python -- 第 7 章 协作开发(下)

第 7 章 协作开发(下)

第 52 条:用适当的方式打破循环依赖关系

在和他人协作时,难免会写出相互依赖的模块。而有的时候,即使自己一个人开发程序,也仍然会写出相互依赖的代码。

例如,GUI(图形用户界面)程序要显示一个对话框,请用户来选择文档的保存地点。程序可以在事件处理器(event handler)里,把需要显示在对话框中的数据,通过参数传递过去。而对话框那边,也需要读取一些全局状态,例如,它要根据用户的配置信息(user preferences,用户偏好)来决定如何把自身正确地渲染出来。

下面定义的这个 dialog 模块,会从 app 模块的全局配置信息中,获取默认的文档保存地点:

# dialog.py
import app

class Dialog(object):
    def __init__(se1f, save_dir):
        self.save_dir = save_dir
    # ...

save_dialog = Dialog(app.prefs.get('save_dir'))

def show():
    # ...

问题在于:包含 prcfs 对象的那个 app 模块,又需要引入 dialog 模块,这样才能在程序启动的时候,把对话框显示出来。

# app. py
import dialog

class Prefs(object):
    #...
    def get(self, name):
        # ...

prefs = Prefs()
dialog.show

这就形成了循环依赖关系。如果想从主程序里面使用 app 模块,那么引入该模块时,就会出现异常。

Traceback (most recent ca11 last):
  File "main.py", line 4, in <module>
    import app
  File "app.py", line 4, in <module>
    import dialog
  File "dialog.py", line 16, in <module>
    save_dialog = Dialog(app.prefs.get('save_dir'))
AttributeError: 'module' object has no attribute 'prefs'

为了理解上面这个错误的产生原因,必须要知道 Python 系统在执行 import 语句时的详细机制。引入模块的时候,Python 会按照深度优先的顺序执行下列操作:

  • 1)在由 sys.path 所指定的路径中,搜寻待引入的模块。
  • 2)从模块中加载代码,并保证这段代码能够正确编译。
  • 3)创建与该模块相对应的空对象。
  • 4)把这个空的模块对象,添加到 sys.modules 里面。
  • 5)运行模块对象中的代码,以定义其内容。

循环依赖所产生的问题是:某些属性必须要等 Python 系统把对应的代码执行完毕之后(也就执行完第 5 步之后),才可以具备完整的定义。但是,包含该属性的模块,却只需要等 Python 系统执行完第 4 步,就可以用 import 语句引入并添加到 sys.modules 里面了。

在上例中,app 模块在尚未定义任何内容之前,就先引入了 dialog 模块,然后,dialog 模块又引入了 app 模块。而这个时候,app 模块尚未完成整个引入过程,它还处于正在引入 dialog 的状态之中,按照上面第 4 步的规则,可以说,此时的 app 模块,只是个空壳而已。由于 app 模块的第 5 步还没有完成,所以在 app 模块中,定义 prefs 对象所用的那段代码,就尚未得到运行,而 dialog 模块的第5步,却需要用到这个 prefs,于是,就抛出了 AttributcError 异常。

解决该问题的最佳方案,就是重构代码,把 prefs 数据结构放在依赖树的最底层。然后,令 app 与 dialog 模块都引人那个包含 prefs 的工具模块,以避免出现循环依赖关系。但是,未必总能够实现这种清晰的划分方式,而且有的时候,这样重构所耗费的精力实在太大了。

下面还有三种办法,也可以避免循环依赖关系。

1.调整引入顺序

要介绍的第一个办法,是调整引入顺序。例如,可以把引入 dialog 模块所用的那条 import 语句,移动到 app 模块底部,也就是说,先等 app 模块的主要内容运行完毕,然后再引入 dialog 模块,这样 AttributeError 错误就会消失。

# app.py
class Prefs(object):
    # ...

prefs = Prefs()

import dialog  # Moved
dialog.show()

这种办法是可行的。由于 app 模块在相当晚的时候才引入 dialog 模块,所以当 dialog 模块反向引用 app 时,app 的第 5 步几乎已经执行完了,因此,dialog 模块能够找到 app.prefs 的定义。

这个办法虽然能避开 AttributeError 错误,但是却与 PEP 8 风格指南不符。PEP 8 推荐开发者总是在 Python 文件顶部引入其他模块。这样可以令第一次阅读该代码的人,知道本模块会依赖其他哪些模块,而且也能够保证引人的模块都位于本模块的范围内,从而使本模块内的所有代码都能使用那些模块。

这种稍后引入模块的做法,容易使代码出现问题,而且会导致代码的顺序发生少许变动,而这种变动,可能会令整个模块都无法运作。因此,我们不应该通过调整引入顺序来解决循环依赖问题。

2.先引入、再配置、最后运行

解决循环引人的第二种办法,是尽量缩减模块在引入时所产生的副作用。也就是说,只在模块中给出函数、类和常量的定义,而不要在引入的时候真正去运行那些函数。每个模块都将提供 configure 函数,等其他模块都引入完毕之后,要在该模块上面调用一次 configure,而这个 configure 函数,则会访问其他模块的属性,以便将本模块的状态准备好。等到所有模块都引入完毕(也就是第 5 步执行完),那些模块中的属性肯定已经定义好了,于是就可以放心地执行 configure 了。

下面重新定义 dialog 模块,令该模块只会在外界调用 configure 函数的时候,才去访问 prefs 对象:

# dialog. py
import app
class Dialog(object):
    # ...

save_dialog = Dialog()

def show():
    # ...

def configure():
    save_dialog.save_dir = app.prefs.get('save_dir')

同时也重新定义 app 模块,令它不要在引入的时候执行任何动作。

# app.py
import dialog

class Prefs(object):
    # ...

prefs = Prefs()

def configure():
    # ...

现在,在 main 模块中,分三个阶段来执行代码:首先引入所有模块,然后配置它们,最后执行程序中的第一个动作。

# main.py
import app
import dialog

app. configure()
dialog.configure()
dialog.show()

这种方案在很多情况下都非常合适,而且方便开发者实现依赖注入(dependencyinjection)等模式。但是,有时很难从代码中清晰地提取出 configure 步骤。另外,在模块内部划分不同的阶段,也会令代码变得不易理解,因为这样做,会把对象的定义与对象的配置分开。

3.动态引入

解决循环引入的第三种办法,是在函数或方法内部使用 import 语句,这种办法是最为简单的。程序会等到真正需要运行相关的代码时,才去触发模块的引入操作,而不会在刚开始启动并初始化其他模块时,就去引入那个模块,所以,这种方案又称为动态引入(dynamic import)。

下面,采用动态引入方案,来重新定义 dialog 模块。这一次,dialog.show 函数要等到运行的时候,才会引人 app 模块,而不是像原来那样,在初始化的时候就引人 app 模块。

# dialog. py
class Dialog(object):
    # ...

save_dialog = Dialog()

def show():
    import app  # Dynamic import
    save_dialog.save_dir = app.prefs.get('save_dir')
    # ...

app 模块的写法,与最早的那份范例代码一样,也是在开头就引入 dialog,并在结尾调用 dialog.show。

# app.py
import dialog

class Prefs(object):
    # ...

prefs = Prefs()
dialog.show()

该方案的实际效果,与刚才提到的先引入、再配置、最后运行的那套方案,是相似的。区别在于,本方案不需要从结构上面修改模块的定义方式和引入方式。只是把循环引入推迟到了程序真正需要访问其他模块的那一刻。而在那个时间点上,则可以确信,其他模块都已经彻底初始化好了(因为每个模块的第 5 步都已经完成了)。

一般来说,还是尽量不要使用这种动态引入方案。因为 import 语句的执行开销,还没有小到可以忽略不计的地步,而且在循环中反复引入模块,更是一种不好的编程方式。此外,这种旨在推迟代码执行时机的动态引入方案,还可能会在程序运行的时候,导致非常奇怪的错误,例如,程序会在运行了很久之后,突然抛出 SyntaxError 异常。

总结

  • 如果两个模块必须相互调用对方,才能完成引入操作,那就会出现循环依赖现象,这可能导致程序在启动的时候崩溃。
  • 打破循环依赖关系的最佳方案,是把导致两个模块互相依赖的那部分代码,重构为单独的模块,并把它放在依赖树的底部。
  • 打破循环依赖关系的最简方案,是执行动态的模块引入操作,这样既可以缩减重构所花的精力,也可以尽量降低代码的复杂度。

第 53 条:用虚拟环境隔离项目,并重建其依赖关系

如果程序构建得比较庞大、比较复杂,那么它通常会依赖 Python 社区中的许多软件包。可能要在开发过程中,通过 pip 命令,安装 pytz、numpy 及其他一些软件包。

问题在于,通过 pip 命令安装的新软件包,是全局性的,也就是说,这些安装好的模块,可能会影响系统里的所有 Python 程序。从理论上看,好像不应该出现这种问题。如果安装了某个软件包,但却从来不引入它,那么该软件包怎么会影响自己的程序呢?

然而,真正麻烦的地方却在于依赖性的传递(transitive dependency),也就是说,所安装的包,可能还要依赖其他一些包。例如,在安装完 Sphinx 包之后,可以通过 pip 命令来看看:该软件包还依赖其他哪些软件包。

$ pip3 show Sphinx
---
Name: Sphinx
version: 1.2.2
Location: /usr/loca1/1ib/python3.4/site-packages
Requires: docutils, Jinja2, Pygments

如果还安装了 flask 等包,那么也可以用 pip 命令查询它的依赖关系。我们会看到,它与 Sphinx 一样,都依赖 Jinja2 包。

$ pip3 show flask
---
Name: Flask
version: 0.10.1
Location: /usr/local/lib/python3.4/site-packages
Requires: werkzeug, Jinja2, itsdangerous

Sphinx 包与 flask 包以后可能会各自演化,而这也许就会导致冲突。这两个包,目前或许都在使用相同版本的 Jinja2,所以暂时相安无事。但半年或一年之后,Jinja2 可能会发布新的版本,而那个新版本,可能包含一些重大的变化,从而对使用 Jinja2 库的其他模块造成影响。如果我们通过 pip install --upgrade 命令来更新整个系统的 Jinja2 包,那么可能就会出现 Sphinx 包无法使用,而 flask 包却可以照常使用的奇怪现象。

这个问题的根源是:在同一时刻,Python 只能把模块的某一个版本,安装为整个系统的全局版本。如果某个已经安装好的软件包,必须使用新版模块,而另外一个已经安装好的软件包,又必须使用旧版模块,那么系统就没办法正常运作了。

即使软件包的维护者尽力保持新旧版本之间的 API 兼容性,这种问题也依然有可能发生。因为新版的程序库在行为上面可能发生了比较微妙的变化,而使用这套 API 的原有代码,又依赖于这些行为。用户可能更新了系统中的某一个软件包,但却没有更新其他软件包,从而导致依赖关系遭到破坏。所以说,软件包之间的这种递进式依赖关系,总是有风险的。

当与其他开发者相互协作,而那些开发者又分别在各自的电脑上面编程时,这个问题就更加严重了。有理由相信:他们所安装的 Python 版本及全局软件包的版本,可能与你所安装的版本略有区别。这就会产生一种尴尬的局面,也就是说:同一份代码,在某位程序员的电脑上可以很好地运行,而在另一位程序员的电脑上,却完全无法运作。

这些问题都可以通过名为 pyvenv 的工具来解决,此工具提供了一套虚拟环境(virtual environment)。从 Python 3.4 开始,这个工具会随着 Python,默认安装到电脑中,开发者可以在命令行界面里,通过 pyvenv 来调用它,也可以通过 python -m venv 命令来访问它。对于早前的 Python 版本,必须用 pip install virtualenv 命令单独安装这个工具包,并在命令行里通过 virtualenv 来使用它。

pyvenv 使得可以创建版本互不相同的 Python 环境。通过 pyvenv,可以在同一个系统上面,同时安装某软件包的多个版本,并且使这些版本之间不发生冲突。这样就能在同一台电脑上面,用多种不同的工具来开发多个不同的项目。
借助 pyvenv,可以把不同版本的软件包以及其依赖关系,分别安装在彼此隔绝的目录结构之中,这使得我们可以重现一套特定的 Python 开发环境,以确保某个项目的代码肯定能够在这套环境下面正常运作。于是,就可以有效地避免因软件包的依赖关系而导致的各种奇怪问题。

1.pyvenv命令

下面扼要地讲解 pyvenv 命令的使用方式。在使用该工具之前,首先要确定命令行中 python3 命令,在系统里的含义。在示范电脑上,python3 位于 /usr/local/bin 目录下,并且会指向 3.4.2 版本。

$ which python3
/usr/local/bin/python3
$ python3 --version
Python 3.4.2

可以运行一条引入 pytz 模块的 Python 命令,看它会不会出错,以此来检验这套开发环境配置得是否正确。由于已经把 pytz 软件包安装为全局模块,所以这条 Python 命令能够正确地执行。

$ python3 -c 'import pytz'
$

现在,用 pyvenv 命令来新建名为 myproject 的虚拟环境。每一套虚拟环境,都必须位于各自独立的目录之中。这条命令执行完毕后,该目录下面会产生相应的目录树与文件。

$ pyvenv /tmp/myproject
$ cd /tmp/myproject
$ ls
bin     include     lib     pyvenv.cfg

为了启用这套虚拟环境,在命令行界面中,使用 source 命令来运行 bin/activate 脚本。activate 脚本会修改所有的环境变量,使之与虚拟环境相匹配。它还会更新命令提示符,把虚拟环境的名称(本例中,是 ‘myproject’)包含进来,使开发者可以明确地知道自己当前所处的工作环境。

$ source bin/activate
(myproject)$

激活这套虚拟环境之后,可以看到,命令行中的 python3,已经不再直接指向整个系统中的 Python 命令了,而是会指向虚拟环境目录中的那个 Python 命令。

(myproject)$ which python3
/tmp/myproject/bin/python3
(myproject)$ ls -l /tmp/myproject/bin/python3
... -> /tmp/myproject/bin/python3.4
(myproject)s ls -l /tmp/myproject/bin/python3.4
... -> /usr/local/bin/python3.4

这就能够确保外围系统不会影响这套虚拟环境。即便外围系统中的 python3 命令已经更新到 3.5 版,虚拟环境中的 python3 命令依然会指向 3.4 版。

用 pyvenv 所创建的这套虚拟环境,除了 pip 与 setuptools,是没有安装任何软件包的。外围系统虽然已经把 pytz 包安装为全局模块,但是虚拟环境却并不知道有这个包,所以,如果在虚拟环境里使用它,就会报错。

(myproject)$ python3 -c 'import pytz'
Traceback (most recent ca1l last):
  File "<string>", line 1, in <module>
ImportError: No module named 'pytz'

可以用 pip 命令把 pytz 模块安装到虚拟环境里。

(myproject)$ pip3 install pytz

安装好之后,可以用刚才那条命令验证。

(myproject)$ python3 -c 'import pytz'
(myproject)$

使用完虚拟环境之后,可以通过 deactivate 命令返回默认的系统。这将把开发环境恢复到系统的默认值,其中,python3 命令行工具,也将指向原来的位置。

(myproject)$ deactivate
$ which python3
/usr/local/bin/python3

如果想重新回到 myproject 环境,那就和原来一样,用 source bin/activate 命令运行 myproject 目录中的 activate 脚本。

2.重建项目的依赖关系

有了虚拟环境之后,就可以用 pip 命令来安装自己所需的软件包了。以后,可能想把自己这套环境复制到其他地方。例如,要把这套开发环境部署到产品服务器中,或要把别人的开发环境克隆到自己的电脑上面,以运行其中的代码。

pyvenv 可以轻松地解决上述需求。我们用 pip freeze 命令,把开发环境对软件包的依赖关系,明确地保存到文件之中。按惯例,这个文件应该叫做 requirements.txt。

(myproject)$ pip3 freeze > requirements.txt
(myproject)$ cat requirements.txt
numpy==1.8.2
pytz==2014.4
requests==2.3.0

现在,假设想再构建一套与 myproject 相符的虚拟环境,那么,还是可以像原来那样,用 pyvenv 命令来新建环境目录,并且用 activate 脚本来激活新环境。

$ pyvenv /tmp/otherproject
$ cd /tmp/otherproject
$ source bin/activate
(otherproject)$

这套新的开发环境,目前并没有安装其他软件包。

(otherproject)$ pip3 list
pip (1.5.6)
setuptools (2.1)

刚才我们在第一套虚拟环境里,通过 pip freeze 命令生成了 requirements.txt 文件,而现在,则可以根据该文件,用 pip install 命令把所有相关的软件包都安装到第二套虚拟环境之中。

(otherproject)$ pip3 install -r /tmp/myproject/requirements.txt

上面这条命令,必须花费一定的时间才能运行完毕,因为它要把所需的软件包下载并安装到目前的环境中,使其与第一套虚拟环境相匹配。执行完这条命令后,列出第二套环境里面所安装的软件包,大家可以看到,这份依赖关系列表,与第一套环境中的列表是相同的。

(otherproject)$ pip listnumpy (1.8.2)
pip (1.5.6)
pytz (2014.4)
requests (2.3.0)
setuptools (2.1)

如果正在通过修订控制系统(revision control system)与他人协作,那么使用 requirements.txt 文件来描述依赖关系,是一种相当好的办法。在提交代码的时候,可以同时更新这份描述软件包依赖关系的列表,使两者之间保持同步。

使用虚拟环境时,有个地方要注意,就是别去移动环境目录,因为所有的路径(包括 python3 命令所指向的路径),都以硬编码的形式写在了安装目录之中,如果移动了,那么环境就会失效。然而这并不是个大问题。因为虚拟环境的用途,就在于使开发者能够方便地重建一套与原环境相似的配置。所以,一般都不会移动虚拟环境所在的目录,而是会用 pip freeze 命令把旧环境的依赖关系导出,然后创建新的环境,并根据 requirements.txt 文件,把旧环境中的软件包重新安装到新环境之中。

总结

  • 借助虚拟环境,可以在同一台电脑上面同时安装某软件包的多个版本,而且能保证它们不会冲突。
  • pyvenv 命令可以创建虚拟环境,source bin/activate 命令可以激活虚拟环境,deactivate命令可以停用虚拟环境。
  • pip freeze 命令可以把某套环境所依赖的软件包,汇总到一份文件里面。把这个 requirements.txt 文件提供给 pip install -r 命令,即可重建一套与原环境相仿的新环境。
  • 如果使用 Python 3.4 之前的版本做开发,那么必须单独下载并安装类似的 pyvenv 工具。那个命令行工具不叫 pyvenv,而是叫做 virtualenv。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值