Clojure 리더(Reader) 이해하기

Clojure 리더(Reader) 이해하기

Clojure는 호모이코닉(homoiconic) 언어예요. 어려운 말이지만, 핵심은 간단해요. Clojure 프로그램이 Clojure 데이터 구조로 표현된다는 뜻이죠. 대부분의 다른 프로그래밍 언어가 문자 스트림·파일의 문법을 기준으로 정의되는 것과 달리, Clojure는 데이터 구조의 평가(evaluation) 를 기준으로 정의돼요. 그래서 Clojure 프로그램이 다른 Clojure 프로그램을 만들고, 변형하고, 조작하는 일이 아주 흔하고 쉬워요.

출처: Clojure 공식문서

본문

그런데 말이죠. 대부분의 Clojure 프로그램은 결국 텍스트 파일에서 시작해요. 그 텍스트를 파싱해서 컴파일러가 볼 데이터 구조로 바꾸는 일을 하는 게 바로 리더(reader) 란 만들이에요. 리더는 단순히 컴파일러의 한 단계가 아니에요. XML이나 JSON을 쓰는 여러 상황에서 리더와 Clojure 데이터 표현을 그 자체로 쓸 수 있을 만큼 독립적인 가치가 있어요.

리더는 문자 단위의 문법을 정의하고, Clojure 언어는 심볼·리스트·벡터·맵 등으로 이뤄진 문법을 정의한다고 볼 수 있어요. 리더는 read 함수로 대표되는데, 이 함수는 스트림에서 문자 하나가 아니라 다음 폼(form) 하나를 읽어 그 폼이 나타내는 객체를 돌려줘요.

리더 폼(Reader forms)

리더가 읽는 폼들을 하나씩 살펴볼게요.

심볼(Symbols)

  • 심볼은 숫자가 아닌 문자로 시작하고, 영숫자 문자와 *, +, !, -, _, ', ?, <, >, =를 포함할 수 있어요(다른 문자도 나중엔 허용될 수 있어요).
  • /는 특별한 의미가 있어요. 심볼 가운데에 한 번 쓸 수 있는데, 네임스페이스와 이름을 구분해요. 예: my-namespace/foo. / 하나만 쓰면 나눗셈 함수를 가리켜요.
  • .도 특별해요. 심볼 가운데에 한 번 이상 쓸 수 있고, 정규화된(fully-qualified) 클래스 이름을 나타내요. 예: java.util.BitSet. 네임스페이스 이름에도 쓸 수 있어요. .로 시작하거나 끝나는 심볼은 Clojure가 예약해요. /.를 포함한 심볼을 '정규화된(qualified)' 심볼이라고 불러요.
  • :로 시작하거나 끝나는 심볼은 Clojure가 예약해요. 심볼은 반복되지 않는 :을 하나 이상 포함할 수 있어요.
  • & 심볼은 문법(syntax)용으로 예약되어 있어서 로컬 바인딩 이름이 될 수 없어요.

