스페셜 폼
스페셜 폼 (Special Forms)
Clojure 코드를 보다 보면 def, if, let, fn 같은 형태가 아주 자주 등장해요. 이들은 평범한 함수 호출처럼 보이지만, 사실은 평가 규칙이 일반적인 Clojure 평가 규칙과 다르고 컴파일러가 직접 이해하는 특별한 문법이에요. 이 문서에서는 이런 스페셜 폼(special form) 들이 각각 무엇을 하고, 어떤 문법을 갖는지 하나씩 살펴볼게요. 또 let 이나 fn 안에서 쓰는 바인딩 폼(디스트럭처링) 도 자세히 다뤄요.
출처: Clojure 공식문서
본문
스페셜 폼은 표준 Clojure 평가 규칙과 다른 평가 규칙을 가지며, Clojure 컴파일러가 직접 이해해요.
문서에서 각 스페셜 폼의 제목이 사용하는 문법 표기는 정규 표현식 표기법을 따르는데, ?는 선택(0 또는 1개), *는 0개 이상, +는 1개 이상을 뜻해요. 비종결(non-terminal) 항목은 이탤릭체로 표시돼요.
(def _symbol_ __doc-string__? __init__?)
def는 현재 네임스페이스(*ns*)의 값에 해당하는 네임스페이스에서, symbol 이라는 이름의 전역 var를 만들고 intern하거나 찾아요. init 이 주어지면 이를 평가해서 var의 루트 바인딩(root binding)을 그 결과값으로 설정해요. init 이 없다면 var의 루트 바인딩은 그대로예요. def는 항상 루트 바인딩에 적용돼요 — def를 호출한 시점에 var가 스레드 바인딩되어 있어도 마찬가지예요. def는 var 자체를 값으로 돌려주지, var의 값을 돌려주는 게 아니에요. 만약 symbol 이 이미 네임스페이스에 있으면서 intern된 var로 매핑되어 있지 않으면 예외를 던져요. doc-string 지원은 Clojure 1.3에서 추가됐어요.
symbol 에 붙은 메타데이터도 함께 평가되어 var 자체의 메타데이터가 돼요. 이 중 특별한 해석을 받는 메타데이터 키가 몇 가지 있어요:
:private— var의 접근 제어를 나타내는 boolean이에요. 이 키가 없으면 기본적으로 public으로 취급돼요(:private false인 것과 같아요).:doc— var 내용에 대한 짧은(1~3줄) 문서 문자열이에요.:test—assert로 여러 동작을 검사하는 인자 없는 함수예요. 메타데이터 맵 안의 리터럴 함수를 평가하는 동안 var 자체를 접근할 수 있어요.:tag— 클래스 이름을 가리키는 심볼 또는 Class 객체예요. var 안 객체의 Java 타입을 나타내며, 객체가 함수라면 그 반환값의 타입을 나타내요.
추가로 컴파일러가 var에 다음 메타데이터 키들을 넣어줘요:
:file문자열:line정수:name단순 심볼:nsvar가 intern된 네임스페이스:macrovar가 매크로를 가리키면true:arglistsdefn에 넘긴 인자 형식(argument forms)의 벡터(들)로 이루어진 리스트
var 메타데이터는 애플리케이션 고유의 목적으로도 쓸 수 있어요. 충돌을 피하려면 네임스페이스 한정 키(예: :myns/foo)를 쓰는 걸 권장해요.
(defn
^{:doc "mymax [xs+] gets the maximum value in xs using > "
:test (fn []
(assert (= 42 (mymax 2 42 5 4))))
:user/comment "this is the best fn ever!"}
mymax
([x] x)
([x y] (if (> x y) x y))
([x y & more]
(reduce mymax (mymax x y) more)))
user=> (meta #'mymax)
{:name mymax,
:user/comment "this is the best fn ever!",
:doc "mymax [xs+] gets the maximum value in xs using > ",
:arglists ([x] [x y] [x y & more])
:file "repl-1",
:line 126,
:ns #<Namespace user >,
:test #<user$fn__289 user$fn__289@20f443 >}
defn, defmacro 같은 많은 매크로는 def로 확장돼요. 그래서 이 매크로들도 이름으로 쓰는 symbol 의 메타데이터를 결과 var에 전달해요.
최상위(toplevel)가 아닌 곳에서 def로 var의 루트값을 바꾸는 것은 보통 var를 변경 가능한 전역으로 쓰고 있다는 뜻이라, 나쁜 스타일로 간주돼요. 이럴 땐 binding으로 var에 스레드 로컬 값을 주거나, var 안에 ref나 agent를 넣고 트랜잭션·액션으로 변경하는 방식을 고려해 보세요.
(if _test_ _then_ __else__?)
if는 test 를 평가해요. 그 값이 단일값 nil이나 false가 아니면 then 을 평가해서 그 값을 돌려주고, 그렇지 않으면 else 를 평가해서 돌려줘요. else 가 없으면 기본값은 nil이에요. Clojure의 다른 모든 조건문도 같은 논리에 기반해요 — 즉 nil과 false가 논리적 거짓이고, 그 외의 전부가 논리적 참이며, 그 의미가 전체에 걸쳐 적용돼요. if는 boolean Java 메서드 반환값을 Boolean으로 변환하지 않고 그대로 조건 검사를 수행해요. 한 가지 주의할 점이 있는데, if는 java.lang.Boolean의 임의의 값이 아니라 단일값 false(Java의 Boolean.FALSE)만을 검사해요. 그래서 직접 박싱된 Boolean을 만들 때는 Boolean 생성자 대신 Boolean/valueOf를 사용해야 해요.
(do __expr__*)
do는 포함된 표현식 expr 들을 순서대로 평가하고 마지막 표현식의 값을 돌려줘요. 표현식이 하나도 없으면 nil을 돌려줘요.
(let [ binding* ] expr*)`
binding => binding-form init-expr
let은 expr 들을 어휘적(lexical) 맥락에서 평가하는데, 그 맥락에서는 binding-form 들의 심볼이 각자의 init-expr 또는 그 일부에 바인딩돼 있어요. 바인딩은 순차적이라서, 각 binding 은 이전 바인딩을 볼 수 있어요. expr 들은 암시적 do 안에 들어 있어요. binding 심볼에 메타데이터 태그가 있으면 컴파일러가 그 태그를 클래스 이름으로 해석해 보려고 하고, 이후 그 binding 을 참조할 때 그 타입이라고 간주해요. 가장 단순한 binding-form 은 심볼이며, 전체 init-expr 에 바인딩돼요:
(let [x 1
y x]
y)
-> 1
binding 심볼의 :tag 메타데이터가 FunctionalInterface로 주석된 Java 인터페이스라면, init-expr 는 필요하다면 지정된 인터페이스로 (coerce) 변환돼요:
(let [coll (java.util.ArrayList. (range 10))
^java.util.function.Predicate pred even?]
(.removeIf coll pred) ;; mutate coll
coll)
-> [1 3 5 7 9]
바인딩 폼에 대한 더 자세한 내용은 아래의 Binding Forms를 참고하세요.
let으로 만든 지역 변수는 변수가 아니에요. 한 번 만들어지면 그 값은 절대 변하지 않아요!
(quote _form_)
quote는 평가되지 않은 form 그대로를 돌려줘요.
user=> '(a b c)
(a b c)
여기서 함수 a를 호출하려는 시도가 전혀 없다는 점에 주목하세요. 반환값은 심볼 3개로 이루어진 리스트예요.
(var _symbol_)
symbol 은 var로 해석되어야 하고, Var 객체 자체(값이 아니라)가 반환돼요. 리더 매크로 #'x는 (var x)로 확장돼요.
(fn __name__? [__params__* ] __expr__*) / (fn __name__? ([__params__* ] __expr__*)+)
params => positional-param* 또는 positional-param* & rest-param+ positional-param => binding-form+ rest-param => binding-form+ name => symbol
fn은 함수(fn)를 정의해요. fn은 IFn 인터페이스를 구현하는 일급(first-class) 객체예요. IFn 인터페이스는 0~20까지의 다양한 arity로 오버로드된 invoke() 함수를 정의해요. 하나의 fn 객체는 하나 이상의 invoke 메서드를 구현할 수 있어서 arity로 오버로드될 수 있어요. 오버로드 중 정확히 하나만 ampersand(&)와 단일 rest-param 을 지정해 가변 인자(variadic)가 될 수 있어요. 이 가변 인자 진입점은 position 파라미터를 넘어서는 인자들과 함께 호출되면, 그 초과 인자들을 seq로 모아서 rest param에 바인딩하거나 디스트럭처링해요. 만약 넘겨진 인자가 position 파라미터를 넘지 않으면, rest param은 nil이 돼요.
첫 번째 형식은 invoke 메서드가 하나인 fn을 정의하고, 두 번째 형식은 오버로드된 invoke 메서드가 하나 이상인 fn을 정의해요. 각 오버로드의 arity는 서로 달라야 해요. 두 경우 모두 표현식의 결과는 단일 fn 객체예요.
expr 들은 params 가 실제 인자에 바인딩된 환경에서 컴파일돼요. expr 들은 암시적 do 안에 있어요. 이름 symbol 이 제공되면, 함수 정의 안에서 그 이름이 함수 객체 자신에 바인딩돼서 자기 호출(self-calling)이 가능해져요 — 익명 함수에서도요. param 심볼에 메타데이터 태그가 있으면 컴파일러가 그 태그를 클래스 이름으로 해석해 보려고 하고, 이후 그 바인딩을 참조할 때 그 타입이라고 간주해요.
(def mult
(fn this
([] 1)
([x] x)
([x y] (* x y))
([x y & more]
(apply this (this x y) more))))
mult 같은 이름 있는 fn은 보통 defn으로 정의하며, defn은 위와 같은 형태로 확장돼요.
fn(오버로드)은 함수의 맨 위에 재귀 지점(recursion point)을 만들어요. 그 arity는 _param__의 개수와 같고, rest param이 있으면 그것도 포함돼요. recur를 참고하세요.
fn은 Java의 Callable, Runnable, Comparator 인터페이스를 구현해요.
Since 1.1
함수는 런타임 전·후 조건(pre-/post-conditions) 지정을 지원해요.
함수 정의 문법은 다음과 같이 됩니다:
(fn __name__? [__param__* ] __condition-map__? __expr__*) / (fn name? ([__param__* ] __condition-map__? __expr__*)+)
이 문법 확장은 defn 및 fn 폼으로 확장되는 다른 매크로에도 적용돼요.
주의: 파라미터 벡터 바로 다음에 오는 형식이 맵 하나뿐이라면, 그 맵은 함수 본문으로 취급되지 조건 맵으로 취급되지 않아요.
condition-map 파라미터는 함수의 전·후 조건을 지정하는 데 써요. 형식은 다음과 같아요:
{:pre [pre-expr*]
:post [post-expr*]}
두 키 중 어느 쪽이든 선택적이에요. 조건 맵은 arglist의 메타데이터로 제공될 수도 있어요.
pre-expr 와 post-expr 는 함수의 파라미터를 참조할 수 있는 boolean 표현식이에요. 추가로 post-expr 안에서 %를 쓰면 함수의 반환값을 참조할 수 있어요. 조건 중 하나라도 false로 평가되고 *assert*가 참이면, java.lang.AssertionError 예외가 던져져요.
예시:
(defn constrained-sqr [x]
{:pre [(pos? x)]
:post [(> % 16), (< % 225)]}
(* x x))
바인딩 폼에 대한 더 자세한 내용은 아래의 Binding Forms를 참고하세요.
(loop [ binding* ] expr*)`
loop은 let과 정확히 같아요. 단, 루프의 맨 위에 바인딩 개수와 같은 arity의 재귀 지점(recursion point)을 만든다는 점이 달라요. recur를 참고하세요.
(recur __expr__*)
recur은 expr 들을 순서대로 평가한 다음, 병렬로 재귀 지점의 바인딩을 expr 의 값으로 다시 바인딩해요. 재귀 지점이 fn 메서드였다면 파라미터를 다시 바인딩하고, loop였다면 loop 바인딩을 다시 바인딩해요. 그런 다음 실행은 재귀 지점으로 돌아가요. recur 표현식은 재귀 지점의 arity와 정확히 일치해야 해요. 특히 재귀 지점이 가변 인자 fn 메서드의 맨 위라면, rest 인자를 모으는 일은 없어요 — 단일 seq(또는 null)를 넘겨야 해요. 꼬리(tail) 위치가 아닌 곳에서의 recur은 오류예요.
recur은 Clojure에서 스택을 소비하지 않는 유일한 반복 구조라는 점을 기억하세요. Clojure에는 꼬리 호출 최적화(tail-call optimization)가 없어서, 경계를 알 수 없는 반복을 위해 자기 호출을 쓰는 것은 권장되지 않아요. recur은 함수형이며, 꼬리 위치에서의 사용은 컴파일러가 검증해요.
(def factorial
(fn [n]
(loop [cnt n acc 1]
(if (zero? cnt)
acc
(recur (dec cnt) (* acc cnt))))))
(throw _expr_)
throw는 expr 을 평가해서 던져요. 따라서 expr 은 Throwable의 어떤 파생 클래스 인스턴스여야 해요.
(try __expr__* __catch-clause__* __finally-clause__?)
catch-clause -> (catch classname name expr)+ finally-clause -> (finally expr)
try는 expr 들을 평가하고, 예외가 없다면 마지막 표현식의 값을 돌려줘요. 예외가 발생하고 catch-clause 들이 제공되면 각각을 차례로 검사해서, 던져진 예외가 classname 의 인스턴스인 첫 번째 절을 매칭되는 절로 간주해요. 매칭되는 catch-clause 가 있으면, name 이 던져진 예외에 바인딩된 맥락에서 그 절의 expr 들을 평가하고, 마지막 표현식의 값이 함수의 반환값이 돼요. 매칭되는 절이 없으면 예외는 함수 밖으로 전파돼요. 정상이든 비정상이든 반환하기 전에, 어떤 finally-clause 의 expr 들은 그 부수 효과(side effect)를 위해 평가돼요.
(monitor-enter _expr_) / (monitor-exit _expr_)
이들은 동기화 프리미티브인데, 사용자 코드에서는 피해야 해요. locking 매크로를 사용하세요.
기타 스페셜 폼
필드의 스페셜 폼인 점(.), new, set!은 참조 문서의 Java Interop 섹션에 설명돼 있어요.
var의 set!은 참조 문서의 Vars 섹션에 설명돼 있어요.
Binding Forms (디스트럭처링)
Clojure에서 가장 단순한 binding-form 은 심볼이에요. 하지만 Clojure는 디스트럭처링(destructuring) 이라는 추상 구조 바인딩도 지원해요. let 바인딩 리스트, fn 파라미터 리스트, 그리고 확장해서 let이나 fn으로 확장되는 모든 매크로에서 사용할 수 있어요. 디스트럭처링은 컬렉션 안의 값들에 바인딩 집합을 만드는 방법인데, 컬렉션과 유사한 구조를 바인딩 폼으로 사용해요. 벡터 형식은 순차 컬렉션에서 위치로 바인딩을 지정하고, 맵 형식은 연관 컬렉션에서 키로 바인딩을 지정해요. 디스트럭처링 폼은 binding-form 이 올 수 있는 곳이라면 어디든 나타날 수 있고, 그래서 중첩도 돼요. 컬렉션 접근자로 쓰는 것보다 훨씬 더 명확한 코드가 돼요.
자료가 없어서 — 즉 순차 구조에 요소가 너무 적거나, 연관 구조에 키가 없거나 할 때 — 해당 부분과 일치하지 못하는 binding-form 들은 nil에 바인딩돼요.
순차 디스트럭처링 (Sequential destructuring)
벡터 binding-form 들은 벡터, 리스트, seq, 문자열, 배열처럼 nth를 지원하는 컬렉션의 값들을 순차적으로 바인딩해요. 순차 디스트럭처링 폼은 binding-form 들의 벡터인데, nth로 조회해 init-expr 의 연속 요소들에 바인딩돼요. 추가로(선택적으로) & 다음에 오는 binding-form 은 아직 바인딩되지 않은 나머지 시퀀스 부분에 바인딩되는데, nthnext로 조회돼요.
마지막으로(역시 선택적으로), :as 다음에 오는 심볼이 그 심볼을 전체 init-expr 에 바인딩해요:
(let [[a b c & d :as e] [1 2 3 4 5 6 7]]
[a b c d e])
->[1 2 3 (4 5 6 7) [1 2 3 4 5 6 7]]
이 폼들은 중첩될 수 있어요:
(let [[[x1 y1][x2 y2]] [[1 2] [3 4]]]
[x1 y1 x2 y2])
->[1 2 3 4]
순차의 모든 경우에서, 디스트럭처 바인딩의 binding-form 들은 원하는 값이 놓인 대상 데이터 구조의 위치와 일치해요.
연관 디스트럭처링 (Associative destructuring)
맵 binding-form 들은 맵, 집합, 벡터, 문자열, 배열(마지막 세 개는 정수 키를 가져요) 같은 컬렉션에서 값을 조회해 바인딩을 만드는 방식이에요. 이는 binding-form->key 쌍들의 맵으로 이루어지며, 각 binding-form 은 제공된 키에서 init-expr 의 값에 바인딩돼요. 추가로(선택적으로) 바인딩 폼의 :as 키 다음의 심볼이 그 심볼을 전체 init-expr 에 바인딩해요. 입력 맵에 없는 키에 기본값을 제공하려면 :or 지시자를 쓰면 돼요. :or의 값은 바인딩 이름 또는 [1.13부터] 키를 기본값에 매핑한 맵이에요.
(let [{a :a, b :b, c :c, :as m :or {a 2 b 3}} {:a 5 :c 6}]
[a b c m])
->[5 3 6 {:c 6, :a 5}]
:or 맵에 적은 기본값을 코드 뒤에서 다시 쓰지 않고 어떤 용도로 캡처해 쓰고 싶다면, :defaults 지시자 [1.13부터]를 쓸 수 있어요. 디스트럭처링 중에 만들어진, 키를 기본값에 매핑한 맵에 이름을 바인딩해 줘요.
(let [{a :a, b :b, c :c, :as m :or {a 2 b 3 :c 42} :defaults df} {:a 5}] [a b df])
->[5 3 {:a 2, :b 3, :c 42}]
대응하는 맵 키와 같은 이름으로 심볼을 바인딩하고 싶을 때가 자주 있어요. :keys 지시자는 binding-form->key 쌍에서 자주 보이는 중복을 없애 줘요:
(let [{fred :fred ethel :ethel lucy :lucy} m] ...
이것을 다음과 같이 쓸 수 있어요:
(let [{:keys [fred ethel lucy]} m] ...
맵 디스트럭처링 폼에서 접두사가 붙은 맵 키를 쓸 수도 있어요:
(let [m {:x/a 1, :y/b 2}
{:keys [x/a y/b]} m]
(+ a b))
-> 3
접두사 키를 쓰는 경우, 바인딩된 심볼 이름은 접두사 키의 오른쪽 부분과 같아요. :keys 지시자에서 자동 해석(auto-resolved) 키워드 폼을 쓸 수도 있어요:
(let [m {::x 42}
{:keys [::x]} m]
x)
-> 42
문자열 키와 심볼 키를 매칭하는 비슷한 :strs와 :syms 지시자도 있어요. :syms는 접두사가 붙은 심볼 키도 허용해요.
Clojure 1.9는 같은 네임스페이스를 공유하는 여러 키(또는 심볼)를 직접 디스트럭처링하는 다음 키 폼들을 추가했어요:
:__ns__/keys— ns 는 입력에서 조회할 키의 기본 네임스페이스를 지정해요- keys 요소는 네임스페이스를 지정하면 안 돼요
- keys 요소는
:keys와 마찬가지로 새 지역 심볼도 정의해요
:__ns__/syms— ns 는 입력에서 조회할 심볼의 기본 네임스페이스를 지정해요- syms 요소는 네임스페이스를 지정하면 안 돼요
- syms 요소는
:syms와 마찬가지로 새 지역 심볼도 정의해요
(let [m #:domain{:a 1, :b 2}
{:domain/keys [a b]} m]
[a b])
-> [1 2]
맵 디스트럭처링 중에 필요한 키가 반드시 존재하도록 보장하고 싶다면, :keys/:syms/:strs 바인딩 지시자의 검사 변형인 :keys!/:syms!/:strs! [1.13부터]를 쓸 수 있어요. 이는 입력 맵에서 지정된 키 중 하나라도 빠져 있으면 예외를 던져요. 이 검사 지시자들은 &도 받아들이는데, [1.13부터] 그 뒤에 여전히 필요하고 검사되지만 지역 이름에는 바인딩되지 않는 추가 키들을 나열할 수 있어요.
(let [{:keys! [fred ethel lucy & :ricky]} m] ... ;; m must have keys :fred, :ethel, :lucy, and :ricky
바인딩하지 않을 키를 문서화하거나 :select에 포함하고 싶다면, :keys, :strs, :syms에서 & [1.13부터] 뒤에 나열할 수 있어요. & 뒤의 리터럴 키는 :keys [a & :b "c" 'd]처럼 이질적일 수 있는데, 심볼은 바인딩 이름과 구분하려면 따옴표를 붙여야 해요.
다루는 키를 알아야 하거나, 추가 키를 용납하지 않는 대상에 데이터를 넘겨야 하는데 처리 방식은 유연하게 두고 싶을 때 :select [1.13부터]를 쓸 수 있어요. :select 지시자는 입력 맵에 기본값을 더한 부분집합인 맵을 만들어 내는데, 디스트럭처링 폼에서 언급된 키들로 한정돼요.
(let [{:keys [a b & :c] :as m :or {a 42 b 3 :c 4} :select sm :defaults df} {:a 5}]
[a b m sm df])
-> [5 3 {:a 1} {:c 4, :b 3, :a 5} {:a 42, :b 3, :c 4}]
입력 맵처럼 생겼는데 빠진 키에는 기본값이 대입된 맵을 얻고 싶다면 :all 지시자를 써요. :all은 [1.13부터] 중첩된 맵을 깊이 관통해요.
(let [{:keys [a & :b] :as m :or {:a 42 :b 3} :all all-m} {}]
[a all-m])
-> [42 {:a 42, :b 3}]
키워드 인자 (Keyword Arguments)
키워드 인자는 akey aval bkey bval... 형태의 선택적인 가변(trailing variadic) 인자예요. 함수 본문에서 연관 디스트럭처링으로 접근할 수 있어요. 또한 Clojure 1.11부터, kwargs를 받도록 지정된 함수에 키/값 쌍 대신(또는 그것에 더해 그 뒤에) 단일 맵을 넘길 수 있게 됐어요. 단일 맵만 넘기면 그 맵이 디스트럭처링에 그대로 사용되고, 그 외에는 선행 키/값으로 만들어진 맵에 conj로 증가되어 뒤에 추가된 맵이 돼요. 키워드 인자를 받는 함수를 정의하려면 rest-param 선언 위치에 맵 디스트럭처링 폼을 제공하면 돼요. 예를 들어 시퀀스와 선택적 키워드 인자를 받아 값들을 담은 벡터를 돌려주는 함수는 이렇게 정의해요:
(defn destr [& {:keys [a b] :as opts}]
[a b opts])
(destr :a 1)
->[1 nil {:a 1}]
(destr {:a 1 :b 2})
->[1 2 {:a 1 :b 2}]
destr에서 & 오른쪽의 맵 binding-form 은 위에서 상세히 다룬 연관 디스트럭처링 binding-form 이에요.
아래의 foo 두 선언은 동등한데, 연관 디스트럭처링이 seq를 어떻게 해석하는지 보여줘요:
(defn foo [& {:keys [quux]}] ...)
(defn foo [& opts]
(let [{:keys [quux]} opts] ...))
중첩 디스트럭처링 (Nested destructuring)
바인딩 폼은 서로 자유롭게 중첩될 수 있으므로, 거의 무엇이든 풀어낼 수 있어요:
(let [m {:j 15 :k 16 :ivec [22 23 24 25]}
{j :j, k :k, i :i, [r s & t :as v] :ivec, :or {i 12 j 13}} m]
[i j k r s t v])
-> [12 15 16 22 23 (24 25) [22 23 24 25]]