模块

Julia 中的模块帮助将代码组织成连贯的单元。它们在语法上由 `module NameOfModule ... end` 划分,并具有以下功能

  1. 模块是独立的命名空间,每个命名空间都引入了一个新的全局作用域。这很有用,因为它允许在不同的模块中使用相同的名称来表示不同的函数或全局变量,只要它们位于不同的模块中。

  2. 模块具有详细命名空间管理的功能:每个模块都定义了一组它 `export` 的名称,并且可以使用 `using` 和 `import` 从其他模块导入名称(我们将在下面解释这些内容)。

  3. 模块可以预编译以加快加载速度,并且可以包含用于运行时初始化的代码。

通常,在较大的 Julia 包中,您会看到模块代码组织成文件,例如

module SomeModule

# export, using, import statements are usually here; we discuss these below

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

end

文件和文件名与模块几乎没有关系;模块仅与模块表达式相关联。一个模块可以有多个文件,一个文件也可以有多个模块。`include` 的行为就像源文件的内容在包含模块的全局作用域中被评估一样。在本章中,我们使用简短的简化示例,因此不会使用 `include`。

推荐的风格是不缩进模块的主体,因为这通常会导致整个文件被缩进。此外,通常使用 `UpperCamelCase` 作为模块名称(就像类型一样),并在适用时使用复数形式,尤其是如果模块包含类似命名的标识符以避免名称冲突。例如,

module FastThings

struct FastThing
    ...
end

end

命名空间管理

命名空间管理是指语言提供的用于在模块中使名称在其他模块中可用的功能。我们将在下面详细讨论相关的概念和功能。

限定名称

像 `sin`、`ARGS` 和 `UnitRange` 这样的全局作用域中的函数、变量和类型的名称始终属于一个模块,称为 *父模块*,可以使用 `parentmodule` 交互式地找到它,例如

julia> parentmodule(UnitRange)
Base

您也可以在父模块之外使用模块作为前缀来引用这些名称,例如 `Base.UnitRange`。这称为 *限定名称*。父模块可以使用子模块链访问,例如 `Base.Math.sin`,其中 `Base.Math` 称为 *模块路径*。由于语法歧义,限定仅包含符号(如运算符)的名称需要插入冒号,例如 `Base.:+`。少数运算符还需要括号,例如 `Base.:(==)`。

如果一个名称被限定,那么它总是 *可访问的*,如果它是函数,则可以通过使用限定名称作为函数名称来向其添加方法。

在模块内,可以通过将其声明为 `global x` 来“保留”变量名称而无需对其进行赋值。这可以防止在加载时间之后初始化的全局变量的名称冲突。语法 `M.x = y` 不适用于在另一个模块中分配全局变量;全局赋值始终是模块本地的。

导出列表

名称(指代函数、类型、全局变量和常量)可以使用 `export` 添加到模块的 *导出列表* 中:这些是 `using` 模块时导入的符号。通常,它们位于模块定义的顶部或附近,以便源代码的读者可以轻松找到它们,例如

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;

但这只是一个风格建议——模块可以在任意位置拥有多个 `export` 语句。

通常导出构成 API(应用程序编程接口)一部分的名称。在上面的代码中,导出列表建议用户应该使用 `nice` 和 `DOG`。但是,由于限定名称始终使标识符可访问,因此这只是组织 API 的一种选择:与其他语言不同,Julia 没有真正隐藏模块内部的功能。

此外,一些模块根本不导出名称。这通常是在它们在 API 中使用通用词语(如 `derivative`)时完成的,这些词语很容易与其他模块的导出列表发生冲突。我们将在下面看到如何管理名称冲突。

独立 `using` 和 `import`

可能加载模块最常见的方法是 `using ModuleName`。这将 加载 与 `ModuleName` 关联的代码,并引入

  1. 模块名称

  2. 以及导出列表中的元素到周围的全局命名空间。

从技术上讲,`using ModuleName` 语句意味着一个名为 `ModuleName` 的模块将可用于根据需要解析名称。当遇到在当前模块中没有定义的全局变量时,系统将搜索 `ModuleName` 导出的变量,并在那里找到它时使用它。这意味着当前模块中该全局变量的所有使用都将解析为 `ModuleName` 中该变量的定义。

要从包中加载模块,可以使用 `using ModuleName` 语句。要从本地定义的模块中加载模块,需要在模块名称之前添加一个点,例如 `using .ModuleName`。

继续我们的例子,

julia> using .NiceStuff

将加载上面的代码,使 `NiceStuff`(模块名称)、`DOG` 和 `nice` 可用。`Dog` 不在导出列表中,但如果使用模块路径(这里只是模块名称)限定名称,例如 `NiceStuff.Dog`,则可以访问它。

