Julia 언어에서 Lisp의 가장 강력한 유산은 메타프로그래밍 지원입니다. Lisp와 마찬가지로 Julia는 자체 코드를 언어 자체의 데이터 구조로 나타냅니다. 코드는 언어 내에서 만들고 조작할 수 있는 개체로 표현되므로 프로그램이 자체 코드를 변환하고 생성할 수 있습니다. 이것은 추가적인 빌드 단계 없이 정교한 코드 생성을 허용하고 추상 구문 트리(abstract syntax tree, AST) 수준에서 작동하는 진정한 Lisp 스타일 매크로를 허용합니다. 대조적으로, C 및 C++와 같은 전처리기 "매크로" 시스템은 실제 구문 분석(parsing) 이나 해석(interpretation)이 발생하기 전에 텍스트 조작 및 대체를 수행합니다. Julia의 모든 데이터 유형과 코드는 Julia 데이터 구조로 표현되기 때문에 강력한 리플렉션(reflection) 기능을 사용하여 다른 데이터와 마찬가지로 프로그램 및 해당 타입의 내부를 탐색할 수 있습니다.
모든 Julia 프로그램은 문자열로 그 삶을 시작합니다.
julia> prog = "1 + 1"
"1 + 1"
다음엔 무슨 일이 벌어질까요?
다음 단계는 각 문자열을 Julia 의 Expr
타입으로 표시되는 표현식이라는 객체로 구문 분석하는 것입니다.
julia> ex1 = Meta.parse(prog)
:(1 + 1)
julia> typeof(ex1)
Expr
Expr
객체는 두 부분을 포함합니다.
Symbol
은 표현식의 종류를 확인합니다. symbol 은 일종의 interned string [^1] 식별자 입니다.julia> ex1.head
:call
julia> ex1.args
3-element Vector{Any}:
:+
1
1
표현식은 전위 표기법(prefix notation)[^2] 을 사용하여 직접적으로 만들 수 있습니다.
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)
표현식을 보는 또 다른 방법은 주어진 Expr의 S-표현식 (S-expression)형식을 표시하는 Meta.show_sexpr
을 사용하는 것입니다. 이 방법은 Lisp 사용자에게는 상당히 익숙 할 수 있습니다. 다음은 중첩된 Expr
의 디스플레이를 보여주는 예입니다.
julia> Meta.show_sexpr(ex3)
(:call, :/, (:call, :+, 4, 4), 2)
Julia에서 :
문자는 두 가지 구문적인 사용법이 있습니다. 첫 번째 형식은 표현식에서 하나의 빌딩 블록으로 사용되는 interned string인 Symbol
을 생성합니다.
julia> s = :foo
:foo
julia> typeof(s)
Symbol
Symbol
생성자는 다수의 인자를 받을 수 있으며 그 인자들의 문자열 표현을 이어붙여 새로운 symbol 을 만듭니다.
julia> :foo == Symbol("foo")
true
julia> Symbol("func",10)
:func10
julia> Symbol(:var,'_',"sym")
:var_sym
:
구문을 사용하려면 기호 이름이 유효한 식별자여야 합니다. 그렇지 않으면 Symbol(str)
생성자를 사용해야 합니다.
표현식의 내부에서 symbol은 변수에 대한 접근을 나타내는 데 사용됩니다. 표현식이 평가될 때 기호는 적절한 범위 (scope)에서 해당 기호에 바인딩된 값으로 대체됩니다.
때로는 구문 분석의 모호성을 피하기 위해 :
에 대한 인자 주위에 추가 괄호가 필요합니다.
julia> :(:)
:(:)
julia> :(::)
:(::)
:
문자의 두 번째 구문 목적은 명시적으로 Expr
생성자를 사용하지 않고 표현식 개체를 만드는 것입니다. 이것을 인용이라고 합니다. :
문자와 뒤에 오는 괄호쌍(( )
) 에 포함된 단일 명령문으로 된 Julia 코드는 이 코드를 기반으로 Expr
개체를 생성합니다. 다음은 산술 표현식을 인용하는 데 사용되는 짧은 형식의 보기입니다.
julia> ex = :(a+b*c+1)
:(a + b * c + 1)
julia> typeof(ex)
Expr
(이 표현식의 구조를 보자면 ex.head
와 ex.args
를 시도해보거나, 위에서처럼 dump
를 사용하거나 Meta.@dump
를 사용해 보세요)
Meta.parse
를 사용하거나 직접 Expr
형식을 사용하여 동등한 표현식을 구성할 수 있다는 것에 유의하세요.
julia> :(a + b*c + 1) ==
Meta.parse("a + b*c + 1") ==
Expr(:call, :+, :a, Expr(:call, :*, :b, :c), 1)
true
구문분석기(parser)에서 제공하는 표현식은 일반적으로 기호나, 기타 표현식 및 리터럴 값만 인자로 갖는 반면 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
객체를 직접 생성하는 것은 강력하지만 Expr
생성자는 "일반적인"(normal) Julia 구문에 비해 지루할 수 있습니다. 이에 대한 대안으로 Julia는 리터럴 또는 표현식을 인용된 표현식으로 보간할 수 있습니다. 보간은 접두사 $
로 표시됩니다.
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))
표현식 보간을 위해 $
를 사용하는 것은 의도적으로 문자열 보간(string interpolation) 및 명령문 보간 (command interpolation)을 연상시킵니다. 표현식 보간을 사용하면 복잡한 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
그러나 외부의 quoto
표현식은 내부 인용의 $
값을 보간할 수 있습니다. 이것은 여러 $
로 수행됩니다.
julia> e = quote quote $$x end end
quote
#= none:1 =#
$(Expr(:quote, quote
#= none:1 =#
$(Expr(:$, :(1 + 2)))
end))
end
이제 결과에 기호 x
대신 (1 + 2)
가 나타납니다. 이 표현식을 평가하면 보간된 3
값이 생성됩니다.
julia> eval(e)
quote
#= none:1 =#
3
end
이런 동작 이면에서 이루어지는것을 통찰해보면 x
가 각 $
에 대해 한 번씩 평가된다는 것을 알 수 있습니다. 하나의 $
는 eval(:x)
와 유사하게 작동하여 x
의 값을 제공하는 반면 두 개의 $
는 eval(eval(:x))
과 동일합니다.
추상 구문 트리에서 인용 형식의 일반적인 표현은 head :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))))
파서(parsor)는 symbols와 같은 간단한 인용 항목에 대해 QuoteNode
를 생성합니다.
julia> dump(Meta.parse(":x"))
QuoteNode
value: Symbol x
QuoteNode
는 특정 고급 메타프로그래밍 작업에도 사용할 수 있습니다.
주어진 표현식 객체를 Julia가 eval
을 사용하여 전역 범위에서 평가(실행)하도록 할 수 있습니다:
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
모든 모듈(module)에는 전역 범위에서 표현식을 평가하는 자체 적인 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
의 값은 +
함수를 값 1
과 변수 b
에 적용하는 표현식 ex
를 구성하는 데 사용됩니다. a
와 b
가 사용되는 방식의 중요한 차이점에 유의하십시오.
표현식 생성 시 변수 a
의 값은 표현식에서 즉시 값으로 사용됩니다. 따라서 표현식이 평가될 때의 값은 더 이상 중요하지 않습니다. 표현식의 값은 a
값이 무엇이든 상관없이 이미 1
입니다.
반면에 :b
기호는 표현식 구성에 사용되므로 당시 변수 b
의 값은 관련이 없습니다. :b
는 기호일 뿐이고 변수 b
는 정의할 필요도 없습니다. 그러나 표현식이 평가될 때는 기호 :b
의 값은 변수 b
의 값을 조회하여 확인됩니다.
위에서 언뜻 보여줬듯이 Julia의 매우 유용한 기능 중 하나는 Julia 자체 내에서 Julia 코드를 생성하고 조작하는 기능입니다. 우리는 이미 Expr
객체를 반환하는 함수의 한 가지 예를 보았습니다. 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!") )
@sayhello
가 REPL에 입력되면 표현식이 즉시 실행되므로 평가 결과만 표시됩니다.
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, ...)
배열 리터럴(또는 comprehension[^3])을 통해 매크로를 호출하는 다른 방법은 괄호를 사용하지 않고 둘 다 병치하는 것입니다. 이 경우 배열은 매크로에 공급되는 유일한 표현식이 됩니다. 다음 구문은 동일하며 @name [a b] * v
와 다릅니다.
@name[a b] * v
@name([a b]) * v
매크로는 인자를 표현식, 리터럴 또는 기호로 받는다는 것은 아무리 강조해도 지나치지 않습니다. 매크로에 전달되는 인자를 탐색하는 한 가지 방법은 매크로 본문 내에서 show
함수를 호출하는 것입니다.
ulia> 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
객체 형식으로)제공합니다. 이를 통해 매크로는 기존 바인딩과 같은 컨텍스트 정보를 조회하거나 현재 모듈에서 자체 리플렉션(reflection)을 수행하는 런타임 함수 호출에 대한 추가 인자로 값을 삽입할 수 있습니다.
다음은 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))
의 값은 assertion 메시지 슬롯에 연결됩니다. 이렇게 구성된 전체 표현식은 @assert
매크로 호출이 발생하는 구문 트리에 배치됩니다. 그런 다음 실행 시간에 테스트 표현식이 true
로 평가되면 아무 것도 반환되지 않는 반면 테스트가 false
이면 false
인 주장된 표현식을 나타내는 오류가 발생합니다. 조건의 값만 사용할 수 있고 이를 계산한 표현식을 오류 메시지에 표시하는 것이 불가능하기 때문에 이것을 함수로 작성하는 것이 불가능하다는 점에 유의하십시오.
Julia Base에서 @assert
의 실제 정의는 더 복잡합니다. 그것은 사용자가 실패한 표현식을 출력하는 대신 선택적으로 자신의 오류 메시지를 지정할 수 있도록 합니다. 가변 개수의 인자가 있는 함수(Varargs Functions
)와 마찬가지로 마지막 인자 다음에 줄임표가 지정됩니다.
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
매크로가 처리하는 또 다른 경우가 있습니다. "b와 같아야 함"을 인쇄하는 것 외에도 값을 인쇄하고 싶다면 어떻게 될까요? 예를 들어 @assert a==b "a ($a) must equal 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
매크로는 따옴표 붙은 표현식으로 연결하여 매크로 본문 내부의 표현식 조작을 단순화합니다.
더 복잡한 매크로에서 발생하는 문제는 위생(hygiene) 문제입니다. 간단히 말해서, 매크로는 반환된 표현식에 도입한 변수가 확장되는 주변 코드의 기존 변수와 우발적으로 충돌하지 않도록 해야 합니다. 반대로 매크로에 인자로 전달되는 표현식은 기존 변수와 상호 작용하고 기존 변수를 수정하여 주변 코드의 컨텍스트에서 평가되는 경우가 많습니다. 매크로가 정의된 모듈과 다른 모듈에서 호출될 수 있다는 사실에서 또 다른 문제가 발생합니다. 이 경우 모든 전역 변수가 올바른 모듈로 확인되었는지 확인해야 합니다. Julia는 반환된 표현식만 고려하면 된다는 점에서 C와 같은 텍스트 매크로 확장이 있는 언어에 비해 이미 큰 이점이 있습니다. 다른 모든 변수(예: 위의 @assert
에서 msg
)는 일반적인 범위 지정 블록 동작(normal scoping block bahvior)을 따릅니다.
이러한 문제를 설명하기 위해 표현식을 인자로 받고, 시간을 기록하고, 표현식을 평가하고, 시간을 다시 기록하고, 이전 시간과 이후 시간의 차이를 출력하고, 다음 값을 갖는 @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
여기에서 우리는 t0
, t1
및 val
이 private한 임시 변수가 되기를 원하고 time_ns
가 사용자가 가질 수 있는 time_ns
변수가 아니라 Julia Base의 time_ns
함수를 참조하기를 원합니다(println
에도 동일하게 적용됨). 사용자 표현식 ex
에도 t0
이라는 변수에 대한 할당이 포함되어 있거나 자체 time_ns
변수를 정의한 경우 발생할 수 있는 문제를 상상해 보십시오. 오류가 발생하거나 이상하게 잘못된 동작이 발생할 수 있습니다.
Julia의 매크로 확장기는 다음과 같은 방식으로 이러한 문제를 해결합니다. 첫째, 매크로 결과 내의 변수는 로컬 또는 글로벌로 분류됩니다. 변수가 할당되거나(글로벌로 선언되지 않음), 로컬로 선언되거나, 함수 인수 이름으로 사용되는 경우 변수는 로컬로 간주됩니다. 그렇지 않으면 전역으로 간주됩니다. 그런 다음 지역 변수는 고유하도록 이름이 바뀌고(새 기호를 생성하는 gensym
함수 사용) 전역 변수는 매크로 정의 환경 내에서 해결됩니다. 따라서 위의 두 가지 문제가 모두 처리됩니다. 매크로의 로컬은 사용자 변수와 충돌하지 않으며 time_ns
및 println
은 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
를 0
으로 설정합니다.
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
에서 인수를 래핑합니다. 이에 대한 몇 가지 예에는 단순히 schedule(Task(() -> $body))
을 반환하는 @task
본문과 eval(QuoteNode(expr))
을 반환하는 @eval 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 함수와 마찬가지로 매크로는 generic 합니다. 이는 다중 디스패치 덕분에 다중 메소드 정의를 가질 수도 있음을 의미합니다.
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는 자체 전처리기 (preprocessor) 역할을 하며 언어 내부에서 코드 생성을 허용합니다. 위의 코드는 :
전위 인용 형식(prefix quoting form)을 사용하여 약간 더 간결하게 작성할 수 있습니다.
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
문자열(Strings
)에서 다음을 기억하십시오 : 식별자가 접두사로 붙은 문자열 리터럴을 비표준 문자열 리터럴이라고 하며 접두사가 붙지 않은 문자열 리터럴과 다른 의미를 가질 수 있습니다. 예를 들어:
r"^\s*(?:#|$)"
는 문자열이 아닌 정규식 개체를 생성합니다.b"DATA\xff\u2200"
은 [68,65,84,65,255,226,136,128]
에 대한 바이트 배열 리터럴(byte array literal)입니다.놀랍게도 이러한 동작은 Julia 구문 분석기(parsor)나 컴파일러에 하드 코딩되지 않습니다. 대신 누구나 사용할 수 있는 일반 메커니즘에서 제공하는 사용자 지정 동작입니다. 접두어가 붙은 문자열 리터럴은 특수하게 명명된 매크로에 대한 호출로 구문 분석됩니다. 예를 들어 정규식 매크로는 다음과 같습니다.
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
또한 컴파일러가 모든 루프에서 정규식 객체가 일정하다고 결정할 수 없는 경우 특정한 최적화는 불가능할 수 있으므로 이 버전은 위의 보다 편리한 리터럴 형식보다 여전히 덜 효율적입니다. 물론 비리터럴 형식이 더 편리한 상황이 여전히 존재합니다. 변수를 정규식으로 보간해야 하는 경우 이보다 장황한 접근 방식을 취해야 합니다. 정규식 패턴 자체가 동적이고 각 루프 반복에서 잠재적으로 변경되는 경우 매번의 반복에서 새 정규식 개체를 생성해야 합니다. 그러나 대부분의 사용 사례에서 정규식은 런타임 데이터를 기반으로 구성되지 않습니다. 이 대부분의 경우 정규식을 컴파일 타임 값으로 작성하는 기능은 매우 중요합니다.
사용자 정의 문자열 리터럴의 메커니즘은 매우 강력합니다. Julia의 비표준 리터럴이 이를 사용하여 구현될 뿐만 아니라 명령 리터럴 구문(echo "Hello, $person"
)도 다음과 같은 무해해 보이는 매크로를 사용하여 구현됩니다.
macro cmd(str)
:(cmd_gen($(shell_parse(str)[1])))
end
물론 이 매크로 정의에 사용된 함수에는 많은 복잡성이 숨겨져 있지만 모두 Julia로 작성된 함수일 뿐입니다. 소스를 읽고 그들이 하는 일을 정확하게 볼 수 있습니다. 그리고 그들이 하는 일은 프로그램의 구문 트리에 삽입할 표현식 개체를 구성하는 것뿐입니다.
문자열 리터럴과 마찬가지로 명령 리터럴에도 식별자가 접두사로 붙어 비표준 명령 리터럴이라고 하는 것을 형성할 수 있습니다. 이러한 명령 리터럴은 특별히 명명된 매크로에 대한 호출로 구문 분석됩니다. 예를 들어 custom
`literal
` 구문은 @custom_cmd "literal"
로 구문 분석됩니다. Julia 자체에는 비표준 명령 리터럴이 포함되어 있지 않지만 패키지는 이 구문을 사용할 수 있습니다. 다른 구문과 _str
접미사를 대신하는 _cmd
접미사를 제외하면 비표준 명령 리터럴은 비표준 문자열 리터럴과 똑같이 작동합니다.
두 개의 모듈이 동일한 이름의 비표준 문자열 또는 명령 리터럴을 제공하는 경우 모듈 이름으로 문자열 또는 명령 리터럴을 한정할 수 있습니다. 예를 들어, Foo
와 Bar
가 모두 비표준 문자열 리터럴 @x_str
을 제공하는 경우 둘 사이를 명확하게 하기 위해 Foo.x"literal"
또는 Bar.x"literal"
을 작성할 수 있습니다.
매크로를 정의하는 또 다른 방법은 다음과 같습니다.
macro foo_str(str, flag)
# do stuff
end
이 매크로는 다음 구문으로 호출할 수 있습니다.
foo"str"flag
위에서 언급한 구문에서 플래그 유형은 문자열 리터럴 뒤에 오는 모든 내용을 포함하는 문자열입니다.
매우 특별한 매크로인 @generated
를 이용하여 소위 생성된 함수(generated function) 를 정의할 수 있습니다. 이들은 다중 디스패치로 달성할 수 있는 것보다 더 유연거나 더 적은량의 코드를 포함하는 (혹은 둘 다인) 코드를 인자 유형에 따라 특화되도록 생성할 수 있습니다. 매크로는 구문 분석 시 표현식과 함께 작동하고 입력 타입에 접근할 수 없지만, 생성된 함수는 인자 유형을 알지만 함수가 아직 컴파일되지 않은 시점에 확장됩니다.
일부 계산이나 작업을 수행하는 대신 생성된 함수 선언은 따옴표로 묶인 표현식을 반환한 다음 인수 유형에 해당하는 메서드의 본문을 구성합니다. 생성된 함수가 호출되면 반환하는 표현식이 컴파일된 다음 실행됩니다. 이를 효율적으로 만들기 위해 결과는 일반적으로 캐시됩니다. 그리고 이것을 추론할 수 있도록 하기 위해 언어의 제한된 하위 집합만 사용할 수 있습니다. 따라서 생성된 함수는 허용된 구성에 대한 더 큰 제한을 희생하면서 런타임에서 컴파일 시간으로 작업을 이동하는 유연한 방법을 제공합니다.
생성된 함수를 정의할 때 일반 함수를 정의할 때와 와 5가지 주요 차이점이 있습니다.
@generated
매크로로 함수 선언에 주석을 답니다. 이것은 컴파일러가 이것이 생성된 함수임을 알 수 있도록 하는 일부 정보를 AST에 추가합니다.
생성된 함수의 본문에서는 값이 아닌 인수 타입에만 접근할 수 있습니다.
무언가를 계산하거나 어떤 작업을 수행하는 대신 평가될 때 원하는 것을 수행하는 인용된 표현식을 반환합니다.
생성된 함수는 생성된 함수의 정의 이전에 정의된 함수만 호출할 수 있습니다. (이를 따르지 않으면 미래 세계 시대의 함수를 참조하는 MethodErrors
가 발생할 수 있습니다.)
생성된 함수는 상수가 아닌 전역 상태(예를 들자면 IO, 잠금(locks), 비로컬 사전 또는 hasmethod
사용 을 포함하는)를 변경하거나 관찰해서는 안 됩니다. 즉, 전역 상수만 읽을 수 있으며 부작용이 없습니다. 즉, 완전히 순수해야 합니다. 구현 제한으로 인해 이는 현재 클로저 또는 생성자(generator)를 정의할 수 없음을 의미합니다.
이것을 예를 들어 설명하는 것이 가장 쉽습니다. 생성된 함수 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"
생성된 함수의 각 메서드에는 정의된 함수에 대한 고유한 view가 있습니다.
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) = x
및 bar(x::Integer) = x ^ 2
를 정의하면 동일한 작업을 수행하지만 더 간단하고 빠릅니다.baz
함수는 병적입니다.생성된 함수에서 시도해서는 안 되는 일련의 연산은 제한이 없으며 런타임 시스템은 현재 유효하지 않은 연산의 하위 집합만 감지할 수 있습니다. 일반적으로 잘못된 정의와 명백하게 연결되지 않은 미묘한 방식으로 알림 없이 런타임 시스템을 단순히 손상시키는 다른 작업이 많이 있습니다. 함수 생성기는 추론 중에 실행되기 때문에 해당 코드의 모든 제한 사항을 준수해야 합니다.
다음은 시도하지 말아야 할 몇 가지 연산에 포함됩니다. :
네이티브 포인터의 캐싱.
어떤 식으로든 Core.Compiler
의 콘텐츠 또는 메서드와의 상호작용.
모든 변경 가능한 상태를 관찰하는것.
잠금 가져오기: 당산이 호출하는 C 코드는 내부적으로 잠금을 사용할 수 있지만(예를 들어, 대부분의 구현에 내부적으로 잠금이 필요하더라도 malloc
을 호출하는 것은 문제가 되지 않음) Julia 코드를 실행하는 동안 잠금을 유지하거나 획득하려고 시도하지 마십시오.
생성된 함수의 본문 이후에 정의된 함수를 호출하는것. 이 조건은 점진적으로 로드되는 미리 컴파일된 모듈에 대해 완화되어 모듈의 모든 함수를 호출할 수 있습니다.
자, 이제 생성된 함수가 작동하는 방식을 더 잘 이해했으므로 이를 사용하여 좀 더 고급(그리고 유효한) 기능을 빌드해 보겠습니다.
Julia의 기본 라이브러리에는 n개의 다중선형 인덱스 세트를 기반으로 선형 인덱스를 n차원 배열로 계산하는 내부 sub2ind
함수가 있습니다. 즉 A[x,y,z,...]
대신 A[i]
를 사용하게 합니다. 한 가지 가능한 구현은 다음과 같습니다.
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
동일한 일을 재귀(recursion)을 통해 할 수 있습니다.
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
블록을 사용합니다.
함수 상단에 오류 검사를 추가했습니다. 이 코드는 두 버전 모두에 공통적이며 두 버전 모두에서 런타임 코드입니다(인용되고 생성된 버전에서 표현식으로 반환됨). 즉, 코드 생성 시 지역 변수의 값과 타입을 사용할 수 없습니다. 코드 생성 코드는 인자 유형만 볼 수 있습니다.
이러한 정의 스타일에서 코드 생성 기능은 기본적으로 선택적 최적화입니다. 컴파일러는 편리한 경우 이를 사용하지만, 그렇지 않으면 대신 일반 구현을 사용하도록 선택할 수 있습니다. 이 스타일은 컴파일러가 더 많은 결정을 내리고 더 많은 방식으로 프로그램을 컴파일할 수 있게 하고 일반 코드가 코드 생성 코드보다 더 가독성이 높기 때문에 선호됩니다. 그러나 사용되는 구현은 컴파일러 구현 세부 정보에 따라 다르므로 두 구현이 동일하게 작동하는 것이 필수적입니다.
[^1] string internation 은 각각의 문자열을 오직 1) immutable 하도록 하고 2) 오직 안 카피만을 저장하게 하는 방법을 말합니다. Interned string 은 이런 방식으로 처리/저장되는 문자열힙니다.
[^2] 전위 표기법(prefix notation) 혹은 폴란드식 표기법(Polish notaion) 은 연산자를 피연산자의 왼쪽에 두는 표기법입니다. 예를 들어 + 3 4
는 3+4
를 의미한다. 열으로 (5-6) × 7
은 전위 표기법으로 × (− 5 6) 7
로 씁니다.
[^3] [sin(x) for x in 0:0.1:π]
와 같은 방법으로 배열을 구성하는 것을 배열 컴프리헨션(comprehension)이라 한다.
[^4] 건강, 청결과 연관된 건강하다는 의미의 위생 맞다. 더 적합한 번역어가 있을까?