本文主要对Python文档Modules做一个学习总结、记录。

背景

UPDATE: 2019年1月9日,内部做了工程经验分享,对Python Modules看得更多一些,对内容做一些更新。

————

前几天因为import相关的问题导致代码没有通过公司的编码规范检查,因为任务优先,当时就选择临时豁免了。 今天算是实现当时的想法,对6. Modules做一个学习和记录。 此外,从How To Package Your Python Code学习了package的结构,下一个工程就全面按照这个规范来做,这样就总算是“代码规范”了吧~

正文

本文主要由以下内容组成,

  1. Module相关:Module是什么;module相关的知识
  2. Package相关:Package是什么;import相关的知识;如何去组织package;开发实践(开发Package时怎么才能方便测试?)

Module

Module是什么

原文的定义是:

A module is a file containing Python definitions and statements.

就是把Python的定义、声明给放到一起的文件。一个*.py就是一个Module,module name就是文件名。Module本身有一个__name__的变量,一般存储的就是自己的名字。

Module应该是Python代码组织结构的一环:Package - Module - Class。 Module也是代码逻辑组织与文件组织共同体现。 写Module的意义,当然是为了重用性和可维护。公用功能拆分、大逻辑解耦,都需要落实到module上。

Module里可以包含函数、变量的定义,也可以包含一些可运行的代码,These statements are intended to initialize the module.

一个Module包含自己独立的符号表(private symbol table),module内部声明的变量、函数等都在这个符号表里(import进来的也放在这里),类似C++的命名空间。这使得我们不必担心不同module间重名的问题。

将Module作为Script运行

Script通常指可以运行的脚本,如果一个module可以通过Python解释器直接运行(即 python xx.py ),那么我们称被运行的module为script. 区别module与script,是根据它的运行状态:被直接运行的是script,否则就是module.

而之所以要区分这个概念,是因为当一个module成为script后,其 __name__ 属性值发生了变化:由原来的自身名字,变为了 __main__. 这个行为对程序流、relative import时路径搜索有关键影响。

例如,我们常常在一个module里写上

if __name__ == "__main__":
    # some statements for running as scripts

这就是区分其作为script或被import的module时不同行为的:只有作为script被直接运行, if 下面的代码才会被解释执行。

pycpyo相关

这两个都是源程序编译为的机器无关的字节码,跟Java的class一样,是可以独立于操作系统、机器结构等的——只要有对应的interpreter, 都可以正常解释为机器码。

pyo是使用python -O xxx.py时,被依赖的module被编译成的文件格式,也就是做了优化的格式。相比pyc

The optimizer currently doesn’t help much; it only removes assert statements. When -O is used, all bytecode is optimized

官网上面的说法感觉有点矛盾……这里姑且取第一句,认为pyo就是把assert的语句给删了,仅此而已。除了-O, 还有-OO的优化,似乎只是把_doc__给删了……为了简单,后面就只说pyc, 不再提pyo.

pyc已经是字节码,因此已经可以直接发布了——不再需要源文件。当你的代码不开源时,一定程度上可以用来反工程(This can be used to distribute a library of Python code in a form that is moderately hard to reverse engineer.)。根据我之前在某动实习时师兄告诉我的经验,pyc是挡不住的,需要先把python源代码利用Cython给转成C代码,再拿C编译器编译成so,再发布……

当既有pyc, 又有py是,解释器首先看pyc是不是最新的,是就直接用,不是再编。关键是怎么看是不是最新的呢?之前我以为就是比两个文件的时间戳,不过真实的是:

The modification time of the version of spam.py used to create spam.pyc is recorded in spam.pyc, and the .pyc file is ignored if these don’t match.

把源文档变更时间直接写到pyc里了,的确更加稳妥!

最后,pyc只是加速启动时间,跟运行速度无关。

dir函数和__builtin__sys的特殊变量

dir是内建函数,可以把Module内的全局变量(module的符号表)返回为list;特别地,如果是在交互式环境直接使用(不带参数),那么返回的是整个交换式环境下的所有名字,变量啊、类啊都混在了一起。而其中就有一个__builtins__的特殊名字,是所有内建名字的代替吧。文档里说用import __builtin__; dir()可以看到一大串,可以看到包括Errors定义、内建函数、特殊变量等。