重要的是,**`using ModuleName` 是唯一一种导出列表起作用的形式**。

相反,

julia> import .NiceStuff

只将 *模块名称* 引入作用域。用户需要使用 `NiceStuff.DOG`、`NiceStuff.Dog` 和 `NiceStuff.nice` 来访问其内容。通常,`import ModuleName` 用于用户希望保持命名空间干净的环境中。正如我们将在下一节中看到,`import .NiceStuff` 等效于 `using .NiceStuff: NiceStuff`。

您可以将多个相同类型的 `using` 和 `import` 语句组合成一个逗号分隔的表达式,例如

julia> using LinearAlgebra, Statistics

使用 `using` 和 `import` 指定标识符,以及添加方法

当 `using ModuleName:` 或 `import ModuleName:` 后面跟着一个逗号分隔的名称列表时,模块将被加载,但 *只有这些特定的名称被该语句引入命名空间*。例如,

julia> using .NiceStuff: nice, DOG

将导入名称 `nice` 和 `DOG`。

重要的是,模块名称 `NiceStuff` *将不会* 处于命名空间中。如果要使其可访问,则必须显式列出它,例如

julia> using .NiceStuff: nice, DOG, NiceStuff

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:0
 [2] top-level scope
   @ none:1

此错误可以防止意外地向其他模块中的函数添加方法,而您只想使用这些函数。

有两种方法可以解决此问题。您可以始终使用模块路径限定函数名称

julia> using .NiceStuff

julia> struct Cat end

julia> NiceStuff.nice(::Cat) = "nice 😸"

或者,您可以 `import` 特定的函数名称

julia> import .NiceStuff: nice

julia> struct Cat end

julia> nice(::Cat) = "nice 😸"
nice (generic function with 2 methods)

选择哪一个取决于你的风格。第一种形式清楚地表明你正在向另一个模块中的函数添加一个方法(记住,导入和方法定义可能在不同的文件中),而第二种形式更短,如果定义多个方法时,这特别方便。

一旦一个变量通过 usingimport 变得可见,一个模块就不能创建具有相同名称的自己的变量。导入的变量是只读的;对全局变量的赋值总是影响当前模块拥有的变量,否则会引发错误。

使用 as 重命名

通过 importusing 引入作用域的标识符可以使用关键字 as 重命名。这对于解决命名冲突以及缩短名称很有用。例如,Base 导出函数名 read,但 CSV.jl 包也提供 CSV.read。如果我们要多次调用 CSV 读取,去掉 CSV. 限定符会很方便。但这样一来,就无法确定我们指的是 Base.read 还是 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 中的所有导出名称进行操作。

混合多个 usingimport 语句

当使用多个上述形式的 usingimport 语句时,它们的效果将按照出现的顺序组合。例如,

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
B

语句 using .A, .B 可以工作,但当你尝试调用 f 时,你会收到一个警告

julia> using .A, .B

julia> f
WARNING: both B and A export "f"; uses of it in module Main must be qualified
ERROR: UndefVarError: `f` not defined

在这里,Julia 无法确定你指的是哪个 f,因此你必须做出选择。以下解决方案是常用的

  1. 简单地使用限定名称,例如 A.fB.f。这使得你的代码的阅读者可以清楚地了解上下文,特别是如果 f 只是恰好相同,但在不同的包中具有不同的含义。例如,degree 在数学、自然科学和日常生活中都有不同的用途,这些含义应该保持分离。

  2. 在上面使用 as 关键字重命名一个或两个标识符,例如

    julia> using .A: f as f
    
    julia> using .B: f as g
    

    将使 B.f 可用为 g。这里,我们假设你之前没有使用 using A,否则 f 就会被引入命名空间。

  3. 当所讨论的名称确实共享一个含义时,一个模块通常会从另一个模块导入它,或者有一个轻量级的“基础”包,其唯一功能是定义像这样的接口,其他包可以使用这个接口。按照惯例,这种包名以 ...Base 结尾(与 Julia 的 Base 模块无关)。

默认顶层定义和裸模块

模块自动包含 using Coreusing Base 以及 evalinclude 函数的定义,这些函数在该模块的全局范围内评估表达式/文件。

如果不需要这些默认定义,可以使用关键字 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) 定义,代码可以用 @evalCore.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)
25

标准模块

有三个重要的标准模块

  • Core 包含所有“内置”于语言中的功能。
  • Base 包含在几乎所有情况下都很有用的基本功能。
  • Main 是顶层模块,也是 Julia 启动时的当前模块。
标准库模块

默认情况下,Julia 带有一些标准库模块。它们的行为与普通的 Julia 包类似,只是你不需要显式地安装它们。例如,如果你想执行一些单元测试,你可以加载 Test 标准库,如下所示

