巨集指令

本頁使用了標題或全文手工轉換
維基教科書,自由的教學讀本


« Introducing Julia
Metaprogramming
»
Plotting Modules and packages

何為元編程?[編輯]

元編程是指編寫 Julia 代碼來處理和修改 Julia 代碼。使用元編程工具,您可以編寫 Julia 代碼來修改源文件的其他部分,甚至可以控制修改後的代碼是否運行以及何時運行。

在 Julia 中,原始原始碼的執行分為兩個階段。(實際上,還有更多的階段,但在這一點上,我們只關注這兩個階段。)

階段1 是原始 Julia 代碼被解析 - 轉換為適合於求值的形式。您會對這個階段比較熟悉,因為這時候所有語法錯誤都能被發現……這樣做的結果是 抽象語法樹 或 AST (Abstract Syntax Tree) ,該結構包含所有代碼,但其格式比通常使用的人類友好語法更易於操作。

階段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 。相反,它報告將對這些特定參數使用哪種方法。它還會告訴您包含方法定義的源文件和行號。

元編程的其他用途包括 通過編寫生成較大代碼塊的短代碼 來實現單調編碼工作的自動化,以及能通過生成您可能不希望手工編寫的更快的代碼來提高「標準」代碼的性能。

冒號表達式(Quoted expressions)[編輯]

要使元編程成為可能,在解析階段完成後,Julia 就需要一種方法來存儲未計算但已解析的表達式。這就是 ':' (冒號) 前綴運算符:

julia> x = 3
3

julia> :x
:x 

在 Julia 中, :x 表示一個未求值的符號或一個引用符號。

(如果您不熟悉編程中引用符號(Quoted Symbols)的用途,請想想在書寫中如何使用引用來區分普通用途和特殊用途。例如,在句子中:

'Copper' contains six letters.

引號表明 「Copper」 這個詞不是指金屬,而是指這個單詞本身。同樣,在 :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

解析完成,並準備好做接下來的事情。

對表達式進行求值[編輯]

Julia 還有一個函數 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

更有用的是,可以在對表達式進行求值之前修改表達式的內容。

表達式的內部(Inside Expressions)[編輯]

只要將 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)[編輯]

這種代碼解析後的表示方式稱為 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

儘管這是一個被引用(quoted)了的表達式,因此未被計算,但表達式中的 sin(1) + cos(1) 卻是被執行了,它的值被插入到了表達式中,原始代碼則被值替換了。這種操作稱為「拼接」。


與字符串插值一樣,只有當你想要插入一個表達式的值時候才需要使用圓括號,插入單個符號的值用 $ 就行。

巨集指令[編輯]

現在你已經知道如何創建並處理未求值的 Julia 表達式了,你肯定會想知道該怎樣去修改它們。巨集指令—— macro 就是從一個未求值的表達式生成新表達式的途徑之一。 當你的 Julia 程序運行時,它首先會解析巨集指令,並對巨集指令進行求值,然後將巨集指令生成的代碼當成普通的表達式來計算。

下面是一個簡單的巨集指令定義,它只是列印出傳入表達式的內容,然後直接將該傳入表達式返回給調用者(在這裡,調用者就是 REPL)。巨集指令定義的語法和函數定義的語法很相似:

macro p(n)
    if typeof(n) == Expr 
       println(n.args)
    end
    return n
end

您可以通過在名稱前添加 @ 前綴來運行巨集指令。這個巨集指令只需要一個參數,你直接提供未求值的 Julia 代碼給它就行。也不必像調用函數那樣,用括號將參數括起來。

先嘗試一下用數值做參數:

julia> @p 3
3

數字並不是表達式,因此巨集指令的 if 條件結果是 false。這個巨集指令會直接返回 n。但如果你傳入一個表達式,巨集指令裡邊的代碼就能夠在表達式被求值前,通過 .args 屬性來審查或處理表達式的內容:

julia> @p 3 + 4 - 5 * 6 / 7 % 8
Any[:-,:(3 + 4),:(((5 * 6) / 7) % 8)]
2.7142857142857144

在上面的例子中,if 條件結果為 true,輸入的表達式的未求值形式就被列印了出來。因此你能看到表達式的 AST 結構——也就是 Julia 表達式被解析得到的結果,對它進一步求值,就得到表達式的值。 你也能發現解析操作會考慮到算術運算符的不同優先級。注意上層操作符和子表達式都被冒號(:)引用了起來。

Also notice that the macro p returned the argument, which was then evaluated, hence the 2.7142857142857144. But it doesn't have to — it could return a quoted expression instead.

As an example, the built-in @time macro returns a quoted expression rather than using eval() to evaluate the expression inside the macro. The quoted expression returned by @time is evaluated in the calling context when the macro has done its work. Here's the definition:

macro time(ex)
    quote
        local t0 = time()
        local val = $(esc(ex))
        local t1 = time()
        println("elapsed time: ", t1-t0, " seconds")
        val
    end
end

Notice the $(esc(ex)) expression. This is the way that you 'escape' the code you want to time, which is in ex, so that it isn't evaluated in the macro, but left intact until the entire quoted expression is returned to the calling context and executed there. If this just said $ex, then the expression would be interpolated and evaluated immediately.

If you want to pass a multi-line expression to a macro, use the begin ... end form:

@p begin
    2 + 2 - 3
