이번 장에서는 클로저의 제어 구문에 대해 알아본다.

클로저는 여러가지 제어 구문을 제공하지만, if 특수 형식(special form)을 제외한 나머지 모든 제어 구문은, if 특수 형식을 내부적으로 감싼 매크로로 구현되어 있다. 따라서 가장 중요한 제어 구문은 if이다.

1. 논리적 true와 논리적 false

클로저의 제어 구문에서 참과 거짓을 판별할 때, falsenil을 제외한 모든 값은 '논리적으로 true’이다. 오로지 falsenil만이 '논리적으로 false’인 것으로 취급된다. 따라서 0이나 빈 문자열 "", 빈 컬렉션인 (), [], {}, #{} 모두 논리적인 true로 평가된다는 점에 유의하자.

2. if

if 형식
(if test then-part else-part?)

if 구문은 test 부분을 평가한 결과가 논리적으로 참이면 then-part를, 논리적으로 거짓이면 else-part를 평가한다.

(if (< 5 10) "yes" "no")   ;=> "yes"

(if (> 5 10) "yes" "no")   ;=> "no"

test 부분을 평가한 결과가 논리적 거짓일 때, else-part가 제공되지 않으면 nil을 반환한다.

(if (> 5 10) "yes")   ;=> nil

then-partelse-part에서 여러 개의 식을 평가하고자 할 때에는 do 특수 형식으로 감싸 주어야 한다.

(if (< 5 10)
  (do
    (println "5 is less than 10.")
    "yes")
  (do
    (println "This will not be printed.")
    "no"))
;>> 5 is less than 10.
;=> "yes"

do 특수 형식(special form)은, 다른 C++이나 자바 언어에서의 { …​ }와 같은 코드의 '블럭’을 표시하는 기능을 수행한다. do는 그 안의 모든 식들을 차례대로 평가한 후, 맨 마지막에 평가된 식의 결과를 반환한다.

(do
  (+ 1 2)
  (+ 3 4)
  (+ 5 6))
;=> 11

맨 마지막 식을 제외한 그 앞에 있는 모든 식들은 결과적으로 부수 효과(side effect)를 수행하게 된다.

(do
  (println "LOG: Computing...")   ; printed in stdout
  (+ 1 1))
;>> LOG: Computing...
;=> 2

다음의 fn, defn, let 등도 클로저가 내부적으로 do로 자동적으로 감싸주기 떄문에 그 안에 여러 개의 식들을 나열할 수 있게 된다.

((fn [name]
  (println "Something")   ; printed in stdout
  (str "Hello, " name "!"))
 "Tom")
;>> Something
;=> "Hello, Tom!"

(defn hello [name]
  (println "Something")     ; printed in stdout
  (str "Hello, " name "!"))

(hello "Becky")
;>> Something
;=> "Hello, Becky!"

(let [name "John"]
  (println "Something")     ; printed in stdout
  (str "Hello, " name "!"))
;>> Something
;=> "Hello, John!"

if-notif와 정반대의 동작을 수행한다.

(if-not (zero? 0) "not zero" "zero")
;=> "zero"

3. when

when 형식
(when test expression+)

when 구문은 test 부분이 논리적인 참으로 평가될 때, 그 뒤의 모든 식을 내부적으로 do로 감싸 평가한다. 그래서 다음의 두 식은 완전히 동일하다.

(when (= 1 1)
  (+ 1 2)
  (+ 3 4)
  (+ 5 6))
;=> 11

(if (= 1 1)
  (do
    (+ 1 2)
    (+ 3 4)
    (+ 5 6)))
;=> 11

그리고 test 부분이 논리적인 거짓으로 평가될 때에는 단순히 nil을 반환한다.

(when (= 1 2)
  (+ 1 2)
  (+ 3 4)
  (+ 5 6))
;=> nil

그래서 whenifthen-part가 여러 개의 식들로 이루어져 있고, else-part는 필요 없을 때 사용된다.

when-notwhen과 정반대의 동작을 수행한다.

(when-not (= 1 2)
  (+ 1 2)
  (+ 3 4)
  (+ 5 6))
;=> 11

(when-not (= 1 1)
  (+ 1 2)
  (+ 3 4)
  (+ 5 6))
;=> nil

4. if-let/when-let

if-let 형식
(if-let [local-symbol expression] then-part else-part?)

if-let은 먼저 expression을 평가한 결과를 local-symbol에 바인딩한다. 그후에 바인딩된 값이 논리적 참이면 then-part를, 그렇지 않으면 else-part를 실행한다.

(defn if-let-demo [arg]
  (if-let [a arg]
    (str "arg: " a)
    "no"))

(if-let-demo 10)      ;=> "arg: 10"
(if-let-demo nil)     ;=> "no"
(if-let-demo false)   ;=> "no"

