python工程的合理化组织(详解import)

python工程的合理化组织

目录

python工程的合理化组织

版本日期作者内容
1.02023-1-18zhaolb初始版本
1.12023-1-27zhaolb完善 import章节
1.22023-1-28zhaolb其它章节持续更新中

1、python的工程化要达到什么目的

python的工程化,就是要合理的组织大量的代码文件。

  • 合理的组织结构
  • 合理的分层化、模块化设计
  • 合理的对外接口形式
  • 版本管理、pip安装、说明文档等
  • 测试与样例代码

2、import 详解

import 是 python 中模块间相互依赖引用的方式,搞懂 import 是学好 python 的必要条件,按照实际工作情况看,对 import 不熟悉,可能会导致各种莫名其妙的运行异常。

本文的可能用到的例子如下:

lubo@lubo-L460:~/work/python/VSCodeProjects/test_import$ tree .
.
├── main.py
└── pyfamily
    ├── child
    │   ├── daughter.py
    │   ├── grandchild
    │   │   ├── grandson.py
    │   │   └── __init__.py
    │   ├── __init__.py
    │   └── son.py
    ├── __init__.py
    └── vehicle
        ├── car.py
        ├── __init__.py
        └── motor.py

2.1 可以 import 哪类内容

从导入内容的所属方来看,主要分为如下几种:

  • 系统内建模块
  • pip安装的第三方模块
  • 本工程内的其它模块

2.2 import 的一些写法、形式

import 的作用

一句话概括:
import 的作用是,把要 import 的对象,添加到执行 import 的模块的命名空间内。

换句话说,你导入的是谁,你就【能且只能】【直接】访问谁(以及它命名空间内的子对象),访问导入对象时,添加多余的前缀(命名空间)、或遗漏该有的前缀,都会导致访问异常。

注意1:此处的对象可能是包、模块、函数、变量、类等,按照 python 的说法,“一切皆对象”。

注意2:包可以认为是一种特殊的“模块”,它既可以是被导入的对象,也可以是导入其它对象的执行者,init.py 是包的的“模块代码”,可参与导入或被导入。

接下来,我们从3个场景,反复验证 “【能且只能】【直接】访问被导入对象(及其命名空间内的子对象)” 这句话的要义。

  • import 一个“包”
  • import 一个包中的“所有或某些对象”
  • import 一个模块或模块中的所有或某些对象

import 一个 “包”

import xxx.package1
或者
import package1

package1 是一个 python 包(含有 __init__.py)。

导入一个包会发生什么,要分两种情况讲。

情况1,当 package1.__init__.py 中没有显式定义 import 规则时(没有 import 本包中任何模块)。

这种情况下,运行虽然不会报错,但并没有什么卵用,包中的任何模块文件,都无法被导入

举例:

#pyfamily.child.__init__.py
#内容为空
#main.py
import pyfamily.child

#无法访问!!!!!
s = pyfamily.child.son.Son()

这时会报错,AttributeError: module ‘pyfamily.child’ has no attribute ‘son’
因为 child.__init__.py 为空,意味着 child 这个“模块”内没有子对象(内建属性除外)。

情况2,package1.__init__.py 中显式 import 了 package1 中某些模块或模块内的某些内容时。

这种情况下,其它模块就可以像导入普通模块那样,来导入 package1 或 package1 中的部分对象。

举例1:

#pyfamily.child.__init__.py

#将 son 模块添加到 child 的命名空间内
from . import son
#将 daughter 模块的 Daughter 类添加到 child 的命名空间内
from .daughter import Daughter
# main.py

import pyfamily.child

# 可正常访问 pyfamily.child.xxx :
s = pyfamily.child.son.Son()
d = pyfamily.child.Daughter()

# 无法访问 child.daughter !!!!!
d2 = pyfamily.child.daughter.Daughter()

解释:因为导入的是 pyfamily.child 这个包,所以,main.py 中能且只能以 pyfamily.child.xxx 的形式访问该包内的内容。

