模块

Julia 中的模块有助于将代码组织成连贯的部分。 它们在语法上以 module Name ... end 界定,并具有以下特点:

  1. 模块是独立的命名空间,每个都引入了一个新的全局作用域。 这很有用,因为它允许对不同的函数或全局变量使用相同的名称而不会发生冲突,只要它们在不同的模块中即可。

  2. 模块具有用于命名空间管理的工具:每个模块定义一组它export的名称,并且可以使用 usingimport 从其他模块导入名称(我们将在下面解释这些)。

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

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

  1. module SomeModule
  2. # export, using, import statements are usually here; we discuss these below
  3. include("file1.jl")
  4. include("file2.jl")
  5. end

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

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

  1. module FastThings
  2. struct FastThing
  3. ...
  4. end
  5. end

命名空间管理

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

合格的名称

全局作用域内的函数、变量和类型的名称,如sinARGSUnitRange始终属于一个模块,称为母模块,例如,可以与parentmodule交互来找到该模块

  1. julia> parentmodule(UnitRange)
  2. Base

也可以通过在它们的模块前面加上前缀来引用它们的父模块之外的这些名称,例如Base.UnitRange。 这称为限定名称。 父模块可以使用像Base.Math.sin这样的子模块链来访问,其中Base.Math被称为模块路径。 由于句法歧义,限定只包含符号的名称,例如运算符,需要插入冒号,例如 Base.:+。 少数运算符还需要括号,例如 Base.:(==)

如果一个名称是限定的,那么它总是可访问的,在函数的情况下,它也可以通过使用限定的名称作为函数名称来添加方法。

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

导出列表

名称(指函数、类型、全局变量和常量)可以通过 export 添加到模块的 *导出列表 *。 通常,它们位于或靠近模块定义的顶部,以便源代码的读者可以轻松找到它们,如

  1. module NiceStuff
  2. export nice, DOG
  3. struct Dog end # singleton type, not exported
  4. const DOG = Dog() # named instance, exported
  5. nice(x) = "nice $x" # function, exported
  6. end

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

导出构成 API(应用程序接口)一部分的名称是很常见的。 在上面的代码中,导出列表建议用户应该使用niceDOG。 然而,由于限定名称总是使标识符可访问,这只是组织 API 的一个选项:与其他语言不同,Julia 没有真正隐藏模块内部的功能。

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

单独使用using和import

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

  1. 模块名称

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

严格来说,声明 using ModuleName 意味着一个名为 ModuleName 的模块可用于根据需要解析名称。 当遇到当前模块中没有定义的全局变量时,系统会在ModuleName导出的变量中查找,找到就使用。 这意味着当前模块中该全局变量的所有使用都将解析为ModuleName中该变量的定义。

继续我们的例子,

  1. using NiceStuff

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

重要的是,导出列表只在using ModuleName 的形式下起作用

相反,

  1. import NiceStuff

将模块名称带入作用域。 用户需要使用 NiceStuff.DOGNiceStuff.DogNiceStuff.nice 来访问其内容。 通常,当用户想要保持命名空间干净时,在上下文中使用 import ModuleName。 正如我们将在下一节中看到的,import NiceStuff 等同于 using NiceStuff: NiceStuff

你可以用逗号分隔符来组合相同类型的多个usingimport语句,例如:

  1. using LinearAlgebra, Statistics

具有特定标识符的using 和 import ,并添加方法

using ModuleName:import ModuleName: 后跟以逗号分隔的名称列表时,模块会被加载,但 只有那些特定的名称才会被语句带入命名空间。 例如,

  1. using NiceStuff: nice, DOG

将导入名称niceDOG

重要的是,模块名称NiceStuff 不会出现在命名空间中。 如果要使其可访问,则必须明确列出它,如

  1. using NiceStuff: nice, DOG, NiceStuff

Julia有两种形式来表示似乎相同的内容,因为只有 import ModuleName:f 允许在 没有模块路径的情况下向 f 添加方法。也就是说,以下示例将给出一个错误:

  1. using NiceStuff: nice
  2. struct Cat end
  3. nice(::Cat) = "nice 😸"

