循环 Import (Circular Import) 陷阱原理

⚠ 转载请注明出处:作者:ZobinHuang,更新日期:May.3 2021


知识共享许可协议

    本作品ZobinHuang 采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,在进行使用或分享前请查看权限要求。若发现侵权行为,会采取法律手段维护作者正当合法权益,谢谢配合。

1. 循环 Import 为什么会出错?

    考虑以上代码,我们创建了两个 module,分别叫作 module_amodule_b,我们在 module_a 里定义了 Class_1,在 module_b 里定义了 Class_2。同时,我们还在 module_a 中将 module_b 中的 Class_2 import 进来,在 module_b 中将 module_a 中的 Class_1 import 进来,也就是所谓 循环导入 (Circular Import)
    然后,我们在 Command Line 中输入 "$ python module_a.py" 以 module_a 为入口来运行这份代码,接下来我们来看看解释器在运行的过程中会发生什么事情。

    在输入命令后,结合上图我们来过一遍流程。
    解释器会首先将我们输进去的 module_a.py 代表的 module_a 模块命名为 __main__,这也就是为什么所谓 python 的 main 函数要写成 if __name__ == "__main__" 的形式,其本质的意思就是说:“如果本模块有幸成为入口文件,请顺带执行 if __name__ == "__main__" 后面的内容”。
    回到我们的例子,解释器完成对 module_a 的重命名后,它就会从头开始执行模块 __main__ 里的内容。注意到当 python 程序里调用 import 命令时,其实质是去 import 的目标代码文件里同样地从头开始执行一遍模块的内容。因此在我们的 __main__ 的这堆 import 中,解释器就会首先去执行一遍 flask 模块的内容,并且将 Flask 类找出来,记录在一个 Checklist (官方可能不是这个名字,但是是这个意思) 中。然后解释器又会去执行一遍 module_b 模块的内容,并且企图把 Class_2 找出来。如果你比较细心,你会在上图中发现一个结论,即使 __main__ 模块还没有运行完,它就已经被加入到 Checklist 中去了。
    让我们来到由于 __main__ 的 line 2 而被执行的 module_bmodule_b 里第一行表面它想 import module_a,很多人会以为解释器此时就会在这里报错,因为我们刚刚其实已经执行过 module_a.py 里的内容,而且没有执行完,Checklist里的记录仅仅会记录我们执行过这个模块但是没有我们想要的 Class_1 的记录。其实不然,解释器不会倒在这,因为我们刚刚执行过的模块叫 __init__ 而不叫 module_a,解释器此时检查 Checklist 会认为它还没执行过 module_a,即使 __init__module_a 是同一份代码。所以它此时就又会去执行 module_a 模块,并且值得注意的是此时 Checklist 里会加上 module_b 已被执行过的条目,只是下面什么都还没找出来。
    来到 module_a 模块,第一行 import flask 模块里的 Flask 类的时候,解释器会从 Checklist 中发现它已经执行过这个模块了,并且想要的类 Flask 也找到了,所以它就会继续往下走。来到第二行 import module_b 里的 Class_2 这边,解释器会从 Checklist 里发现它已经执行过 module_b 了,而且想要的类 Class_2 并没有找到,所以解释器在这里就停下来了,认为 module_b 里并没有 Class_2 这个类,然后错误由此就出现了。

2. 一种暴力的解决办法

    暴力的解决办法说白了也很简单,就是保证我们的模块在 import 的时候,前后顺序是合理的,不会出现:被后面导入的模块依赖的内容,在前面的执行中没有被找到的情况,其实就是顺序问题。

    如上图所示我们给出了针对前一个小节描述的 bug 问题的解决方案。其实就改了两个地方:
        (1) 把 module_a 里 import module_b 的代码挪到了 定义完 Class_1 之后。
        (2) 把 module_b 中的 import 语句改为了从 __main__ 模块引入。
    这样一来,按照我们前面分析的方法过一遍流程,我们就能发现整个程序执行下来是没有问题的,在这里就不再赘述。

3. 优雅的解决办法

    上述暴力的解决方法似乎是解决了问题,但是万一某天我们的 module_a 不是我们程序的入口的话,那我们整个程序就都跑不起来了。所以这样写的后果是整个工程的可维护性和可拓展性极差,因此在这里我们需要一种优雅的解决办法 —— 使用包 (Package) 来解决这个问题。

    如上图所示,我们把函数的入口提取出来,放到 run.py 中,然后我们把 modules 都打包进一个 package 里,run.py 就直接从 package 里调用它想要的实例、类或函数等,这样就不用去考虑 modules 谁可能会成为函数的入口。这样一来,我们就能比较 “心无旁骛” 地在 package 的组织中谨慎地考虑执行的顺序,以让他们能够正确的 import 彼此。