리터럴(Literals)

  • 문자열(Strings)"double quotes"로 감싸요. 여러 줄에 걸칠 수 있고, 표준 Java 이스케이프 문자를 지원해요.
  • 숫자(Numbers) — 일반적으로 Java 표현을 따라요.
    • 정수는 무한정 길어질 수 있고, 범위 안이면 Long, 범위를 벗어나면 clojure.lang.BigInt로 읽어요. N 접미사가 붙은 정수는 항상 BigInt로 읽어요. 0 접두사로 8진수, 0x 접두사로 16진수 표기가 가능해요. 가능하면 진수(radix) 2~36의 모든 밑으로 쓸 수 있어요(Long.parseLong() 참고). 예를 들어 2r101010, 052, 8r52, 0x2a, 36r16, 42는 모두 같은 Long이에요.
    • 부동소수점은 Double로, M 접미사가 붙으면 BigDecimal로 읽어요.
    • 비율(Ratio) 도 지원돼요. 예: 22/7.
  • 문자(Characters) — 역슬래시를 앞에 붙여요: \c. \newline, \space, \tab, \formfeed, \backspace, \return은 각각 해당 문자를 돌려줘요. 유니코드는 Java처럼 \uNNNN으로, 8진수는 \oNNN으로 표기해요.
  • nil — '아무것도 없음/값 없음'을 뜻해요. Java의 null을 나타내고 논리적으로 false로 평가돼요.
  • 불리언(Booleans)truefalse.
  • 기호 값(Symbolic values)##Inf, ##-Inf, ##NaN.
  • 키워드(Keywords) — 심볼과 비슷한데 다음 차이가 있어요.
    • 콜론으로 시작해야 하고, 그래야만 해요. 예: :fred.
    • 심볼처럼 네임스페이스를 가질 수 있어요. :person/name처럼요. 여기엔 .이 들어갈 수도 있어요.
    • 콜론 두 개로 시작하는 키워드는 현재 네임스페이스에서 자동으로 정규화된 키워드로 해석돼요.
      • 키워드가 정규화되지 않았다면 네임스페이스는 현재 네임스페이스가 돼요. user에서 ::rect:user/rect로 읽혀요.
      • 키워드가 정규화되어 있다면 현재 네임스페이스의 별칭(alias)을 이용해 해석돼요. xexample의 별칭인 네임스페이스에서 ::x/foo:example/foo로 해석돼요.

리스트(Lists)

괄호로 감싼 0개 이상의 폼이에요: (a b c)

벡터(Vectors)

대괄호로 감싼 0개 이상의 폼이에요: [1 2 3]

맵(Maps)

  • 중괄호로 감싼 0개 이상의 키/값 쌍이에요: {:a 1 :b 2}
  • 콤마는 공백으로 취급돼서 쌍을 정리하는 데 쓸 수 있어요: {:a 1, :b 2}
  • 키와 값은 어떤 폼이든 될 수 있어요.

맵 네임스페이스 문법(Map namespace syntax)

Clojure 1.9에서 추가됨

맵 리터럴은 #:ns 접두사를 이용해 해당 맵 키의 기본 네임스페이스 문맥을 선택적으로 지정할 수 있어요. 여기서 ns는 네임스페이스 이름이고, 접두사는 맵의 여는 중괄호 { 앞에 와요. 추가로 #::를 쓰면 자동 해석 키워드와 같은 의미로 네임스페이스를 자동 해석할 수 있어요.

네임스페이스 문법이 있는 맵 리터럴은 없는 맵과 다음 차이로 읽혀요.

  • 키(Keys)
    • 네임스페이스가 없는 키워드/심볼 키는 기본 네임스페이스로 읽혀요.
    • 네임스페이스가 있는 키워드/심볼 키는 영향받지 않아요. 단, 특별한 네임스페이스 _는 읽을 때 제거돼요. 이걸로 네임스페이스 문법 맵에서 네임스페이스 없는 키워드/심볼을 키로 지정할 수 있어요.
    • 심볼이나 키워드가 아닌 키는 영향받지 않아요.
  • 값(Values)
    • 값은 영향받지 않아요.
    • 중첩된 맵 리터럴의 키도 영향받지 않아요.

예를 들어, 다음 네임스페이스 문법이 있는 맵 리터럴은

#:person{:first "Han"
         :last "Solo"
         :ship #:ship{:name "Millennium Falcon"
                      :model "YT-1300f light freighter"}}

이렇게 읽혀요.

{:person/first "Han"
 :person/last "Solo"
 :person/ship {:ship/name "Millennium Falcon"
               :ship/model "YT-1300f light freighter"}}

집합(Sets)

#가 앞에 붙은 중괄호로 감싼 0개 이상의 폼이에요: #{:a :b :c}

deftype, defrecord, 생성자 호출(Constructor calls)