此错误可防止意外将方法添加到你仅打算使用的其他模块中的函数。

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

  1. using NiceStuff
  2. struct Cat end
  3. NiceStuff.nice(::Cat) = "nice 😸"

或者,你可以import特定的函数名称:

  1. import NiceStuff: nice
  2. struct Cat end
  3. nice(::Cat) = "nice 😸"

你选择哪一个取决于你的代码风格。第一种形式表明你正在向另一个模块中的函数添加一个方法(请记住,导入和方法定义可能在单独的文件中),而第二种形式较短,如果你定义了多个方法,这一点尤其方便。

一旦一个变量通过 usingimport 引入,当前模块就不能创建同名的变量了。而且导入的变量是只读的,给全局变量赋值只能影响到由当前模块拥有的变量,否则会报错。

用as来重命名

importusing 引入作用域的标识符可以用关键字as 重命名。 这对于解决名称冲突以及缩短名称很有用。 例如,Base 导出函数名read,但CSV.jl 包也提供了CSV.read。 如果我们要多次调用 CSV 读取,删除 CSV. 限定符会很方便。 但是,我们指的是Base.read还是CSV.read是模棱两可的:

  1. julia> read;
  2. julia> import CSV: read
  3. WARNING: ignoring conflicting import of CSV.read into Main

重命名提供了一个解决方案:

  1. julia> import CSV: read as rd

导入的包本身也可以重命名:

  1. import BenchmarkTools as BT

as 仅在将单个标识符引入作用域时才与 using 一起使用。 例如,using CSV: read as rd 有效,但using CSV as C 无效,因为它对 CSV 中的所有导出名称进行操作。

混合使用多个 using 和 import 语句

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

  1. using NiceStuff # exported names and the module name
  2. import NiceStuff: nice # allows adding methods to unqualified functions

会将 NiceStuff 的所有导出名称和模块名称本身带入作用域,并且还允许向 nice 添加方法而不用模块名称作为前缀。

处理名称冲突

考虑两个(或更多)包导出相同名称的情况,如

  1. module A
  2. export f
  3. f() = 1
  4. end
  5. module B
  6. export f
  7. f() = 2
  8. end

using A, B 语句有效,但是当你尝试调用 f 时,你会收到警告

  1. WARNING: both B and A export "f"; uses of it in module Main must be qualified
  2. ERROR: LoadError: UndefVarError: f not defined

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

  1. 只需继续使用限定名称,如A.fB.f。 这使代码的读者可以清楚地了解上下文,特别是如果 f 恰好重合但在不同的包中具有不同的含义。 例如,degree在数学、自然科学和日常生活中有多种用途,这些含义应该分开。

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

    1. using A: f as f
    2. using B: f as g

    会使B.f 可用作g。 在这里,我们假设您之前没有使用 using A, 这会把f代入命名空间。

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

默认顶层定义以及裸模块

模块自动包含 using Coreusing Base 以及 evalinclude 函数的定义,这些函数在该模块的全局作用域内计算表达式/文件 .

如果不需要这些默认定义,可以使用关键字 baremodule 来定义模块(注意:Core 仍然是导入的)。 就 baremodule 而言,一个标准的 module 看起来像这样:

  1. baremodule Mod
  2. using Base
  3. eval(x) = Core.eval(Mod, x)
  4. include(p) = Base.include(Mod, p)
  5. ...
  6. end

标准模块

有三个重要的标准模块:

  • Core 包含了语言“内置”的所有功能。
  • Base 包含了绝大多数情况下都会用到的基本功能。
  • Main 是顶层模块,当 julia 启动时,也是当前模块。

Standard library modules

默认情况下,Julia 附带了一些标准库模块。 除了你不需要显式安装它们之外,它们的行为与常规 Julia 包类似。 例如,如果您想执行一些单元测试,你可以按如下方式加载 Test 标准库:

  1. using Test

子模块和相对路径

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

建议子模块在 usingimport 语句中使用 相对模块限定符 来引用封闭父模块中的其他模块(包括后者)。 相对模块限定符以句点 (.) 开头,它对应于当前模块,每个连续的 . 都指向当前模块的父级。 如有必要,这应该跟在模块之后,最后是要访问的实际名称,所有名称都以.分隔。