import 一个包中的“所有或某些对象”

首先要明确的是,此处的包内的“对象”,可能是某个模块,也可能是某个模块内的某个对象。python 的“一切皆对象”。

from package1 import *

这种情况,和上文中 import 一个 “包” 是类似的,只不过是将 package1 中的内容直接导入到 main.py 的命名空间,在 main.py 中访问时,不能加 package1 前缀,而是直接访问 package1 中的模块。

具体能导入哪些内容要看 dir2.init.py 中是否进一步定义了 import 哪些内容。

举例1:

#pyfamily.child.__init__.py

from .son import Son
from .daughter import Daughter
# main.py

from pyfamily.child import *

# 可正常访问:
s = Son()
d = Daughter()

# 命名空间错误,无法正常访问!!!!!
s2 = pyfamily.child.Son()

# child.__init__.py 中并没有导入 son ,所以无法正常访问!!!!!
s3 = son.Son()

解释:因为导入的是 pyfamily.child 内的对象,而不是 pyfamily.child 这个包,所以,main.py 的命名空间内,是无法访问 pyfamily.child.xxx 的,能且只能【直接】访问 child 包内的对象。

import 一个模块或模块中的所有或某些对象

#module.py

var1;
var2;

导入一个模块的写法:

import module

module.var1;

导入一个模块所有对象的写法:

from module import *

var1;

导入一个模块中某些内容的写法:

from module import var1

#可正常访问:
var1;

#无法正常访问!!!
module.var1;

或者

from module import var1 as VAR1, var2 as V2

VAR1;
V2;

2.3 对 import 的进一步理解

探究 import 的本质

首先强调,python 内一切皆对象,或者精确点说,一切都是某个类的实例。
例如,模块就是一种特殊的类,模块对象就是一个已经被导入内存的具体模块。打印 type(modulexxx) 的显示结果是 class ‘module’,代码中可以用 isinstance(xxx, types.ModuleType) 来判断某个对象是不是模块。

命名空间树

当执行 import a.b.c 时,发生了什么?
一开始,我会粗略的认为,解释器会将 “a.b.c” 作为一个整体,导入到当前模块的命名空间,其实不然。
因为,在当前模块的属性列表(__dict__)中只有 a,没有 b 和 c ,更没有 “a.b.c”,但进一步查看,a.__dict__ 包含 b ,b.__dict__ 包含 c。
这说明,解释器在导入所有模块后,会在进程范围内生成一棵“树”,靠这棵树来维护模块关系,它可以认为是以“属性”为节点的命名空间树。解释器会根据这个树来查找访问具体的模块节点。
这也解释了我们经常遇到的一种异常,当访问一个并没有被导入的模块 a.b.x 时,运行会报如下错误:
AttributeError: module ‘b’ has no attribute ‘x’

例如,当访问 a.b.c 时,解释器的处理方式很可能是这样的:先在当前模块属性列表中中查找 ‘a’,再去 a 的属性列表查找 ‘b’,再去b 的属性列表查找 ‘c’。当某个节点查找失败,会提示没有这个属性。虽然从逻辑上来讲,是这样的一个过程,但解释器会不会为了性能而走其它方式(比如利用 sys.modules 字典),不好说。(后文中有专门一节来测试访问 a.b.c 和直接访问 c 的耗时差异)

再进一步,由于保存了 a 这个根节点,当前模块并不是只能访问 a.b.c,而是能访问 a 节点下面所有其它已导入内存的模块节点,例如 a.d.e 、 a.m.n ,实际运行测试也验证了这一点。

另外,虽然任何对象都有属性列表,但我们此处关心的是“模块”级别的颗粒,所以,为了简化树的结构,我们不再对非模块类型的属性进行展开(遍历其子属性),也就是说,我们把模块类型的属性,看成是树的非叶子节点,把其它类型的属性(变量、方法、类等),看做是叶子节点。

导入方式对命名空间树的影响

当我们执行如下两条 import 语句后:

