元編程

Lisp 在 Julia 語言中最強大的遺產是其元編程支援。與 Lisp 一樣,Julia 將其自己的程式碼表示為語言本身的資料結構。由於程式碼是由可以在語言內部建立和操作的物件表示,因此程式可以轉換和產生自己的程式碼。這允許在沒有額外建置步驟的情況下進行複雜的程式碼產生,並且還允許在抽象語法樹層級上運作的真實 Lisp 風格巨集。相反地,像 C 和 C++ 那樣的預處理器「巨集」系統在任何實際的剖析或解釋發生之前執行文字操作和替換。由於 Julia 中的所有資料類型和程式碼都由 Julia 資料結構表示,因此強大的反射功能可用於探索程式的內部結構及其類型,就像任何其他資料一樣。

警告

元程式編寫是一個強大的工具,但它引入了複雜性,可能會讓程式碼更難理解。例如,要取得正確的範圍規則可能出乎意料的困難。元程式編寫通常只應在其他方法(例如 高階函式閉包)無法套用的情況下使用。

eval 和定義新的巨集通常應作為最後的手段使用。使用 Meta.parse 或將任意字串轉換為 Julia 程式碼幾乎從來都不是個好主意。若要處理 Julia 程式碼,請直接使用 Expr 資料結構,以避免 Julia 語法如何解析的複雜性。

元程式編寫的最佳用途通常會在執行時期輔助函式中實作其大部分功能,努力將其產生的程式碼量減到最低。

程式表示

每個 Julia 程式都是從字串開始的

julia> prog = "1 + 1"
"1 + 1"

接下來會發生什麼事?

下一步是將每個字串 解析 為稱為表達式的物件,由 Julia 類型 Expr 表示

julia> ex1 = Meta.parse(prog)
:(1 + 1)

julia> typeof(ex1)
Expr

Expr 物件包含兩個部分

  • 識別表達式類型的 Symbol。符號是 內部字串 識別碼(下方有更多討論)。
julia> ex1.head
:call
  • 表達式引數,可能是符號、其他表達式或文字值
julia> ex1.args
3-element Vector{Any}:
  :+
 1
 1

表達式也可以直接以 前綴表示法 建構

julia> ex2 = Expr(:call, :+, 1, 1)
:(1 + 1)

透過解析和直接建構所建構的兩個表達式是等效的

julia> ex1 == ex2
true

這裡的重點是 Julia 程式碼在內部表示為一個可從語言本身存取的資料結構。

dump 函數提供 Expr 物件的縮排和註解顯示

julia> dump(ex2)
Expr
  head: Symbol call
  args: Array{Any}((3,))
    1: Symbol +
    2: Int64 1
    3: Int64 1

Expr 物件也可以巢狀

julia> ex3 = Meta.parse("(4 + 4) / 2")
:((4 + 4) / 2)

檢視表達式的另一種方式是使用 Meta.show_sexpr,它會顯示給定 ExprS 表達式 形式,這對 Lisp 使用者來說可能看起來很熟悉。以下是說明在巢狀 Expr 上顯示的範例

julia> Meta.show_sexpr(ex3)
(:call, :/, (:call, :+, 4, 4), 2)

符號

: 字元在 Julia 中有兩個語法目的。第一個形式會從有效的識別碼中建立一個 Symbol,一個 內嵌字串,用作表達式的其中一個建構區塊

julia> s = :foo
:foo

julia> typeof(s)
Symbol

Symbol 建構函數會採用任意數量的引數,並透過串接它們的字串表示形式來建立一個新的符號

julia> :foo === Symbol("foo")
true

julia> Symbol("1foo") # `:1foo` would not work, as `1foo` is not a valid identifier
Symbol("1foo")

julia> Symbol("func",10)
:func10

julia> Symbol(:var,'_',"sym")
:var_sym

在表達式的脈絡中,符號用來表示存取變數;當評估一個表達式時,符號會被替換為在適當 範圍 中繫結到該符號的值。

有時需要在 : 的引數周圍加上額外的括號,以避免解析時的歧義

julia> :(:)
:(:)

julia> :(::)
:(::)

表達式和評估

引用

: 字元的第二個語法目的是在不使用明確的 Expr 建構函數的情況下建立表達式物件。這稱為引用: 字元後面加上一對括號,括號中包含一個 Julia 程式碼的單一陳述式,會根據括號中的程式碼產生一個 Expr 物件。以下是用於引用算術表達式的簡短形式範例

julia> ex = :(a+b*c+1)
:(a + b * c + 1)

julia> typeof(ex)
Expr

