Python——包导入流程


整体总结

  • 导入即执行import 会自顶向下执行 __init__.py 的全部顶层代码(含级联导入),但仅首次导入执行一次,之后走 sys.modules 缓存
  • __all__ 是公共 API 白名单 :硬性控制 import * 的范围,软性向人、IDE、类型检查器声明包的公开接口,但不提供任何真正的访问限制
  • 包中的各种对象,包括普通对象、类名、函数名、甚至包名等都是属于 dict 对象的 key-value 结构存储的,所以他们可以被修改

修改导入的对象

模块的命名空间是一个 dict

  • 模块中的一切顶层名字(变量、函数、类、子模块等)都存储在该模块对象的 __dict__ 中(可通过 vars(a) 查看)
  • 名字只是指向对象的引用,因此可以随时重新绑定(即 monkey patch)

通过模块属性访问:动态查找,修改全局可见

  • 这行代码把 a.__dict__["b"] 重新绑定到 c

    1
    2
    import a
    a.b = c
  • 此后所有通过 a.b 这种属性访问方式读取的代码(无论在哪个模块),都会查到新值 c

    • 因为每次 a.b 都是运行时在模块 dict 中的一次实时查找

from a import b:一次性值拷贝,之后与 a.b 无关

  • from a import b 导入的本质:
    • 等价于 b = a.b:把当时 a.__dict__["b"] 指向的对象,绑定到当前模块的本地名字 b
    • 此后:
      • 若执行 a.b = c,只改变了 a 模块 dict 中的绑定;本地的 b 仍指向原对象,不受影响
      • 反之亦然:给本地 b 重新赋值也不影响 a.b
  • 两个名字是独立的引用 ,只是曾经指向同一个对象

导入包时执行了哪些代码

核心规则:导入 = 执行

  • Python 的 import 不是简单的”声明引用”,而是真正执行代码
  • 当执行 import mypkg 时,发生以下事情:
    • 1)查找 :解释器按 sys.path 顺序查找名为 mypkg 的包(目录)或模块(.py 文件)
    • 2)检查缓存 :先查 sys.modules,如果已导入过则直接复用,不会重复执行
    • 3)执行 :首次导入时,自顶向下完整执行 mypkg/__init__.py 中的所有模块级代码
    • 4)绑定 :把生成的模块对象绑定到当前命名空间的 mypkg 名字上

哪些代码会被执行

  • __init__.py 中所有模块级(顶层)语句 都会执行,下面是 __init__.py 定义的示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    # mypkg/__init__.py

    print("包被导入了") # 会执行:打印语句
    VERSION = "1.0" # 会执行:变量赋值
    from .core import Engine # 会执行:触发 core.py 的完整执行
    import logging # 会执行:导入依赖

    logging.basicConfig(...) # 会执行:任何函数调用

    def helper(): # 会执行 def 语句本身(创建函数对象)
    print("hello") # 但函数体不执行,直到被调用

    class Config: # 同理,class 语句执行(创建类对象)
    def __init__(self):
    DEBUG = True # 类体内的语句在创建类时执行一次
    • 关键区分:defclass 语句本身会执行(定义名字),但函数体只在调用时执行

导入的级联效应

  • 如果 __init__.py 里有 from .core import Engine,那么导入包时会级联执行 core.py 的全部顶层代码,core.py 的 import 又会继续级联
  • 注:这是很多副作用(如注册机制、monkey patch)的实现基础

不同导入形式的差异

  • 一般来说有下面几种导入方式:

    1
    2
    3
    4
    import mypkg                  # 只执行 mypkg/__init__.py
    import mypkg.sub # 执行__init__.py + sub.py(父包一定先执行)
    from mypkg import sub # 同上
    from mypkg.sub import func # 同上,再把 func 绑定到当前命名空间
  • 核心说明:

    • 导入子模块必然先执行父包的 __init__.py
    • import mypkg 不会自动导入子模块,除非 __init__.py 里显式导入了它们
    • 无论哪种形式, 模块只执行一次,之后都走 sys.modules 缓存

常见问题陷阱

  • 副作用不可控__init__.py 里的耗时操作(连数据库、加载模型)会拖慢所有导入者
  • 循环导入 :A 导入 B,B 又导入 A
    • 此时 A 还没执行完,B 拿到的是”半成品”模块,可能 AttributeError
    • 解决办法:延迟导入(在函数内 import)或重构依赖
  • 重复执行的错觉 :同一模块通过不同路径导入(如 import aimport pkg.a)可能在 sys.modules 中变成两个条目,导致代码执行两次
    • 注:sys.modules 是按照包名(路径)来作为 dict 管理包对象的

补充:__init__.py__all__ 的特殊含义

__all__ 作用:硬性控制 from pkg import *

  • 示例:

    1
    2
    3
    4
    5
    # mypkg/__init__.py
    from .core import Engine,_internal_helper
    from .utils import format_data

    __all__ = ["Engine", "format_data"]
  • 当用户执行 from mypkg import * 时:

    • 定义了 __all__ :只导入列表中列出的名字(Engineformat_data),且严格按列表来
      • 即使名字带下划线也会导入,列表中不存在的名字会报 AttributeError
    • 未定义 __all__ :导入模块命名空间中所有不以下划线开头的名字
      • 注意:是所有 不以下划线开头 的,包括不小心引入的 import os 中的 os

作为 “公共 API 契约” 的软性作用

  • 即使没人用 import *__all__ 仍然重要:
    • 文档意义 :明确声明”这些是包的公共接口,其他都是内部实现,随时可能变动”
    • IDE / 补全 :许多编辑器和语言服务器用 __all__ 决定补全提示和 re-export 的合法性
    • 类型检查器 :mypy 等工具在 strict 模式下,from .core import Engine 这种重导出如果不在 __all__ 中(或不写成 from .core import Engine as Engine),会被视为私有,外部使用时报错
    • 文档生成工具 :Sphinx 等按 __all__ 决定哪些内容进入 API 文档

补充:__all__ 不能做什么

  • 不是访问控制__all__ = ["Engine"] 并不阻止用户写 mypkg._internal_helper,Python 没有真正的私有
  • 不影响普通导入from mypkg import xxx 中的 xxx 不需要在 __all__
  • 不影响执行__all__ 只是一个普通的字符串列表赋值,不改变 __init__.py 的执行行为

__all__ 惯用写法

  • __init__.py写法示例:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # 方式一:显式列表(最常见)
    __all__ = ["Engine", "Config", "run"]

    # 方式二:重导出并聚合子模块的__all__
    from .core import *
    from .utils import *
    from . import core, utils
    __all__ = core.__all__ + utils.__all__

    # 方式三:类型检查友好的显式重导出
    from .core import Engine as Engine # "as 自身" 表示有意的公开重导出