import  a.b.c
from a.b import c

这时,当前模块的属性列表中有 a 和 c,当前模块可以通过 a.b.c 访问 c ,也可以直接访问 c,说明:
这个按照实际的模块文件路径来生成的命名空间树,还是存在,只不过当前模块也存储了 c 节点的“快捷访问方式”(对象引用),使得它可以不必通过根节点 a 来一级级查找,而是直接跳到正确节点。
此时的 sys.modules 中,会有如下几条:
a
a.b
a.b.c

而当我们执行如下一条 import 语句后:

from a.b import c

这时,当前模块的属性列表中只有 c,所以只可以直接访问 c,而不能通过 a.b.c 来访问,
而此时的 sys.modules 中,依然会有如下相同的几条:
a
a.b
a.b.c

这说明,解释器从进程层面生成的这棵命名空间树,是以模块文件的真实路径来生成的,不受模块导入方式的影响,导入方式仅影响访问范围,并不影响命名空间树的存在性或结构性。

经过上述分析,import 的本质已经比较清晰了,一句话概括如下:

import 的本质是,解释器将被导入模块(及其完整的访问路径)添加到进程内唯一的【命名空间树】内,并在执行 import 的当前模块的【命名空间】内,保存一个访问被导入对象的【入口】。

解释:

  • 命名空间树。这是一个虚拟概念,它的对外呈现形式有两种:sys.modules 及 从主模块开始对属性的深度或广度遍历。
  • 模块的命名空间。可以认为就是模块的属性列表,可通过 dir(obj)+getattr(obj,name) 或 obj.__dict__ 来获取该对象的命名空间内的所有可访问对象。
  • 入口,也就是属性列表内的某个模块类型的对象。

import 一个“对象”(模块或模块内的变量、类、方法等),会发生如下几件事:

  • 解释器通过查看该“对象”所属模块(的完整路径)是否已经存在于 sys.modules 字典中,来判断该模块是否已经被系统导入了;如果已经在 sys.modules ,说明已经系统已经导入过一次,不再重复导入。
  • 如果被导入模块是首次导入内存,则会执行它的主代码(模块文件中没有缩进的代码),这相当于在内存中创建了一个对象实例。
  • 将该“对象”的引用添加到主模块的属性列表中。
  • 多个模块都导入同一个内容,只是对同一个对象创建了多个引用。

查看某个对象(模块、类、类对象等都可以认为是对象)的属性列表(命名空间)的方法,可以有两种方式,dir(obj)+getattr(obj) 或者 obj.__dict__ ,参考代码如下:

def print_attrs(obj):
    attrs = dir(obj)
    print("attrs of", obj,":",attrs)
    for name in attrs:
        print("attr[",name,"]: type =",type(getattr(obj,name)),"   ","value =",getattr(obj,name))
    print("show attrs by __dict__:",obj.__dict__)

# 查看当前模块(主程序)的属性列表的方法:
print_attrs(sys.modules['__main__']);

__all__ 的作用(及局限性)

  • 在普通模块文件中的作用
  • 在 __init__.py 中的作用

1,在普通模块文件中的作用。

默认情况下,当执行 from module import * 时,module 中的所有非私有属性(不带下划线前缀),都会导入。
这时,module 中一些不必要的变量可能会污染当前命名空间。
而如果我们用 __all__,就可以显式指定哪些对外可见,除此之外的都对外不可见,并且,可以显式让一些私有属性对外可见。

例如下面这种情况,只有原本是私有属性的 _var2 是对外可见的,而原本是公有属性的 Class1 及 var1 反倒无法对外可见了。

#module.py

class Class1:
    ...
var1;
_var2;
__all__=["_var2";];
#main.py

from module import *

# 运行报错!!!!!
module.Class1();

# 运行报错!!!!!
module.var1;

# 运行正常
module._var2;

但是,注意,__all__ 仅适用于 from module import * 的场景,而无法适用于 import module 的场景。