end
Any[:( # none, line 2:),:((2 + 2) - 3)]
1

(You can also call macros with parentheses similar to the way you do when calling functions, using the parentheses to enclose the arguments:

julia> @p(2 + 3 + 4 - 5)
Any[:-,:(2 + 3 + 4),5]
4

This would allow you to define macros that accepted more than one expression as arguments.)

eval() and @eval[編輯]

There's an eval() function, and an @eval macro. You might be wondering what's the difference between the two?

julia> ex = :(2 + 2)
:(2 + 2) 

julia> eval(ex)
4

julia> @eval ex
:(2 + 2)

The function version (eval()) expands the expression and evaluates it. The macro version doesn't expand the expression you supply to it automatically, but you can use the interpolation syntax to evaluate the expression and pass it to the macro.

julia> @eval $(ex)
4

In other words:

julia> @eval $(ex) == eval(ex)
true

Here's an example where you might want to create some variables using some automation. We'll create the first ten squares and ten cubes, first using eval():

for i in 1:10
   symbolname = Symbol("var_squares_$(i)")
   eval(quote $symbolname = $(i^2) end)
end

which creates a load of variables named var_squares_n, such as:

julia> var_squares_5
25

and then using @eval:

for i in 1:10
   symbolname = Symbol("var_cubes_$(i)")
   @eval $symbolname = $(i^3)
end

which similarly creates a load of variables named var_cubes_n, such as:

julia> var_cubes_5
125

Once you feel confident, you might prefer to write like this:

julia> [@eval $(Symbol("var_squares_$(i)")) = ($i^2) for i in 1:10]

Scope and context[編輯]

When you use macros, you have to keep an eye out for scoping issues. In the previous example, the $(esc(ex)) syntax was used to prevent the expression from being evaluated in the wrong context. Here's another contrived example to illustrate this point.

macro f(x)
    quote
        s = 4
        (s, $(esc(s)))
    end
end

This macro declares a variable s, and returns a quoted expression containing s and an escaped version of s.

Now, outside the macro, declare a symbol s:

julia> s = 0

Run the macro:

julia> @f 2
(4,0)

You can see that the macro returned different values for the symbol s: the first was the value inside the macro's context, 4, the second was an escaped version of s, that was evaluated in the calling context, where s has the value 0. In a sense, esc() has protected the value of s as it passes unharmed through the macro. For the more realistic @time example, it's important that the expression you want to time isn't modified in any way by the macro.

Expanding macros[編輯]

To see what the macro expands to just before it's finally executed, use the macroexpand() function. It expects a quoted expression containing one or more macro calls, which are then expanded into proper Julia code for you so that you can see what the macro would do when called.

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

(The #none, line 1: is a filename and line number reference that's more useful when used inside a source file than when you're using the REPL.)

Here's another example. This macro adds a dotimes construction to the language.

macro dotimes(n, body)
    quote
        for i = 1:$(esc(n))
            $(esc(body))
        end
    end
end

This is used as follows:

julia> @dotimes 3 println("hi there")
hi there
hi there
hi there

Or, less likely, like this:

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

If you use macroexpand() on this, you can see what happens to the symbol names:

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 
)

with the following output:

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

The i local to the macro itself has been renamed to #101#i, so as not to clash with the original i in the code we passed to it.

A more useful example: @until[編輯]

Here's how to define a macro that is more likely to be useful in your code.

Julia doesn't have an until condition ... do some stuff ... end statement. Perhaps you'd like to type something like this:

until x > 100
    println(x)
end

You'll be able to write your code using the new until macro like this:

until <condition>
    <block_of_stuff>
end

but, behind the scenes, the work will be done by actual code with the following structure:

while true
    <block_of_stuff>
    if <condition>
        break
    end
end

This forms the body of the new macro, and it will be enclosed in a quote ... end block, like this, so that it executes when evaluated, but not before:

quote
    while true
        <block_of_stuff>
        if <condition>
            break
        end
    end
end

So the nearly-finished macro code is like this:

macro until(<condition>, <block_of_stuff>)
    quote
        while true
            <block_of_stuff>
            if <condition>
                break
            end
        end
    end
end

All that remains to be done is to work out how to pass in our code for the <block_of_stuff> and the <condition> parts of the macro. Recall that $(esc(...)) allows code to pass through 'escaped' (i.e. unevaluated). We'll protect the condition and block code from being evaluated before the macro code runs.

The final macro definition is therefore:

macro until(condition, block)
    quote
        while true
            $(esc(block))
            if $(esc(condition))
                break
            end
        end
    end
end

The new macro is used like this:

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

or

julia> x = 5
5

julia> @until x < 1 (println(x); global x -= 1)
5
4
3
2
1

Under the hood[編輯]

If you want a more complete explanation of the compilation process than that provided here, visit the links shown in Further Reading, below.

Julia performs multiple 'passes' to transform your code to native assembly code. As described above, the first pass parses the Julia code and builds the 'surface-syntax' AST, suitable for manipulation by macros. A second pass lowers this high-level AST into an intermediate representation, which is used by type inference and code generation. In this intermediate AST format all macros have been expanded and all control flow has been converted to explicit branches and sequences of statements. At this stage the Julia compiler attempts to determine the types of all variables so that the most suitable method of a generic function (which can have many methods) is selected.

延伸閱讀[編輯]

  • Julia Introspects(英文) Leah Hanson 2013 寫的關於 Julia 內部表示的文章
« Introducing Julia
Metaprogramming
»
Plotting Modules and packages