ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Julia 模块系统完全指南:命名空间管理、子模块与预编译机制

Julia 模块系统完全指南:命名空间管理、子模块与预编译机制 Julia 模块系统完全指南命名空间管理、子模块与预编译机制【免费下载链接】juliaThe Julia Programming Language项目地址: https://gitcode.com/gh_mirrors/ju/julia本指南以 Julia 官方手册 doc/src/manual/modules.md 为核心系统讲解 Julia 模块Module的语法与组织方式从命名空间隔离、export/public导出机制、using/import的细微差别到子模块相对路径、名称冲突处理再到模块初始化__init__与预编译缓存precompilation的底层原理。读完本文你将能正确设计包package的模块骨架、写出与 Julia 1.11 兼容的公开 API、规避预编译陷阱并理解--compiled-modules等命令行标志对加载行为的影响。模块的基本语法与组织方式模块module在 Julia 中用于把代码组织成内聚的单元语法上由module NameOfModule ... end界定。一个模块具备三项核心能力独立命名空间每个模块引入一个全新的全局作用域因此不同模块中可以使用同名的函数或全局变量而互不冲突细粒度的命名空间管理模块通过export/public声明对外暴露的名字集合并通过using与import从其他模块引入名字可预编译模块可以被预编译以加快加载速度并可以包含运行时初始化代码。在大型 Julia 包中模块代码通常拆分为多个文件通过include组织module SomeModule # export、public、using、import 语句通常集中在这里下文详述 include(file1.jl) include(file2.jl) end文件与模块之间没有绑定关系模块只与module表达式相关联一个模块可以对应多个文件一个文件也可以包含多个模块。include的行为等同于把被包含文件的内容在包含它的模块的全局作用域中求值。官方推荐的代码风格包括不要缩进模块体——否则整个文件都会被整体缩进影响可读性模块名使用UpperCamelCase与类型命名一致在可能的情况下使用复数形式——尤其是当模块内含有同名的标识符时复数命名可以避免名称冲突。module FastThings struct FastThing ... end end命名空间管理命名空间管理是 Julia 模块体系的核心指的是语言提供的、让一个模块中的名字可以被其他模块使用的整套机制。限定名Qualified Names与模块路径全局作用域中的函数、变量与类型名字如sin、ARGS、UnitRange总是隶属于某个模块这个模块称为它的父模块parent module。可以在交互环境中用parentmodule查询julia parentmodule(UnitRange) Base该函数的底层实现在 base/reflection.jlparentmodule(f::Function)返回某个泛型函数首个定义所在的模块parentmodule(f, types)返回与指定参数类型匹配的首个方法所在的模块Julia 1.10 起也支持传入Method对象。在 base/errorshow.jl 中还有一个特殊的parentmodule_before_main变体用于在Main模块初始化完成前输出错误信息。在父模块之外引用这些名字时可以给名字加上模块前缀例如Base.UnitRange这种写法称为限定名。父模块本身可能要通过一串子模块才能访问如Base.Math.sin其中Base.Math称为模块路径module path。需要注意两类语法细节如果名字只包含符号例如运算符限定它时必须插入冒号如Base.:少数运算符还需要加括号例如Base.:()。限定名始终可访问对于函数而言还可以直接使用限定名作为函数名来为它添加方法。在一个模块内部可以用global x在不赋值的情况下“预留”一个变量名避免与加载期之后才初始化的全局量发生名字冲突。注意M.x y这种写法不能用来给其他模块中的全局变量赋值——全局赋值永远是模块本地的。导出列表export与public用export可以把名字函数、类型、全局变量、常量加入模块的导出列表这些符号正是using该模块时被自动引入的名字。通常export语句写在模块定义靠近顶部的位置方便读者快速找到但这只是风格建议——export可以出现在模块中的任意位置且可以有多个julia module NiceStuff export nice, DOG struct Dog end # singleton type, not exported const DOG Dog() # named instance, exported nice(x) nice $x # function, exported end;习惯上导出的是构成模块 API应用编程接口的名字。在上面的例子中导出列表暗示用户应当使用nice和DOG。但需要注意限定名始终能让标识符可被访问所以导出只是组织 API 的一种选择与其他语言不同Julia没有真正的“隐藏模块内部实现”的机制。也有些模块完全不导出任何名字常见于其 API 使用了过于常见的词如derivative容易与其他模块的导出列表冲突。Julia 1.11 起提供了public关键字它把名字标记为公共 API 的一部分但不产生任何命名空间影响即不会像export那样进入using者的命名空间。为了兼容 Julia 1.10 及以下版本可以使用 Compat 包中的compat宏或使用版本感知的写法VERSION v1.11.0-DEV.469 eval(Meta.parse(public a, b, c))需要留意两个关键字的语法差异export在任何位置都是关键字而public目前仅限于文件或模块的语法顶层这是为了兼容性——public是 Julia 1.11 才引入的新关键字export则从 Julia 1.0 起就存在。未来版本可能放宽这一限制因此不要用public作为标识符。独立的using与import交互式使用中最常见的加载方式是using ModuleName。它通过代码加载机制加载模块关联的代码并把以下内容引入当前全局命名空间模块名本身导出列表中的元素。从语义上讲using ModuleName意味着名为ModuleName的模块可用于按需解析名字当当前模块中遇到一个没有定义的全局变量时系统会到ModuleName导出的变量中去查找并使用它。因此当前模块中对该全局量的所有使用都会解析为ModuleName中的定义。加载方式有一个重要区分加载包中的模块直接写using ModuleName加载本地定义的模块模块名前需要加一个点如using .ModuleName。julia using .NiceStuff这会加载上面的示例代码使NiceStuff模块名、DOG和nice可用。Dog不在导出列表上但可以用模块路径限定为NiceStuff.Dog访问。关键点只有using ModuleName这种形式才关心导出列表。与之相对import .NiceStuff只把模块名引入作用域用户必须通过NiceStuff.DOG、NiceStuff.Dog、NiceStuff.nice才能访问其内容。import .NiceStuff等价于using .NiceStuff: NiceStuff当用户希望保持命名空间干净时通常使用import ModuleName或using ModuleName: ModuleName。同类的多个using/import语句可以用逗号合并julia using LinearAlgebra, Random当前这等价于using LinearAlgebra; using Random但未来 Julia 版本可能改为并行加载这些包。因此如果交换using A, B为using B, A会导致出错那往往暗示某处存在 bug。指定标识符的using/import与添加方法当using ModuleName:或import ModuleName:后跟逗号分隔的名字列表时模块会被加载但只有列出的名字被引入命名空间。例如julia using .NiceStuff: nice, DOG只导入nice和DOG。模块名NiceStuff不会自动进入命名空间需要显式列出julia using .NiceStuff: nice, DOG, NiceStuff当两个或多个包/模块导出了同一个名字、且它们指向不同的定义而包又是通过不带显式名字列表的using加载时不加限定地引用该名字会报错。因此为了兼容依赖包与 Julia 的未来版本例如已发布包中的代码建议显式列出每个包中要使用的名字如using Foo: Foo, f而不是using Foo。Julia 之所以提供两种看似相同的写法是因为只有import ModuleName: f允许不加模块路径地为f添加方法。下面的例子会报错julia using .NiceStuff: nice julia struct Cat end julia nice(::Cat) nice ERROR: invalid method definition in Main: function NiceStuff.nice must be explicitly imported to be extended Stacktrace: [1] top-level scope none:1这个错误能防止你意外地给只想使用、并不想扩展的、位于其他模块中的函数添加方法。有两种合法做法方式一始终用模块路径限定函数名这样代码能清楚地表明你在给其他模块的函数添加方法即便import和方法定义可能分散在不同文件中julia using .NiceStuff julia struct Cat end julia NiceStuff.nice(::Cat) nice 方式二import具体的函数名更简短定义多个方法时尤其方便julia import .NiceStuff: nice julia struct Mouse end julia nice(::Mouse) nice nice (generic function with 3 methods)导入的变量是只读的一旦某个变量通过using或import可见模块就不能再创建同名变量对全局变量的赋值永远只会影响当前模块拥有的变量否则报错。用as重命名通过import或using引入的标识符可以用as关键字重命名既可用于规避名称冲突也可用于缩短名字。例如Base导出了read函数而 CSV.jl 包也提供了CSV.read。如果频繁调用 CSV 读取会希望省掉CSV.前缀但此时read就有歧义julia read; julia import CSV: read WARNING: ignoring conflicting import of CSV.read into Main重命名可以解决这个问题julia import CSV: read as rd被导入的包本身也可以重命名import BenchmarkTools as BT注意as与using搭配时仅当只引入单个标识符才有效using CSV: read as rd可以但using CSV as C不行因为后者操作的是CSV的全部导出名字。混合使用多个using与import上述各形式的using/import可以组合其效果按出现顺序合并。例如julia using .NiceStuff # exported names and the module name julia import .NiceStuff: nice # allows adding methods to unqualified functions会把NiceStuff的全部导出名字和模块名引入作用域同时允许不加模块前缀地为nice添加方法。名称冲突的处理当两个或多个包导出了同名标识符时julia module A export f f() 1 end A julia module B export f f() 2 end Busing .A, .B本身可以正常执行但调用f时会报错并附提示julia using .A, .B julia f ERROR: UndefVarError: f not defined in Main Hint: It looks like two or more modules export different bindings with this name, resulting in ambiguity. Try explicitly importing it from a particular module, or qualifying the name with the module it should come from.Julia 无法替你决定引用哪个f必须做出选择。常见解决方案有三种直接用限定名A.f、B.f。这让代码读者清楚上下文尤其当f只是名字恰好相同、在不同包中含义迥异时。例如degree在数学、自然科学和日常生活中都有不同含义应当保持区分用as重命名一个或两个标识符julia using .A: f as f julia using .B: f as g这样B.f以g的名字可用前提是你之前没有用过using A把f引入命名空间当名字确实共享同一含义时常见做法是一个模块从另一个模块导入它或者建立一个轻量的“基础”包其唯一职责就是定义这样的接口供其他包使用。惯例是这类包名以...Base结尾这与 Julia 的Base模块无关。定义的优先级顺序全局绑定定义一般有四类来源通过using M产生的隐式导入通过显式导入using M: x、import M: x产生通过global x不带类型声明隐式声明的全局量通过定义语法const、global x::T、struct等显式声明的全局量。从语法上可归纳为三个优先级层级由弱到强隐式导入隐式声明显式声明与导入。通常允许用更强的绑定替换更弱的绑定julia module M1; const x 1; export x; end Main.M1 julia using .M1 julia x # Implicit import from M1 1 julia begin; f() (global x; x 1) end julia x # Implicit declaration ERROR: UndefVarError: x not defined in Main Suggestion: add an appropriate import or assignment. This global was declared but not assigned. julia const x 2 # Explicit declaration 2但在显式优先级层级内部替换在语法上是不被允许的julia module M1; const x 1; export x; end Main.M1 julia import .M1: x julia const x 2 ERROR: cannot declare Main.x constant; it was already declared as an import Stacktrace: [1] top-level scope REPL[3]:1或被忽略julia const y 2 2 julia import .M1: x as y WARNING: import of M1.x into Main conflicts with an existing identifier; ignored.隐式绑定的最终解析结果取决于当前 world age 下所有被using的模块集合详见手册的 world age 章节 与 base/boot.jl 中关于 world age 的实现。默认顶层定义与 baremodule每个模块都会自动包含using Core、using Base以及eval和include两个函数的定义后者用于在该模块的全局作用域内求值表达式/文件。如果不需要这些默认定义可以用baremodule关键字定义模块注意Core仍然会被导入。就baremodule而言一个标准module相当于baremodule Mod using Base eval(x) Core.eval(Mod, x) include(p) Base.include(Mod, p) ... end如果连Core都不想要可以用Module(:YourNameHere, false, false)定义一个不导入任何东西、也不定义任何名字的模块再用eval或Core.eval把代码求值进去julia arithmetic Module(:arithmetic, false, false) Main.arithmetic julia eval arithmetic add(x, y) $()(x, y) add (generic function with 1 method) julia arithmetic.add(12, 13) 25Module构造函数的实现在 src/module.c其中第二个参数控制是否using Base第三个参数控制是否定义eval等默认函数。标准模块Julia 有三个重要的标准模块Core包含语言“内建”的全部功能Base包含几乎所有场景下都有用的基础功能Main顶层模块也是 Julia 启动时的当前模块。此外Julia 默认附带若干标准库模块stdlib。它们的行为与普通 Julia 包一致只是无需显式安装。例如要进行单元测试直接加载Test标准库即可using Test子模块与相对路径模块可以包含子模块嵌套使用同样的module ... end语法用于引入独立的命名空间帮助组织复杂代码库。注意每个module都会引入自己的作用域所以子模块不会自动“继承”父模块的名字。官方建议子模块在using/import语句中引用其所在父模块包括父模块本身内的其他模块时使用相对模块限定符relative module qualifier以点.开头.对应当前模块每多一个.就上溯到当前模块的父模块之后可以继续跟模块名最终跟要访问的实际名字全部用.分隔特殊情况下引用模块根可以不加点省去数层数的麻烦。来看一个示例子模块SubA定义了一个函数随后在它的“兄弟”模块中扩展它julia module ParentModule module SubA export add_D # exported interface const D 3 add_D(x) x D end using .SubA # brings add_D into the namespace export add_D # export it from ParentModule too module SubB import ..SubA: add_D # relative path for a “sibling” module # import ParentModule.SubA: add_D # when in a package, such as when this is loaded by using or import, this would be equivalent to the previous import, but not at the REPL struct Infinity end add_D(x::Infinity) x end end;你可能会在包中看到类似的、不加点号的 import 写法julia import ParentModule.SubA: add_D ERROR: ArgumentError: Package ParentModule not found in current path.它走的是代码加载机制因此只有当ParentModule是某个包中的模块位于文件中时才能工作。如果ParentModule是在 REPL 中定义的就必须使用相对路径julia import .ParentModule.SubA: add_D此外定义顺序也很重要。下面的代码会报错因为Sub试图在y被定义之前使用TestPackage.ymodule TestPackage export x, y x 0 module Sub using ..TestPackage z y # ERROR: UndefVarError: y not defined in Main end y 1 end出于同样的原因循环引用也是不允许的module A module B using ..C # ERROR: UndefVarError: C not defined in Main.A end module C using ..B end end模块初始化与预编译大型模块加载可能要数秒因为执行模块中的全部语句往往意味着编译大量代码。Julia 通过创建**预编译缓存precompiled caches**来缩短这一时间。缓存文件的创建与失效预编译模块文件又称“缓存文件”在import或using加载模块时自动创建和使用。若缓存文件尚不存在模块会被编译并保存供后续复用也可以手动调用Base.compilecache(Base.identify_package(modulename))在不加载模块的情况下生成这些文件生成的缓存文件存放在DEPOT_PATH[1]的compiled子文件夹中。只要系统没有变化后续import/using就会直接使用这些缓存。预编译缓存文件保存了模块的定义、类型、方法和常量也可能保存方法特化method specialization及为其生成的代码——但后者通常要求开发者显式添加precompile指令或在包构建阶段执行能强制编译的工作负载。当以下情况发生时模块会在using/import时自动重新编译更新了模块的依赖改变了模块的源码。这里的“依赖”包括模块 import 的其他模块、Julia 构建本身、include的文件以及在模块文件中用include_dependency(path)显式声明的依赖。失效检测的具体规则可在 base/loading.jl 的require/_require_from_serialized相关实现中看到包括对include加载的文件依赖检查文件大小fsize或内容压缩为哈希是否变化对include_dependency加载的文件依赖检查修改时间mtime是否变化或与截断到最近一秒的mtime是否相等以兼容无法以亚秒精度复制 mtime 的系统检查require搜索逻辑选中的文件路径是否与创建预编译文件时的路径一致考虑当前进程中已加载的依赖集合即使这些模块的文件变化或消失也不会重新编译它们以免运行中的系统与预编译缓存产生不兼容最后还会考虑任何编译期偏好preferences的变化。声明不可预编译__precompile__(false)如果明确知道某模块不适合预编译应在模块文件中通常放在顶部写__precompile__(false)。这会导致Base.compilecache抛出错误using/import直接在当前进程中加载模块跳过预编译与缓存该模块也因此不能被任何其他预编译模块导入。其底层实现在 base/loading.jl__precompile__(isprecompilable::Booltrue)在非可编译且正在生成输出generating_output()时抛出PrecompilableError同一文件 base/loading.jl 定义了该错误类型及其show/precompilableerror辅助函数用于在缓存缺失时给出诊断信息。__init__区分编译期与运行期创建增量共享库incremental shared libraries有一些固有行为需要留意最典型的是外部状态不会被保留。因此应当把必须在运行时执行的初始化步骤与可以在编译时完成的步骤明确分开。为此Julia 允许在模块中定义__init__()函数执行必须在运行时完成的初始化步骤该函数在编译期间--output-*不会被调用可以假定它在代码的整个生命周期中恰好运行一次必要时当然也可以手动调用但默认假设它处理的是本地机器相关的状态——这些状态不需要、也不应该被捕获进编译镜像模块被加载进进程后调用包括以增量编译方式加载--output-incrementalyes但在完整编译过程中不会调用。特别地如果模块中定义了function __init__()Julia 会在模块首次于运行时被加载如通过import、using或require之后立即调用它——只在模块全部语句执行完毕后调用一次。由于它在模块完全导入后才被调用任何子模块或其他被导入模块的__init__会先于外层模块的__init__执行该顺序跨线程同步所有__init__会按依赖顺序执行完毕using才会完成返回。不过与当前模块无依赖关系的其他__init__可能并发执行因此访问当前模块之外共享状态时必要时仍要使用锁。__init__的两个典型用途调用外部 C 库的运行时初始化函数初始化涉及外部库返回指针的全局常量。例如调用 C 库libfoo它要求在运行时调用foo_init()同时我们希望定义全局常量foo_data_ptr保存libfoo的void *foo_data()返回值——该指针地址每次运行都会变化所以必须在运行时而非编译时初始化const foo_data_ptr Ref{Ptr{Cvoid}}(0) function __init__() ccall((:foo_init, :libfoo), Cvoid, ()) foo_data_ptr[] ccall((:foo_data, :libfoo), Ptr{Cvoid}, ()) nothing end注意在__init__之类的函数内部定义全局变量完全可行这正是动态语言的优势之一但把foo_data_ptr声明为全局作用域的常量可以保证编译器知道其类型并生成更优化的代码。显然模块中其他依赖foo_data_ptr的全局量也必须放在__init__中初始化。预编译的已知陷阱涉及大多数非ccall产生的 Julia 对象包括数组这类复杂的堆分配对象的常量不需要放进__init__——它们的定义可以被预编译并从缓存镜像中加载。然而任何返回裸指针值的例程必须在运行时调用才能让预编译正常工作Ptr对象除非隐藏在isbits对象内部否则会变成空指针。这包括cfunction与pointer等 Julia 函数的返回值。使用预编译时要清醒认识编译阶段与执行阶段的区别。在这种模式下往往能更清楚地体会到Julia 是允许执行任意代码的编译器而不是顺带生成编译代码的独立解释器。其他已知的潜在失败场景包括全局计数器例如用于给对象分配唯一 ID。下面的代码本意是给每个实例唯一 id但counter的值在编译结束时就被记录此后所有使用该增量编译模块的实例都会从同一计数值开始mutable struct UniquedById myid::Int let counter 0 UniquedById() new(counter 1) end end注意objectid通过对内存指针做哈希实现也有类似问题。一种替代方案是用宏捕获__MODULE__并连同当前counter值一起存储但更好的做法是重新设计代码使其不依赖这种全局状态。关联容器如Dict、Set需要在__init__中重新哈希未来可能会提供注册初始化函数的机制。依赖编译期副作用延续到加载期例如修改其他 Julia 模块中的数组或变量持有打开的文件或设备句柄存储指向其他系统资源包括内存的指针。意外“复制”其他模块的全局状态——直接引用它而不是通过其查找路径。例如在全局作用域中#mystdout Base.stdout # 无法正确工作因为这会把 Base.stdout 复制进本模块 # # 应改用访问器函数 getstdout() Base.stdout # 最佳方案 # # 或者把赋值移入运行时 __init__() global mystdout Base.stdout # 同样可行 #预编译期间还有几项操作限制用于避免错误行为调用eval在其他模块中产生副作用设置增量预编译标志时会发出警告在__init__()开始后从局部作用域发出global const语句相关错误计划见 Julia issue #12010在增量预编译过程中替换模块属于运行时错误。其他需要注意的点源码文件本身发生变化后包括Pkg.update触发的不会执行代码重载或缓存失效Pkg.rm后也不会做清理预编译会忽略重塑数组reshaped array的内存共享行为每个视图得到自己的拷贝不要在编译期与运行期之间假设文件系统不变例如依赖__FILE__/source_path()在运行时定位资源或使用 BinDeps 的checked_lib宏。有时这不可避免但可行时最好在编译期把资源复制进模块运行时就不必再去寻找WeakRef对象与终结器finalizer目前未被序列化器正确处理将在后续版本修复最好避免捕获Method、MethodInstance、MethodTable、TypeMapLevel、TypeMapEntry等内部元数据对象的实例引用——这可能会让序列化器困惑。这么做不一定会报错但系统会尝试复制其中一部分对象、为另一部分创建唯一实例。命令行标志与调试模块开发期间关闭增量预编译有时很有帮助。命令行标志--compiled-modules{yes|no|existing}可以控制模块预编译的开关strict也是合法取值详见下文源码--compiled-modulesno加载模块及其依赖时忽略编译缓存中的序列化模块--compiled-modulesexisting加载已有的预编译模块但不创建新的--compiled-modulesstrict只从缓存加载找不到预编译镜像时直接报错。更细粒度的控制是--pkgimages{yes|no|existing}它只影响预编译期间的原生代码存储native-code storageBase.compilecache仍然可以手动调用。这些命令行标志的状态会传给Pkg.build以便在安装、更新和显式构建包时禁用自动预编译触发。以上标志对应的底层开关可以在源码中看到JLOptions()结构体中的use_compiled_modules::Int8与use_pkgimages::Int8字段定义于 base/options.jl加载逻辑位于 base/loading.jluse_compiled_modules ! 0时先尝试从缓存加载_require_search_from_serialized 3strict时若缓存不可用直接抛出错误 1yes时则会等待或触发后台预编译任务后重试缓存。标志与命令行参数的映射可参考 base/util.jl。此外可以用环境变量调试部分预编译失败问题设置JULIA_VERBOSE_LINKINGtrue可能有助于排查编译后原生代码共享库的链接失败。更多细节见 Julia 手册“开发者文档”部分中关于 “Package Images” 的章节。小结Julia 的模块系统把“组织代码”与“管理名字”两件事分离module ... end提供命名空间隔离export/public声明 APIusing/import控制名字的引入方式与扩展权限只有import ModuleName: f才能无前缀地为f添加方法as重命名与限定名则用于化解名称冲突子模块相对路径.SubA、..SubA让复杂代码库的组织保持清晰而预编译机制配合__init__的编译期/运行期划分在显著加快加载速度的同时也要求开发者避开全局状态、裸指针、Dict/Set哈希等陷阱。掌握这些规则是写出结构清晰、加载快速、可长期维护的 Julia 包的基础。更深入的实现细节可以继续阅读以下仓库文件base/reflection.jlparentmodule的实现base/loading.jl模块加载、预编译缓存与__precompile__的完整逻辑base/options.jluse_compiled_modules、use_pkgimages等JLOptions字段src/module.cModule构造函数的 C 实现doc/src/manual/modules.md本文所依据的官方手册原文手册的 代码加载 与 world age 章节与模块加载、绑定解析相关的延伸主题。【免费下载链接】juliaThe Julia Programming Language项目地址: https://gitcode.com/gh_mirrors/ju/julia创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表