例如下面这种情况,module.py 虽然用 __all__ 限定了没有任何对象对外可见,但 main.py 通过 module 前缀来访问 module 内的所有内容,是完全可行的:

#module.py

class Class1:
    ...
var1;
_var2;

__all__=[];

#-------------------
#main.py

import module

# 运行正常
module.Class1();

# 运行正常
module.var1;

# 运行正常
module._var2;

2,在 init.py 中的作用。

__all__ 在 init.py 中的作用和普通模块的情况类似,当执行 from package import * 时生效,当执行 import package 时,__all__ 是不起作用的!

举例,假设 package 下包含两个文件 init.py , module.py ,

# __init__.py
from module import var1, var2, _priv3, _priv4

__all__ = ["var1", "_priv3"]

#main.py
from package import *

# 可访问 var1
var1;

# 不可访问 var2,因为不在 all 列表中!!!
var2;

# 可访问私有属性 _priv3,因为在 all 列表中
_priv3;

# 不可访问 _priv4 ,因为不在 all 列表中!!!
_priv4;
#main.py
import package

#可访问 var1
package.var1;

#同样可访问 _priv3,关于私有属性的可见性,详见下文
package._priv3;

私有属性的可见性

私有属性是指用下划线做前缀来定义的任何类型对象,如 _var1, _func1, _class1 等。

一句话概括私有属性的可见性:
当前模块无法直接访问其它模块内的私有属性,两种情况除外:带有命名空间前缀的私有属性;__all__ 列表中包含的私有属性。

举几个例子。

(1)普通情况。当前模块(main.py)无法直接访问其它模块(module.py)的私有属性。

# module.py

var1;
_var2;
# main.py

from module import *

# 运行错误!!!!
_var2;

(2)带有命名空间前缀的私有属性,可访问。

# main.py

import module

# 运行正常
module._var2;

(3)__all__ 中包含的私有属性可访问。

# module.py

var1;
_var2;

__all__ = ["var1", "_var2"]
# main.py

from module import *

# 运行正常
_var2;

导入模块时如何避免命名空间污染

命名空间污染的意思是,假设 main.py 中 import module *
如果 module 中定义的变量或类与 main.py 定义的命名发生冲突,则可能会被覆盖或者引发莫名其妙的运行逻辑错误。

1,尽量避免导入模块的所有内容,而是仅导入我们需要的函数或类。

from module import var1/method1/class1

或者,利用 as 起个别名来避免命名冲突.

from module import var1 as VVV, method1 as MMM, class1 as CCC

2,如果一定要导入模块的全部内容,则应注意 import 用法。

合理的导入及访问方式:

import module
module.var1 = xxx;
module.method1();

将 module 作为一个整体导入,这样的好处是,只能通过前缀 “module” 来访问 module 内的内容,一般不会产生本地命名空间污染;并且,提高了代码可读性。

不合理的导入及访问方式:

from module import *
var1 = xxx;
method1();

不是将 module 作为整体导入,而是将 module 内的所有内容导入,这样的坏处是,可以直接访问 module 内的内容,很容易发生本地命名空间污染;并且,严重降低了代码可读性(一眼看不出 var1/method1 属于哪个模块)

3,模块内部的设计实现,应遵循如下几个原则。

  • 尽量减少全局变量的使用,不得不定义但不必对外可见的全局变量、方法、类,要用下划线前缀(如 _var1、_class1)将其设为私有类型
  • 对外可见的全局变量,命名要合理,比如名字不能太短,可增加合理的前缀名
  • 利用 __all__ 来严格、精确控制对外可见的内容(仅适用于 from module import * 场景)

模块的命名空间长度对其访问性能的影响

既然前文知道了,python 进程内会维护一颗“命名空间树”,那么,当我们以不同长度的路径访问同一个模块时,访问效率有影响吗?

例如下面两种访问变量 var 的方式,耗时是否有明显差异?

import a.b.c.d.module
from a.b.c.d import module

a.b.c.d.module.var = 1
module.var = 1

