Wait the light to fall

Julia 中的 模块

焉知非鱼

Modules

模块

Julia 中的模块是独立的变量工作空间,即它们引入了一个新的全局作用域。它们在语法上是有分界的,在 module Name ... end 里面。模块允许您创建顶层定义(也就是全局变量),而不用担心您的代码与别人的代码一起使用时的名称冲突。在一个模块中,你可以控制哪些来自其他模块的名字是可见的(通过导入),并指定哪些名字是要公开的(通过导出)。

下面的例子展示了模块的主要功能。这个例子并不是为了运行,而是为了说明问题。

module MyModule
using Lib

using BigLib: thing1, thing2

import Base.show

export MyType, foo

struct MyType
    x
end

bar(x) = 2x
foo(a::MyType) = bar(a.x) + 1

show(io::IO, a::MyType) = print(io, "MyType $(a.x)")
end

需要注意的是,这个样式并不是要在模块的正文中缩进,因为这通常会导致整个文件被缩进。

这个模块定义了一个 MyType 类型和两个函数。函数 fooMyType 类型是导出的,因此可以导入到其他模块中。函数 barMyModule 的私有函数。

using Lib 语句意味着将有一个名为 Lib 的模块可以根据需要解析名称。当遇到一个全局变量在当前模块中没有定义时,系统会在 Lib 导出的变量中搜索它,如果在那里找到了,就会导入它。这意味着在当前模块内对该全局的所有使用都将解析为该变量在 Lib 中的定义。

using BigLib: thing1, thing2 语句,只将标识符 thing1thing2 从模块 BigLib 中带入作用域。如果这些名称指的是函数,那么将不允许向它们添加方法(你只能 “使用 “它们,而不是扩展它们)。

import 关键字支持与 using 相同的语法。importusing 的不同之处在于,使用 import 导入的函数可以用新的方法进行扩展。

在上面的 MyModule 中,我们想给标准的 show 函数添加一个方法,所以我们必须写 import Base.show。只有通过 using 才能看到名字的函数不能被扩展。

一旦一个变量通过 usingimport 变得可见,一个模块就不能创建自己的同名变量。导入的变量是只读的,分配给全局变量总是会影响到当前模块所拥有的变量,否则会引发错误。

模块使用情况概述 #

要加载一个模块,可以使用两个主要的关键词:usingimport。要了解它们的区别,请看下面的例子。

module MyModule

export x, y

x() = "x"
y() = "y"
p() = "p"

end

在这个模块中,我们导出了 xy 函数(用关键字 export),也有非导出的函数 p,有几种不同的方法可以将 Module 及其内部函数加载到当前的工作空间中。

导入命令 带入带作用域中的东西 可用于方法扩展
using MyModule 所有导出的名字(xy), MyModule.x, MyModule.yMyModule.p MyModule.x, MyModule.yMyModule.p
using MyModule: x, p xp
import MyModule MyModule.x, MyModule.yMyModule.p MyModule.x, MyModule.yMyModule.p
import MyModule.x, MyModule.p xp xp
import MyModule: x, p xp xp

模块和文件 #

文件和文件名大多与模块无关,模块只与模块表达式有关。一个模块可以有多个文件,一个文件可以有多个模块。

module Foo

include("file1.jl")
include("file2.jl")

end

在不同的模块中包含相同的代码,提供了类似 mixin 的行为。人们可以使用这一点来用不同的基础定义来运行相同的代码,例如,通过使用某些操作符的"安全"版本来测试代码。

module Normal
include("mycode.jl")
end

module Testing
include("safe_operators.jl")
include("mycode.jl")
end

标准模块 #

There are three important standard modules:

Core 包含"内置于"语言中的所有功能。 Base 包含几乎在所有情况下都有用的基本功能。 Main 是当 Julia 被启动时的顶级模块和当前模块。

默认的顶层定义和裸模块 #

除了 using Base 之外,模块还自动包含 evalinclude 函数的定义,这些函数在该模块的全局作用域内评估表达式/文件。

如果不想要这些默认的定义,可以使用关键字 baremodule 来代替定义模块(注意: Core 仍然是导入的,如上所述)。以 baremodule 来说,一个标准的模块是这样的。

baremodule Mod

using Base

eval(x) = Core.eval(Mod, x)
include(p) = Base.include(Mod, p)

...

end

相对和绝对模块路径 #

给定 using Foo 语句,系统会查询内部的顶层模块表,寻找一个名为 Foo 的模块。如果该模块不存在,系统会尝试 require(:Foo),这通常会导致从安装的包中加载代码。

然而,有些模块包含子模块,这意味着你有时需要访问一个非顶层模块。有两种方法可以做到这一点。第一种是使用绝对路径,例如 using Base.Sort。第二种是使用相对路径,这样可以更容易地导入当前模块的子模块或其任何一个外层模块。

module Parent

module Utils
...
end

using .Utils