(若要查看此表達式的結構,請嘗試使用 ex.headex.args,或如上使用 dumpMeta.@dump

請注意,可以使用 Meta.parse 或直接 Expr 形式建構等效的表達式

julia>      :(a + b*c + 1)       ==
       Meta.parse("a + b*c + 1") ==
       Expr(:call, :+, :a, Expr(:call, :*, :b, :c), 1)
true

解析器提供的表達式通常只包含符號、其他表達式和文字值作為其參數,而由 Julia 程式碼建構的表達式可以包含任意執行時期值,而無文字形式作為參數。在此特定範例中,+a 是符號,*(b,c) 是子表達式,而 1 是文字 64 位元有號整數。

還有一種第二種引號語法形式可供多個表達式使用:以 quote ... end 括起來的程式碼區塊。

julia> ex = quote
           x = 1
           y = 2
           x + y
       end
quote
    #= none:2 =#
    x = 1
    #= none:3 =#
    y = 2
    #= none:4 =#
    x + y
end

julia> typeof(ex)
Expr

內插

直接建構具有值參數的 Expr 物件很強大,但與「一般」Julia 語法相比,Expr 建構函式可能會很繁瑣。作為替代方案,Julia 允許將文字或表達式內插到引號表達式中。內插由前綴 $ 表示。

在此範例中,變數 a 的值被內插

julia> a = 1;

julia> ex = :($a + b)
:(1 + b)

不支援內插到未引號的表達式,這將導致編譯時期錯誤

julia> $a + b
ERROR: syntax: "$" expression outside quote

在此範例中,將元組 (1,2,3) 作為表達式內插到條件測試中

julia> ex = :(a in $:((1,2,3)) )
:(a in (1, 2, 3))

使用 $ 進行表達式內插,是為了刻意讓人聯想到 字串內插指令內插。表達式內插允許方便、易讀的程式化建構複雜的 Julia 表達式。

展開內插

請注意,$ 內插語法只允許將單一表達式插入到封裝表達式中。偶爾,您會有一個表達式陣列,並且需要它們全部成為周圍表達式的引數。這可以使用 $(xs...) 語法來完成。例如,以下程式碼會產生一個函式呼叫,其中引數數量是由程式化決定的

julia> args = [:x, :y, :z];

julia> :(f(1, $(args...)))
:(f(1, x, y, z))

巢狀引號

自然地,引號表達式有可能包含其他引號表達式。了解內插在這些情況下如何運作可能會有點棘手。考慮這個範例

julia> x = :(1 + 2);

julia> e = quote quote $x end end
quote
    #= none:1 =#
    $(Expr(:quote, quote
    #= none:1 =#
    $(Expr(:$, :x))
end))
end

請注意,結果包含 $x,這表示 x 尚未評估。換句話說,$ 表達式「屬於」內部引號表達式,因此其引數只會在內部引號表達式時評估

julia> eval(e)
quote
    #= none:1 =#
    1 + 2
end

然而,外部 quote 表達式能夠內插內部引號中 $ 內的值。這是透過多個 $ 來完成的

julia> e = quote quote $$x end end
quote
    #= none:1 =#
    $(Expr(:quote, quote
    #= none:1 =#
    $(Expr(:$, :(1 + 2)))
end))
end

請注意,現在結果中出現 (1 + 2),而不是符號 x。評估此表達式會產生內插的 3

julia> eval(e)
quote
    #= none:1 =#
    3
end

此行為背後的直覺是,x 會針對每個 $ 評估一次:一個 $ 的運作方式類似於 eval(:x),會提供 x 的值,而兩個 $ 則會執行等同於 eval(eval(:x)) 的動作。

QuoteNode

AST 中 quote 形式的常見表示法是具有 :quote 頭部的 Expr

julia> dump(Meta.parse(":(1+2)"))
Expr
  head: Symbol quote
  args: Array{Any}((1,))
    1: Expr
      head: Symbol call
      args: Array{Any}((3,))
        1: Symbol +
        2: Int64 1
        3: Int64 2

正如我們所見,此類表達式支援使用 $ 進行內插。然而,在某些情況下,有必要在執行內插的情況下引用程式碼。此類引用目前尚無語法,但會在內部表示為 QuoteNode 類型的物件

julia> eval(Meta.quot(Expr(:$, :(1+2))))
3

julia> eval(QuoteNode(Expr(:$, :(1+2))))
:($(Expr(:$, :(1 + 2))))

對於像符號等簡單的引號項目,剖析器會產生 QuoteNode

julia> dump(Meta.parse(":x"))
QuoteNode
  value: Symbol x

QuoteNode 也可以用於某些進階的元程式設計任務。

評估表達式

給定一個表達式物件,可以使用 eval 讓 Julia 在全域範圍內評估 (執行) 它

julia> ex1 = :(1 + 2)
:(1 + 2)

julia> eval(ex1)
3

julia> ex = :(a + b)
:(a + b)

julia> eval(ex)
ERROR: UndefVarError: `b` not defined
[...]

julia> a = 1; b = 2;

julia> eval(ex)
3

每個 模組 都具有自己的 eval 函數,用於評估其全域範圍內的表達式。傳遞給 eval 的表達式不限於傳回值,它們也可以產生副作用,從而改變封裝模組環境的狀態

julia> ex = :(x = 1)
:(x = 1)

julia> x
ERROR: UndefVarError: `x` not defined

julia> eval(ex)
1

julia> x
1

在此,表達式物件的評估會導致將值指定給全域變數 x

由於表達式只是可以透過程式設計建構並評估的 Expr 物件,因此可以動態產生任意程式碼,然後使用 eval 執行。以下是簡單的範例

julia> a = 1;

julia> ex = Expr(:call, :+, a, :b)
:(1 + b)

julia> a = 0; b = 2;

julia> eval(ex)
3

a 的值用於建構表達式 ex,將函數 + 套用至值 1 和變數 b。請注意 ab 使用方式之間的重要區別

  • 在表達式建構時間,變數 a 的值用作表達式中的立即值。因此,在評估表達式時,a 的值不再重要:表達式中的值已經是 1,與 a 的值為何無關。
  • 另一方面,符號 :b 用於表達式建構,因此變數 b 在那時的數值無關緊要 – :b 只是個符號,而變數 b 甚至不必定義。然而,在表達式評估時間,符號 :b 的值會透過查詢變數 b 的值來解析。

Expr 表達式的函數

如上所述,Julia 極為有用的功能之一,就是可以在 Julia 內部產生和操作 Julia 程式碼。我們已經看過一個回傳 Expr 物件的函數範例:Meta.parse 函數,它會取得 Julia 程式碼字串並回傳對應的 Expr。函數也可以將一個或多個 Expr 物件當作引數,並回傳另一個 Expr。以下是一個簡單的激勵範例

julia> function math_expr(op, op1, op2)
           expr = Expr(:call, op, op1, op2)
           return expr
       end
math_expr (generic function with 1 method)

julia>  ex = math_expr(:+, 1, Expr(:call, :*, 4, 5))
:(1 + 4 * 5)

julia> eval(ex)
21

作為另一個範例,這裡有一個函式可以將任何數字參數加倍,但保留表達式不變

julia> function make_expr2(op, opr1, opr2)
           opr1f, opr2f = map(x -> isa(x, Number) ? 2*x : x, (opr1, opr2))
           retexpr = Expr(:call, op, opr1f, opr2f)
           return retexpr
       end
make_expr2 (generic function with 1 method)

julia> make_expr2(:+, 1, 2)
:(2 + 4)

julia> ex = make_expr2(:+, 1, Expr(:call, :*, 5, 8))
:(2 + 5 * 8)

julia> eval(ex)
42

巨集

巨集提供一種機制,將產生的程式碼包含在程式的最後主體中。巨集將一個參數元組對應到一個回傳的表達式,而產生的表達式會直接編譯,而不需要執行時期的 eval 呼叫。巨集參數可以包含表達式、文字值和符號。

基礎

這裡有一個非常簡單的巨集

julia> macro sayhello()
           return :( println("Hello, world!") )
       end
@sayhello (macro with 1 method)

巨集在 Julia 的語法中有一個專屬字元:@(at 符號),後面跟著在 macro NAME ... end 區塊中宣告的唯一名稱。在此範例中,編譯器會將所有 @sayhello 的實例替換為

:( println("Hello, world!") )

當在 REPL 中輸入 @sayhello 時,表達式會立即執行,因此我們只會看到評估結果

julia> @sayhello()
Hello, world!

現在,考慮一個稍微複雜一點的巨集

julia> macro sayhello(name)
           return :( println("Hello, ", $name) )
       end
@sayhello (macro with 1 method)

此巨集接受一個參數:name。當遇到 @sayhello 時,引用的表達式會展開,將參數的值內插到最後的表達式中

julia> @sayhello("human")
Hello, human

我們可以使用函式 macroexpand 來檢視引用的回傳表達式(重要注意事項:這是除錯巨集的極有用工具)

julia> ex = macroexpand(Main, :(@sayhello("human")) )
:(Main.println("Hello, ", "human"))

julia> typeof(ex)
Expr

我們可以看到文字 "human" 已內插到表達式中。

還有一個巨集 @macroexpand,它可能比 macroexpand 函式更方便

julia> @macroexpand @sayhello "human"
:(println("Hello, ", "human"))

暫停:為什麼要使用巨集?

我們已經在前面的章節中看過一個函式 f(::Expr...) -> Expr。事實上,macroexpand 也是這樣的函式。那麼,為什麼巨集存在呢?

巨集是必要的,因為它們在解析程式碼時執行,因此,巨集允許程式設計師在執行完整程式之前產生並包含自訂程式碼的片段。為了說明差異,請考慮以下範例

julia> macro twostep(arg)
           println("I execute at parse time. The argument is: ", arg)
           return :(println("I execute at runtime. The argument is: ", $arg))
       end
@twostep (macro with 1 method)

julia> ex = macroexpand(Main, :(@twostep :(1, 2, 3)) );
I execute at parse time. The argument is: :((1, 2, 3))

第一次呼叫 println 是在呼叫 macroexpand 時執行的。產生的表達式包含第二個 println

julia> typeof(ex)
Expr

julia> ex
:(println("I execute at runtime. The argument is: ", $(Expr(:copyast, :($(QuoteNode(:((1, 2, 3)))))))))

julia> eval(ex)
I execute at runtime. The argument is: (1, 2, 3)

巨集呼叫

巨集使用以下一般語法呼叫

@name expr1 expr2 ...
@name(expr1, expr2, ...)

請注意巨集名稱前的區別符號 @,以及第一個形式中引數表達式之間沒有逗號,以及第二個形式中 @name 之後沒有空白。這兩種樣式不應混用。例如,以下語法與上述範例不同;它將元組 (expr1, expr2, ...) 作為一個引數傳遞給巨集

@name (expr1, expr2, ...)

呼叫陣列文字(或理解)上的巨集的另一種方法是不使用括號並列。在這種情況下,陣列將是傳遞給巨集的唯一表達式。以下語法是等效的(與 @name [a b] * v 不同)

@name[a b] * v
@name([a b]) * v

強調巨集接收其引數為表達式、文字或符號非常重要。探索巨集引數的一種方法是在巨集主體內呼叫 show 函數

julia> macro showarg(x)
           show(x)
           # ... remainder of macro, returning an expression
       end
@showarg (macro with 1 method)

julia> @showarg(a)
:a

julia> @showarg(1+1)
:(1 + 1)

julia> @showarg(println("Yo!"))
:(println("Yo!"))

除了給定的引數清單外,每個巨集都會傳遞名為 __source____module__ 的額外引數。

引數 __source__ 提供有關巨集呼叫中 @ 符號的剖析器位置的資訊(以 LineNumberNode 物件的形式)。這允許巨集包含更好的錯誤診斷資訊,並且通常由記錄、字串剖析器巨集和文件使用,例如,以及實作 @__LINE__@__FILE__@__DIR__ 巨集。

位置資訊可透過參照 __source__.line__source__.file 來存取

julia> macro __LOCATION__(); return QuoteNode(__source__); end
@__LOCATION__ (macro with 1 method)

julia> dump(
            @__LOCATION__(
       ))
LineNumberNode
  line: Int64 2
  file: Symbol none

引數 __module__ 提供有關巨集呼叫擴充套件內容的資訊(以 Module 物件的形式)。這允許巨集查詢內容文字資訊,例如現有繫結,或將值插入為執行時函數呼叫的額外引數,在目前模組中進行自我反射。

建立進階巨集

以下是 Julia 的 @assert 巨集的簡化定義

julia> macro assert(ex)
           return :( $ex ? nothing : throw(AssertionError($(string(ex)))) )
       end
@assert (macro with 1 method)

這個巨集可以使用在像這樣的地方

julia> @assert 1 == 1.0

julia> @assert 1 == 0
ERROR: AssertionError: 1 == 0

在寫入語法的同時,巨集呼叫會在剖析時間擴充套件至其傳回的結果。這等同於寫

1 == 1.0 ? nothing : throw(AssertionError("1 == 1.0"))
1 == 0 ? nothing : throw(AssertionError("1 == 0"))

也就是說,在第一次呼叫中,表達式 :(1 == 1.0) 會拼接至測試條件槽位,而 string(:(1 == 1.0)) 的值會拼接至斷言訊息槽位。如此建構的完整表達式會置於 @assert 巨集呼叫發生的語法樹中。接著在執行時間,如果測試表達式評估為 true,則會傳回 nothing,而如果測試為 false,則會引發錯誤,指出錯誤的斷言表達式。請注意,無法將其寫成函數,因為只有條件的可用,而且無法在錯誤訊息中顯示計算該值的表達式。

Julia Base 中 @assert 的實際定義較為複雜。它允許使用者選擇性地指定自己的錯誤訊息,而不僅僅是列印失敗的表達式。就像具有可變數目引數的函數 (可變引數函數) 一樣,這會在最後一個引數後面加上省略號來指定

julia> macro assert(ex, msgs...)
           msg_body = isempty(msgs) ? ex : msgs[1]
           msg = string(msg_body)
           return :($ex ? nothing : throw(AssertionError($msg)))
       end
@assert (macro with 1 method)

現在 @assert 有兩種運作模式,取決於它接收的引數數目!如果只有一個引數,msgs 擷取的表達式組將會是空的,而且它會與上述較簡單的定義一樣運作。但是現在如果使用者指定第二個引數,它會列印在訊息主體中,而不是失敗的表達式。你可以使用適切命名的 @macroexpand 巨集來檢查巨集擴充的結果

julia> @macroexpand @assert a == b
:(if Main.a == Main.b
        Main.nothing
    else
        Main.throw(Main.AssertionError("a == b"))
    end)

julia> @macroexpand @assert a==b "a should equal b!"
:(if Main.a == Main.b
        Main.nothing
    else
        Main.throw(Main.AssertionError("a should equal b!"))
    end)

還有另一個情況是實際的 @assert 巨集處理的:如果除了印出「a 應等於 b」之外,我們還想要印出它們的值呢?有人可能會天真地嘗試在自訂訊息中使用字串內插,例如,@assert a==b "a ($a) 應等於 b ($b)!",但這不會如預期般使用上述巨集運作。你能看出原因嗎?從 字串內插 回想一下,內插字串會改寫為對 string 的呼叫。比較

julia> typeof(:("a should equal b"))
String

julia> typeof(:("a ($a) should equal b ($b)!"))
Expr

julia> dump(:("a ($a) should equal b ($b)!"))
Expr
  head: Symbol string
  args: Array{Any}((5,))
    1: String "a ("
    2: Symbol a
    3: String ") should equal b ("
    4: Symbol b
    5: String ")!"

所以現在巨集並未在 msg_body 中取得純粹的字串,而是取得一個完整的表達式,需要評估才能如預期般顯示。這可以用作 string 呼叫的引數,直接拼接至回傳的表達式中;請參閱 error.jl 以取得完整的實作。

@assert 巨集大量使用拼接至引號表達式中,以簡化巨集主體內表達式的處理。

衛生

在較複雜的巨集中會出現的問題是衛生。簡而言之,巨集必須確保它們在返回的表達式中引入的變數不會意外地與它們擴充到的周圍程式碼中的現有變數衝突。相反地,傳遞給巨集作為參數的表達式通常預期在周圍程式碼的上下文中評估,與現有變數互動並修改它們。另一個問題來自於巨集可能在定義它的不同模組中被呼叫。在這種情況下,我們需要確保所有全域變數都解析為正確的模組。Julia 已經比具有文字巨集擴充的語言(例如 C)有很大的優勢,因為它只需要考慮返回的表達式。所有其他變數(例如上面 @assert 中的 msg)都遵循正常的範圍區塊行為

為了說明這些問題,讓我們考慮撰寫一個 @time 巨集,它將一個表達式作為其參數,記錄時間,評估表達式,再次記錄時間,列印前後時間的差,然後將表達式的值作為其最終值。巨集可能如下所示

macro time(ex)
    return quote
        local t0 = time_ns()
        local val = $ex
        local t1 = time_ns()
        println("elapsed time: ", (t1-t0)/1e9, " seconds")
        val
    end
end

在這裡,我們希望 t0t1val 是私有暫時變數,我們希望 time_ns 參照 Julia Base 中的time_ns函數,而不是使用者可能有的任何 time_ns 變數(對 println 也是如此)。想像一下如果使用者表達式 ex 也包含對稱為 t0 的變數的指定,或定義了自己的 time_ns 變數,可能會發生什麼問題。我們可能會得到錯誤,或神秘的錯誤行為。

Julia 的巨集擴充器以以下方式解決這些問題。首先,巨集中變數的結果會分類為區域或全域。如果變數已指派(且未宣告為全域)、宣告為區域,或用作函式引數名稱,則該變數會被視為區域。否則,會被視為全域。接著,區域變數會重新命名為唯一(使用 gensym 函式,它會產生新的符號),而全域變數會在巨集定義環境中解析。因此,上述兩個問題都已處理;巨集的區域變數不會與任何使用者變數衝突,而 time_nsprintln 會參考 Julia Base 定義。

然而,還有一個問題。考慮以下使用這個巨集的方式

module MyModule
import Base.@time

time_ns() = ... # compute something

@time time_ns()
end

這裡的使用者表達式 ex 是對 time_ns 的呼叫,但不是巨集使用的同一個 time_ns 函式。它顯然指的是 MyModule.time_ns。因此,我們必須安排在巨集呼叫環境中解析 ex 中的程式碼。這可以透過使用 esc 來「跳脫」表達式

macro time(ex)
    ...
    local val = $(esc(ex))
    ...
end

巨集擴充器會讓以這種方式包裝的表達式保持原樣,並直接貼到輸出中。因此,它會在巨集呼叫環境中解析。

必要時,這種跳脫機制可用於「違反」衛生,以引入或操作使用者變數。例如,以下巨集在呼叫環境中將 x 設為零

julia> macro zerox()
           return esc(:(x = 0))
       end
@zerox (macro with 1 method)

julia> function foo()
           x = 1
           @zerox
           return x # is zero
       end
foo (generic function with 1 method)

julia> foo()
0

這類變數操作應該謹慎使用,但偶爾會相當方便。

取得正確的衛生規則可能是一項艱鉅的挑戰。在使用巨集之前,您可能想要考慮函式封閉是否足夠。另一個有用的策略是將盡可能多的工作推遲到執行階段。例如,許多巨集只是將其引數包裝在 QuoteNode 或其他類似的 Expr 中。這方面的一些範例包括 @task body,它只會傳回 schedule(Task(() -> $body)),以及 @eval expr,它只會傳回 eval(QuoteNode(expr))

為了示範,我們可以將上述 @time 範例改寫為

macro time(expr)
    return :(timeit(() -> $(esc(expr))))
end
function timeit(f)
    t0 = time_ns()
    val = f()
    t1 = time_ns()
    println("elapsed time: ", (t1-t0)/1e9, " seconds")
    return val
end

然而,我們不這樣做是有原因的:將 expr 包裝在新的範圍區塊(匿名函式)中也會稍微改變表達式的意義(其中任何變數的範圍),而我們希望 @time 在對包裝程式碼的影響最小的情況下可以使用。

巨集和分派

巨集,就像 Julia 函式一樣,是通用的。這表示它們也可以有多個方法定義,這要歸功於多重分派

julia> macro m end
@m (macro with 0 methods)

julia> macro m(args...)
           println("$(length(args)) arguments")
       end
@m (macro with 1 method)

julia> macro m(x,y)
           println("Two arguments")
       end
@m (macro with 2 methods)

julia> @m "asd"
1 arguments

julia> @m 1 2
Two arguments

然而,應該記住,巨集分派是根據傳遞給巨集的 AST 類型,而不是 AST 在執行時評估的類型

julia> macro m(::Int)
           println("An Integer")
       end
@m (macro with 3 methods)

julia> @m 2
An Integer

julia> x = 2
2

julia> @m x
1 arguments

程式碼產生

當需要大量的重複樣板程式碼時,通常會以程式化方式產生它以避免重複。在大多數語言中,這需要額外的建置步驟,以及一個單獨的程式來產生重複的程式碼。在 Julia 中,表達式內插和 eval 允許此類程式碼產生在程式執行過程中進行。例如,考慮以下自訂類型

struct MyNumber
    x::Float64
end
# output

我們希望為其新增一些方法。我們可以在以下迴圈中以程式化方式執行此操作

for op = (:sin, :cos, :tan, :log, :exp)
    eval(quote
        Base.$op(a::MyNumber) = MyNumber($op(a.x))
    end)
end
# output

現在,我們可以使用這些函式搭配我們的自訂類型

julia> x = MyNumber(π)
MyNumber(3.141592653589793)

julia> sin(x)
MyNumber(1.2246467991473532e-16)

julia> cos(x)
MyNumber(-1.0)

以這種方式,Julia 作為它自己的 預處理器,並允許從語言內部產生程式碼。上述程式碼可以使用 : 前綴引號形式寫得更簡潔

for op = (:sin, :cos, :tan, :log, :exp)
    eval(:(Base.$op(a::MyNumber) = MyNumber($op(a.x))))
end

然而,這種使用 eval(quote(...)) 樣式的語言內程式碼產生很常見,因此 Julia 附帶一個巨集來縮寫此樣式

for op = (:sin, :cos, :tan, :log, :exp)
    @eval Base.$op(a::MyNumber) = MyNumber($op(a.x))
end

@eval 巨集將此呼叫改寫為與上述較長版本完全等效。對於較長的產生程式碼區塊,傳遞給 @eval 的表達式引數可以是一個區塊

@eval begin
    # multiple lines
end

非標準字串文字

字串 回想,字串字面值加上識別字首碼稱為非標準字串字面值,且可能具有與未加上首碼的字串字面值不同的語意。例如

也許令人驚訝的是,這些行為並非硬編碼到 Julia 的剖析器或編譯器中。相反地,它們是由一般機制提供的自訂行為,任何人都可以使用:加上首碼的字串字面值會剖析成對特別命名的巨集呼叫。例如,正規表示式巨集就是以下內容

macro r_str(p)
    Regex(p)
end

僅此而已。此巨集表示字串字面值 r"^\s*(?:#|$)" 的字面內容應該傳遞給 @r_str 巨集,且該展開的結果應該放置在字串字面值出現的語法樹中。換句話說,表達式 r"^\s*(?:#|$)" 等於將以下物件直接放入語法樹中

Regex("^\\s*(?:#|\$)")

字串字面值形式不僅較短且更方便,而且也更有效率:由於正規表示式已編譯,且 Regex 物件實際上是在 編譯程式碼時 建立的,因此編譯只會發生一次,而不是每次執行程式碼時。考慮正規表示式出現在迴圈中的情況

for line = lines
    m = match(r"^\s*(?:#|$)", line)
    if m === nothing
        # non-comment
    else
        # comment
    end
end

由於正規表示式 r"^\s*(?:#|$)" 在剖析此程式碼時已編譯並插入語法樹中,因此該表達式只會編譯一次,而不是每次執行迴圈時編譯。為了在沒有巨集的情況下完成此操作,必須像這樣撰寫此迴圈

re = Regex("^\\s*(?:#|\$)")
for line = lines
    m = match(re, line)
    if m === nothing
        # non-comment
    else
        # comment
    end
end

此外,如果編譯器無法確定 regex 物件在所有迴圈中都是常數,某些最佳化可能無法進行,這使得此版本仍然比上述更方便的文字形式效率低。當然,非文字形式在某些情況下仍然更方便:如果需要將變數內插到正規表示式中,則必須採取這種更冗長的途徑;在正規表示式模式本身是動態的情況下,可能會在每次迴圈迭代時發生變化,必須在每次迭代時構造一個新的正規表示式物件。然而,在絕大多數使用案例中,正規表示式並非基於執行時期資料構造。在這些大多數情況下,將正規表示式寫為編譯時期值的能力非常有價值。

使用者定義字串文字的機制非常、非常強大。Julia 的非標準文字不僅使用它來實作,而且命令文字語法 (`echo "Hello, $person"`) 也使用以下看起來無害的巨集來實作

macro cmd(str)
    :(cmd_gen($(shell_parse(str)[1])))
end

當然,此巨集定義中使用的函式隱藏了大量的複雜性,但它們只是函式,完全用 Julia 編寫。您可以閱讀它們的原始碼並準確地看到它們的作用 - 它們所做的就是構造要插入到程式語法樹中的表達式物件。

與字串文字一樣,命令文字也可以由識別碼作為前綴,以形成所謂的非標準命令文字。這些命令文字被解析為對特別命名的巨集的呼叫。例如,語法 custom`literal` 被解析為 @custom_cmd "literal"。Julia 本身不包含任何非標準命令文字,但套件可以使用此語法。除了不同的語法和 _cmd 字尾(而不是 _str 字尾)之外,非標準命令文字的行為與非標準字串文字完全相同。

如果兩個模組提供具有相同名稱的非標準字串或命令字面值,則可以使用模組名稱限定字串或命令字面值。例如,如果 FooBar 都提供非標準字串字面值 @x_str,則可以撰寫 Foo.x"literal"Bar.x"literal" 來區分這兩個字面值。

定義巨集的另一種方法如下

macro foo_str(str, flag)
    # do stuff
end

然後可以使用下列語法呼叫此巨集

foo"str"flag

上述語法中旗標的類型將是 String,其內容為字串字面值之後的所有內容。

產生的函式

一個非常特殊的巨集是 @generated,它允許您定義所謂的產生函式。這些函式有能力根據其引數的類型產生專門的程式碼,其靈活性高於多重分派,且程式碼更少。雖然巨集在解析時間處理運算式,且無法存取其輸入的類型,但產生函式會在已知引數類型,但函式尚未編譯時進行擴充。

產生函數宣告會傳回引號包住的表達式,而不是執行某些計算或動作,這個表達式會形成對應於引數類型的函式主體。當產生函數被呼叫時,它傳回的表達式會被編譯然後執行。為了讓這個過程更有效率,結果通常會被快取。為了讓這個過程可推論,只有語言的有限子集可以使用。因此,產生函數提供了一個靈活的方式,可以在允許建構的限制下,將工作從執行時間移到編譯時間。

在定義產生函數時,有五個主要區別於一般函數的地方

  1. 使用 @generated 巨集註解函數宣告。這會將一些資訊新增到 AST,讓編譯器知道這是產生函數。
  2. 在產生函數的主體中,你只能存取引數的類型,而不是它們的值。
  3. 你傳回一個引號包住的表達式,而不是計算某些東西或執行某些動作,這個表達式在評估時會執行你想要執行的動作。
  4. 產生函數只允許呼叫在產生函數定義之前定義的函數。(如果不遵循這個規則,可能會導致 MethodErrors 參照來自未來世界年齡的函數。)
  5. 產生函數不得變異觀察任何非常數的全球狀態(例如,IO、鎖、非區域字典,或使用 hasmethod)。這表示它們只能讀取全球常數,而且不能有任何副作用。換句話說,它們必須完全純粹。由於實作限制,這也表示它們目前無法定義封閉或產生器。

最簡單的說明方式就是舉例。我們可以宣告一個產生的函數 foo

julia> @generated function foo(x)
           Core.println(x)
           return :(x * x)
       end
foo (generic function with 1 method)

請注意,函數主體回傳一個引號包住的表達式,也就是 :(x * x),而不是單純回傳 x * x 的值。

從呼叫者的角度來看,這和一般的函數沒有兩樣;事實上,你不需要知道自己呼叫的是一般函數還是產生的函數。讓我們看看 foo 的行為

julia> x = foo(2); # note: output is from println() statement in the body
Int64

julia> x           # now we print x
4

julia> y = foo("bar");
String

julia> y
"barbar"

因此,我們看到在產生的函數主體中,x 是傳遞參數的類型,而產生的函數回傳的值,是我們從定義中回傳的引號包住表達式經過評估的結果,現在加上 x

如果我們再次使用已經用過的類型評估 foo 會發生什麼事?

julia> foo(4)
16

請注意,沒有列印出 Int64。我們可以看到,產生的函數主體只執行一次,針對特定的參數類型組合,而且結果已快取。之後,針對這個範例,產生的函數在第一次呼叫時回傳的表達式會重新用作方法主體。然而,實際的快取行為是實作定義的效能最佳化,因此過度依賴這種行為是無效的。

產生函式的次數可能只會有一次,但也可能會更常發生,或看起來根本沒有發生。因此,您絕不應該撰寫具有副作用的產生函式 - 副作用的發生時間和頻率是不確定的。(這也適用於巨集 - 就像巨集一樣,在產生函式中使用 eval 表示您以錯誤的方式執行某些操作。)不過,與巨集不同的是,執行時期系統無法正確處理對 eval 的呼叫,因此不允許這樣做。

了解 @generated 函式如何與方法重新定義互動也很重要。遵循正確的 @generated 函式不得觀察任何可變狀態或造成任何全域狀態突變的原則,我們會看到以下行為。請注意,產生函式無法呼叫在產生函式定義之前未定義的任何方法。

最初 f(x) 有個定義

julia> f(x) = "original definition";

定義使用 f(x) 的其他運算

julia> g(x) = f(x);

julia> @generated gen1(x) = f(x);

julia> @generated gen2(x) = :(f(x));

我們現在為 f(x) 新增一些新定義

julia> f(x::Int) = "definition for Int";

julia> f(x::Type{Int}) = "definition for Type{Int}";

並比較這些結果有何不同

julia> f(1)
"definition for Int"

julia> g(1)
"definition for Int"

julia> gen1(1)
"original definition"

julia> gen2(1)
"definition for Int"

產生函式的每個方法都有其自己對已定義函式的觀點

julia> @generated gen1(x::Real) = f(x);

julia> gen1(1)
"definition for Type{Int}"

上述產生函式範例 foo 沒有執行一般函式 foo(x) = x * x 無法執行的任何操作(除了在第一次呼叫時列印類型,並產生較高的開銷)。不過,產生函式的功能在於它能根據傳遞給它的類型計算不同的引號表示式

julia> @generated function bar(x)
           if x <: Integer
               return :(x ^ 2)
           else
               return :(x)
           end
       end
bar (generic function with 1 method)

julia> bar(4)
16

julia> bar("baz")
"baz"

(儘管當然這個人為的範例會更容易使用多重分派來實作...)

濫用此功能會損壞執行時期系統並造成未定義的行為

julia> @generated function baz(x)
           if rand() < .9
               return :(x^2)
           else
               return :("boo!")
           end
       end
baz (generic function with 1 method)

由於產生函式的本體是非確定性的,因此它的行為和所有後續程式碼的行為都是未定義的。

不要複製這些範例!

這些範例有望有助於說明已產生函數是如何運作的,無論是在定義端或呼叫端;然而,請勿複製它們,原因如下

  • foo 函數有副作用(呼叫 Core.println),而且無法明確定義這些副作用將在何時、多久或幾次發生
  • bar 函數解決的問題更適合使用多重分派來解決 - 定義 bar(x) = xbar(x::Integer) = x ^ 2 會執行相同的工作,但更簡單且更快。
  • baz 函數是病態的

請注意,不應在已產生函數中嘗試的運算集合是無限的,而執行時期系統目前只能偵測無效運算的子集合。還有許多其他運算只會在未通知的情況下損壞執行時期系統,通常以不明顯且與不良定義無關的方式。由於函數產生器是在推論期間執行,因此它必須遵守該程式碼的所有限制。

不應嘗試的一些運算包括

  1. 快取原生指標。

  2. 以任何方式與 Core.Compiler 的內容或方法互動。

  3. 觀察任何可變狀態。

    • 已產生函數的推論可能會在任何時間執行,包括在您的程式碼嘗試觀察或變異此狀態時。
  4. 取得任何鎖定:您呼叫的 C 程式碼可能會在內部使用鎖定(例如,呼叫 malloc 沒有問題,即使大多數實作在內部需要鎖定),但請勿嘗試在執行 Julia 程式碼時持有或取得任何鎖定。

  5. 呼叫任何在已產生函數主體後定義的函數。此條件對於遞增載入的預編譯模組放寬,以允許呼叫模組中的任何函數。

好,現在我們對產生函數的工作原理有了更深入的瞭解,讓我們使用它們來建立一些更進階(且有效的)功能...

進階範例

Julia 的基礎函式庫有一個內部 sub2ind 函數,用於根據一組 n 個多線性指標計算 n 維陣列中的線性指標,換句話說,計算指標 i,可以使用它使用 A[i] 而不是 A[x,y,z,...] 來索引陣列 A。以下是一個可能的實作

julia> function sub2ind_loop(dims::NTuple{N}, I::Integer...) where N
           ind = I[N] - 1
           for i = N-1:-1:1
               ind = I[i]-1 + dims[i]*ind
           end
           return ind + 1
       end
sub2ind_loop (generic function with 1 method)

julia> sub2ind_loop((3, 5), 1, 2)
4

可以使用遞迴來完成相同的事情

julia> sub2ind_rec(dims::Tuple{}) = 1;

julia> sub2ind_rec(dims::Tuple{}, i1::Integer, I::Integer...) =
           i1 == 1 ? sub2ind_rec(dims, I...) : throw(BoundsError());

julia> sub2ind_rec(dims::Tuple{Integer, Vararg{Integer}}, i1::Integer) = i1;

julia> sub2ind_rec(dims::Tuple{Integer, Vararg{Integer}}, i1::Integer, I::Integer...) =
           i1 + dims[1] * (sub2ind_rec(Base.tail(dims), I...) - 1);

julia> sub2ind_rec((3, 5), 1, 2)
4

這兩個實作雖然不同,但本質上做的事情是一樣的:在陣列的維度上執行執行時期迴圈,將每個維度中的偏移量收集到最終指標中。

但是,迴圈所需的所有資訊都嵌入在引數的類型資訊中。這允許編譯器將迭代移至編譯時間並完全消除執行時期迴圈。我們可以利用產生函數來達到類似的效果;在編譯器術語中,我們使用產生函數手動展開迴圈。主體變得幾乎相同,但我們不是計算線性指標,而是建立一個計算指標的表達式

julia> @generated function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
           ex = :(I[$N] - 1)
           for i = (N - 1):-1:1
               ex = :(I[$i] - 1 + dims[$i] * $ex)
           end
           return :($ex + 1)
       end
sub2ind_gen (generic function with 1 method)

julia> sub2ind_gen((3, 5), 1, 2)
4

這段程式碼會產生什麼?

找出答案的一個簡單方法是將主體萃取到另一個(常規)函數中

julia> @generated function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
           return sub2ind_gen_impl(dims, I...)
       end
sub2ind_gen (generic function with 1 method)

julia> function sub2ind_gen_impl(dims::Type{T}, I...) where T <: NTuple{N,Any} where N
           length(I) == N || return :(error("partial indexing is unsupported"))
           ex = :(I[$N] - 1)
           for i = (N - 1):-1:1
               ex = :(I[$i] - 1 + dims[$i] * $ex)
           end
           return :($ex + 1)
       end
sub2ind_gen_impl (generic function with 1 method)

現在我們可以執行 sub2ind_gen_impl 並檢查它傳回的表達式

julia> sub2ind_gen_impl(Tuple{Int,Int}, Int, Int)
:(((I[1] - 1) + dims[1] * (I[2] - 1)) + 1)

因此,在此使用的函數主體完全不包含迴圈,僅對兩個元組進行索引、乘法和加法/減法。所有迴圈都在編譯時執行,我們完全避免在執行期間進行迴圈。因此,我們僅對每個類型迴圈一次,在本例中對每個 N 迴圈一次(除了函數產生多次的邊緣情況,請參閱上面的免責聲明)。

選擇性產生的函數

產生的函數可以在執行時達到高效率,但會產生編譯時間成本:必須為每個具體參數類型的組合產生新的函數主體。通常,Julia 能夠編譯適用於任何參數的函數的「一般」版本,但對於產生的函數來說,這是不可行的。這表示大量使用產生的函數的程式可能無法靜態編譯。

為了解決這個問題,此語言提供語法來撰寫產生的函數的正常、非產生的替代實作。應用於上述的 sub2ind 範例,它看起來像這樣

function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
    if N != length(I)
        throw(ArgumentError("Number of dimensions must match number of indices."))
    end
    if @generated
        ex = :(I[$N] - 1)
        for i = (N - 1):-1:1
            ex = :(I[$i] - 1 + dims[$i] * $ex)
        end
        return :($ex + 1)
    else
        ind = I[N] - 1
        for i = (N - 1):-1:1
            ind = I[i] - 1 + dims[i]*ind
        end
        return ind + 1
    end
end

在內部,此程式碼會建立函數的兩個實作:一個是在 if @generated 中使用第一個區塊的產生實作,以及一個在 else 區塊中使用的正常實作。在 if @generated 區塊的 then 部分中,程式碼具有與其他產生函數相同的語意:參數名稱指的是類型,而程式碼應傳回一個表示式。可能會出現多個 if @generated 區塊,在這種情況下,產生的實作會使用所有 then 區塊,而替代實作會使用所有 else 區塊。

請注意,我們在函數的最上方新增了一個錯誤檢查。這段程式碼將在兩個版本中都共用,且在兩個版本中都是執行時期的程式碼(它將會被引用並從產生的版本中做為一個表達式傳回)。這表示在程式碼產生時,無法取得區域變數的值和類型——程式碼產生程式碼只能看到參數的類型。

在這種定義樣式中,程式碼產生功能基本上是一個可選的最佳化。編譯器會在方便時使用它,否則可能會選擇改用一般的實作。建議使用這種樣式,因為它允許編譯器做出更多決策並以更多方式編譯程式,且一般程式碼比程式碼產生程式碼更易於閱讀。不過,使用哪種實作取決於編譯器的實作細節,因此兩個實作的行為必須完全相同。