考虑以下示例,其中子模块SubA定义了一个函数,然后在其“兄弟”模块中进行扩展:

  1. module ParentModule
  2. module SubA
  3. export add_D # exported interface
  4. const D = 3
  5. add_D(x) = x + D
  6. end
  7. using .SubA # brings `add_D` into the namespace
  8. export add_D # export it from ParentModule too
  9. module SubB
  10. import ..SubA: add_D # relative path for a “sibling” module
  11. struct Infinity end
  12. add_D(x::Infinity) = x
  13. end
  14. end

你可能会在包中看到代码,在类似的情况下,它使用

  1. import ParentModule.SubA: add_D

然而,这是通过 代码加载 操作的,因此仅当 ParentModule 在包中时才有效。 最好使用相对路径。

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

  1. module TestPackage
  2. export x, y
  3. x = 0
  4. module Sub
  5. using ..TestPackage
  6. z = y # ERROR: UndefVarError: y not defined
  7. end
  8. y = 1
  9. end

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

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

  1. module A
  2. module B
  3. using ..C # ERROR: UndefVarError: C not defined
  4. end
  5. module C
  6. using ..B
  7. end
  8. end

模块初始化和预编译

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

当用 importusing 加载一个模块时,模块增量预编译文件会自动创建并使用。这会让模块在第一次加载时自动编译。 另外,你也可以手工调用 Base.compilecache(modulename),产生的缓存文件会放在 DEPOT_PATH[1]/compiled/ 目录下。 之后,当该模块的任何一个依赖发生变更时,该模块会在 usingimport 时自动重新编译; 模块的依赖指的是:任何它导入的模块、Julia 自身、include 的文件或由 include_dependency(path) 显式声明的依赖。

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

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

在开发模块的时候,你可能需要了解一些与增量编译相关的固有行为。例如,外部状态不会被保留。为了解决这个问题,需要显式分离运行时与编译期的部分。Julia 允许你定义一个 __init__() 函数来执行任何需要在运行时发生的初始化。在编译期(--output-*),此函数将不会被调用。你可以假设在代码的生存周期中,此函数只会被运行一次。当然,如果有必要,你也可以手动调用它,但在默认的情况下,请假定此函数是为了处理与本机状态相关的信息,注意这些信息不需要,更不应该存入预编译镜像。此函数会在模块被导入到当前进程之后被调用,这包括在一个增量编译中导入该模块的时候(--output-incremental=yes),但在完整编译时该函数不会被调用。

特别的,如果你在模块里定义了一个名为 __init__() 的函数,那么 Julia 在加载这个模块之后会在第一次运行时(runtime)立刻调用这个函数(例如,通过 importusing,或者 require 加载时),也就是说 __init__ 只会在模块中所有其它命令都执行完以后被调用一次。因为这个函数将在模块完全载入后被调用,任何子模块或者已经载入的模块都将在当前模块调用 __init__ 之前 调用自己的 __init__ 函数。

__init__的典型用法有二,一是用于调用外部 C 库的运行时初始化函数,二是用于初始化涉及到外部库所返回的指针的全局常量。例如,假设我们正在调用一个 C 库 libfoo,它要求我们在运行时调用foo_init() 这个初始化函数。假设我们还想定义一个全局常量 foo_data_ptr,它保存 libfoo 所定义的 void *foo_data() 函数的返回值——必须在运行时(而非编译时)初始化这个常量,因为指针地址不是固定的。可以通过在模块中定义 __init__ 函数来完成这个操作。

  1. const foo_data_ptr = Ref{Ptr{Cvoid}}(0)
  2. function __init__()
  3. ccall((:foo_init, :libfoo), Cvoid, ())
  4. foo_data_ptr[] = ccall((:foo_data, :libfoo), Ptr{Cvoid}, ())
  5. nothing
  6. end