...
end

这里模块 Parent 包含一个子模块 UtilsParent 中的代码希望 Utils 的内容可见。这可以通过在 using 路径中使用点号来实现。添加更多的前导点号会使模块的层次结构上升。例如,using ..Utils 会在 Parent 的外层模块中查找Utils,而不是在 Parent 本身中查找。

注意相对导入限定符只在使用和导入语句中有效。

命名空间杂项 #

如果一个名字是限定的(例如 Base.sin),那么即使它没有被导出,也可以被访问。这在调试时往往很有用。它也可以通过使用限定名作为函数名来添加方法。但是,由于会产生语法上的歧义,如果你想给不同模块中的一个函数添加方法,而这个函数的名称只包含符号,例如一个运算符,Base.+,你必须使用 Base.:+ 来引用它。如果运算符的长度超过一个字符,你必须用括号把它括起来,比如 Base.:(==)

在导入和导出语句中,宏的名称用 @ 书写,例如 import Mod.@mac。其他模块中的宏可以用 Mod.@mac@Mod.mac 来调用。

语法 M.x = y 不能用于分配其他模块中的全局,全局分配总是模块-局部的。

变量名可以通过声明为 global x 来 “保留"而不分配给它,这样可以防止加载后初始化的 globals 的名称冲突。

模块初始化和预编译 #

大型模块可能需要几秒钟的时间来加载,因为执行一个模块中的所有语句往往需要编译大量的代码。Julia 创建了模块的预编译缓存来减少这个时间。

当使用 importusing 加载模块时,会自动创建并使用增量的预编译模块文件。这将导致它在第一次导入时自动编译。另外,您也可以手动调用 Base.compilecache(modulename)。由此产生的缓存文件将存储在 DEPOT_PATH[1]/compiled/ 中。随后,只要模块的任何依赖关系发生变化,模块就会在 usingimport 时自动重新编译;依赖关系是指导入的模块、Julia 构建的模块、包含的文件,或者模块文件中 include_dependency(path) 声明的显式依赖关系。

对于文件依赖,通过检查由 include 加载的文件或由 include_dependency 显式添加的文件的修改时间(mtime)是否保持不变,或者是否等于被截断到最接近秒的修改时间(以适应无法以亚秒级精度复制 mtime 的系统)来确定变化。它还考虑到在 require 中搜索逻辑选择的文件路径是否与创建预编译文件的路径匹配。它还会考虑到已经加载到当前进程中的一组依赖关系,即使这些模块的文件发生变化或消失,也不会重新编译这些模块,以避免在运行系统和预编译缓存之间产生不兼容的情况。

如果你知道某个模块预编译你的模块是不安全的(例如,出于下面描述的原因之一),你应该在模块文件中加上 __precompile__(false)(通常放在顶部)。这将导致 Base.compilecache 抛出一个错误,并将导致 using / import 直接将其加载到当前进程中而跳过预编译和缓存。这也因此阻止了该模块被任何其他预编译模块导入。

您可能需要注意创建增量共享库时固有的某些行为,在编写模块时可能需要注意。例如,外部状态不会被保存。为了适应这一点,明确地将任何必须在运行时发生的初始化步骤与可以在编译时发生的步骤分开。为此,Julia 允许您在您的模块中定义一个 __init__() 函数来执行任何必须在运行时发生的初始化步骤。这个函数在编译时不会被调用(--output-*)。实际上,你可以假设它在代码的生命周期中只运行一次。当然,如果有必要的话,你可以手动调用它,但是默认情况下,你可以假设这个函数处理的是本地机器的计算状态,它不需要–甚至不应该–在编译后的镜像中捕获。它将在模块被加载到一个进程后被调用,包括如果它被加载到增量编译中(--output-incremental=yes),但如果它被加载到一个完整的编译进程中,则不会被调用。

特别是,如果你在一个模块中定义了一个 function __init__(),那么 Julia 将在模块被加载后(例如通过 importusingrequire)在运行时第一次立即调用 __init__()(也就是说,__init__ 只被调用一次,而且是在模块中的所有语句被执行后才被调用)。因为它是在模块完全导入之后被调用的,所以任何子模块或其它导入的模块都会在外层模块的 __init__ 之前调用它们的 __init__ 函数。

__init__ 的两个典型用途是调用外部 C 库的运行时初始化函数和初始化涉及外部库返回指针的全局常量。例如,假设我们正在调用一个 C 库 libfoo,它要求我们在运行时调用 foo_init() 初始化函数。假设我们还想定义一个全局常量 foo_data_ptr,用来存放 libfoo 定义的 void *foo_data() 函数的返回值–这个常量必须在运行时(而不是在编译时)初始化,因为指针地址会随着运行而改变。你可以通过在你的模块中定义下面的 __init__ 函数来实现。

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 的 globals 也必须在 __init__ 中初始化。

