1. clojure.spec 소개

  • clojure.spec은 클로저 언어의 창시자인 Rich Hickey가 만든 라이브러리로, clojure 1.9.0 버전에 추가될 예정이다.

  • 이 발표에 사용한 clojure.spec 버전은 clojure 1.9.0-alpha14이다.

  • Racket 언어의 Contracts 시스템의 영향을 많이 받아 만들어졌다.

  • Dynamic type/value Checking: spec을 정의하면, runtime에 type과 value를 검사할 수 있다.

  • Generative Testing: spec을 정의하면, 테스트 케이스를 자동 생성하고, 자동으로 테스트를 수행하게 할 수 있다.

  • 이와같은 기능을 특정 이름공간(namespace) 또는 특정 함수만을 대상으로 켜거나 끌 수 있다. 그래서 개발/테스트 기간에만 이 기능을 작동시키고, 제품이 정식으로 출시될 때 중단시키면, 프로그램의 실행 속도에 전혀 영향을 미치지 않는다.

  • 결과적으로, 동적 언어인 클로저에서 정적 언어의 특징들을 이용할 수 있게 해준다.

2. 기존의 Clojure 타입 체킹 라이브러리와의 비교

              type checking        run-time       generative   ClojureScript
                                value checking      testing        지원
------------------------------------------------------------------------------
core.typed    compile-time           X                X             X
schema          run-time             X                △             O
core.spec       run-time             O                O             O

2.1. schema와 clojure.spec 예제 비교

(ns schema-examples
  (:require [schema.core :as s]))

(def Data
  "A schema for a nested data type"
  {:a {:b s/Str
       :c s/Int}
   :d [{:e s/Keyword
        :f [s/Num]}]})

(s/validate
  Data
  {:a {:b "abc"
       :c 123}
   :d [{:e :bc
        :f [12.2 13 100]}
       {:e :bc
        :f [-1]}]})
;; Success!

(s/validate
  Data
  {:a {:b 123
       :c "ABC"}})
;; Exception -- Value does not match schema:
;;  {:a {:b (not (instance? java.lang.String 123)),
;;       :c (not (integer? "ABC"))},
;;   :d missing-required-key}
clojure.spec의 예
(s/def ::b string?)
(s/def ::c int?)
(s/def ::a (s/keys :req [::b ::c]))

(s/def ::e keyword?)
(s/def ::f (s/coll-of number?))
(s/def ::d (s/coll-of (s/keys :req [::e ::f])))

(s/def ::data (s/keys :req [::a ::d]))


(s/valid? ::data {::a {::b "abc"
                       ::c 123}
                  ::d [{::e :bc
                        ::f [12.2 13 100]}
                       {::e :bc
                        ::f [-1] }]})
; => true

(s/valid? ::data {::a {::b 123
                       ::c "ABC"}})
; => false

(s/explain ::data {::a {::b 123
                        ::c "ABC"}})
; >> val: {:a {:b 123, :c "ABC"}} fails
;    spec: ::data
;    predicate: (contains? % ::d)
;
;    In: [::a ::b]
;    val: 123 fails
;    spec: ::b
;    at: [::a ::b]
;    predicate: string?
;
;    In: [::a ::c]
;    val: "ABC" fails
;    spec: ::c
;    at: [::a ::c]
;    predicate: int?

3. clojure.spec의 용도

3.1. Documentation

  • spec은 함수 입출력 데이터의 구조를 명시적으로 정의함으로써, 표준화된 documentation을 제공한다. 팀원들 사이에 그리고 자기 자신의, 코드에 대한 이해도를 높일 수 있다.

기존의 제가 사용하던 방식
(defn snake-case
  "Converts lisp-case keyword to snake-case string.
   <k keyword>
   <return string>

   ex) :get-sock-address => \"get_sock_address\""
  [k]
  (-> (name k) (str/replace "-" "_")))
clojure.spec을 이용한 방식
(s/fdef snake-case
  :args (s/cat keyword?)
  :ret string?)

(defn snake-case
  "Converts lisp-case keyword to snake-case string.
   ex) :get-sock-address => \"get_sock_address\""
  [k]
  (-> (name k) (str/replace "-" "_")))

3.2. Debugging

  • spec은 runtime에 데이터의 유효성(validation), 즉 타입 및 값을 검증할 수 있게 해주므로, 버그 발생 가능성을 현저히 줄일 수 있다.

3.2.1. 문제 없는 예

(max 1 2 3 4)
; => 4

(max)
; >> 1. Caused by clojure.lang.ArityException
;       Wrong number of args (0) passed to: core/max
;
;                  AFn.java:  429  clojure.lang.AFn/throwArity
;               RestFn.java:  399  clojure.lang.RestFn/invoke
;                 intro.clj:   14  spec-guide.intro/eval13365
;                 intro.clj:   14  spec-guide.intro/eval13365
;             Compiler.java: 6978  clojure.lang.Compiler/eval
;             Compiler.java: 7430  clojure.lang.Compiler/load

3.2.2. 문제 있는 예

(defn my-max [coll]
  (apply max coll))

(my-max [1 2 3 4])
; => 4