실제로 클로저로 코딩하다 보면 다음과 같은 패턴을 자주 접하게 된다.

(defn drop-one [coll]
  (let [s (seq coll)]
    (if s
      (rest s)
      coll)))

(drop-one [1 2 3])   ;=> (2 3)
(drop-one [])        ;=> []

이럴 때 다음과 같이 if-let을 사용하면 코드가 간결해진다.

(defn drop-one2 [coll]
  (if-let [s (seq coll)]
    (rest s)
    coll))

(drop-one2 [1 2 3])   ;=> (2 3)
(drop-one2 [])        ;=> []

if-let을 사용할 때 주의할 점은 다음과 같다.

  • let과는 달리, if-let은 지역 심볼을 한 개만 바인딩할 수 있다. 두 개 이상 바인딩하면 다음과 같이 예외가 발생한다.

    (if-let [a 10
             b 20]
      (+ a b)
      "no")
    :>> IllegalArgumentException if-let requires exactly 2 forms in binding vector

    이 문제를 우회하려면 if-let을 중첩해 사용해야 한다.

    (if-let [a 10]
      (if-let [b 20]
        (+ a b)
        "no"))
    ;=> 30
  • 바인딩된 지역 심볼을 else-part에서 참조해서는 안된다. 사용하면 다음과 같은 예외가 발생한다.

    (if-let [a 10]
      (+ a 20)
      (str "arg: " a))
    ;>> CompilerException java.lang.RuntimeException: Unable to resolve symbol: a in this context

when-letif-let의 관계는 whenif의 관계와 같다.

(defn drop-one3 [coll]
  (when-let [s (seq coll)]
    (println s)
    (rest s)))

(drop-one3 [1 2 3])
;>> (1 2 3)
;=> (2 3)

(drop-one3 [])
;=> nil

5. cond

cond 구문은 C 언어의 switch 구문과 유사하다.

cond 형식
(cond clause+)

clause := test expression

이 구문은 나열된 clause를 차례대로 실행하다가, 최초로 논리적 참을 반환하는 test를 만나면 그에 해당하는 expression을 평가한 후, 그 결과를 cond 구문의 결과로 즉시 반환한다.

(defn pos-neg-or-zero [n]
  (cond
    (< n 0) "negative"
    (> n 0) "positive"
    :else "zero"))

(pos-neg-or-zero 5)    ;=> "positive"
(pos-neg-or-zero -1)   ;=> "negative"
(pos-neg-or-zero 0)    ;=> "zero"

맨 마지막 clausetest 부분은, 위의 코드에서처럼 관례적으로 :else라는 키워드를 사용하지만, 논리적인 참값을 반환하는 어떤 식(expression)도 가능하다. 예를 들어, 현실성은 없지만 다음과 같이 할 수도 있다. 즉, (= 5 5)는 언제나 true를 반환하므로 default인 경우의 처리가 가능하다.

(defn pos-neg-or-zero2 [n]
  (cond
    (< n 0) "negative"
    (> n 0) "positive"
    (= 5 5) "zero"))

(pos-neg-or-zero2 0)   ;=> "zero"

만약 해당하는 clause가 한 개도 없을 때에는 nil을 반환한다.

(defn pos-neg-or-zero3 [n]
  (cond
    (< n 0) "negative"
    (> n 0) "positive"))

(pos-neg-or-zero3 0)   ;=> nil

6. condp

condp 형식
(condp pred expr clause+ default-expr?)

clause := test-expr result-expr | test-expr :>> result-fn

pred := 두 개의 인수를 받는 함수이어야 한다.
        이 함수의 첫 번째 인자로 clause 부분의 test-expr이 들어가고, 두 번째 인자로 expr이 들어간다.
        결과적으로 (pred test-expr expr) 식이 실행된다.
result-fn := 한 개의 인수를 받는 함수이어야 한다.
             (pred test-expr expr) 식을 평가한 결과가 이 함수의 인수로 들어간다.
default-expr := 디폴트 처리를 담당한다.

다음과 같은 cond 구문이 있다고 할 때,

(defn cond-demo [value]
  (cond
    (instance? Number value) (* value 2)
    (instance? String value) (* (count value) 2)
    :else                    "Unexpected type."))

condp 구문을 이용하면, 다음과 같이 더 간단하게 표현할 수 있다[1].

(defn condp-demo [value]
  (condp instance? value
    Number (* value 2)
    String (* (count value) 2)
    "Unexpected type."))   ; (1)

(condp-demo 10)          ;=> 20
(condp-demo "Clojure")   ;=> 14
(condp-demo :hello)      ;=> "Unexpected type."
1 default-expr 부분은 식이 한 개뿐인 것에 주목하자.

condp 구문은 cond 구문과는 달리, 일치하는 절(clause)이 없을 경우에 디폴트 처리부인 default-expr 부분이 없으면 예외가 발생한다.

