메타프로그래밍
메타프로그래밍
Julia 언어가 Lisp에서 물려받은 가장 강력한 유산은 바로 메타프로그래밍 지원이에요. Lisp처럼 Julia도 자기 자신의 코드를 언어 자체의 데이터 구조로 표현해요. 코드가 언어 안에서 만들고 조작할 수 있는 객체로 표현되기 때문에, 프로그램이 자기 자신의 코드를 변형하고 생성할 수 있죠. 이 글에서는 Julia 코드가 Expr 객체로 어떻게 표현되는지부터, 이를 조작하는 보간과 매크로, 문자열 리터럴 문법, 그리고 생성 함수(generated functions)까지 차근차근 살펴볼게요.
본문
Julia 언어가 Lisp에서 물려받은 가장 강력한 유산은 메타프로그래밍 지원이에요. Lisp처럼 Julia도 자기 자신의 코드를 언어 자체의 데이터 구조로 표현해요. 코드가 언어 안에서 만들고 조작할 수 있는 객체로 표현되기 때문에, 프로그램이 자기 자신의 코드를 변형하고 생성하는 것이 가능해요. 이 덕분에 추가 빌드 단계 없이 정교한 코드 생성이 가능하고, 추상 구문 트리(abstract syntax trees) 수준에서 동작하는 진짜 Lisp 스타일 매크로도 가능해요. 반면 C나 C++의 전처리기 "매크로" 시스템은 실제 파싱이나 해석이 일어나기 전에 텍스트 조작과 치환을 수행해요. Julia의 모든 데이터 타입과 코드가 Julia 데이터 구조로 표현되기 때문에, 프로그램과 그 타입의 내부를 다른 데이터처럼 탐색할 수 있는 강력한 리플렉션(reflection) 능력도 쓸 수 있어요.
참고: 메타프로그래밍은 강력한 도구지만, 코드를 이해하기 어렵게 만드는 복잡성을 도입해요. 예를 들어 스코프 규칙을 올바르게 맞추는 게 놀라울 정도로 어려울 수 있어요. 메타프로그래밍은 보통 고차 함수나 클로저(closure) 같은 다른 접근법을 적용할 수 없을 때만 써야 해요.
eval과 새 매크로 정의는 보통 마지막 수단으로 써야 해요. 임의의 문자열을 Julia 코드로 변환하는 Meta.parse를 쓰는 것도 거의 좋은 생각이 아니에요. Julia 코드를 조작한다면 Expr 데이터 구조를 직접 사용해서 Julia 문법이 파싱되는 복잡성을 피하는 게 좋아요.
메타프로그래밍의 가장 좋은 쓰임새는 상당 부분의 기능을 런타임 헬퍼 함수로 구현해서, 생성하는 코드의 양을 최소화하는 데 힘쓰는 경우가 많아요.
프로그램 표현
모든 Julia 프로그램은 문자열로 시작해요.
julia> prog = "1 + 1"
"1 + 1"
다음에는 무슨 일이 일어날까요?
다음 단계는 각 문자열을 표현식(expression)이라 불리는 객체로 파싱하는 거예요. 이 표현식은 Julia 타입 Expr로 표현돼요.
julia> ex1 = Meta.parse(prog)
:(1 + 1)
julia> typeof(ex1)
Expr
Expr 객체는 두 부분을 담아요.
- 표현식의 종류를 식별하는
Symbol. 심볼은 interned string 식별자예요 (아래에서 더 설명).
julia> ex1.head
:call
- 표현식 인자. 심볼, 다른 표현식, 또는 리터럴 값일 수 있어요.
julia> ex1.args
3-element Vector{Any}:
:+
1
1
표현식은 전위 표기법(prefix notation)으로 직접 만들 수도 있어요.
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로, 주어진 Expr의 S-표현식(S-expression) 형태를 보여줘요. Lisp 사용자에게는 아주 익숙해 보일 거예요. 중첩된 Expr의 표시를 보여주는 예시예요.
julia> Meta.show_sexpr(ex3)
(:call, :/, (:call, :+, 4, 4), 2)
심볼
: 문자는 Julia 문법에서 두 가지 구문적 목적을 가져요. 첫 번째 형태는 유효한 식별자로부터 Symbol인 interned string을 만들어요. 표현식의 구성 요소 중 하나로 쓰이죠.
julia> s = :foo
:foo
julia> typeof(s)
Symbol
Symbol 생성자는 몇 개든 인자를 받고, 그들의 문자열 표현을 이어 붙여 새 심볼을 만들어요.
julia> :foo === Symbol("foo")
true
julia> Symbol("1foo") # `:1foo`는 동작하지 않는다. 1foo는 유효한 식별자가 아니기 때문
Symbol("1foo")
julia> Symbol("func", 10)
:func10
julia> Symbol(:var, '_', "sym")
:var_sym
표현식 문맥에서 심볼은 변수에 대한 접근을 나타내는 데 쓰여요. 표현식을 평가할 때, 심볼은 적절한 스코프에서 그 심볼에 묶인 값으로 대체돼요.
가끔은 파싱의 모호함을 피하기 위해 :의 인자 주변에 추가 괄호가 필요할 때가 있어요.
julia> :(:)
:(:)
julia> :(::)
:(::)
표현식과 평가
따옴표(quoting)
: 문자의 두 번째 구문적 목적은 명시적인 Expr 생성자를 쓰지 않고 표현식 객체를 만드는 거예요. 이를 *따옴표(quoting)*라고 불러요. : 문자 뒤에 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](https://docs.julialang.org/en/v1/base/base/#Base.Meta.parse-Tuple{AbstractString, Int64}) 또는 직접 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
보간(Interpolation)
값 인자로 Expr 객체를 직접 만드는 건 강력하지만, "평범한" Julia 문법보다 Expr 생성자는 지루할 수 있어요. 대안으로 Julia는 따옴표로 감싼 표현식에 리터럴이나 표현식의 *보간(interpolation)*을 허용해요. 보간은 접두사 $로 표시돼요.
이 예시에서 변수 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 표현식을 편리하고 읽기 쉽게 프로그램적으로 구성할 수 있게 해줘요.
펼침(splatting) 보간
$ 보간 문법은 하나의 표현식만 바깥 표현식에 삽입할 수 있다는 점을 눈여겨보세요. 가끔은 표현식 배열이 있고 그 전부가 주변 표현식의 인자가 되길 원할 때가 있어요. 이것은 $(xs...) 문법으로 할 수 있어요. 예를 들어 다음 코드는 인자 개수가 프로그램적으로 결정되는 함수 호출을 생성해요.
julia> args = [:x, :y, :z];
julia> :(f(1, $(args...)))
:(f(1, x, y, z))
중첩 따옴표
당연하게도, quote 표현식이 다른 quote 표현식을 포함하는 게 가능해요. 이런 경우 보간이 어떻게 동작하는지 이해하는 건 조금 까다로울 수 있어요. 이 예시를 생각해볼게요.
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가 아직 평가되지 않았다는 뜻이에요. 다른 말로, $ 표현식은 "안쪽 quote 표현식에 속하고", 그래서 그 인자는 안쪽 quote 표현식이 평가될 때만 평가돼요.
julia> eval(e)
quote
#= none:1 =#
1 + 2
end
하지만 바깥 quote 표현식은 안쪽 quote의 $ 안에 값을 보간할 수 있어요. 이것은 $를 여러 개 써서 해요.
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))과 동등해요.
QuoteNode
AST에서 quote 형태의 일반적인 표현은 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))))
파서는 심볼 같은 단순한 따옴표 항목에 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 in `Main`
[...]
julia> a = 1; b = 2;
julia> eval(ex)
3
모든 모듈은 자신의 전역 스코프에서 표현식을 평가하는 자신만의 eval 함수를 가져요. eval에 전달된 표현식은 값을 반환하는 데 제한되지 않아요. 둘러싼 모듈의 환경 상태를 바꾸는 부작용도 가질 수 있어요.
julia> ex = :(x = 1)
:(x = 1)
julia> x
ERROR: UndefVarError: `x` not defined in `Main`
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이고,a의 값이 무엇이든 무관해요. - 반면 심볼
:b는 표현식 구성에 쓰이므로, 그 시점의 변수b의 값은 무관해요.:b는 그냥 심볼이고 변수b는 정의되지 않아도 돼요. 하지만 표현식 평가 시점에는:b심볼의 값이 변수b의 값을 조회해 해석돼요.
Expr에 대한 함수
위에서 암시했듯이, Julia의 아주 유용한 기능 하나는 Julia 자체 안에서 Julia 코드를 생성하고 조작하는 능력이에요. Expr 객체를 반환하는 함수의 예를 이미 봤어요. Julia 코드 문자열을 받아 대응하는 Expr를 반환하는 [Meta.parse](https://docs.julialang.org/en/v1/base/base/#Base.Meta.parse-Tuple{AbstractString, Int64}) 함수죠. 함수는 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, ...)
배열 리터럴(또는 컴프리헨션)에 매크로를 호출하는 대체 방법은 괄호 없이 나란히 붙이는 거예요. 이 경우 배열이 매크로에 전달되는 유일한 표현식이 돼요. 다음 문법은 동등해요 (@name [a b] * v와는 달라요).
@name [a b] * v
@name ([a b]) * v
매크로는 인자를 표현식, 리터럴, 또는 심볼로 받는다는 점을 강조하는 게 중요해요. 매크로 인자를 탐구하는 한 가지 방법은 매크로 본문 안에서 show 함수를 호출하는 거예요.
julia> macro showarg(x)
show(x)
# ... 매크로의 나머지 부분, 표현식 반환
end
@showarg (macro with 1 method)
julia> @showarg(a)
:a
julia> @showarg(1 + 1)
:(1 + 1)
julia> @showarg(println("Yo!"))
:(println("Yo!"))
julia> @showarg(1) # 숫자 리터럴
julia> @showarg("Yo!") # 문자열 리터럴
"Yo!"
julia> @showarg("Yo! $("hello")") # 보간이 있는 문자열은 String이 아니라 Expr
:("Yo! $("hello")")
주어진 인자 목록에 더해, 모든 매크로는 __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)이 테스트 조건 슬롯에 접합(splice)되고, string(:(1 == 1.0))의 값이 assertion 메시지 슬롯에 접합돼요. 이렇게 구성된 전체 표현식은 @assert 매크로 호출이 일어나는 신택스 트리에 놓여요. 그런 다음 실행 시점에, 테스트 표현식이 true로 평가되면 nothing을 반환하고, false면 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 should equal b"를 출력하는 것에 더해 그 값들도 출력하고 싶다면요? 커스텀 메시지에 문자열 보간을 쓰려고 시도할 수 있어요. 예를 들어 @assert a==b "a ($a) should 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)
더 복잡한 매크로에서 생기는 문제는 위생(hygiene)이에요. 요컨대 매크로는 자신이 반환하는 표현식에 도입하는 변수들이, 확장되는 주변 코드의 기존 변수와 우연히 충돌하지 않도록 보장해야 해요. 반대로 매크로에 인자로 전달된 표현식은 종종 주변 코드의 문맥에서 평가되는 것이 기대돼서, 기존 변수와 상호작용하고 수정하게 되죠. 또 다른 우려는 매크로가 정의된 모듈과 다른 모듈에서 호출될 수 있다는 사실에서 생겨요. 이 경우 모든 전역 변수가 올바른 모듈로 해석되도록 해야 해요. 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
여기서 우리는 t0, t1, val이 비공개 임시 변수이길 원하고, 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() = ... # 무언가 계산
@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 # 0이다
end
foo (generic function with 1 method)
julia> foo()
0
이런 변수 조작은 신중하게 써야 하지만, 가끔은 아주 유용해요.
위생 규칙을 올바르게 맞추는 건 만만치 않은 도전이 될 수 있어요. 매크로를 쓰기 전에 함수 클로저로 충분한지 먼저 고려해보는 게 좋을 수 있어요. 또 다른 유용한 전략은 가능한 한 많은 작업을 런타임으로 미루는 거예요. 예를 들어 많은 매크로는 단순히 인자를 QuoteNode나 그와 비슷한 다른 Expr로 감싸요. 그 예로는 단순히 schedule(Task(() -> $body))를 반환하는 @task body와, 단순히 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 함수처럼 제네릭해요. 다중 디스패치 덕분에 여러 메서드 정의를 가질 수 있다는 뜻이에요.
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
코드 생성
상당한 양의 반복적인 boilerplate 코드가 필요할 때는, 중복을 피하기 위해 프로그램적으로 생성하는 게 흔해요. 대부분의 언어에서 이것은 추가 빌드 단계와 반복 코드를 생성하는 별도 프로그램을 필요로 해요. 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(pi)
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
# 여러 줄
end
비표준 문자열 리터럴
문자열에서, 식별자가 접두사로 붙은 문자열 리터럴을 비표준 문자열 리터럴이라고 부르고, 접두사가 없는 문자열 리터럴과 다른 의미를 가질 수 있다는 점을 떠올려보세요. 예를 들어:
r"^\s*(?:#|$)"은 문자열 대신 정규식 객체를 만들어요.b"DATA\xff\u2200"은[68,65,84,65,255,226,136,128]에 대한 바이트 배열 리터럴이에요.
의외일 수 있지만, 이 동작들은 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
# 주석 아님
else
# 주석
end
end
정규식 r"^\s*(?:#|$)"이 이 코드가 파싱될 때 컴파일되고 신택스 트리에 삽입되므로, 이 표현식은 루프가 실행될 때마다가 아니라 한 번만 컴파일돼요. 매크로 없이 이걸 하려면 루프를 이렇게 써야 해요.
re = Regex("^\\s*(?:#|$)")
for line = lines
m = match(re, line)
if m === nothing
# 주석 아님
else
# 주석
end
end
게다가 컴파일러가 regex 객체가 모든 루프에서 상수인지 판단하지 못하면, 특정 최적화가 불가능해져서 이 버전이 위의 더 편리한 리터럴 형태보다 여전히 덜 효율적일 수 있어요. 물론 비리터럴 형태가 더 편리한 상황도 여전히 있어요. 정규식에 변수를 보간해야 한다면 더 장황한 접근을 택해야 하고; 정규식 패턴 자체가 동적이어서 루프 반복마다 바꿀 수 있는 경우에는 각 반복마다 새 정규식 객체를 만들어야 해요. 하지만 대다수 사용 사례에서 정규식은 런타임 데이터에 기반해 만들지 않아요. 그 대다수의 경우에는 정규식을 컴파일 타임 값으로 쓸 수 있는 능력이 매우 귀중해요.
사용자 정의 문자열 리터럴 메커니즘은 깊고 강력해요. Julia의 비표준 리터럴이 이것을 써서 구현될 뿐 아니라, 명령 리터럴 문법(echo "Hello, $person")도 다음의 눈에 띄지 않는 매크로로 구현돼요.
macro cmd(str)
:(cmd_gen($(shell_parse(str)[1])))
end
물론 이 매크로 정의에 쓰인 함수들에 많은 복잡성이 숨겨져 있지만, 그것들은 전부 Julia로 완전히 쓰인 그냥 함수일 뿐이에요. 그 소스를 읽고 정확히 무엇을 하는지 볼 수 있고, 하는 일은 전부 프로그램의 신택스 트리에 삽입될 표현식 객체를 만드는 거예요.
문자열 리터럴처럼, 명령 리터럴도 식별자가 접두사로 붙어 이른바 비표준 명령 리터럴을 형성할 수 있어요. 이 명령 리터럴은 특별히 이름 지어진 매크로에 대한 호출로 파싱돼요. 예를 들어 문법 customliteral``은 @custom_cmd "literal"로 파싱돼요. Julia 자체는 비표준 명령 리터럴을 포함하지 않지만, 패키지가 이 문법을 활용할 수 있어요. _str 접미사 대신 _cmd 접미사와 다른 문법을 빼면, 비표준 명령 리터럴은 비표준 문자열 리터럴과 정확히 똑같이 동작해요.
두 모듈이 같은 이름의 비표준 문자열 또는 명령 리터럴을 제공한다면, 문자열이나 명령 리터럴에 모듈 이름으로 한정할 수 있어요. 예를 들어 Foo와 Bar가 둘 다 비표준 문자열 리터럴 @x_str을 제공한다면, Foo.x"literal" 또는 Bar.x"literal"로 써서 둘 사이를 구분할 수 있어요.
매크로를 정의하는 또 다른 방법은 이렇게 하는 거예요.
macro foo_str(str, flag)
# 작업 수행
end
이 매크로는 다음 문법으로 호출할 수 있어요.
foo"str"flag
위 문법에서 flag의 타입은, 문자열 리터럴 뒤에 따라붙는 무엇이든 담긴 String이에요.
생성 함수 (Generated functions)
아주 특별한 매크로는 @generated로, 이른바 *생성 함수(generated functions)*를 정의할 수 있게 해줘요. 이것은 인자의 타입에 따라 특화된 코드를, 다중 디스패치로 달성하는 것보다 더 유연하고/또는 더 적은 코드로 생성하는 능력을 가져요. 매크로는 파싱 시점의 표현식으로 동작하고 입력의 타입에 접근할 수 없지만, 생성 함수는 인자의 타입이 알려졌지만 함수가 아직 컴파일되지 않은 시점에 확장돼요.
어떤 계산이나 동작을 수행하는 대신, 생성 함수 선언은 따옴표로 감싼 표현식을 반환하고, 그것이 인자 타입에 해당하는 메서드의 본문을 형성해요. 생성 함수가 호출되면, 반환된 표현식이 컴파일되고 실행돼요. 효율을 위해 결과는 보통 캐시돼요. 추론 가능하게 만들기 위해 언어의 제한된 부분집합만 쓸 수 있어요. 그래서 생성 함수는 허용된 구조에 더 큰 제한을 대가로, 작업을 런타임에서 컴파일 타임으로 옮기는 유연한 방법을 제공해요.
생성 함수를 정의할 때 일반 함수와 다섯 가지 큰 차이가 있어요.
- 함수 선언을
@generated매크로로 주석 처리해요. 이것은 컴파일러가 이게 생성 함수라는 걸 알게 해주는 정보를 AST에 추가해요. - 생성 함수 본문에서는 인자의 타입에만 접근할 수 있고 값에는 접근할 수 없어요.
- 무언가를 계산하거나 동작을 수행하는 대신, 평가되면 원하는 일을 하는 따옴표로 감싼 표현식을 반환해요.
- 생성 함수는 생성 함수 정의 이전에 정의된 함수만 호출할 수 있어요. (따르지 않으면 미래 world-age의 함수를 가리키는
MethodError를 얻을 수 있어요.) - 생성 함수는 비상수 전역 상태를 변형하거나 관찰해서는 안 돼요 (예: IO, 락, 비로컬 딕셔너리,
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); # 참고: 출력은 본문의 println() 문에서 나온다
Int64
julia> x # 이제 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) = x와bar(x::Integer) = x^2을 정의하면 똑같은 일을 하지만 더 단순하고 빠르죠.baz함수는 병리적(pathological)이에요.
생성 함수에서 시도해서는 안 되는 연산의 집합은 무한하고, 런타임 시스템은 현재 유효하지 않은 연산의 부분집합만 감지할 수 있다는 점을 기억하세요. 단순히 알림 없이 런타임 시스템을 손상시키는 다른 연산이 많고, 보통 나쁜 정의와 명백히 연결되지 않는 미묘한 방식으로 그렇게 돼요. 함수 생성기가 추론 중에 실행되므로, 그 코드의 모든 제한을 존중해야 해요.
시도해서는 안 되는 일부 연산은 다음과 같아요.
- 네이티브 포인터 캐싱.
- 어떤 방식으로든
Core.Compiler의 내용이나 메서드와 상호작용. - 어떤 가변 상태 관찰.
- 생성 함수에 대한 추론은 아무 때나 실행될 수 있어요. 코드가 이 상태를 관찰하거나 변형하려는 동안에도요.
- 어떤 락 잡기: 호출하는 C 코드는 내부적으로 락을 쓸 수 있어요 (예를 들어 대부분의 구현이 내부적으로 락을 요구하지만
malloc을 호출하는 건 문제없어요), 하지만 Julia 코드를 실행하는 동안 어떤 락도 들고 있거나 획득하려 하지 마세요. - 생성 함수 본문 뒤에 정의된 어떤 함수 호출. 이 조건은 점진적으로 로드된 사전 컴파일 모듈에서 모듈의 어떤 함수든 호출하도록 완화돼요.
좋아요, 이제 생성 함수가 어떻게 동작하는지 더 잘 이해했으니, 더 고급(그리고 유효한) 기능을 만드는 데 써볼게요...
고급 예시
Julia의 기본 라이브러리에는 n차원 배열에 대한 선형 인덱스를, n개의 다선형 인덱스 집합을 기반으로 계산하는 내부 sub2ind 함수가 있어요. 다른 말로 A[x,y,z,...] 대신 A[i]로 배열 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;
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
이 두 구현은 다르지만 본질적으로 같은 일을 해요. 배열의 차원에 대한 런타임 루프를 돌면서 각 차원의 오프셋을 최종 인덱스에 모으는 거죠.
하지만 루프에 필요한 모든 정보는 인자의 타입 정보에 박혀 있어요. 이 덕분에 컴파일러가 반복을 컴파일 타임으로 옮기고 런타임 루프를 완전히 제거할 수 있어요. 비슷한 효과를 내기 위해 생성 함수를 활용할 수 있어요. 컴파일러 용어로, 생성 함수로 루프를 수동으로 펼치기(unroll) 하는 거죠. 본문은 거의 동일하지만, 선형 인덱스를 계산하는 대신 인덱스를 계산하는 표현식을 쌓아올려요.
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;
julia> sub2ind_gen((3, 5), 1, 2)
4
어떤 코드가 생성될까요?
쉽게 찾는 방법은 본문을 또 다른 (일반) 함수로 추출하는 거예요.
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;
julia> @generated function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
return sub2ind_gen_impl(dims, I...)
end;
julia> sub2ind_gen((3, 5), 1, 2)
4
이제 sub2ind_gen_impl을 실행하고 반환하는 표현식을 조사할 수 있어요.
julia> sub2ind_gen_impl(Tuple{Int,Int}, Int, Int)
:(((I[1] - 1) + dims[1] * (I[2] - 1)) + 1)
그래서 여기서 쓰일 메서드 본문은 루프를 전혀 포함하지 않아요. 두 튜플을 인덱싱하고, 곱셈과 덧셈/뺄셈만 있죠. 모든 반복은 컴파일 타임에 수행되고, 실행 중에는 반복을 완전히 피해요. 그래서 우리는 타입마다 한 번, 이 경우 N마다 한 번만 반복해요 (함수가 한 번보다 많이 생성되는 경계 경우들은 위의 고지사항 참고).
선택적으로 생성되는 함수
생성 함수는 런타임에 높은 효율을 달성할 수 있지만 컴파일 타임 비용이 따라와요. 구체적인 인자 타입의 모든 조합마다 새 함수 본문을 생성해야 하거든요. 보통 Julia는 어떤 인자에든 동작할 "제네릭" 버전의 함수를 컴파일할 수 있지만, 생성 함수로는 그것이 불가능해요. 이것은 생성 함수를 많이 쓰는 프로그램은 정적으로 컴파일하는 게 불가능할 수도 있다는 뜻이에요.
이 문제를 해결하기 위해 언어는, 생성 함수의 일반적이고 생성되지 않은 대체 구현을 작성하는 문법을 제공해요. 위 sub2ind 예시에 적용하면 이렇게 생겼어요.
julia> function sub2ind_gen_impl(dims::Type{T}, I...) where T <: NTuple{N,Any} where N
ex = :(I[$N] - 1)
for i = (N - 1):-1:1
ex = :(I[$i] - 1 + dims[$i] * $ex)
end
return :($ex + 1)
end;
julia> function sub2ind_gen_fallback(dims::NTuple{N}, I) where N
ind = I[N] - 1
for i = (N - 1):-1:1
ind = I[i] - 1 + dims[i]*ind
end
return ind + 1
end;
julia> function sub2ind_gen(dims::NTuple{N}, I::Integer...) where N
length(I) == N || error("partial indexing is unsupported")
if @generated
return sub2ind_gen_impl(dims, I...)
else
return sub2ind_gen_fallback(dims, I)
end
end;
julia> sub2ind_gen((3, 5), 1, 2)
4
내부적으로 이 코드는 함수의 두 구현을 만들어요. if @generated의 첫 블록이 쓰이는 생성 구현과, else 블록이 쓰이는 일반 구현이에요. if @generated 블록의 then 부분 안에서 코드는 다른 생성 함수와 같은 의미를 가져요. 인자 이름이 타입을 가리키고, 코드는 표현식을 반환해야 해요. if @generated 블록이 여러 개 있을 수 있는데, 그 경우 생성 구현은 모든 then 블록을 사용하고 대체 구현은 모든 else 블록을 사용해요.
함수 맨 위에 오류 검사를 추가했다는 점에 주목하세요. 이 코드는 두 버전에 공통이고, 두 버전 모두에서 런타임 코드예요 (생성 버전에서는 표현식으로 따옴표 처리되어 반환될 거예요). 즉 로컬 변수의 값과 타입은 코드 생성 시점에 사용할 수 없어요. 코드 생성 코드는 인자의 타입만 볼 수 있어요.
이 정의 스타일에서는 코드 생성 기능이 본질적으로 선택적 최적화예요. 컴파일러는 편리하면 쓰지만, 그렇지 않으면 일반 구현을 선택할 수도 있어요. 이 스타일은 컴파일러가 더 많은 결정을 내리고 더 다양한 방식으로 프로그램을 컴파일할 수 있게 해주고, 일반 코드가 코드 생성 코드보다 더 읽기 쉽기 때문에 선호돼요. 하지만 어떤 구현이 쓰일지는 컴파일러 구현 세부사항에 달려 있어서, 두 구현이 동일하게 동작하는 게 필수적이에요.