模組

Julia 中的模組有助於將程式碼組織成相干的單元。它們在語法上界定於 module NameOfModule ... end 內,並具有以下特點

  1. 模組是獨立的命名空間,每個模組都引入一個新的全域範圍。這很有用,因為它允許在不同的函式或全域變數中使用相同的名稱,而不會發生衝突,只要它們位於不同的模組中即可。

  2. 模組具有詳細的命名空間管理功能:每個模組都定義了一組它「匯出」的名稱,並可以使用 usingimport 從其他模組匯入名稱(我們在下面說明這些內容)。

  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

命名空間管理

命名空間管理是指語言提供的功能,用於在其他模組中提供模組中的名稱。我們在下面詳細討論相關概念和功能。

限定名稱

全局範圍中函式、變數和類型的名稱,例如 sinARGSUnitRange,總是屬於一個模組,稱為父模組,可以使用 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(應用程式程式介面)一部分的名稱很常見。在上述程式碼中,匯出清單建議使用者應使用 niceDOG。但是,由於限定名稱總是讓識別碼可存取,這只是整理 API 的一種選項:與其他語言不同,Julia 沒有真正隱藏模組內部結構的機制。

此外,有些模組根本不會匯出名稱。如果它們在 API 中使用常見字詞(例如 derivative),通常會這麼做,因為這些字詞很容易與其他模組的匯出清單發生衝突。我們將在下面看到如何管理名稱衝突。

獨立的 usingimport

載入模組最常見的方式可能是 using ModuleName。這會 載入ModuleName 相關聯的程式碼,並載入

  1. 模組名稱

  2. 以及匯出清單的元素到周圍的全局命名空間。

技術上來說,陳述式 using ModuleName 表示一個稱為 ModuleName 的模組將可用於根據需要解析名稱。當遇到在目前模組中沒有定義的全局變數時,系統會在 ModuleName 匯出的變數中搜尋它,如果在那裡找到它,就會使用它。這表示在目前模組中對該全局變數的所有使用都會解析為 ModuleName 中該變數的定義。

若要從套件載入模組,可以使用陳述式 using ModuleName。若要從區域定義的模組載入模組,需要在模組名稱前面加上一個點,例如 using .ModuleName

要繼續我們的範例,

julia> using .NiceStuff

會載入上述程式碼,使 NiceStuff(模組名稱)、DOGnice 可用。Dog 不在匯出清單中,但如果使用模組路徑(這裡僅為模組名稱)限定名稱,則可以存取它,例如 NiceStuff.Dog

重要的是,using ModuleName 是匯出清單有任何意義的唯一形式

相反地,

julia> import .NiceStuff

僅將模組名稱載入範圍。使用者需要使用 NiceStuff.DOGNiceStuff.DogNiceStuff.nice 來存取其內容。通常,import ModuleName 用於使用者想要保持命名空間乾淨的內容中。正如我們將在下一節中看到的,import .NiceStuff 等同於 using .NiceStuff: NiceStuff

你可以使用逗號分隔表達式來結合多個相同類型的 usingimport 陳述式,例如。

julia> using LinearAlgebra, Statistics

usingimport 與特定識別碼,以及新增方法

using ModuleName:import ModuleName: 後面接著一個逗號分隔的名稱清單時,模組會載入,但 只有那些特定名稱會透過陳述式帶入名稱空間。例如,

julia> using .NiceStuff: nice, DOG

將會匯入名稱 niceDOG

重要的是,模組名稱 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.readCSV.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 建置、它包含的檔案,或模組檔案中由 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 的全域變數也必須在 __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

    雖然此程式碼的用意是讓每個執行個體都有唯一的識別碼,但計數器值會在編譯結束時記錄下來。此後所有使用此增量編譯模組的動作都會從相同的計數器值開始。

    請注意,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 手冊的開發人員文件部分,您可以在「套件影像」下的文件內部文件部分找到更多詳細資訊。