この記事は,東京大学航空宇宙工学科/専攻 Advent Calendar 2019 16日目の記事です.
学科の内容とは全く関係ないですが是非最後まで読んでくださると幸いです.
ほとんど公式ドキュメントそのままの内容になのはご了承ください.
初心者なのでかなり間違えてる箇所があるかと思いますので,是非その時はコメントで優しく教えてくださるとうれしいです.
このドキュメントは全てJuliaのREPL環境の中で動かします.
こちらからバイナリをダウンロードして実行するだけで簡単に動かせますので,是非試してみてください.
メタプログラミングとは
まず最初にメタプログラミングとは何かというものについて述べたいと思います.
日本語版wikipediaを参照するとメタプログラミングとは以下のようなものらしいです.
メタプログラミング (metaprogramming) とはプログラミング技法の一種で、ロジックを直接コーディングするのではなく、あるパターンをもったロジックを生成する高位ロジックによってプログラミングを行う方法、またその高位ロジックを定義する方法のこと。主に対象言語に埋め込まれたマクロ言語によって行われる。
正直よく分かりませんね....
ここで英語版wikipediaを参照すると以下のような記述があります
Metaprogramming is a programming technique in which computer programs have the ability to treat other programs as their data. It means that a program can be designed to read, generate, analyze or transform other programs, and even modify itself while running.In some cases, this allows programmers to minimize the number of lines of code to express a solution, in turn reducing development time. It also allows programs greater flexibility to efficiently handle new situations without recompilation.
こちらの方が分かりやすいですね.つまりメタプログラミングとは他のプログラムをデータのように保持や処理が出来るプログラミングのことらしいです.
Juliaにおける表現と抽象構文木
文字列で表現されたコードをMeta.parse
関数で Expr
型で表現することが可能になります.
julia> prog = "1 + 1"
"1 + 1"
julia> ex1 = Meta.parse(prog)
:(1 + 1)
julia> dump(ex1)
Expr
head: Symbol call
args: Array{Any}((3,))
1: Symbol +
2: Int64 1
3: Int64 1
また Expr()
関数を用いることで直接表現することも可能になります.
julia> ex2 = Expr(:call,:+,1,1)
:(1 + 1)
julia> dump(ex2)
Expr
head: Symbol call
args: Array{Any}((3,))
1: Symbol +
2: Int64 1
3: Int64 1
またこれは先ほどの Meta.parse
関数を用いた場合と等価になります.
julia> ex1 == ex2
true
ここでExpr
という型に付いて述べたいと思います.
これは抽象構文木(Abstract syntax tree, AST)を表すデータ型になっています.
プログラムコードに対して意味のある情報のみを抽出した木構造になっており,Juliaではこの構造を用いて処理の内容を保持します.
よって木構造なので以下のような表現も可能になります.
julia> ex3 = Meta.parse("(4 + 4) / 2")
:((4 + 4) / 2)
julia> dump(ex3)
Expr
head: Symbol call
args: Array{Any}((3,))
1: Symbol /
2: Expr
head: Symbol call
args: Array{Any}((3,))
1: Symbol +
2: Int64 4
3: Int64 4
3: Int64 2
またdump()
の出力結果を見ると,Symbol
という型が使われているのが分かります.
Symbolはコードの変数を表すのに使われる型になります.Juliaの内部では演算子や変数の区別をこの時点ではしておらず,const
などもSymbolとして用いられます.
またシンボルは名前の先頭に :
をつけることで呼び出すことが出来ます.
ただし=
だけは:=
だとセイウチ演算子と混同する恐れがあるためなのか:(=)
で呼び出す必要があります.
julia> ex4 = Expr(:const,Expr(:(=),:x,1))
:(const x = 1)
julia> dump(ex4)
Expr
head: Symbol const
args: Array{Any}((1,))
1: Expr
head: Symbol =
args: Array{Any}((2,))
1: Symbol x
2: Int64 1
またExpr()
の代わりに:(...)
やquote...end
で処理コードをラップすることでExpr
型を得る事が出来ます.
quote...end
でラップした場合には行数や定義された場所などが保持されます.
julia> dump(:(const x=1))
Expr
head: Symbol const
args: Array{Any}((1,))
1: Expr
head: Symbol =
args: Array{Any}((2,))
1: Symbol x
2: Int64 1
julia> ex5 = quote
const x=1
end
quote
#= REPL[89]:2 =#
const x = 1
end
julia> dump(ex5)
Expr
head: Symbol block
args: Array{Any}((2,))
1: LineNumberNode
line: Int64 2
file: Symbol REPL[89]
2: Expr
head: Symbol const
args: Array{Any}((1,))
1: Expr
head: Symbol =
args: Array{Any}((2,))
1: Symbol x
2: Int64 1
最後にExpr
型で定義された処理はeval
関数を用いることで実行する事ができます
julia> eval(ex1)
2
julia> eval(ex5)
1
julia> @show x
x = 1
1
マクロの作成方法
マクロはJuliaにおいてメタプログラミングととても関わり深いのでよく一緒に紹介されます.マクロでは生成されたコードをプログラムの最終本体に含める事が出来ます.
例えば@time
というマクロを使用すると,処理する時間を測る事が出来ます.
ここではこのようなマクロの作成方法について述べます.
julia> using Random
julia> @time sort(shuffle(1:5))
0.000019 seconds (7 allocations: 448 bytes)
5-element Array{Int64,1}:
1
2
3
4
5
マクロはmacro NAME ... end
で処理を囲んで作成します.実行する際には@NAME
と打つだけです.
julia> macro sayhello()
return :(println("Hello,Wolrd!"))
end
@sayhello (macro with 1 method)
julia> @sayhello
Hello,Wolrd!
マクロでExpr
型で値を返した場合には最後に実行します.
引数を受け取る際には以下のようにします.また多重ディスパッチにも対応してます.
julia> macro sayhello(name)
return :(println("Hello,",$name))
end
@sayhello (macro with 2 methods)
julia> @sayhello "Japan!"
Hello,Japan!
julia> macro sayhello(number::Int64)
word = :("")
for i=1:number
word = :($word * "Hello, World!")
end
return word
end
@sayhello (macro with 3 methods)
julia> @sayhello 2
"Hello, World!Hello, World!"
ここで$
は補間演算子になります.変数の中身の値を参照するようになります.
@macroexpand
を使うとマクロが返すExpr
型の中身を見る事ができます.
julia> @macroexpand @sayhello 3
:((("" * "Hello, World!") * "Hello, World!") * "Hello, World!")
メタプログラミングの良いところ
ここでは自分が感じたメタプログラミングの良いところについて述べていきたいと思います.
プログラムそのものにアクセス出来る
先ほどちらっと使いましたが@show
と同じ様な事が出来るマクロを自分で簡単に以下のように作ってみてみました.このマクロでは変数名=変数の値
を標準出力します.
julia> macro show2(val)
return :(println($(string(val)),"=",$val))
end
@show2 (macro with 1 method)
julia> @show2 y
y=4
また違う値が入っていたらAssertionエラーを出す@assert
を以下のように作成します. 1==1
を入力した際には値が合っているので何も起きませんが,
1==0
では値が異なるのでAssrtionエラーと,Assertionエラーになっているコードを表示してます.
julia> macro assert(ex)
return :( $ex ? nothing : throw(AssertionError($(string(ex)))) )
end
@assert (macro with 1 method)
julia> @assert 1==1
julia> @assert 1==0
ERROR: AssertionError: 1 == 0
この例のようにメタプログラミングでは変数名などのプログラムに直接アクセスする事が可能なので,プログラムを書く側が分かりやすい出力をする事が可能になります.
実行が早くなる
実行時間を測るのにBenchmarkToolsというものを使用します,以下のコードを実行してインストールしておきましょう.
using Pkg; Pkg.add("BenchmarkTools")
まずgenerated functionに関して説明します. この関数は@generated
というマクロを使って定義する関数で.この関数自体では引数の型にしか参照できず,値にはアクセスする事が出来ません.また返す値はExpr型である必要があります(ただし呼び出された後にこの返り値は実行されます.).以下に例を示します.
julia> @generated function foo(x)
Core.println(x)
return :(x * x)
end
foo (generic function with 1 method)
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"
ここでは与えられた引数の平均値を求める関数を以下のように二つ定義します.
julia> function mean1(objs...)
total = objs[1]
for i in 2:length(objs)
total += objs[i]
end
return total/length(objs)
end
mean1 (generic function with 1 method)
julia> @generated function mean2(objs...)
total = :(objs[1])
for i in 2:length(objs)
total = :($total + objs[$i])
end
:($total/$(length(objs)))
end
mean2 (generic function with 1 method)
型を二種類入れたTupleを用意します.
julia> values = ([i for i=1:0.25:5]...,[i for i=1:10]...)
(1.0, 1.25, 1.5, 1.75, 2.0, 2.25, 2.5, 2.75, 3.0, 3.25, 3.5, 3.75, 4.0, 4.25, 4.5, 4.75, 5.0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
julia> typeof(values)
Tuple{Float64,Float64,Float64,Float64,Float64,Float64,Float64,Float64,Float64,Float64,Float64,Float64,Float64,Float64,Float64,Float64,Float64,Int64,Int64,Int64,Int64,Int64,Int64,Int64,Int64,Int64,Int64}
実際に用意した関数とTupleで実行時間を比較してみましょう
julia>using BenchmarkTools
julia> @btime mean1(values...)
1.052 μs (44 allocations: 704 bytes)
julia> @btime mean2(values...)
416.925 ns (18 allocations: 288 bytes)
実行時間を比較すると2.5倍程度,メタプログラミングを使用した関数の方が早くメモリの使用量も少ない結果になりました.
Julia プログラミングクックブックには400倍程度変わるもっと露骨な例が載っています.
平均値を計算する際にmean1
では値を直接計算しているのに対してmean2
では値を計算してる訳ではなく返しているのもExprというのが主な違いとなっており.その違いによりJuliaのJITコンパイラが最適化する際にgenerated functionの方が最適化しやすいという訳らしいです.
最後に
簡単に短い内容ではありましたがJuliaでのメタプログラミングに関して興味を持っていただけましたでしょうか.
今後もちょっとずつ勉強していくつもりではあるのでまた何か書けそうなネタがあれば書いていきたいなと思っております.
参考
Julia プログラミングクックブック
Juliaのシンボルとは?
Metaprogramming · The Julia Language