最后再说sys, 先说sys.path, 决定了import搜索路径的东西。之前都是通过操作这个列表来完成不同层级的module的导入的。有一个初始化顺序需要了解一下:

the directory containing the input script (or the current directory).
`PYTHONPATH` (a list of directory names, with the same syntax as the shell variable PATH).
the installation-dependent default.

此外还发现两个有意思的,sys.ps1, sys.ps2, 第一个是交互式解释器第一层的提示字符串(prompts), 第二个是第二层;默认情况下是>>>...;是可以直接赋值的。

Package

Package是什么

Package是Module的集合,a way of structuring Python’s module namespace by using “dotted module names”. 直观而不严谨的说明,一个package通常包含一个层次化的module集合,对应到文件系统,就是层次化的文件结构。文件系统的层次化用/表示,对应到Package,就是.. 例如文件系统结构 a/b, 对应的mudule表示是a.b. 特别地,为了防止意外的引入不期望的文件,一个文件夹要对应到一个Package或Sub-Package,必须包含一个__init__.py文件。

需要注意的是,Package只是Python用来组织module的concept, 仅仅对应到逻辑上的概念。在Python实际运行中真实存在的对象,还是是module,具体的,Package文件夹下的__init__.py就是Package在运行中的表示。可以试试在__init__.py中打印__name__,其值就是Package的名字。

__init__.py中的内容

__init__.py可以是一个空文件,仅仅表示这个文件夹对应到一个Package;不过,它也可以作为这个package初始化代码存放的地方。__init__.py中定义的名字是被放在该package的符号表中的。如果一个package里有sub-package, 只有把这个sub-package一import的方式放到__init__.py中,才能通过package名字访问到该sub-package。例如:

package-store-dir
    setup.py
    package
        __init__.py
        sub_package.py
            __init__.py

必须在package-store-dir/package/__init__.py里写上import package.sub_package as sub_package之后,才能在外部的Scripts中这样访问sub-package: import package; package.sub_package, 否则在package上是看不到sub_package的!当然,可以这样做: import package.sub_package,这是直接通过path引入sub-package,而不是通过package符号表的方式去索引。这大概也是非常容易混淆的点。

此外,它还有一个特别的变量__all__,它是一个list,只在from package import *这种语法出现的时候有意义——相当于在import *时人为覆盖了由Python自己枚举的__all__. 特别的,Python自己枚举的__all__也不是全部的module,而是a. 在__init__.py里定义的names; b. 在之前已经显式import进来的属于该package的子模块。不知道自己理解(翻译)对不对,或者说又没有解释清楚——可以参考import * from a package. 在生产代码中,永远不要使用import *,它是代码可读性降低,同时可能让你implicitly覆盖掉import进来的名字。

Absolute import, relative import(Explicit, Implicit)

import的方式分为两种,分别是绝对引入(absolute import)和相对引入(relative import)。在Python2中相对引入还分为 Explicit relative import 和 Implicit relative import, 而在Python3中,Implicit relative import 已经被废弃。

所谓绝对与相对引入,可以类比文件系统中的绝对和相对路径。只不过,文件系统的绝对路径是唯一的(也就是从/开始),但是绝对引入确实基于一个TOP-LEVEL的路径列表来算的:凡是在这个TOP-LEVEL中的任意一个路径下找到了需要import的包,那么这就是绝对引入。

TOP-LEVEL, 其实可以等价sys.path. 按照先后顺序,它由以下内容构成: 1. 入口script所在文件夹的绝对路径 2. PYTHONPATH环境变量 3. standard modules位置 4. site.py(在standard modules中)引入的site-packages, 和其中*.pth文件引入的扩展位置.

当我们禁止implicit relative import后,其实absolute import 和 explicit relative import非常好区分:explicit relative import 的import路径,总是以 . 开头的,且语法只能是from IMPORT-PATH import NAME ..., 例如

from . import input_name
from .input_name import InputName
from ...util import text
# import .input => ERROR

不过如果我们允许了implicit relative import, 那么我们从代码上格式就没法区分其与absolute import的区别——只有实际看路径是相对路径,还是绝对路径,才知道是何种import.

所以,为了不让人混淆,最好就是禁止implicit relative import. 在Python2.7中,可以通过 from __future__ import absolute_import来实现这个行为。

为了更直观地理解上面说的,复制官网的例子:

sound/                        Top-level package
    __init__.py               Initialize the sound package
    formats/                  Subpackage for file format conversions
            __init__.py
            wavread.py
            ...
    effects/                  Subpackage for sound effects
            __init__.py
            echo.py
            surround.py
            ...

假设surround.py要引入echo.py

  1. 如果是 import sound.effects.echo 显然是绝对引入
  2. 如果是 import echo,那么是隐式相对引入
  3. 如果是 from . import echo,那么是显式相对引入。

为何最好禁止implicit relative import

在Python2.7上禁止implicit relative import 基本是共识。原因是这种方式功能不完备,极易带来混淆, 甚至导致不可预期的行为。

首先说功能不完善:还是上面的例子,现在假设 sourround.py要引入wavread.py, 那么

  1. 绝对引入: import sound.formats.wavread
  2. 隐式相对引入: 不可能
  3. 显式相对引入: from ..formats import wavread

可以看到绝对引入和显式相对引入是功能完善的,而隐式相对引入,实际是很弱,仅仅是特定情况下的一种便利!这种便利其实完全可以通过显式相对引入来达到——基本可以认为多加一个from .就行了。

“混淆”体现在,除了让人从思想上难以分清绝对和相对引入,还会在module存在重名的情况下带来事实上的混淆。例如:

main.py
foo/
    __init__.py
    bar.py
    util.py
util.py

main.py如入口script, 而在bar.py中写有import util, 可以看到在其同级,和main.py的同级——也就是TOP-LEVEL,都有util.py, 那么这个时候, bar.py里究竟引入的是哪一个util.py呢?如果你不深入了解import在未禁止implicit relative import条件下 的路径搜索逻辑,那么你只有猜测。反过来,如果你禁止了隐式相对引入,同时要么用import util就必然是TOP-LEVEL下的util.py, 而要用同级的util, 就使用from . import util即可!

最后,解释一下其为何会导致“不可预期的行为”.

被直接运行的,如前面所说,是Scripts, 也就是有__name__ == "__main__"的Module.

应该可以说,绝对引入(absolute import),相对引入(relative import, including implicit relative import, explicit relative import) 都是在Package下的概念!对于Scripts是不存在的,Scripts只有一个 search path的概念:从search path从去找package名字,找到就导入。

上面的说法估计不是正确的,但是Scripts没有 implicit relative import是必然的。因为implicit relative import 依赖的是当前module.__name__的层次化表示的名字,而Scripts的__name__ == "__main__",没有层次化名字!

前面说了半天,还没有说什么是绝对引入和相对引入。

from __future__ import absolute_import以及正确使用

这里参考的是python future package的几个特性,我觉得写得很棒!

虽然名字是absolute_import,但是其真正含义是禁止隐式相对引入.

用了from __future__ import absolute_import的Module, 如果把这个Module当作Scripts来运行,常常报这个错: ValueError: Attempted relative import in non-package. 原因就是前面说到的,相对引入的概念只存在于Package下(依赖于module.__name__来路由位置),Scripts是没有这个东西的。

所以,一定要清楚,你写的Module是Package的一部分,还是用来作为Scripts运行的。二者不要想得兼。自己以前总是混写,以后得规范了。

推荐方法如ref的文章所言,Package的Module内用绝对引入或显式相对引入,内部定义一个main函数,然后再写一个scripts调用。当然,这个main放在scripts里也不错。

import一个Module发生了什么

假设要import的module名称是module_a, 根据the import statement, 引入这个module时的大致行为如下:

  1. 检查sys.modules是否包含module_a的名字 a. 如果包含,则直接返回查找的instance-of-module_a; b. 否则,对module_a做初始化操作,并在sys.modules中创建 key= name-of-module_a, value = instance-of-module_a
  2. 在本地命名空间创建一个名字(具体名字根据import语法确定),将该名字绑定到instance-of-module_a

如何去组织Package

来自How To Package Your Python Code, 不赘述。

此外,Building and Distributing Packages with Setuptools是更加详细的setup教程。

开发实践

整体思路是,应该按照Packge的组织去写。然后不嫌麻烦的话,可以把Scripts单独放到一个文件夹下;如果嫌麻烦,可以把Scripts直接放到相应的sub-package下,因为是package,自然是从包的位置开始去引入,因此这个scripts的位置不影响其import操作,所以最后调试好了,再把这些Scripts放到一个bin文件夹里。

待实践后确定……