1. 클로저 코드의 특징들

컴퓨터 언어의 역사에서 Lisp 계열 언어들(Common Lisp, Scheme, Racket, Clojure, …​)은 Algol 계열 언어들과는 다른 계보를 형성해 왔다. 오늘날의 주류 언어들(C, C++, C#, Java, JavaScript, Python, Ruby, Perl, …​)이 모두 알골계 언어들인데 반해, 클로저는 리습 계열 언어여서 실제 코드를 본 후 아마도 생소한 느낌을 갖는 사람들이 많았을 것이다. 따라서 클로저 언어를 본격적으로 익히기에 앞서, 다른 언어들과의 두드러진 차이점 몇 가지를 먼저 소개한다.

1.1. 괄호의 사용

클로저 코드를 처음 접한 사람들은 아마도 그 많은 괄호에 강한 거부감을 느꼈을 것이다. 그런 느낌은 이 글을 쓰고 있는 필자들의 경우도 예외는 아니었다. 하지만 그 형태가 아니라, 괄호가 있음으로 인해 가지게 되는 막강한 효용에 눈뜨기 시작할 때부터, 거부감의 대상이던 그 괄호들이 아름답게 보이기 시작한다. 이것은 단순한 주관적인 느낌이 아니라, 리습 언어를 알아가는 사람들의 공통된 심리적 패턴이다.

괄호가 있어서 얻을 수 있는 첫 번째 이점은, 코드의 의미가 명확해져 연산자 우선 순위를 따로 외울 필요가 없다는 점이다. 다음의 두 코드를 보면 어느 것의 의미가 명확한지는 자명하다.

C
a > b + c * d && e
Clojure
(and (> a (+ b (* c d))) e)

하지만, 괄호를 사용하는 진정한 위력은 매크로를 작성할 때 나온다. 리습 언어가 Programmable Programming Language라고 불리는 이유는 매크로를 통해 코드를 조작할 수 있기 때문인데, 이 괄호가 있음으로 해서 코드 조작이 수월해진다. 위의 클로저 코드를 잘 살펴보면, 이미 코드 형태 자체가 구문 트리(Syntax Tree)의 형식이어서, 이 코드를 변형하기 위해 별도의 파싱(parsing) 과정을 거칠 필요가 없기 때문이다. 지금 당장은 잘 이해가 안되더라도 매크로를 작성하다 보면 그 가치를 깨닫게 된다. 그리고 바로 그 순간부터 괄호가 아름답게 보이기 시작한다. 사실 괄호가 있음으로 해서 좀더 강력하면서도 간결한 프로그래밍이 가능해진다면, 괄호가 많다는 것이 무슨 그리 큰 대수이겠는가?

1.2. 전위 표기법(Prefix Notation)

리습 언어들에서는 연산자와 함수명이 괄호 내의 맨 앞 자리(전위 표기법: Prefix Notation)에 온다. 사실 함수명의 경우에는 C 언어나 클로저나 큰 차이가 없다. 단지 함수 이름이 괄호 밖에 있는가 안에 있는가의 차이와, 함수의 인수들 사이에 콤마를 찍느냐 마느냐의 차이밖에는 없다. 함수명의 경우에는, 약간의 차이만 있을 뿐 C 언어도 이미 전위 표기법을 사용하고 있다는 말이다.

C
printf("ASCII value = %d, Character = %c\n", ch , ch);
Clojure
(printf "ASCII value = %d, Character = %c%n" ch ch)

차이가 있다면 연산자의 경우인데, C 언어에서는 중위 표기법(Infix Notation)을 사용하고 클로저에서는 함수명과 마찬가지로 전위 표기법을 사용한다.

C
a + b + c + d + e;
Clojure
(+ a b c d e)

클로저의 경우에는 전위 표기법을 사용함으로써, 연산항이 많을 때 + 기호를 반복할 필요가 없다는 장점이 있다.

리습 계열 언어에서는 연산자와 함수가 다른 취급을 받지 않는다. 연산자의 경우, 수학에서 이미 중위 표기법을 사용하고 있어 대부분의 언어가 그것을 답습하고 있을 뿐, 프로그래밍에서는 굳이 그것을 구분해 처리하지 않는 것이 오히려 일관되고 편리하다. 연산자가 중간에 위치하게 되면 컴파일러에서는 이를 따로 파싱해야하는 수고를 한 번 더 거쳐야 하기 때문이다.

예를 들어, C 언어에서 ** 연산자를 거듭제곱 연산자로 여러분이 언어에 추가하고 싶다고 가정해 보자. 그러자면 우선 C 표준을 다루는 위원회에 제안을 해야 할 것이다. 물론 이 제안이 받아들여진다는 보장도 없지만, 이 위원회가 그 타당성을 인정해 새로운 C 언어 표준에 반영하기로 했다고 가정해 보자. 그러면 그 후에 모든 C 컴파일러를 이 새로운 표준에 맞춰 다시 작성해야 할 것이고, 이 연산자를 이용해 작성한 C 소스는 구 버전의 컴파일러에서는 제대로 컴파일이 되지도 않을 것이다. 이 상황을 클로저에 적용해 보면 일은 너무도 간단해진다. 클로저 프로그래머는 다음과 같이 코드를 작성하면 된다.

(defn ** [a b]
  (Math/pow a b))

(** 2 8)   ; => 256.0

얼마나 간단한가? 연산자와 함수를 구별할 필요가 없는 데서 오는 편리함이다. 함수 추가하듯이 연산자를 추가하면 된다. 엄밀하게 말하자면, 클로저에는 다른 언어들에서의 연산자라는 개념이 따로 없다. 함수의 이름으로 기호를 사용할 수 있어서 오직 함수만이 존재할 뿐이다. 연산자의 중위 표기법을 과감하게 버림으로써, 다른 언어에서 언어의 표준 위원회에서나 결정할 수 있는 일을 개발자가 직접 할 수 있게 된 것이다.

1.3. 모든 것이 식(expression)이다.

클로저는 리습 계열의 언어이다. 따라서 클로저가 갖는 리습 언어로서의 특징을 먼저 이해해야 한다. 클로저에서는 모든 것이 식이다. 즉, 어떤 코드의 일부를 실행하든 전체를 실행하든 반드시 어떤 값(value)을 반환한다. 다른 언어들에서의 문(statement)이라는 개념이 클로저에는 없다.

이해를 돕기 위해, C 언어에서의 삼항 연산자(? :)와 if 문을 예로 들어 보자.

아래의 삼항 연산자는 식(expression)이어서, 이 연산자 자체가 10이나 20을 반환한 후 그 결과값이 result에 저장된다.

result = a > b ? 10 : 20;

하지만 아래의 if 문은 식(expression)이 아니라 문(statement)이어서, if 문 자체가 10이나 20을 반환하는 것이 아니라, if 문의 내부에서 result 변수의 상태를 변경하고 있다.

if (a > b) {
    result = 10;
} else {
    result = 20;
}

만약 C의 if가 식이였다면 다음의 코드가 작동해야 한다(물론, 다음의 코드는 적법한 C 코드가 아니다).

result = if (a > b) 10 else 20;

이와 같이 문(statement)은 주로 어떤 명령을 수행해서 부수 효과(side effect)를 내기 위한 용도로 주로 쓰인다. 반면에 식(expression)은 코드가 어떤 값을 반환하느냐 하는 것에 관심이 있을 때 쓰인다.

그러면 이것이 왜 중요한가? 모든 코드가 식으로 구성되면, 코드의 표현력이 늘어나고 아울러 코드의 조합력이 증가한다. 왜냐하면 코드가 부수 효과 없이 어떤 값만을 반환하다면, 코드는 참조 투명성(referential transparency)이 보장되며, 또한 일급 객체(first citizen)로 다루어질 수 있어서 함수의 파라미터로 사용될 수 있기 때문이다.

if가 문이 아니라 식일 때, 코드의 표현력이 어떻게 증가하는지 다음의 실제 예제를 통해 살펴보자.

다음의 버전 1은, 우리가 흔히 사용하는 if 문의 예이다. n이 참이면, -n만큼 이동하고, 거짓이면 -1만큼 이동한다.

Version 1
(if n            ; condition
  (move (- n))   ; then-part
  (move -1))     ; else-part

if가 식이 아니라 문인 언어에 익숙해져 있는 사람들에게는 위의 코드를 별도의 함수를 사용하지 않고 더 간결하게 표현할 수 있다는 생각 자체가 떠오르지 않을 것이다. 하지만 리습 언어로 사고하는 사람들은 문제점을 발견한다. 즉, 위 코드의 문제점은 move 호출이 두 번씩이나 중복되어 있다는 것이다. if가 식인 언어에서는, 이를 수학에서 인수분해할 때 공통 인수 뽑아내듯이 더 간단하게 다음과 같이 줄일 수 있다.

Version 2
(move (if n (- n) -1))

위의 코드가 C 언어에서 동작할 수 없는 이유는, move 함수의 첫 번째 인수 자리에 놓인 if 문이 어떤 값도 반환할 수 없기 때문이다. 하지만 리습 계열 언어에서는 모든 것이 식이이서, if조차도 값을 반환할 수 있어 위와 같은 표현이 가능해진다.

한 걸음 더 나아가 -도 한 번 더 공통 인수로 뽑아낼 수 있다.

Version 3
(move (- (if n n 1)))

ifor 로 대치하면 더 간결해 진다.

Version 4
(move (- (or n 1)))

클로저에서는 반복문조차도 값을 반환한다.

(for [n [1 2 3 4 5]]
  (* 2 n))
; => (2 4 6 8 10)

stdout에 결과를 출력(이것도 일종의 부수 효과이다)하기 위한 함수 println조자도 nil이라는 값을 반환한다[1]. 아래에서 ;>> 기호는 stdout 출력 결과를, ;=> 기호는 함수의 반환 결과를 표시한다.

(println "Hello" "world!")
;>> Hello world!
;=> nil

위와 같이 클로저와 같은 리습 계열 언어에서는 코드의 일부 또는 전체가 모두 식으로 구성되어 있다. 그래서 모든 리습 계열 언어에서는 코드를 '실행(execution)'한다고 하지 않고 '평가(evaluation)'한다고 표현하는데, 그 이유는 evaluation이라는 말 자체가 접두어 e-(out)와 value(값)의 합성어로, 어떤 값을 내놓는다, 즉 평가한다는 의미를 갖고 있기 때문이다. 다시 말해, 비리습 계열 언어에서의 실행한다는 말 속에는 실행 결과가 어떤 값을 내놓지 않을 수도 있다는 의미가 함축되어 있기 때문에, 리습 계열 언어에서는 평가한다는 말을 주로 사용한다.

아울러 모든 코드가 식이라는 사실은, 나중에 배우게 될 매크로(Macros)의 구현에도 대단히 중요한 의미를 지닌다.

1.4. return 문이 없다

클로저에는 값을 반환하기 위한 return 문이 별도로 존재하지 않는다. 마지막에 위치해 있는 식이 평가된 결과가 곧 반환값이 된다.

(defn my-add [a b]
  (println "LOG: Computing...")
  (+ a b))

(my-add 1 2)
;>> LOG: Computing...
;=> 3

아울러 return 문이 아예 존재하지 않으므로, 함수 실행 도중에 함수의 실행을 종료할 수 없다. 일단 함수가 실행되면 함수의 끝에 도달해야 함수가 종료된다. 이것은 함수형 언어의 일반적인 특징이기도 하다.

2. 주석(Comment)

클로저에서 주석을 표현하는 방식에는 세 가지가 있다.

  • ; reader macro

  • comment macro

  • #_ reader macro

2.1. ; 주석 문자

클로저에서 라인 단위 주석 문자는 ;이다. 이 주석 문자가 나온 부분부터 라인의 끝까지 주석으로 처리된다. 참고로, 리습 계열 언어들은 일반적으로 ; 문자를 라인 주석 문자로 사용한다.

리습 계열 언어에서는 주석을 달 때 일반적으로 다음과 같은 관행을 따른다. 반드시 따라야 하는 것은 아니지만, 참고로 알아 두자.

  • 패키지 또는 이름공간 수준의 주석은 ; 기호 4개를 사용한다.

  • 여러 개의 함수에 공통적으로 해당되는 주석은 ; 기호 3개를 사용한다.

  • 함수 한 개에 대한 설명(예를 들면, 구현 알고리즘)이나, 함수 내 코드 블럭에 대한 주석은 ; 기호 2개를 사용한다. 함수 내 코드 블럭에 대한 주석인 경우에는, 주석을 코드 블럭 앞에 두고, 코드 블럭에 맞게 들여쓰기를 맞춘다.

  • 코드 뒤에 주석을 달 때는 ; 기호 1개를 사용한다.

다음은 위의 관행을 따른 예제이다.

;;;; frob namespace
(ns my-project.frob)

;;; The next 20 functions do various sorts of frobbing

;; frob1 function
(defn frob1 [num]
  ;; return double frob of num
  (let [tmp (ran-int num)]   ; breaks if 0, fix!
    (double-frob tmp num :with-good-luck true)))

;; frob2 function
(defn frob2 [lst]
  (frob-aux (first lst)))

2.2. comment 매크로

comment 매크로는 여러 개의 최상위(top level) 코드들을 한꺼번에 모두 주석 처리하고 싶을 때 주로 사용한다.

(comment
  (def greeting "Hello, World!")

  ;; The first version of main function.
  ;; TODO: delete if a new version completes.
  (defn -main []
    "I can say 'Hello World'."
    (println greeting))
)

(defn hello-world [] (println "Hello, World!"))

(defn -main []
  "I can call a function, which prints 'Hello World'."
  (hello-world))

comment 매크로는 괄호로 둘러 싸인 부분을 주석으로 처리한 후, 반환값으로 항상 nil을 반환한다. 따라서 다음과 같은 곳에서 이 매크로를 사용하면 안된다.

;; 다음의 코드는 결과적으로 (+ 10 20 nil 30)을 계산하게 된다.
;; 이때 + 함수는 nil을 대상으로 연산을 수행할 수 없으므로 예외가 발생한다.
(+ 10 20 (comment (* 2 3)) 30)
;>> NullPointerException   clojure.lang.Numbers.ops

아울러 comment 매크로로 둘러싸인 코드는, 클로저 문법에 맞는 코드이어야 한다. 다음의 코드는 클로저가 읽어 들일 수 없는 코드이므로 예외가 발생했다.

(comment
  a : b
)
;>> RuntimeException Invalid token: :

2.3. #_ reader 매크로

#_ reader 매크로는 그 뒤에 나오는 형식(form) 한 개만을 주석 처리한다.

Caution
#_ reader 매크로는 그 다음에 나오는 형식(form)의 소스 코드를 클로저 자료형으로 아예 읽어 들이지 않고 무시한다. 반면에 comment 일반 매크로는 괄호로 둘러 싸인 소스 코드를 클로저 자료형으로 일단 읽어 들인 후 무시한다는 점에서 #_ reader 매크로와 다르다. 그래서 comment의 경우에는 소스 코드를 읽어 들이는 과정에서 오류가 없어야 한다.
;; 아래의 코드는 (+ 10 20 30)과 동일하다.
(+ 10 20 #_ (* 2 3) 30)      ;=> 60

;; 아래의 코드는 (+ 20 30)과 동일하다.
(+ #_ 10 20 #_ (* 2 3) 30)   ;=> 50

1. 참고로, 클로저에서 부수 효과를 수행하는 함수들은 대체로 nil을 반환한다.