Julia/超程式設計介紹
超程式設計是指編寫 Julia 程式碼來處理和修改 Julia 程式碼。使用超程式設計工具,你可以編寫 Julia 程式碼來修改原始檔中的其他部分,甚至控制修改後的程式碼何時以及是否執行。
在 Julia 中,原始原始碼的執行分為兩個階段。(實際上,階段不止這兩個,但現在我們只關注這兩個。)
階段 1 是解析你的原始 Julia 程式碼——將其轉換為適合評估的形式。你應該熟悉這個階段,因為這就是所有語法錯誤被發現的時候......這個階段的結果是抽象語法樹或 AST(抽象語法樹),一個包含所有程式碼的結構,但格式比通常使用的人類友好語法更容易操作。
階段 2 是執行解析後的程式碼。通常,當你將程式碼輸入 REPL 並按下回車鍵,或者從命令列執行 Julia 檔案時,你不會注意到這兩個階段,因為它們發生得太快。然而,使用 Julia 的超程式設計功能,你可以在程式碼被解析之後但被評估之前訪問它。
這讓你可以做一些你通常無法做的事情。例如,你可以將簡單的表示式轉換為更復雜的表示式,或者在程式碼執行之前檢查它並更改它以使其執行得更快。任何你使用這些超程式設計工具攔截和修改的程式碼最終都會以通常的方式被評估,並像普通的 Julia 程式碼一樣快速執行。
你可能已經使用過 Julia 中的兩個現有的超程式設計示例:
- @time 宏
julia> @time [sin(cos(i)) for i in 1:100000]; 0.102967 seconds (208.65 k allocations: 9.838 MiB)
@time 宏在程式碼開頭插入“啟動秒錶”命令,並在末尾新增一些程式碼來“停止秒錶”,並計算經過的時間和記憶體使用情況。然後修改後的程式碼被傳遞以進行評估。
- @which 宏
julia> @which 2 + 2
+(x::T, y::T) where T<:Union{Int128, Int16, Int32, Int64, Int8, UInt128, UInt16, UInt32, UInt64, UInt8} in Base at int.jl:53
這個宏根本不允許表示式 2 + 2 被評估。相反,它會報告將使用哪個方法來處理這些特定的引數。它還會告訴你包含該方法定義的原始檔以及行號。
超程式設計的其他用途包括透過編寫生成較長程式碼塊的短程式碼段來自動化繁瑣的編碼任務,以及透過生成你可能不想手動編寫的更快的程式碼來提高“標準”程式碼的效能。
為了使超程式設計成為可能,Julia 必須有一種方法來儲存未評估但已解析的表示式,以便在解析階段完成之後立即執行。這是 ':'(冒號)字首運算子
julia> x = 3 3 julia> :x :x
對 Julia 而言,:x 是一個未評估或引用的符號。
(如果你不熟悉計算機程式設計中引用的符號的使用,想想在寫作中如何使用引號來區分普通用法和特殊用法。例如,在句子中
'銅'包含六個字母。
引號表示“銅”這個詞不是指金屬,而是指這個詞本身。同樣,在 :x 中,符號前的冒號是為了讓你和 Julia 認為 'x' 是一個未評估的符號,而不是值 3。)
要引用整個表示式而不是單個符號,請以冒號開頭,然後將 Julia 表示式括在括號中
julia> :(2 + 2) :(2 + 2)
:( ) 結構有另一種形式,它使用 quote ... end 關鍵字來括起和引用一個表示式
quote
2 + 2
end
這將返回
quote
#= REPL[123]:2 =#
2 + 2
end
而這個表示式
expression = quote
for i = 1:10
println(i)
end
end
將返回
quote
#= REPL[124]:2 =#
for i = 1:10
#= REPL[124]:3 =#
println(i)
end
end
expression 物件的型別為 Expr
julia> typeof(expression)
Expr
它已解析、準備就緒,可以執行。
還有一個用於評估未評估表示式的函式。它被稱為 eval()
julia> eval(:x)
3
julia> eval(:(2 + 2))
4
julia> eval(expression)
1
2
3
4
5
6
7
8
9
10
使用這些工具,可以建立任何表示式並存儲它而不必對其進行評估
e = :(
for i in 1:10
println(i)
end
)
返回
:(for i = 1:10 # line 2:
println(i)
end)
然後稍後進行回憶和評估
julia> eval(e) 1 2 3 4 5 6 7 8 9 10
更有用的是,可以在表示式被評估之前修改其內容。
一旦你在未評估的表示式中獲得了 Julia 程式碼,而不是將其作為字串中的文字片段,你就可以對其進行操作。
這是一個表示式
P = quote
a = 2
b = 3
c = 4
d = 5
e = sum([a,b,c,d])
end
這將返回
quote
#= REPL[125]:2 =#
a = 2
#= REPL[125]:3 =#
b = 3
#= REPL[125]:4 =#
c = 4
#= REPL[125]:5 =#
d = 5
#= REPL[125]:6 =#
e = sum([a, b, c, d])
end
注意已為引用的表示式中的每一行新增的幫助行號。(每行的標籤都新增到上一行的末尾。)
我們可以使用 fieldnames() 函式來檢視這個表示式內部的內容
julia> fieldnames(typeof(P)) (:head, :args, :typ)
head 欄位是 :block。args 欄位是一個數組,包含表示式(包括註釋)。我們可以使用 Julia 的常見技術來檢查它們。例如,第二個子表示式是什麼
julia> P.args[2] :(a = 2)
將它們打印出來
for (n, expr) in enumerate(P.args)
println(n, ": ", expr)
end
1: #= REPL[125]:2 =# 2: a = 2 3: #= REPL[125]:3 =# 4: b = 3 5: #= REPL[125]:4 =# 6: c = 4 7: #= REPL[125]:5 =# 8: d = 5 9: #= REPL[125]:6 =# 10: e = sum([a, b, c, d])
如你所見,表示式 P 包含許多子表示式。我們可以很容易地修改這個表示式;例如,我們可以更改表示式的最後一行以使用 prod() 而不是 sum(),這樣,當 P 被評估時,它將返回變數的乘積而不是它們的總和。
julia> eval(P) 14 julia> P.args[end] = quote prod([a,b,c,d]) end quote #= REPL[133]:1 =# prod([a, b, c, d]) end julia> eval(P) 120
或者,你可以透過進入表示式來直接定位 sum() 符號
julia> P.args[end].args[end].args[1] :sum julia> P.args[end].args[end].args[1] = :prod :prod julia> eval(P) 120
這種在解析程式碼後表示程式碼的方式被稱為 AST(抽象語法樹)。它是一個巢狀的分層結構,旨在讓你和 Julia 都能輕鬆地處理和修改程式碼。
非常有用的 dump 函式可以讓你輕鬆地視覺化表示式的分層性質。例如,表示式 :(1 * sin(pi/2)) 以這種方式表示
julia> dump(:(1 * sin(pi/2)))
Expr
head: Symbol call
args: Array{Any}((3,))
1: Symbol *
2: Int64 1
3: Expr
head: Symbol call
args: Array{Any}((2,))
1: Symbol sin
2: Expr
head: Symbol call
args: Array{Any}((3,))
1: Symbol /
2: Symbol pi
3: Int64 2
typ: Any
typ: Any
typ: Any
你可以看到 AST 完全由 Expr 和原子(例如符號和數字)組成。
在某種程度上,字串和表示式是相似的——它們可能包含的任何 Julia 程式碼通常不會被評估,但你可以使用插值來評估其中的一部分程式碼。我們已經遇到了字串插值運算子,即美元符號($)。當它用在字串中時,可能還會使用括號將表示式括起來,這將評估 Julia 程式碼並將結果值插入到字串中的那個位置
julia> "the sine of 1 is $(sin(1))" "the sine of 1 is 0.8414709848078965"
同樣,你可以使用美元符號將執行的 Julia 程式碼的結果插值到表示式中(否則表示式不會被評估)
julia> quote s = $(sin(1) + cos(1)); end
quote # none, line 1:
s = 1.3817732906760363
end
即使這是一個引用的表示式,因此不會被評估,sin(1) + cos(1) 的值也會被計算並插入表示式中,替換原始程式碼。此操作稱為“拼接”。
與字串插值一樣,只有當你想要包含表示式的值時才需要括號——單個符號可以使用單個美元符號插值。
一旦你學會了如何建立和處理未評估的 Julia 表示式,你就會想知道如何修改它們。一個宏是一種方法,它可以在給定一個未評估的輸入表示式的情況下生成一個新的輸出表達式。當你的 Julia 程式執行時,它首先解析並評估宏,然後宏生成的處理後的程式碼最終像普通表示式一樣被評估。
以下是定義一個簡單宏的方法,該宏會打印出傳遞給它的內容,然後將表示式返回給呼叫環境(此處為 REPL)。語法與你定義函式的方式非常相似
macro p(n)
if typeof(n) == Expr
println(n.args)
end
return n
end
你透過在名稱前面加上@字首來執行宏。這個宏期望一個引數。你提供的是未評估的 Julia 程式碼,不必像函式引數那樣用括號括起來。
首先,讓我們用一個數字引數來呼叫它
julia> @p 3 3
數字不是表示式,因此宏中的if條件不適用。宏所做的只是返回n。但是,如果你傳遞一個表示式,宏中的程式碼就可以在表示式被評估之前使用.args欄位檢查和/或處理表達式的內容。
julia> @p 3 + 4 - 5 * 6 / 7 % 8 Any[:-,:(3 + 4),:(((5 * 6) / 7) % 8)] 2.7142857142857144
在這種情況下,if條件被觸發,傳入表示式的引數以未評估的形式打印出來。因此你可以看到引數作為一個表示式陣列,它們在被 Julia 解析後但未被評估之前。你還可以看到算術運算子的不同優先順序是如何在解析操作中被考慮的。注意頂層運算子和子表示式是如何用冒號 (:) 引用。
還要注意,宏p返回了引數,然後該引數被評估,因此得到了2.7142857142857144。但它不必這樣做——它可以返回一個引用的表示式。
例如,內建的@time宏返回一個引用的表示式,而不是使用eval()來評估宏中的表示式。由@time返回的引用表示式在宏完成其工作後,在呼叫上下文中被評估。以下是定義
macro time(ex)
quote
local t0 = time()
local val = $(esc(ex))
local t1 = time()
println("elapsed time: ", t1-t0, " seconds")
val
end
end
注意$(esc(ex))表示式。這就是你“轉義”要計時程式碼的方式,程式碼在ex中,這樣它不會在宏中被評估,而是保持完整,直到整個引用表示式返回到呼叫上下文並在那裡執行。如果只是說$ex,那麼表示式將被內插並立即評估。
如果你要向宏傳遞多行表示式,請使用begin ... end形式
@p begin
2 + 2 - 3
end
Any[:( # none, line 2:),:((2 + 2) - 3)] 1
(你也可以用括號呼叫宏,就像呼叫函式一樣,使用括號括住引數
julia> @p(2 + 3 + 4 - 5) Any[:-,:(2 + 3 + 4),5] 4
這將允許你定義接受多個表示式作為引數的宏。)
有一個eval()函式,還有一個@eval宏。你可能想知道這兩個有什麼區別?
julia> ex = :(2 + 2) :(2 + 2) julia> eval(ex) 4 julia> @eval ex :(2 + 2)
函式版本 (eval()) 會擴充套件表示式並對其進行評估。宏版本不會自動擴充套件你提供給它的表示式,但你可以使用內插語法來評估表示式並將它傳遞給宏。
julia> @eval $(ex) 4
換句話說
julia> @eval $(ex) == eval(ex) true
以下是一個你可能想要使用一些自動化來建立一些變數的示例。我們將建立前十個平方和十個立方,首先使用eval()
for i in 1:10
symbolname = Symbol("var_squares_$(i)")
eval(quote $symbolname = $(i^2) end)
end
這會建立許多名為var_squares_n的變數,例如
julia> var_squares_5 25
然後使用@eval
for i in 1:10
symbolname = Symbol("var_cubes_$(i)")
@eval $symbolname = $(i^3)
end
這類似地建立了許多名為var_cubes_n的變數,例如
julia> var_cubes_5 125
一旦你感到自信,你可能更喜歡這樣寫
julia> [@eval $(Symbol("var_squares_$(i)")) = ($i^2) for i in 1:10]
當你使用宏時,你必須注意作用域問題。在前面的示例中,$(esc(ex))語法用於防止表示式在錯誤的上下文中被評估。以下是一個另一個人為的示例來說明這一點。
macro f(x)
quote
s = 4
(s, $(esc(s)))
end
end
這個宏聲明瞭一個變數s,並返回一個包含s和s的轉義版本的引用表示式。
現在,在宏之外,宣告一個符號s
julia> s = 0
執行宏
julia> @f 2 (4,0)
你可以看到宏返回了符號s的不同值:第一個是宏上下文中的值,為 4,第二個是s的轉義版本,它在呼叫上下文中被評估,其中s的值為 0。從某種意義上說,esc()在s值無損地透過宏時對其進行了保護。對於更現實的@time示例,重要的是你要計時的表示式不會以任何方式被宏修改。
要檢視宏在最終執行之前擴充套件成什麼樣子,請使用macroexpand()函式。它期望一個包含一個或多個宏呼叫的引用表示式,這些宏呼叫隨後將擴充套件成適當的 Julia 程式碼,這樣你就可以看到宏被呼叫時會做什麼。
julia> macroexpand(Main, quote @p 3 + 4 - 5 * 6 / 7 % 8 end) Any[:-,:(3 + 4),:(((5 * 6) / 7) % 8)] quote #= REPL[158]:1 =# (3 + 4) - ((5 * 6) / 7) % 8 end
(#none, line 1:是一個檔名和行號引用,它在原始檔中使用比在使用 REPL 時更有用。)
以下是一個另一個示例。這個宏向語言添加了一個dotimes構造。
macro dotimes(n, body)
quote
for i = 1:$(esc(n))
$(esc(body))
end
end
end
它使用方法如下
julia> @dotimes 3 println("hi there")
hi there
hi there
hi there
或者,不太可能,像這樣
julia> @dotimes 3 begin
for i in 4:6
println("i is $i")
end
end
i is 4 i is 5 i is 6 i is 4 i is 5 i is 6 i is 4 i is 5 i is 6
如果你對它使用macroexpand(),你可以看到符號名稱發生了什麼變化
macroexpand(Main, # we're working in the Main module
quote
@dotimes 3 begin
for i in 4:6
println("i is $i")
end
end
end
)
輸出如下
quote
#= REPL[160]:3 =#
begin
#= REPL[159]:3 =#
for #101#i = 1:3
#= REPL[159]:4 =#
begin
#= REPL[160]:4 =#
for i = 4:6
#= REPL[160]:5 =#
println("i is $(i)")
end
end
end
end
end
宏本身的區域性變數i被重新命名為#101#i,以避免與我們傳遞給它的程式碼中的原始i衝突。
以下是定義一個宏的方法,該宏更有可能在你的程式碼中發揮作用。
Julia 沒有直到條件 ... 做一些事情 ... 結束語句。也許你想輸入類似的東西
until x > 100
println(x)
end
你將能夠使用新的until宏編寫你的程式碼,像這樣
until <condition>
<block_of_stuff>
end
但是,在幕後,工作將由具有以下結構的實際程式碼完成
while true
<block_of_stuff>
if <condition>
break
end
end
這形成了新宏的主體,它將被包含在一個quote ... end塊中,像這樣,以便在評估時執行,但不是之前
quote
while true
<block_of_stuff>
if <condition>
break
end
end
end
因此,幾乎完成的宏程式碼是這樣的
macro until(<condition>, <block_of_stuff>)
quote
while true
<block_of_stuff>
if <condition>
break
end
end
end
end
剩下的唯一工作是弄清楚如何傳遞<block_of_stuff>和<condition>部分的程式碼。回想一下,$(esc(...))允許程式碼以“轉義”(即未評估)的方式傳遞。我們將保護條件和程式碼塊免於在宏程式碼執行之前被評估。
因此,最終的宏定義是這樣的
macro until(condition, block)
quote
while true
$(esc(block))
if $(esc(condition))
break
end
end
end
end
新宏使用方法如下
julia> i = 0
0
julia> @until i == 10 begin
global i += 1
println(i)
end
1 2 3 4 5 6 7 8 9 10
或者
julia> x = 5 5 julia> @until x < 1 (println(x); global x -= 1) 5 4 3 2 1
如果你想更完整地瞭解編譯過程(而不是這裡提供的解釋),請訪問下面“進一步閱讀”部分中顯示的連結。
Julia 執行多個“傳遞”將你的程式碼轉換為本機彙編程式碼。如上所述,第一個傳遞解析 Julia 程式碼並構建適合宏操作的“表面語法”AST。第二個傳遞降低這個高階 AST 到一箇中間表示,它被型別推斷和程式碼生成使用。在這個中間 AST 格式中,所有宏都被擴充套件,所有控制流都被轉換為顯式分支和語句序列。在此階段,Julia 編譯器嘗試確定所有變數的型別,以便選擇通用函式(可以有多個方法)的最合適方法。
- https://docs.julialang.org/en/v1/devdocs/reflection/ 關於官方 Julia 文件中抽象語法樹的更多資訊
- http://blog.leahhanson.us/post/julia/julia-introspects.html Julia Introspects,Leah Hanson 2013 年撰寫的一篇有用的文章