using Test

子模块和相对路径

模块可以包含子模块,嵌套相同的语法 module ... end。它们可以用来引入独立的命名空间,这对于组织复杂的代码库很有帮助。请注意,每个 module 都引入了自己的 作用域,因此子模块不会自动“继承”其父级的名称。

建议子模块使用 usingimport 语句中的相对模块限定符来引用包含父模块中的其他模块(包括后者)。相对模块限定符以句点 (.) 开头,对应于当前模块,每个后续的 . 都会指向当前模块的父级。这后面应该加上模块(如果有必要),最终加上要访问的实际名称,所有这些都用 . 分隔。

考虑以下示例,子模块 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
       struct Infinity end
       add_D(x::Infinity) = x
       end
       end;

你可能会在包中看到代码,在这种情况下,代码使用

julia> import .ParentModule.SubA: add_D

但是,这通过 代码加载 来操作,因此只有当 ParentModule 位于一个包中时才有效。最好使用相对路径。

请注意,如果要评估值,定义的顺序也很重要。考虑一下

module TestPackage

export x, y

x = 0

module Sub
using ..TestPackage
z = y # ERROR: UndefVarError: `y` not defined
end

y = 1

end

其中 Sub 试图在定义 TestPackage.y 之前使用它,因此它没有值。

出于类似的原因,你不能使用循环排序

module A

module B
using ..C # ERROR: UndefVarError: `C` not defined
end

module C
using ..B
end

end

模块初始化和预编译

大型模块可能需要几秒钟才能加载,因为执行模块中的所有语句通常涉及编译大量的代码。Julia 创建了模块的预编译缓存,以减少这段时间。

预编译的模块文件(有时称为“缓存文件”)在 importusing 加载模块时自动创建和使用。如果缓存文件尚不存在,模块将被编译并保存以供将来重用。你也可以手动调用 Base.compilecache(Base.identify_package("modulename")) 来创建这些文件,而无需加载模块。生成的缓存文件将存储在 DEPOT_PATH[1]compiled 子文件夹中。如果你的系统没有任何变化,这些缓存文件将在你使用 importusing 加载模块时使用。

预编译缓存文件存储模块、类型、方法和常量的定义。它们也可能存储方法专门化及其生成的代码,但这通常要求开发人员添加显式的 precompile 指令或执行在包构建期间强制编译的工作负载。

但是,如果你更新模块的依赖项或更改其源代码,模块将在 usingimport 时自动重新编译。依赖项是它导入的模块、Julia 构建、它包含的文件,或者模块文件(s) 中 include_dependency(path) 明确声明的依赖项。

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

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

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

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

__init__ 的两种典型用法是调用外部 C 库的运行时初始化函数,以及初始化涉及外部库返回的指针的全局常量。例如,假设我们正在调用一个需要我们在运行时调用 foo_init() 初始化函数的 C 库 libfoo。假设我们还想定义一个全局常量 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 的全局变量也必须在 __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__ 并将其与当前 counter 值一起存储,但是,最好重新设计代码以不依赖于此全局状态。

  2. 关联集合(例如 DictSet)需要在 __init__ 中重新哈希。(将来,可能会提供一种机制来注册初始化函数。)

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

  4. 通过直接引用另一个模块而不是通过其查找路径来创建另一个模块的全局状态的意外“副本”。例如,(在全局范围内)

    #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 对象和终结器目前没有被序列化程序正确处理(这将在即将发布的版本中修复)。
  5. 通常最好避免捕获对内部元数据对象的实例的引用,例如 MethodMethodInstanceMethodTableTypeMapLevelTypeMapEntry 以及这些对象的字段,因为这可能会混淆序列化器,并且可能不会导致您期望的结果。这样做不一定是错误,但您需要做好准备,系统将尝试复制其中一些,并为其他一些创建单个唯一的实例。

在模块开发过程中,有时关闭增量预编译会很有帮助。命令行标志 --compiled-modules={yes|no} 使您可以打开和关闭模块预编译。当 Julia 以 --compiled-modules=no 启动时,在加载模块和模块依赖项时会忽略编译缓存中的序列化模块。--pkgimages=no 提供了更细粒度的控制,它只抑制预编译期间的本地代码存储。Base.compilecache 仍然可以手动调用。此命令行标志的状态传递给 Pkg.build,以在安装、更新和显式构建包时禁用自动预编译触发。

您还可以使用环境变量调试一些预编译失败。设置 JULIA_VERBOSE_LINKING=true 可能有助于解决链接已编译本地代码的共享库的失败。请参阅 Julia 手册的“开发人员文档”部分,您将在其中找到“包映像”下记录 Julia 内部机制的部分中的更多详细信息。