先说结论:路径越长,耗时越多,耗时与路径长度呈线性相关

测试代码如下:

import pyfamily.child.grandchild.grandson
from pyfamily.child import son
from pyfamily.child.grandchild import grandson

cnt=0
calc_range = 100

start_time = time.time_ns()
for i in range(calc_range):
    pyfamily.child.grandchild.grandson.cnt += 1
end_time = time.time_ns()
print("time cost of pyfamily.child.grandchild.grandson.cnt:", end_time - start_time, " ns")

start_time = time.time_ns()
for i in range(calc_range):
    pyfamily.child.son.cnt += 1
end_time = time.time_ns()
print("time cost of pyfamily.child.son.cnt:", end_time - start_time, " ns")

start_time = time.time_ns()
for i in range(calc_range):
    son.cnt += 1
end_time = time.time_ns()
print("time cost of son.cnt: ", end_time - start_time, " ns")

start_time = time.time_ns()
for i in range(calc_range):
    grandson.cnt += 1
end_time = time.time_ns()
print("time cost of grandson.cnt: ", end_time - start_time, " ns")

start_time = time.time_ns()
for i in range(calc_range):
    cnt += 1
end_time = time.time_ns()
print("time cost of cnt:", end_time - start_time, " ns")

执行结果(多次测试结果差不多):

time cost of pyfamily.child.grandchild.grandson.cnt: 25315  ns
time cost of pyfamily.child.son.cnt: 20926  ns
time cost of son.cnt:  14970  ns
time cost of grandson.cnt:  15159  ns
time cost of cnt: 10045  ns

import 与 “引用”

问题探究:

from import 和 import 的另一个区别:当前模块修改导入对象的值后,原模块内的该对象值是否发生变化?

main.py
from module import var

那么,当 main.var 发生变化后,会影响 module.var的原值吗?换句话说,main.var和module.var到底是不是同一个对象?
改成 import module 后呢?

“python 内一切皆为对象。”

我们重新理解一下这句话,用C++的语言描述就是,python 内一切变量都是指向某个对象的指针,而对象只会再第一次创建时开辟空间。这也解释了为什么 python 变量不会限定类型,因为本质上它类似于一个 C++ 语言内的 void* 指针,在运行过程中可以改为指向任意其它类型。
由此延伸出另一个事实:一切等号赋值皆是修改引用。

var1 = Obj(); #创建对象,并让引用var1指向该对象
var2 = var1; #此时,var2和var1都是Obj()的引用。
var1 = 1; #创建对象"1",并让引用var1指向该对象
var2 = var1; #var2和var1都指向 1 这个对象
var2 = 2; #创建对象"2",var2引用发生变化,指向2这个对象,且不影响var1依然指向1)

https://blog.csdn.net/ywsydwsbn/article/details/124594486
https://blog.csdn.net/zheshigeren/article/details/115310328
可变对象与不可变对象
https://www.cnblogs.com/luosongchao/p/3724194.html

https://www.cnblogs.com/bubblebeee/p/15867934.html
https://blog.csdn.net/qq_41987033/article/details/81675514
赋值与引用
https://www.cnblogs.com/daminghuahua/p/16044691.html

现在我们理解了,import 导入的是原对象的一个引用,而不是开辟新空间创建新对象。所以,要搞明白 import 和 from import 的区别就很简单了:

情况1

#main.py
import module
module.var = newvalue

由于 main.py 只是保存了 ‘module’ 的引用,所以修改 main.module.var 的值(引用),就等同于直接修改原 module.var 的值。

情况2

#main.py
from module import var
var = newvalue

这时,main.py 是创建了对 module.var 的一个新引用 var,那修改 var 的值,相当于让 var 引用了别的对象,当然不会对原 module.var 有任何影响了。

情况3

#main.py
from module import list1
list1[0] = newvalue

这种情况,修改的不是 list1 这个引用,而是 list1 引用的实际对象内存空间内的数据,所以,module.list1 的内容是一定会变化的。

