python import的坑年年都有从未消停过
一来是看了网上好多讲解,讲的都没错也都能看懂,但大多都是流于形式、流于一种规则主义的介绍,没有触达正确理解import思想的灵魂
二来是很多排过坑的同学都在发牢骚吐槽python的import非常坑,例如相对路径相关的吐槽,好像是觉得python这样搞很蠢很不优雅
针对以上两点,本人就想写这篇文章来聊一下我的理解,我认为理解这个问题的本质一定要理解抽象的哲学思想,带着抽象的思想看待这个问题,你就会一目了然,并且再也不会觉得python的import的设计是愚蠢的,反而会认识到这是一种优雅、带有美感的清晰的设计哲学!
抽象的理解
我个人对抽象的理解是:系统内外部视角的解耦。
- 外部视角下:抹除掉一个系统的内部细节,在系统的外界视角下,这个系统就是一个黑盒,你摸不到它的内部细节
- 内部视角下:站在这个系统内部中的任何一个位置的视角下,都看不到系统外部的任何细节
当然,一个抽象的系统是要与外界发生交互的,也就是需要一个接口。例如微波炉的接口就是炉门和开关,外界视角下只需要知道往炉门里放入生鸡蛋然后打开开关就可以享受美食(不是)了。而抽象思想中,接口的性质是外向性的:
也即接口是系统内对外提供的,通过接口,只能由外界对系统内部功能进行主动调用和干预,而不能由系统内部对外界产生主动干预
包import的理解
对于import操作来说,Python的一个完整包,就是这样一个系统的抽象!
注意,这里的完整包不是python的官方概念,是我按理解概况的。按python的定义,包就是一个文件夹(python3已经不要求有__init__.py了),但我们都知道经常是一个包的目录里面还有别的包的目录,形成嵌套。而我们这里所说的“完整包”就是这种嵌套的最外层的那个包,而不是指包内嵌套的子包。
实际上,通常包开发者在开发一个包时,开发的最终目标物都是一个“完整包”。为了便于区分避免混淆,我们后面分别称之为“整包”和“子包”,统称为“包”。这时我们可以看到,按照抽象的思想看,整包是一个抽象,而子包则是一个抽象的具象展开的细节。
Python的包的设计,就是遵循着优雅的抽象思想来的。对于今天要重点讨论的import语句来说,python整包作为一个抽象对外提供的用于import操作的接口,就是整包内模块的访问路径结构。包开发者往往会在相关文档(如果他不想挨打他会写清楚的)中阐明这些接口,即提供了哪些可以import的模块、路径是什么。
因此,我们在理解import时也是分成抽象的外部视角和内部视角两方面:
import实现两方面功能:一是在python业务代码中通过import导入需要用的整包,来使用其提供的功能;二是在一个整包抽象的内部,完成子包间的import。前者就是抽象的外部视角,后者则是抽象的内部视角。而后者的import则有一个专门的机制——点开头的相对路径导入,也即我们耳熟能详的.xxx方式,该机制用于且只用于第二种import类型,且第二种import类型必须且只能使用该机制!我们后面详细介绍。
1. 抽象外部视角import
第一类import是为python业务代码导入用到的整包的,执行业务代码的“人”是Python解释器,因此这里的抽象的外部视角就是解释器视角。
解释器会按自己的当前视角去做路径寻址,具体方法是遍历sys.path,直至找到对应的包,默认顺序是先找当前目录、再找python/lib/site-packages。这里注意,当前目录就是起python解释器进程的目录,例如你命令行里执行python命令的目录,在整个执行过程中的任何一个阶段都是这样,除非你手动改sys.path
形象点比喻,你可以把python解释器看成一个干活的打工人,他开始一个工作任务时,他面前的大桌子就是当前目录,然后他工装裤上的大兜子就是python/lib/site-packages里面随身携带了一堆七七八八的工具(镊子锤子扳手),每个工具都相当于一个整包。而桌子上也有一些工具,例如秤、量尺,这是工头(编码者)给这个打工人(解释器)布置的这次特定任务所需要的工具(整包)。打工人干活时要用到哪个工具了(import),就先到桌子上找,找不到再到兜里找,有重名的优先用桌子上的(例如桌子上和兜里都有扳手,当然优先用工头指定的扳手),除非工头特意嘱咐打工人用自己带的扳手(例如把sys.path里当前目录顺序往后移),工头也可以告诉打工人其他工具间的位置用来找工具(添加路径到sys.path);如果就是找不到工具,打工人就罢工不干了,cue包工头这活爷没法干了(抛异常),除非工头头铁给他说那也得继续干(try catch忽略异常)。
注意一点,如果你是import x.y.z,或者import x.y,那这里的抽象整包都是最外层的x,感兴趣的可以试试会发现z.__package__是'x.y.z',是隶属于x这个整包的
2. 抽象内部视角import
在整包这个抽象的内部,往往会有许许多多子包,也就是一个个嵌套的目录,用来实现不同子功能。在抽象内部对子包的import必须用相对路径导入的方式,也即必须用"from ."开头的导入语句!它表示抽象内部组件相互之间的导入操作。
子包可以通过..来寻址到父级目录,...寻址到爷级目录。但是,绝不能溢出到整包外面,否则就会报错!怎么用抽象思想理解python的这个规矩?第一节我们阐述过,“内部视角下:站在这个系统内部中的任何一个位置的视角下,都看不到系统外部的任何细节”,子包它作为抽象的内部视角,是根本看不到整包外面的目录的!所以当你相对寻址往父级方向走的溢出整包了以后,自然就是不合理的了!这就是python所遵循的抽象哲学。
ok,那我们又有了一个区分:可抽象子包和不可抽象子包。
这两个概念也是我为了方便理解说明自己定义的,可抽象子包是指,子包这个目录单独拿出来可以视作一个整包存在,而不可抽象子包则不可使单独视作一个整包存在。区别的核心点,就在于相对路径导入时有无导致溢出该子包的父级方向寻址。如果一个子包单独拿出来,其中所有的父向import都没有造成抽象溢出,那它就是可以作为一个整包的,也即一个可抽象子包。可抽象子包显然具有良好的移植性,可以随便拿出来放到任何地方、任何项目中去复用。
那如果一个整包抽象内部的代码或子包中的代码import时不用点开头的相对路径呢?那这时它就不是一个专门用于抽象内部子包导入的import了,它就被视作一个外部视角的导入了,也即解释器就会对这种情况按照我们前面外部视角里介绍的那一套规则去做寻址了。
3. 整包内的最外层目录from .xxx import
例如有个整包叫test,test目录下有三个子包a b c和一个run.py,run.py里有一句from .a import *
这个写法是正确的,本身没有任何问题。但是当你cd到test目录下,然后python run.py的时候就会报错了
用抽象的思想很容易理解这个报错的原因:你都跑到test这个整包里面起python解释器了,那你这个行为已经破坏掉抽象的外部视角特性了,因为抽象的内部不可见性原则是不允许你跑到它里面去起python的,此时test这个抽象(整包)也就不再是一个抽象(整包)了,它就只是一个普普通通的存储在硬盘上的目录而已,然后你无非只是在这个目录里起了个python解释器。
既然test都不是个整包,那run.py里面那个点开头的相对路径导入也就无从谈起了,因为点开头的相对路径导入仅可用于整包内对子包的导入,自然会报错
编码的优美姿势
从抽象思想上了解了python import的思想,我们就来介绍一些常见问题和编码规范。当然这些规范很多都是我个人的观点,供参考。
单测时好使跑起来报错
很多人会犯的问题,整包内部import子包不用点开头的相对路径,走单测的时候能跑通,一起跑就找不到moudule了,因为这时整包内的这些import就是抽象外部视角了,sys.path打死也没有对应路径的。
想清楚你在写什么:你在写包还是在写本地项目
一定要想清楚你到底在干嘛,你在写的东西是不是将来要给别人用、作为一个别人用的整包而存在的。如果是,那么你写的每一个对内的import都必须是点开头的相对路径导入,不然别人在用的时候就翻车了,就会出现“单测时好使跑起来报错”的问题。
如果我想让整包可以运行?
对于上述两个问题,一个是你可能需要有些包的单测,也可能你既想为别人提供整包又想其本身就可以作为一个项目跑。
好问题!也是困扰我问题。。有时候我会单测的时候用一种import,测完赶紧改回去。如果import的问题只涉及到一个脚本,可以加个if __name__ == '__main__'的判定。再暴力一点的话,索性加个try catch,把两种情况都cover到。
整包内的外部视角import耦合项目时整包不可移植
例如你一个本地项目,有好几个整包,然后其中一个整包的内部有代码直接用外部视角import的方式,导入了该项目的另一个整包。
这种操作对于这个本地项目来说本身是没有问题的,因为你解释器起在这个项目上,那这种import通过sys.path是可以找到另一个整包的。但是这就意味着,用了这种import的整包是耦合于该项目的,没法移植了,除非另一个整包也跟着走。
实际上,本人认为这种编码方式是极其危险的,在代码交接、维护时很容易出问题。站在高内聚低耦合的角度看,如果我拿到这份代码,我倾向于认为,各个整包之间应当是没有耦合的,除非文档明确告诉我这个事情,但如果没有文档,我很可能把另一个整包乱改一通,比如包名字,然后不说了,直接翻车。
那在我看来应该怎么写呢?我认为两个有这种耦合的整包应该合并到一个整包里,他们两个则成为新整包里的两个子包,然后改用抽象内部视角的import。这样别人在看代码时就会知道这是在一个整包里的东西,是内聚的,子包间是有耦合的。
lib/site-packages里的整包使用抽象外部视角import方式导入自身
我直接上个例子,networkx可能很多人都熟悉,主流的图结构数据处理的python包。我们来看下它的样子:
这是它在site-packages目录下的包目录
下面是它的__init__.py
我们可以看到它在import时,对networkx自己都是直接用的抽象外部视角的import方式。
这样写本身倒没问题,因为sys.path寻址时能够寻址到site-packages目录。
但是如果你不通过pip安装,而是直接把它的github源码目录拿过来直接拷贝到你自己的项目目录中去作为一个整包用,你就翻车了:例如你恰好不是把它放在最外层目录,这时sys.path就找不到它了,如下图
我们切到p目录下起一个python:
就翻车了;所以如果说你想开发一个包,但你同时又想让这个开源的包的原生包目录能够直接被放在别的项目的目录下来使用,就要注意这个问题!
总结
没啥总结,反正就是个抽象