注意,在像 __init__ 这样的函数里定义一个全局变量是完全可以的,这是动态语言的优点之一。但是把全局作用域的值定义成常量,可以让编译器能确定该值的类型,并且能让编译器生成更好的优化过的代码。显然,你的模块(Module)中,任何其他依赖于 foo_data_ptr 的全局量也必须在 __init__ 中被初始化。

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

字典和集合类型,或者通常任何依赖于 hash(key) 方法的类型,都是比较棘手的情况。 通常当键是数字、字符串、符号、范围、Expr 或这些类型的组合(通过数组、元组、集合、映射对等)时,可以安全地预编译它们。但是,对于一些其它的键类型,例如 FunctionDataType、以及还没有定义散列方法的通用用户定义类型,回退(fallback)的散列(hash)方法依赖于对象的内存地址(通过 objectid),因此可能会在每次运行时发生变化。 如果您有这些关键类型中的一种,或者您不确定,为了安全起见,您可以在您的 __init__ 函数中初始化这个字典。或者,您可以使用 IdDict 字典类型,它是由预编译专门处理的,因此在编译时初始化是安全的。

当使用预编译时,我们必须要清楚地区分代码的编译阶段和运行阶段。在此模式下,我们会更清楚发现 Julia 的编译器可以执行任何 Julia 代码,而不是一个用于生成编译后代码的独立的解释器。

其它已知的潜在失败场景包括:

  1. 全局计数器,例如:为了试图唯一的标识对象。考虑以下代码片段:

    1. mutable struct UniquedById
    2. myid::Int
    3. let counter = 0
    4. UniquedById() = new(counter += 1)
    5. end
    6. end

    尽管这段代码的目标是给每个实例赋一个唯一的 ID,但计数器的值会在代码编译结束时被记录。任何对此增量编译模块的后续使用,计数器都将从同一个值开始计数。

    注意 objectid (工作原理是取内存指针的 hash)也有类似的问题,请查阅下面关于 Dict 的用法。

    一种解决方案是用宏捕捉 @__MODULE__,并将它与目前的 counter 值一起保存。然而,更好的方案是对代码进行重新设计,不要依赖这种全局状态变量。

  2. DictSet 这种关联集合需要在 __init__ 中 re-hash。Julia 在未来很可能会提供一个机制来注册初始化函数。

  3. 依赖编译期的副作用会在加载时蔓延。例子包括:更改其它 Julia 模块里的数组或变量,操作文件或设备的句柄,保存指向其它系统资源(包括内存)的指针。

  4. 无意中从其它模块中“拷贝”了全局状态:通过直接引用的方式而不是通过查找的方式。例如,在全局作用域下:

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

此处为预编译中的操作附加了若干限制,以帮助用户避免其他误操作:

  1. 调用 eval 来在另一个模块中引发副作用。当增量预编译被标记时,该操作同时会导致抛出一个警告。
  2. __init__() 已经开始执行后,在局部作用域中声明 global const(见 issue #12010,计划为此情况添加一个错误提示)
  3. 在增量预编译时替换模块是一个运行时错误。

一些其他需要注意的点:

  1. 在源代码文件本身被修改之后,不会执行代码重载或缓存失效化处理(包括由 Pkg.update 执行的修改,此外在 Pkg.rm 执行后也没有清理操作)
  2. 变形数组的内存共享特性会被预编译忽略(每个数组样貌都会获得一个拷贝)
  3. 文件系统在编译期间和运行期间被假设为不变的,比如使用 @__FILE__/source_path() 在运行期间寻找资源、或使用 BinDeps 宏 @checked_lib。有时这是不可避免的。但是可能的话,在编译期将资源复制到模块里面是个好做法,这样在运行期间,就不需要去寻找它们了。
  4. WeakRef 对象和完成器目前在序列化器中无法被恰当地处理(在接下来的发行版中将修复)。
  5. 通常,最好避免去捕捉内部元数据对象的引用,如 MethodMethodInstanceTypeMapLevelTypeMapEntry 及这些对象的字段,因为这会迷惑序列化器,且可能会引发你不想要的结果。此操作不足以成为一个错误,但你需做好准备:系统会尝试拷贝一部分,然后创建其余部分的单个独立实例。

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