2.4 绝对导入与相对导入

原则:

  • 对本包(及子包)内的模块,采用相对导入
  • 对其他包内的模块,采用绝对导入
# pyfamily/child/son.py

# 对本包内采取相对导入
from . import daughter
from .grandchild import grandson

# 对其它包采取绝对导入
import pyfamily.vehicle.car as car
import pyfamily.vehicle.motor as motor

关于 sys.path

一般来讲,只允许主模块中添加少量 sys.path 路径,包内的功能模块文件,不得私自添加 sys.path 路径,而是要通过合理的结构设计、合理的 import 方式来避免。

2.5 动态导入

利用 import() 函数或 importlib 来动态导入模块,可用于反射、热补丁等场景。

略。

2.6 总结:import 的原则

  • 大前提:合理的包组织结构及模块划分
  • 合理的导入方式(绝对导入&相对导入)
  • 避免命名空间污染
  • 避免同名冲突覆盖(其实也算命名空间污染)
  • 避免路径过长导致的访问性能低下

3、如何合理化设计工程包的目录结构

工程包从对外的功能呈现形态的角度,可分成如下两类:

  • 库包。仅对外界提供一些功能性的模块,没有 __main__ 脚本,不负责启动进程
  • 主程序包。对外提供功能性模块及启动业务功能的主程序,主程序是包含 __main__ 的脚本

两者又是相辅相成,由于涉及到 sys.path 相关的问题,主程序包最好也设计成 主程序脚本 + 库包 的形式。

3.1 主程序脚本的位置

例如下面这个名叫 test_import 的工程,核心的功能逻辑代码在 pyfamily 包里,pyfamily 对外提供一组业务层面的模块 api,然后在 pyfamily 的同级目录创建一个 main.py 来调用 pyfamily ,启动相关业务。

lubo@lubo-L460:~/work/python/VSCodeProjects/test_import$ tree .
.
├── main.py
└── pyfamily
    ├── child
    │   ├── daughter.py
    │   ├── grandchild
    │   │   ├── grandson.py
    │   │   └── __init__.py
    │   ├── __init__.py
    │   └── son.py
    ├── __init__.py
    └── vehicle
        ├── car.py
        ├── __init__.py
        └── motor.py

为何要这样设计呢?因为:当 python 解释器启动一个 py 脚本时,会将该 py 脚本所在的目录(注意,不是执行命令的目录),自动添加到 sys.path 中。也就是说,在 py 脚本中执行 sys.path.append(“.”) 其实是多此一举。

例如,在如下三个不同的目录中启动 main.py 脚本,效果是一样的,都会自动把 “/work/python$ python3 VSCodeProjects/test_import/” 这个目录添加到 sys.path 中。

lubo@lubo-L460:~/work/python/VSCodeProjects/test_import$ python3 main.py
lubo@lubo-L460:~/work/python/VSCodeProjects$ python3 test_import/main.py
lubo@lubo-L460:~/work/python$ python3 VSCodeProjects/test_import/main.py

所以,只要主程序 main.py 和 包 pyfamily 的相对位置保持不变,不管将 test_import 这整个工程包放到哪个路径、不管在哪个路径下启动 main.py ,都不会存在 import 报错。

总结:主程序 py 文件,应放到和核心包同级的目录中

3.2 模块测试代码如何运行

上一节讲了主程序 py 文件的位置问题。那如果单运行某个模块的测试代码呢?模块往往是位于核心包内部,直接运行该脚本会因为 sys.path 问题导致 import 出错。

例如,如何单独运行 pyfamily/child/son.py ?
正确的做法是:
在主入口程序的目录下,添加 -m 参数来执行模块文件:

lubo@lubo-L460:~/work/python/VSCodeProjects/test_import$ python3 -m pyfamily/child/son.py

-m 参数的作用就是,告诉解释器:当前要执行的是一个“模块”,请将执行命令的路径(而不是模块文件路径)添加到 sys.path 中。

3.3 其它目录

  • 10
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值