Clojure 1.3에서 추가됨

  • Java 클래스·deftype·defrecord 생성자는 완전히 정규화된 클래스 이름 앞에 #를 붙이고 벡터를 이어서 호출할 수 있어요: #my.klass_or_type_or_record[:a :b :c]
  • 벡터 안의 요소는 평가되지 않은 채 해당 생성자에 전달돼요. defrecord 인스턴스는 맵을 받는 비슷한 폼으로도 만들 수 있어요: #my.record{:a 1, :b 2}
  • 맵의 키 값들은 평가되지 않은 채 defrecord의 해당 필드에 할당돼요. 리터럴 맵에 대응 항목이 없는 defrecord 필드는 nil로 할당돼요. 리터럴 맵에 남는 키 값들은 결과 defrecord 인스턴스에 추가돼요.

매크로 문자(Macro characters)

리더의 동작은 내장 구성 요소와 읽기 테이블(read table) 이라는 확장 시스템이 합쳐져 만들어져요. 읽기 테이블의 항목은 특정 문자, 즉 매크로 문자를 특정 읽기 동작, 즉 리더 매크로와 매핑해줘요. 별도 표시가 없는 한 매크로 문자는 사용자 심볼에 쓸 수 없어요.

따옴표(Quote, ')

'form => (quote form)

문자(Character, \)

위에서 본 대로 문자 리터럴을 만들어요. 예: \a \b \c.

흔한 문자에 쓸 수 있는 특별한 문자 리터럴이 있어요: \newline, \space, \tab, \formfeed, \backspace, \return.

유니코드는 기반 Java 버전에 대응하는 Java 관례를 따라요. 유니코드 리터럴은 \uNNNN 형태예요. 예를 들어 \u03A9는 Ω 리터럴이에요.

주석(Comment, ;)

한 줄 주석이에요. 세미콜론부터 줄 끝까지 리더가 무시하게 해요.

역참조(Deref, @)

@form => (deref form)

메타데이터(Metadata, ^)

메타데이터는 어떤 종류의 객체에 결합된 맵이에요. 심볼, 리스트, 벡터, 집합, 맵, IMeta를 돌려주는 태그 리터럴, 그리고 record·type·생성자 호출이 대상이에요. 메타데이터 리더 매크로는 먼저 메타데이터를 읽고, 그다음 읽는 폼에 붙여요(with-meta 참고). 메타데이터를 객체에 붙이는 데 써요.

^{:a 1 :b 2} [1 2 3]은 메타데이터 맵이 {:a 1 :b 2}인 벡터 [1 2 3]을 만들어요.

축약 버전으로 메타데이터가 단순한 심볼이나 문자열일 수 있어요. 이 경우 키가 :tag이고 값이 (해석된) 심볼·문자열인 한 항목짜리 맵으로 취급돼요. 예를 들어

^String x^{:tag java.lang.String} x와 같아요.

타입 시그니처용 축약으로 메타데이터가 벡터일 수 있어요. 이 경우 키가 :param-tags이고 값이 (해석된) 타입 힌트들, 즉 :tag 값이나 _의 벡터인 한 항목짜리 맵으로 취급돼요. 예: ^[String long _]^{:param-tags [java.lang.String long _]}와 같아요. :param-tags가 어떻게 쓰이는지는 java interop 문서의 :param-tags를 참고하세요.

또 다른 축약 버전으로 메타데이터가 키워드일 수 있어요. 이 경우 키가 그 키워드이고 값이 true인 한 항목짜리 맵으로 취급돼요. 예를 들어

^:dynamic x^{:dynamic true} x와 같아요.

메타데이터는 연쇄할 수 있는데, 이 경우 오른쪽에서 왼쪽으로 병합돼요.

디스패치(Dispatch, #)

디스패치 매크로는 리더가 다른 테이블의 리더 매크로를 쓰게 해요. 그 테이블은 # 다음에 오는 문자로 인덱스돼요.

  • #{} — 위 집합 참고.
  • 정규식 패턴(Regex patterns, #"pattern") — 정규식 패턴은 읽을 때 컴파일돼요. 결과 객체는 java.util.regex.Pattern 타입이에요. 정규식 문자열은 문자열과 같은 이스케이프 문자 규칙을 따르지 않아요. 특히 패턴 안의 역슬래시는 그 자체로 취급돼요(추가 역슬래시로 이스케이프할 필요가 없어요). 예를 들어 (re-pattern "\\s*\\d+")#"\s*\d+"로 더 간결하게 쓸 수 있어요.
  • Var-quote(#')#'x => (var x)
  • 익명 함수 리터럴(#())#(...) => (fn [args] (...)). 여기서 args%, %n, %& 형태의 인자 리터럴 존재로 결정돼요. %%1의 동의어고, %n은 n번째 인자(1부터 시작)를, %&는 나머지 인자(rest arg)를 가리켜요. 이건 fn의 대체재가 아니에요. 관용적으로 아주 짧은 일회용 mapping/filter 함수 같은 데 쓰여요. #() 폼은 중첩할 수 없어요.
  • 다음 폼 무시(#_)#_ 다음의 폼은 리더가 완전히 건너뛰어요. (comment 매크로가 nil을 만들어내는 것보다 더 완전한 제거예요.)

문법 따옴표(Syntax-quote, `), 언쿼트(Unquote, ~), 언쿼트-스플라이싱(Unquote-splicing, ~@)

심볼, 리스트, 벡터, 집합, 맵을 제외한 모든 폼에서 `x'x와 같아요.

심볼의 경우 문법 따옴표는 해당 심볼을 현재 문맥에서 해석(resolve) 하여 정규화된 심볼(즉 namespace/name 또는 fully.qualified.Classname)을 만들어요. 심볼이 네임스페이스로 정규화되지 않았고 #로 끝나면, 같은 이름에 _와 고유 id가 붙은 생성 심볼(generated symbol) 로 해석돼요. 예를 들어 x#x_123으로 해석돼요. 문법 따옴표 표현 안에서 그 심볼에 대한 모든 참조는 같은 생성 심볼로 해석돼요.

리스트/벡터/집합/맵의 경우 문법 따옴표는 해당 데이터 구조의 템플릿을 만들어요. 템플릿 안에서 정규화되지 않은 폼은 재귀적으로 문법 따옴표된 것처럼 동작하지만, unquote나 unquote-splicing으로 정규화하면 그 재귀 따옴표에서 제외돼요. 그런 폼은 표현식으로 취급되어 템플릿 안에서 각각 그 값 또는 값들의 시퀀스로 대체돼요.

예를 들어:

user=> (def x 5)
user=> (def lst '(a b c))
user=> `(fred x ~x lst ~@lst 7 8 :nine)
(user/fred user/x 5 user/lst a b c 7 8 :nine)

읽기 테이블은 현재 사용자 프로그램에 접근할 수 없어요.

확장 가능 데이터 표기(edn)

Clojure의 리더는 확장 가능 데이터 표기(edn)상위 집합(superset) 을 지원해요. edn 명세는 활발히 개발 중이며, 언어 중립적인 방식으로 Clojure 데이터 문법의 부분집합을 정의해 이 문서를 보완해요.

태그 리터럴(Tagged Literals)

태그 리터럴은 edn의 tagged elements를 Clojure에서 구현한 것이에요.

Clojure가 시작하면 클래스패스 루트에서 data_readers.clj 또는 data_readers.cljc라는 파일을 찾아요. 각 파일은 다음과 같은 심볼의 Clojure 맵을 담고 있어야 해요.

{foo/bar my.project.foo/bar
 foo/baz my.project/baz}

각 쌍의 키는 태그로, Clojure 리더가 인식해요. 값은 Var의 정규화된 이름인데, 이 Var가 태그 다음 폼을 파싱하기 위해 리더가 호출해요. 예를 들어 위 data_readers.clj 파일이 있을 때 Clojure 리더는 이 폼

#foo/bar [1 2 3]

을 벡터 [1 2 3]에 대해 Var #'my.project.foo/bar를 호출해 파싱해요. 데이터 리더 함수는 폼이 일반 Clojure 데이터 구조로 읽힌 다음에 호출돼요. 자신만의 데이터 리더 함수를 만들 때는 오류 정보를 담은 RuntimeException 인스턴스를 던져 오류를 보고해야 해요.

네임스페이스 한정자 없는 리더 태그는 Clojure가 예약해요. 기본 리더 태그는 default-data-readers에 정의돼 있는데, data_readers.clj/data_readers.cljc에서 덮어쓰거나 *data-readers*를 다시 바인딩해 덮어쓸 수 있어요. 태그에 대한 데이터 리더가 없으면 *default-data-reader-fn*에 바인딩된 함수가 태그와 값을 받아 값을 만들어요. *default-data-reader-fn*nil(기본값)이면 RuntimeException이 던져져요.

data_readers.cljc가 제공되면 다른 cljc 소스 파일과 같은 의미로 리더 조건식(reader conditionals) 과 함께 읽혀요.

내장 태그 리터럴(Built-in tagged literals)

Clojure 1.4는 instantUUID 태그 리터럴을 도입했어요. 인스턴트는 #inst "yyyy-mm-ddThh:mm:ss.fff+hh:mm" 형식이에요. 이 형식의 일부 요소는 선택적이에요(자세한 건 코드 참고). 기본 리더는 주어진 문자열을 기본적으로 java.util.Date로 파싱해요. 예를 들어:

(def instant #inst "2018-03-28T10:48:00.000")
(= java.util.Date (class instant))
;=> true

*data-readers*는 바인딩할 수 있는 동적 변수라서 기본 리더를 다른 것으로 바꿀 수 있어요. 예를 들어 clojure.instant/read-instant-calendar는 리터럴을 java.util.Calendar로, clojure.instant/read-instant-timestampjava.util.Timestamp로 파싱해요.

(binding [*data-readers* {'inst read-instant-calendar}]
  (= java.util.Calendar (class (read-string (pr-str instant)))))
;=> true

(binding [*data-readers* {'inst read-instant-timestamp}]
  (= java.util.Timestamp (class (read-string (pr-str instant)))))
;=> true

#uuid 태그 리터럴은 java.util.UUID로 파싱돼요.

(= java.util.UUID (class (read-string "#uuid \"3b8a31ed-fd89-4f1b-a00f-42e3d60cf5ce\"")))
;=> true

기본 데이터 리더 함수(Default data reader function)

태그 리터럴을 읽을 때 데이터 리더를 찾지 못하면 *default-data-reader-fn*가 호출돼요. 자신만의 기본 데이터 리더 함수를 설정할 수 있고, 제공되는 tagged-literal 함수로 처리되지 않은 리터럴을 저장하는 객체를 만들 수 있어요. tagged-literal이 돌려주는 객체는 :tag:form의 키워드 조회를 지원해요.

(set! *default-data-reader-fn* tagged-literal)

;; read #object as a generic TaggedLiteral object
(def x #object[clojure.lang.Namespace 0x23bff419 "user"])

[(:tag x) (:form x)]
;=> [object [clojure.lang.Namespace 599782425 "user"]]

리더 조건식(Reader Conditionals)

Clojure 1.7은 여러 Clojure 플랫폼에서 로드할 수 있는 휴대용 파일용 확장자(.cljc)를 도입했어요. 플랫폼별 코드를 관리하는 기본 방법은 그 코드를 최소한의 네임스페이스 집합으로 격리하고, 그 네임스페이스의 플랫폼별 버전(.clj/.class 또는 .cljs)을 제공하는 거예요.

코드의 변화하는 부분을 격리하기 어렵거나, 코드가 대부분 휴대 가능하고 조금만 플랫폼별인 경우, 1.7은 리더 조건식도 도입했어요. 리더 조건식은 cljc 파일과 기본 REPL에서만 지원돼요. 리더 조건식은 필요할 때만 아껴서 써야 해요.

리더 조건식은 #? 또는 #?@로 시작하는 새로운 리더 디스패치 폼이에요. 둘 다 cond처럼 번갈아 나오는 기능(feature)과 표현식의 연속으로 이뤄져요. 모든 Clojure 플랫폼에는 잘 알려진 '플랫폼 기능'이 있어요 — :clj, :cljs, :cljr이에요. 리더 조건식의 각 조건은 플랫폼 기능과 일치하는 기능을 찾을 때까지 순서대로 검사돼요. 리더 조건식은 그 기능의 표현식을 읽고 돌려줘요. 선택되지 않은 각 분기의 표현식은 읽히지만 건너뛰어져요. 잘 알려진 :default 기능은 항상 일치하며 기본값을 제공하는 데 쓸 수 있어요. 어떤 분기도 일치하지 않으면 (리더 조건식이 없었던 것처럼) 아무 폼도 읽지 않아요.

참고: 비공식 Clojure 플랫폼 구현자는 이름 충돌을 피하기 위해 플랫폼 기능에 정규화된 키워드를 써야 해요. 정규화되지 않은 플랫폼 기능은 공식 플랫폼용으로 예약되어 있어요.

다음 예시는 Clojure에서는 Double/NaN, ClojureScript에서는 js/NaN, 그 외 플랫폼에서는 nil로 읽혀요.

#?(:clj     Double/NaN
   :cljs    js/NaN
   :default nil)

#?@의 문법은 정확히 같지만, 표현식은 문법 따옴표의 unquote-splicing처럼 주변 문맥에 스플라이스할 수 있는 컬렉션을 돌려줘야 해요. 리더 조건식 스플라이싱을 최상위에서 쓰는 건 지원되지 않고 예외를 던져요. 예시:

[1 2 #?@(:clj [3 4] :cljs [5 6])]
;; in clj =>        [1 2 3 4]
;; in cljs =>       [1 2 5 6]
;; anywhere else => [1 2]

readread-string 함수는 첫 인자로 옵션 맵을 선택적으로 받아요. 현재 기능 집합과 리더 조건식 동작은 옵션 맵에서 이 키와 값으로 설정할 수 있어요.

  :read-cond - :allow to process reader conditionals, or
               :preserve to keep all branches
  :features - persistent set of feature keywords that are active

:read-cond는 리더 조건식을 처리할지(:allow) 모든 분기를 유지할지(:preserve)를, :features는 활성화된 기능 키워드의 영속적 집합(persistent set)을 뜻해요.

Clojure에서 ClojureScript 리더 조건식을 테스트하는 예시:

(read-string
  {:read-cond :allow
   :features #{:cljs}}
  "#?(:cljs :works! :default :boo)")
;; :works!

단, Clojure 리더는 항상 플랫폼 기능 :clj도 주입한다는 점을 기억하세요. 플랫폼에 구애받지 않는 읽기가 필요하면 tools.reader를 보세요.

리더를 {:read-cond :preserve}로 호출하면 리더 조건식과 실행되지 않은 분기가 데이터로 결과 폼에 보존돼요. 리더 조건식은 :form 키와 :splicing? 플래그의 키워드 조회를 지원하는 타입으로 돌아와요. 읽혔지만 건너뛴 태그 리터럴은 :form:tag 키의 키워드 조회를 지원하는 타입으로 돌아와요.

(read-string
  {:read-cond :preserve}
  "[1 2 #?@(:clj [3 4] :cljs [5 6])]")
;; [1 2 #?@(:clj [3 4] :cljs [5 6])]

이 타입들의 술어나 생성자로도 쓸 수 있는 함수들이 있어요: reader-conditional?, reader-conditional, tagged-literal?, tagged-literal.

더 알아보기