(defn condp-demo2 [value]
  (condp instance? value
    Number (* value 2)
    String (* (count value) 2)))

(condp-demo2 :hello)
:>> IllegalArgumentException No matching clause: :hello

clause 부분이 다음과 같이 세 개의 식으로 이루어질 수도 있다.

test-expr :>> result-fn

이때 두 번째 식은 반드시 :>> 키워드이어야 하고, 세 번째 식은 인수가 하나인 함수이어야 한다. (pred test-expr expr) 식을 평가한 결과가 이 함수의 인수로 들어간다.

(defn condp-demo3 [value]
  (condp some value
    #{1 2 3} :>> inc
    #{4 5 6} :>> dec
    #{7 8 9} :>> #(+ % 3)))
;; (some #{1 2 3} [1 2 3]) => 1
;; (some #{4 5 6} [6 5 4]) => 6
;; (some #{7 8 9} [8 7 9]) => 8

(condp-demo3 [1 2 3])   ;=> 2
(condp-demo3 [6 5 4])   ;=> 5
(condp-demo3 [8 7 9])   ;=> 11

7. case

case 형식
(case expr clause+ default-expr?)

clause := test-constant result-expr |
          (test-constant1 ... test-constantN)  result-expr

case 구문[2]clause 내의 test-constant 부분에 나열된 값들이 컴파일 타임에 그 값을 알 수 있어야 한다는 제약이 있다. 따라서 문자열이나 키워드 같은 자기 자신으로 평가되는 값들이 주로 나열된다.

(defn case-demo [value]
  (case value
    ""      0
    "hello" (count value)))

(case-demo "hello")   ;=> 5

condp와 마찬가지로, 일치하는 절(clause)이 없을 경우에 디폴트 처리부인 default-expr 부분이 없으면 예외가 발생한다.

(case-demo "hi")
;>> IllegalArgumentException No matching clause: hi

다음은 default-expr 부분을 제공한 예이다.

(defn case-demo2 [value]
  (case value
    ""      0
    "hello" (count value)
    "no match"))   ; (1)

(case-demo2 "hi")   ;=> "no match"
1 default-expr 부분은 식이 한 개뿐인 것에 주목하자.

clausetest-constant 부분에 다음과 같이 심볼이 올 수도 있다. 이 심볼은 런타임에 평가되지 않고, 컴파일시에 (앞에 인용 기호가 자동으로 붙는) 심볼로 컴파일된다.

(def x 10)

(let [value 'x]
  (case value
    x "x"
    y "y"
    z "z"
    "no-match")
;=> "x"

위의 코드에서 case 내부의 x는 런타임에 10으로 평가되지 않고, 컴파일시에 심볼 'x로 값이 바뀌에 된다. 따라서 런타임에 심볼 'x와 일치하게 되어, 결과적으로 "x"라는 문자열을 반환하게 된다.

위의 코드는 다음처럼 심볼 x, y, z를 리스트로 묶어 한꺼번에 처리할 수도 있다. 이 경우에는 value가 심볼 xy 또는 z이면 "x, y or z"를 반환하게 된다.

(let [value 'x]
  (case value
    (x y z) "x, y or z"   ; (1)
    "no-match")
1 여기서 (x y z)x라는 함수를 인수 yz에 적용하라는 의미가 아닌 것에 주의해아 한다. (x y z)는 컴파일시에 '(x y z)의 형태로 리스트 자료형 자체로 컴파일되기 때문이다.

몇 가지 예를 더 들어 본다.

(let [value ()]
  (case value
    (())    "empty seq"   ; (1)
    ((1 2)) "my seq"
    "no match"))
;=> "empty seq"

(let [value ()]
  (case value
    []      "empty seq"   ; (2)
    ((1 2)) "my seq"
    "no match"))
;=> "empty seq"

(let [value [1 2]]
  (case value
    []          "empty vec"
    (vec (1 2)) "my vec"      ; (3)
    "no match"))
;;=> "my vec"
1 (())에서 바깥의 괄호는 test-expr의 시작과 끝을 알리는 기호로 쓰이므로, 빈 리스트를 나열하고자 할 때는 그 안에 다시 나열해 주어야 한다.
2 그래서 빈 리스트를 검사하고자 할 때는, (())처럼 해 주기 보다는 []로 해 주는 것이 낫다. 왜냐하면 (= () [])의 평가 결과는 true이기 때문이다.
3 (= [1 2] '(1 2))의 결과는 true이므로, "my vec"이 반환되었다.

1. condp 구문은 내부적으로 cond 구문으로 확장된다.
2. case 구문은 내부적으로 condp 구문으로 확장된다. 더 정확히는, (condp = …​.)의 형태로 확장된다. 즉 = 함수가 pred 함수로 사용된다. 이것을 이해해야 case의 동작이 제대로 이해된다.