涉及大多数不是由 ccall 产生的 Julia 对象的常量不需要放在 __init__ 中:它们的定义可以被预编译并从缓存的模块映像中加载。这包括像数组这样复杂的堆分配对象。然而,任何返回原始指针值的例程都必须在运行时调用,以便预编译工作(Ptr 对象将变成空指针,除非它们被隐藏在 isbits 对象中)。这包括 Julia 函数 cfunctionpointer 的返回值。

字典和集合类型,或者一般来说任何依赖于 hash(key) 方法输出的东西,都是比较棘手的情况。在常见的情况下,键是数字、字符串、符号、范围、Expr 或这些类型的组合(通过数组、元组、集合、对等),它们可以安全地进行预编译。然而,对于其他一些关键类型,如 FunctionDataType 和通用的用户定义类型,在这些类型中,你没有定义 hash 方法,回退 hash 方法取决于对象的内存地址(通过它的 objectid),因此可能会在运行时改变。如果你有这些键类型之一,或者如果你不确定,为了安全起见,你可以在你的 __init__ 函数中初始化这个字典。或者,你也可以使用 IdDict 字典类型,它由预编译特别处理,所以在编译时初始化是安全的。

在使用预编译时,保持对编译阶段和执行阶段的清晰认识很重要。在这种模式下,往往会更清楚地认识到 Julia 是一个允许执行任意 Julia 代码的编译器,而不是一个同时生成编译代码的独立解释器。

其他已知的潜在故障情况包括。

  1. 全局计数器(例如,用于试图唯一识别对象)。考虑以下代码片段。
mutable struct UniquedById
    myid::Int
    let counter = 0
        UniquedById() = new(counter += 1)
    end
end

虽然这段代码的目的是给每个实例一个唯一的 id,但计数器的值是在编译结束时记录的。这个增量编译模块的所有后续使用将从同一个计数器值开始。

请注意,objectid(通过哈希内存指针工作)也有类似的问题(参见下面关于 Dict 用法的说明)。

一种替代方法是使用宏来捕获 @MODULE,并将其与当前的计数器值一起单独存储,然而,重新设计代码使其不依赖于这个全局状态可能会更好。

  1. 关联集合(比如 DictSet)需要在 __init__ 中重新洗牌(将来可能会提供一个机制来注册一个初始化函数)。

  2. 根据编译时的副作用在加载时持续存在。例如:修改其他 Julia 模块中的数组或其他变量;维护打开的文件或设备的句柄;存储其他系统资源(包括内存)的指针。

  3. 通过直接引用而不是通过它的查找路径,从另一个模块创建意外的全局状态"副本”。例如,(在全局作用域内)。

#mystdout = Base.stdout #= will not work correctly, since this will copy Base.stdout into this module =#
# instead use accessor functions:
getstdout() = Base.stdout #= best option =#
# or move the assignment into the runtime:
__init__() = global mystdout = Base.stdout #= also works =#

对预编译代码时可以进行的操作进行了一些额外的限制,以帮助用户避免其他错误行为的情况。

  1. 调用 eval 引起另一个模块的副作用。当增量预编译标志被设置时,这也会导致发出警告。

  2. __init__() 被启动后,从本地作用域调用 global const 语句(参见问题 #12010,计划为此增加一个错误)

  3. 在进行增量预编译时,替换一个模块是一个运行时错误。

还有几点需要注意。

  1. 在对源文件本身进行修改之后,不会进行代码重载/缓存无效化(包括通过 Pkg.update),而且在 Pkg.rm 之后也不会进行清理。

  2. 预编译不考虑重塑数组的内存共享行为 (每个视图都有自己的副本)

  3. 期待文件系统在编译时和运行时之间保持不变,例如 @FILE/source_path() 在运行时查找资源,或者 BinDeps 的 @checked_lib 宏。有时这是不可避免的。然而,在可能的情况下,在编译时将资源复制到模块中是一个很好的做法,这样它们就不需要在运行时被找到。

  4. WeakRef 对象和 finalizers 目前还没有被序列化器正确处理(这将在即将发布的版本中得到修正)。

  5. 通常最好避免捕获对内部元数据对象实例的引用,如 MethodMethodInstanceMethodTableTypeMapLevelTypeMapEntry 以及这些对象的字段,因为这可能会混淆序列化器,可能不会导致你想要的结果。这样做不一定会出错,但你只需要做好准备,系统会尝试复制其中的一些对象,并为其他对象创建一个唯一的实例。

在模块开发过程中,有时关闭增量预编译是很有帮助的。命令行标志 --compiled-modules={yes|no} 可以让你开启或关闭模块预编译。当 Julia 以 --compiled-modules=no 启动时,当加载模块和模块依赖时,编译缓存中的序列化模块会被忽略。Base.compilecache 仍然可以被手动调用。这个命令行标志的状态被传递给 Pkg.build,以便在安装、更新和显式构建包时禁用自动预编译触发。