(my-max nil)
; >> 1. Unhandled clojure.lang.ArityException
;       Wrong number of args (0) passed to: core/max
;
;                      AFn.java:  429  clojure.lang.AFn/throwArity
;                   RestFn.java:  399  clojure.lang.RestFn/invoke
;                      AFn.java:  152  clojure.lang.AFn/applyToHelper
;                   RestFn.java:  132  clojure.lang.RestFn/applyTo
;                      core.clj:  657  clojure.core/apply
;                      core.clj:  652  clojure.core/apply
;                          REPL:    7  spec-guide.intro/my-max
;                          REPL:    6  spec-guide.intro/my-max
;                          REPL:   28  spec-guide.intro/eval10841
;                          REPL:   28  spec-guide.intro/eval10841
;                 Compiler.java: 6977  clojure.lang.Compiler/eval
;                 Compiler.java: 6940  clojure.lang.Compiler/eval
;                      core.clj: 3187  clojure.core/eval
;                         ......

3.2.3. core.spec으로 문제 해결

(ns spec-guide.intro
  (:require [clojure.spec :as s]
            [clojure.spec.gen :as gen]
            [clojure.spec.test :as stest]))

(s/fdef my-max2
  :args (s/cat :coll (s/coll-of number?))
  :ret number?)

(defn my-max2 [coll]
  (apply max coll))

(stest/instrument `my-max2)

(my-max2 [1 2 3 4])
; => 4

(my-max2 nil)
; >> 1. Unhandled clojure.lang.ExceptionInfo
;       Call to spec-guide.intro/my-max2 did not conform to spec:
;         In: [0]
;         val: nil fails
;         at: [:args :coll]
;         predicate: coll?
;       :clojure.spec/args (nil)
;       :clojure.spec/failure :instrument
;       :clojure.spec.test/caller {:file "form-init414233231437328049.clj",
;                                  :line 63, :var-scope spec-guide.intro/eval10997}
;
;       {:clojure.spec/problems [{:path [:args :coll],
;                                 :pred coll?,
;                                 :val nil,
;                                 :via [],
;                                 :in [0]}],
;        :clojure.spec/args (nil),
;        :clojure.spec/failure :instrument,
;        :clojure.spec.test/caller {:file "form-init414233231437328049.clj",
;                                   :line 63,
;                                   :var-scope spec-guide.intro/eval10997}}

3.2.4. core.spec은 실행 중 값도 검사할 수 있다

(s/fdef my-max3
  :args (s/and (s/cat :coll (s/coll-of number?))
               #(every? (fn [num]
                          (< num 10))
                        (:coll %) ))
  :ret number?)

(defn my-max3 [coll]
  (apply max coll))

(stest/instrument `my-max3)

(my-max3 [1 2 3 14])
; >> 1. Unhandled clojure.lang.ExceptionInfo
;       Call to #spec-guide.intro/my-max3# did not conform to spec:
;         val: {:coll [1 2 3 14]} fails
;         at: [:args]
;         predicate: (every? (fn [num] (< num 10)) (:coll %))
;       :clojure.spec/args ([1 2 3 14])
;       :clojure.spec/failure :instrument
;       :clojure.spec.test/caller {:file "form-init414233231437328049.clj",
;                                  :line 97,
;                                  :var-scope spec-guide.intro/eval11148}

3.3. Generative Testing

  • spec은 자동 테스트 케이스 생성(generative testing) 및 테스팅 기능을 제공함으로써 코드의 무결성을 높일 수 있다.

(s/fdef ranged-rand
  :args (s/and (s/cat :start int? :end int?)
               #(< (:start %) (:end %)))
  :ret int?
  :fn (s/and #(>= (:ret %) (-> % :args :start))
             #(< (:ret %) (-> % :args :end))))

(defn ranged-rand
  "Returns random int in range start <= rand < end"
  [start end]
  (+ start (long (rand (- end start)))))

;; 자동 샘플 생성
(s/exercise-fn `ranged-rand 5)
; => ([(-2 -1) -2] [(-2 0) -1] [(-2 0) -2] [(0 2) 1] [(-14 1) -3])

;; 자동 테스트 수행
(stest/check `ranged-rand)
; => ({:spec #object[clojure.spec$fspec_impl$reify__14282 0x28315748
;                    "clojure.spec$fspec_impl$reify__14282@28315748"],
;      :clojure.spec.test.check/ret {:result true,
;                                    :num-tests 1000,
;                                    :seed 1478747287406},
;                                    :sym spec-guide.api/ranged-rand})
instrument와 check 비교(검사 수행 여부)
         instrument    check
---------------------------------
:args        O           O
:ret         X           O
:fn          X           O

3.4. Destructuring(구조분해)

  • spec은 데이터의 구조분해(일종의 코드 parsing) 기능을 제공한다. 이 기능이 매크로와 결합되면, 기존에 Clojure에서 불가능하지는 않지만 하기 어려웠던 일을 쉽게 할 수 있다. (참고: Custom defn macro with clojure.spec)

original code target code
(defn add [a b]
  (+ a b))
(defn add [a b]
  (println "add" "has been called.")
  (+ a b))
함수와 매크로 비교
       입력                      출력
   ---------------------------------------
      데이터  -->  함수   -->   데이터
       코드   --> 매크로  -->    코드
(defn prepend-log [name body]
  (cons `(println ~name "has been called.") body))

(defn update-conf [{:keys [:bs] :as conf} body-update-fn]
  (update-in conf [:bs 1 :body] body-update-fn))

(defmacro defnlog [& args]
  (let [{:keys [name] :as conf} (s/conform ::defn-args args)
        new-conf (update-conf conf (partial prepend-log (str name)))
        new-args (s/unform ::defn-args new-conf)]
    (cons `defn new-args)))
(s/conform ::defn-args '(add [a b] (+ a b)))
; => {:name add,
;     :bs [:arity-1 {:args {:args [[:sym a] [:sym b]]},
;     :body [(+ a b)]}]}

(defnlog add [a b]
  (+ a b))

(add 10 20)
; >> add has been called.
; => 30