ANSI Common Lisp 4장. 특화된 자료 구조 학술

ANSI Common Lisp 4장. 특화된 자료 구조앞 장에서는 Lisp에서 가장 다양하게 사용되는 자료 구조인 리스트를 살펴보았습니다. 이 장에서는 배열(벡터와 문자열을 포함), 구조체, 해시 테이블과 같은 Lisp의 다른 자료 구조들을 어떻게 사용하는지 알아보겠습니다. 이 자료 구조들은 리스트처럼 유연하지는 않지만, 그 대신 접근 속도가 빠르고 공간도 적게 소모합니다.


이 외에도 Common Lisp에는 인스턴스라는 자료 구조도 존재합니다. 이것은 11장에서 CLOS와 함께 다루겠습니다.

배열

Common Lisp에서 배열을 만드려면 make-array 함수를 호출합니다. 함수를 호출할 때에는 첫번째 인수로 차원을 담은 리스트를 넘겨야 합니다. 2x3 배열을 만드려면 이렇게 합니다:

> (setf arr (make-array '(2 3) :initial-element nil))
#<Simple-Array T (2 3) BFC4FE>

Common Lisp의 배열은 최소한 7개의 차원을 가질 수 있도록 되어있습니다. 그리고 각 차원은 최소한 1023개의 요소를 가질 수 있지요.


:initial-element 인수는 옵션입니다. 이 키워드 인수를 넘기게 되면 배열 전체가 지정한 값으로 초기화됩니다. 초기화되지 않은 배열의 요소를 가져오려고 시도할 때 발생하는 일은 정의되어 있지 않습니다.


배열의 특정 요소를 가져오려고 할 때에는 aref 함수를 사용합니다. 일반적인 Common Lisp 접근 함수들처럼 aref도 0부터 시작하는 인덱스를 사용합니다:

(aref arr 0 0)
NIL

배열의 특정 요소를 바꾸려면, setf를 aref와 같이 사용합니다:

> (setf (aref arr 0 0) 'b)
B
> (aref arr 0 0)
B

배열을 문자로 표현할 때에는 #na 문법을 사용합니다. n은 배열의 차원입니다. 예를 들어, 위의 arr과 동등한 배열을 아래와 같이 표기할 수 있습니다:

#2a ((b nil nil) (nil nil nil))

전역 변수 *print-array*가 t인 경우 배열은 이런 형태로 출력될 것입니다

> (setf *print-array* t)
T
> arr
#2A((B NIL NIL) (NIL NIL NIL))

1차원 배열만을 원한다면, make-array의 첫번째 인수에 리스트를 넣는 대신 그냥 정수 하나를 넘겨도 됩니다:

> (setf vec (make-array 4 :initial-element nil))
#(NIL NIL NIL NIL)

1차원 배열은 벡터라고도 불립니다. 길게 쓸 것 없이 vector 함수만 호출하면 한 번에 벡터를 만들어서 주어진 인수로 초기화까지 할 수 있습니다:

> (vector "a" 'b 3)
#("a" B 3)

벡터를 문자로 표현할 때에는 이런 문법으로 표시됩니다. 배열을 문자로 표시할 때 #na를 사용하는 것과 비슷하지요.


벡터의 요소를 접근할 때에도 aref를 사용할 수 있습니다만, 벡터에는 svref 함수를 사용하는 것이 더 빠릅니다.

> (svref vec 0)
NIL

이 함수의 이름에 써있는 "sv"는 "simple vector"의 약자입니다. 모든 벡터는 기본적으로 간단한 벡터(simple vector)에 해당됩니다.

(defun bin-search (obj vec)
(let ((len (length vec)))
(and (not (zerop len))
(finder obj vec 0 (- len 1)))))

(defun finder (obj vec start end)
(let ((range (- end start)))
(if (zerop range)
(if (eql obj (aref vec start))
obj
nil)
(let ((mid (+ start (round (/ range 2)))))
(let ((obj2 (aref vec mid)))
(if (< obj obj2)
(finder obj vec start (- mid 1))
(if (> obj obj2)
(finder obj vec (+ mid 1) end)
obj)))))))

예제: 이진 검색

이 절에서는 정렬된 벡터를 대상으로 특정 객체를 검색하는 함수를 어떻게 작성하는지 보여드리겠습니다. 어떤 벡터가 정렬되어 있기만 하다면, 그냥 앞에서부터 순서대로 뒤지는 것(65쪽 find)보다 훨씬 나은 방법으로 검색할 수 있습니다. 먼저 벡터의 가운데 부분을 찾아봅니다. 딱 찍어서 맞았으면 검색 끝난거죠. 그렇지 않다면 벡터의 왼쪽이나 오른쪽을 찾아야 합니다. 가운데 요소보다 우리가 찾으려는 객체가 작으면 왼쪽, 크면 오른쪽 부분을 찾아나가면 됩니다.


그림 4.1은 이런 방식으로 동작하는 함수를 포함하고 있습니다. bin-search 함수는 검색할 구간을 초기화하고 finder를 호출합니다. finder는 벡터 vec의 start와 end 구간 사이에 있는 obj를 검색합니다.


검색 구간의 길이가 0이 되었을 때, 마지막 요소가 찾는 요소이면 obj를 반환하고 그렇지 않으면 nil을 반환합니다. 검색 구간 내에 요소가 몇 개 있는 경우에는 가운데 지점을 찾아서 (round 함수는 주어진 인수에 가장 가까운 정수를 반환합니다.) 그 위치에 있는 요소를 살펴봅니다. (obj2) 만약 obj가 obj2보다 작으면 검색은 벡터의 왼쪽 절반을 재귀적으로 검색해나갑니다. obj가 obj2보다 큰 경우에는 벡터의 오른쪽 절반을 재귀적으로 검색해나가구요. 남아있는 것은 이제 obj = obj2인 경우인데 이 경우에는 우리가 원하는 것을 찾았으니 그냥 반환해버리면 됩니다.

주석을 쓰는 방법

Common Lisp 코드에서는 세미콜론 뒤에 오는 모든 것이 주석으로 취급됩니다.
Lisp 프로그래머들은 여러 개의 세미콜론을 묶어서 주석을 구조적으로 표시하는데 사용합니다:
네 개의 세미콜론은 제목, 세 개의 세미콜론은 함수나 매크로의 설명,
두 개의 세미콜론은 줄 단위 주석으로 사용하고, 한 개의 세미콜론은 코드와 같은 줄의 끝에 주석을 달 때 사용합니다.
이런 방식대로 그림 4.1에 주석을 달아보자면 이렇게 되겠습니다:

;;;; 정렬된 벡터를 대상으로 연산을 수행하는 유틸리티

;;; 정렬된 벡터에서 특정 요소를 찾는다.

(defun bin-search (obj vec)
(let ((len (length vec)))
;; 진짜 벡터라면 finder에게 넘긴다
(and (not (zerop len)) ; 비어있는 벡터라면 nil을 반환한다
(finder obj vec 0 (- len 1)))))

주석을 아주 많이 써야하는 경우에는 #|...|# read-macro를 사용하는 것이 좋습니다. read는 #|와 |# 사이에 있는 모든 문자를 무시합니다.

finder 시작 부분에 아래의 코드를 한 줄 더 집어넣으면

(format t "~A~%" (subseq vec start (+ end 1)))

각 단계마다 검색 대상이 되는 절반 부분에 속한 숫자들을 볼 수 있습니다:

> (bin-search 3 #(0 1 2 3 4 5 6 7 8 9))
#(0 1 2 3 4 5 6 7 8 9)
#(0 1 2 3)
#(3)
3

문자열과 문자

문자열은 문자로 이루어진 벡터입니다. 상수 문자열을 표기할 때에는 큰 따옴표 안에 문자들을 나열합니다. c라는 문자 하나는 #\c로 표기합니다.


각 문자는 정수와 연관되어 있습니다 - 항상 그렇다고는 할 수 없지만 보통은 아스키 숫자이지요: 대부분의 구현체에서는 char-code 함수가 문자에 연관된 숫자를 반환해줍니다. code-char는 반대로 숫자에 연관된 문자를 되돌려줍니다.


char< (작은), char<= (작거나 같은), char= (같은), char>= (크거나 같은), char> (큰), char/= (같지 않은) 함수들은 문자를 비교합니다. 이들은 146쪽에 설명된 숫자 비교 연산자와 비슷하게 동작하지요.

> (sort "elbow" #'char<)
"below"

문자열이 벡터이기 때문에, 시퀀스 함수들이나 배열 함수들 모두 문자열에 적용할 수 있습니다. 똑같이 aref를 써서 요소를 가져올 수 있습니다. 예를 들면,

> (aref "abc" 1)
#\b

그렇지만 문자열에는 char 함수를 쓰는 것이 더 빠릅니다:

> (char "abc" 1)
#\b

setf 함수와 char (또는 aref)를 같이 사용하면 요소를 바꿔 쓸 수 있습니다:

> (let ((str (copy-seq "Merlin")))
(setf (char str 3) #\k)
str)
"Merkin"

두 개의 문자열을 비교할 때에는 equal을 사용할 수 있습니다. 대소문자를 무시하고 비교하려면 string-equal을 사용하면 됩니다:

> (equal "fred" "fred")
T
> (equal "fred" "Fred")
NIL
> (string-equal "fred" "Fred")
T

Common Lisp는 문자열을 비교하고 조작하는 많은 수의 함수들을 제공하고 있습니다. 전체 목록은 부록 D의 364쪽에서 찾아볼 수 있습니다.


문자열을 만드는 몇 가지 방법이 있는데요. 가장 일반적인 방법은 format을 사용하는 것입니다. 첫번째 인수에 nil을 주고 format을 호출하면 화면에 문자열을 출력하지 않고 반환합니다:

> (format nil "~A or ~A" "truth" "dare")
"truth or dare"

문자열 여러 개를 합치고 싶은 경우라면 concatenate 함수를 사용할 수 있습니다. 이 함수는 반환값의 타입을 나타내는 심볼과 하나 이상의 시퀀스를 받습니다:

> (concatenate 'string "not " "to worry")
"not to worry"

시퀀스

Common Lisp의 sequence 타입은 리스트와 벡터를 포함합니다. (당연히 문자열도 포함됩니다.) 리스트에 사용해왔던 함수들 중 일부는 사실 시퀀스 함수들입니다. remove, length, subseq, reverse, sort, every, some 같은 함수들은 시퀀스 함수입니다. 따라서 46쪽에서 작성했던 함수는 다른 종류의 시퀀스에 대해서도 마찬가지로 동작합니다:

> (mirror? "abba")
T

앞에서 이미 시퀀스 내에서 요소를 꺼내오는 네 가지 함수를 봤습니다: 리스트에는 nth, 벡터에는 aref와 svref, 문자열에는 char를 사용했었지요. Common Lisp는 어떤 종류의 시퀀스에도 사용할 수 있는 elt 함수를 제공합니다:

> (elt '(a b c) 1)
B

특정 타입의 시퀀스에 대해서는, 이미 봤던 것처럼 더 빠른 접근 함수가 있습니다. 따라서 시퀀스를 아주 일반적으로 다루어야 할 코드 외에는 elt 함수를 사용할 일이 별로 없을 것입니다. 


elt 함수를 사용해서 벡터에 더 효율적인 mirror? 함수를 만들어 보겠습니다.

(defun mirror? (s)
(let ((len (length s)))
(and (evenp len)
(do ((forward 0 (+ forward 1))
(back (- len 1) (- back 1)))
((or (> forward back)
(not (eql (elt s forward)
(elt s back))))
(> forward back))))))

이 버전은 리스트에도 써먹을 수는 있겠지만 벡터에 더 잘 맞도록 되어있습니다. 리스트는 순차적인 접근 밖에 할 수 없기 때문에, elt를 자주 호출하는 것은 굉장히 비효율적입니다. 벡터는 임의 접근을 지원하므로 아무 요소나 마음대로 접근해도 별 문제가 없습니다.


시퀀스 함수들은 대부분 아래 테이블에 나열된 키워드 인수들을 받아들입니다:


매개변수 목적 기본값
:key 각 요소에 적용할 함수 identity

:test 비교 함수 eql
:from-end 참인 경우 끝에서부터 동작 nil
:start 시작 위치 0

:end 정지 위치 nil


이 모든 것을 받아들이는 함수로는 position이 있습니다. 이 함수는 특정 요소를 찾아서 그 위치를 반환합니다. 찾지 못한 경우에는 nil을 반환하구요. position을 이용해서 위에서 제시한 각 키워드 인수의 역할을 확인해봅시다:

> (position #\a "fantasia")
1
> (position #\a "fantasia" :start 3 :end 5)
4

두번째 예제는 네번째부터 여섯번째 문자 사이에 있는 a의 위치를 찾아달라고 한 것입니다. :start 인수는 고려해야 할 첫번째 요소의 위치입니다. 기본값은 시퀀스의 첫번째 요소입니다. :end 인수는 고려하지 말아야 할 첫번째 요소의 위치입니다. 


만약 :from-end 인수를 주게 되면,

> (position #\a "fantasia" :from-end t)
7

끝에 가장 가까운 a의 위치를 얻게 됩니다. 끝에서부터 찾았다고는 하지만 위치는 일반적인 경우처럼 표시됩니다; 즉, 끝에서부터의 거리가 아니라 앞에서부터의 거리이지요.


:key 인수는 시퀀스에서 각 요소를 꺼내 비교하기 전에 적용할 함수입니다. 아래와 같은 경우,

> (position 'a '((c d) (a b)) :key #'car)
1

car를 적용한 결과가 심볼 a인 첫번째 요소의 위치를 찾으라고 하는 것이 됩니다.


:test 인수는 두 개의 인수를 받아서 비교하는 함수를 정의합니다. 기본값은 항상 eql입니다. 만약 리스트를 비교하고자 한다면, equal을 사용해야 합니다:

> (position '(a b) '((a b) (c d)))
NIL
> (position '(a b) '((a b) (c d)) :test #'equal)
0

:test 인수는 두 개의 인수를 받아들이는 어떤 함수라도 가능합니다. 예를 들어 < 함수를 주게 되면 첫번째 인수보다 큰 첫번째 요소의 위치를 얻게됩니다:

> (position 3 '(1 0 7 5) :test #'<)
2

subseq와 position 함수를 사용하면 시퀀스의 일부분을 얻는 함수를 작성할 수 있습니다. 예를 들어 이 함수는

(defun second-word (str)
(let ((p1 (+ (position #\ str) 1)))
(subseq str p1 (position #\ str :start p1))))

공백으로 구분되는 단어 중 두 번째 단어를 문자열로 반환합니다:

> (second-word "Form follows function.")
"follows"

하나의 인수만 받아들이는 술어를 만족시키는 요소를 찾는 경우에는 position-if를 사용합니다. 이 함수는 함수 하나와 시퀀스를 받아서, 함수를 만족시키는 첫번째 요소의 위치를 반환합니다:

> (position-if #'oddp '(2 3 4 5))
1

이 함수는 :test만 제외하고 모든 키워드 인수를 받아들입니다. 


이 외에도 member나 member-if와 비슷한 시퀀스 함수들이 있습니다. 바로 find(모든 키워드 인수를 받아들입니다.)와 find-if(:test만 제외하고 모든 키워드 인수를 받아들입니다.) 입니다:

> (find #\a "cat")
#\a
> (find-if #'characterp "ham")
#\h

member나 member-if와는 다르게, 이 함수들은 찾고 있던 객체만 반환합니다. 


find-if 대신 find와 :key 인수의 조합으로 바꿔서 쓰는 것이 더 깔끔한 경우가 많습니다. 예를 들어 표현식

(find-if #'(lambda (x)
(eql (car x) 'complete))
lst)

이렇게 쓰는 것보다 아래와 같이 쓰는 편이 훨씬 깔끔하죠.

(find 'complete lst :key #'car)

remove(22쪽)와 remove-if 함수 역시 어떤 종류의 시퀀스에도 동작합니다. 두 함수의 관계는 find와 find-if의 관계와 같습니다. 관련된 함수로는 remove-duplicates가 있는데, 이 함수는 시퀀스 내에서 중복된 요소들을 제거하고 마지막 요소만 남겨둡니다:

> (remove-duplicates "abracadabra")
"cdbra"

이 함수도 표에 나왔던 모든 키워드 인수들을 지원합니다.


reduce 함수는 시퀀스를 하나의 값으로 줄여버립니다. 적어도 함수와 시퀀스, 이렇게 두 개의 인수를 받게 되어 있습니다. 가장 간단한 경우에는 처음 두 개의 요소를 주어진 함수에 넣습니다. 시퀀스의 다음 요소를 두 번째 인수로, 마지막 호출 결과를 첫번째 인수로 함수를 호출하는 과정을 반복합니다. 마지막 함수 호출에서 반환되는 값이 reduce의 값입니다. 따라서 다음과 같은 표현식은,

(reduce #'fn '(a b c d))

아래의 표현식과 동등합니다.

(fn (fn (fn 'a 'b) 'c) 'd)

reduce를 이용하면 두 개의 인수만 받는 함수들을 확장할 수 있습니다. 가령 세 개 이상의 리스트를 대상으로 교집합을 구하려면 이렇게 쓸 수 있습니다.

> (reduce #'intersection '((b r a d 's) (b a d) (c a t)))
(A)

예제: 날짜 파싱

이 절에서는 시퀀스 연산에 대한 한 가지 예로 날짜를 파싱하는 프로그램을 하나 보여드리겠습니다. "16 Aug 1980" 같은 문자열을 받아서 일, 월, 년도를 표현하는 정수의 리스트를 반환하는 프로그램을 작성할 겁니다.

(defun tokens (str test start)
(let ((p1 (position-if test str :start start)))
(if p1
(let ((p2 (position-if #'(lambda (c)
(not (funcall test c)))
str :start p1)))
(cons (subseq str p1 p2)
(if p2
(tokens str test p2)
nil)))
nil)))

(defun constituent (c)
(and (graphic-char-p c)
(not (char= c #\ ))))

그림 4.2: 토큰 인식

그림 4.2는 이 응용 프로그램에서 우리가 필요로 하는 다용도의 파싱 함수를 포함하고 있습니다. 먼저 tokens 함수는 문자열에서 토큰을 추출하기 위해 사용됩니다. 문자열과 테스트 함수를 주면, 주어진 테스트 함수를 만족하는 문자열로 이루어진 부분 문자열로 구성된 리스트를 반환합니다. 예를 들어, 알파벳 문자를 가려내는 alpha-char-p 함수를 테스트 함수로 사용하게 되면:

> (tokens "ab12 3cde.f" #'alpha-char-p 0)
("ab" "cde" "f")

이 함수를 만족시키지 못하는 모든 문자들은 공백으로 취급됩니다 - 토큰을 분리시키는데 이용되지만 토큰의 일부로 포함되지는 않습니다.


constituent 함수는 tokens에 인수로 넘길 용도로 만들어진 것입니다. Common Lisp에서는 그래픽 문자란 우리가 볼 수 있는 모든 문자를 의미합니다. 여기에는 스페이스도 포함됩니다. 따라서 constituent를 테스트 함수로 사용하게 되면,

> (tokens "ab12 3cde.f
gh" #'constituent 0)
("ab12" "3cde.f" "gh")

tokens 함수는 관습적인 개념의 공백(whitespace)을 처리할 수 있게 됩니다.


그림 4.3은 날짜 파싱에 사용되는 함수들을 포함하고 있습니다. parse-date 함수는 정해진 양식의 날짜를 받아서 각 구성 요소를 표현하는 정수의 리스트를 반환합니다:

(defun parse-date (str)
(let ((toks (tokens str #'constituent 0)))
(list (parse-integer (first toks))
(parse-month (second toks))
(parse-integer (third toks)))))

(defconstant month-names
#("jan" "feb" "mar" "apr" "may" "jun"
"jul" "aug" "sep" "oct" "nov" "dec"))

(defun parse-month (str)
(let ((p (position str month-names
:test #'string-equal)))
(if p
(+ p 1)
nil)))

그림 4.3: 날짜 파싱 함수들

> (parse-date "16 Aug 1980:)

(16 8 1980)

이 함수는 tokens 함수를 사용해서 날짜 문자열을 쪼갠 후, 각 요소를 해석하기 위해 parse-month와 parse-integer 함수를 호출합니다. 달을 찾을 때는 parse-month 함수를 호출하는데, 여기서 string-equal 함수를 쓰기 때문에 대소문자를 가리지 않고 달의 이름을 비교하게 됩니다. 일자와 년도를 찾을 때에는 내장 함수인 parse-integer 함수를 사용하는데, 이 함수는 문자열을 받아서 거기에 대응하는 정수를 반환하도록 되어 있습니다. 


만약 직접 정수를 파싱하는 함수를 작성해야 한다면 이렇게 쓸 수 있을 것입니다:

(defun read-integer (str)
(if (every #'digit-char-p str)
(let ((accum 0))
(dotimes (pos (length str))
(setf accum (+ (* accum 10)
(digit-char-p (char str pos)))))
accum)
nil))

이 코드는 Common Lisp에서 어떻게 문자를 숫자로 바꾸는지 보여줍니다. digit-char-p 함수는 문자가 숫자에 해당되는지 테스트할 뿐 아니라 문자에 대응하는 정수까지 반환해줍니다.

구조체

구조체는 벡터의 확장판이라고 생각할 수 있습니다. 가령 다수의 네모난 물체를 추적하는 프로그램을 작성한다고 해봅시다. 3개의 요소 - 높이, 너비, 깊이 - 를 가진 벡터를 사용해서 네모난 물체를 표현할 수 있습니다. 그런데 아무래도 svref 함수를 그냥 쓰는 것보다는 아래와 같은 함수를 정의하는 편이 훨씬 읽기 쉬울겁니다.

(defun block-height (b) (svref b 0))

따라서 구조체는 벡터와 함께 이런 함수들이 모두 미리 정의된 상태로 딸려오는 것이라고 생각할 수 있겠습니다. 


구조체를 정의할 때는 defstruct를 사용합니다. 가장 간단한 경우에는 그냥 구조체의 이름과 각 필드의 이름만 써주면 됩니다.

(defstruct point
x
y)

이렇게 쓰면 x와 y, 두 개의 필드를 가진 point라는 구조체를 정의한 것이 됩니다. 게다가 암시적으로 make-point, point-p, copy-point, point-x, point-y 함수도 정의한 것이 됩니다.


예전에 2.3절에서 Lisp 프로그램을 만드는 Lisp 프로그램에 대해 언급한 적이 있었는데요. 우리가 지금까지 봤던 것들 중에 이것이 가장 두드러지는 예제라고 할 수 있겠습니다. defstruct를 호출하기만 하면 자동으로 몇몇 함수들을 정의하는 코드를 생성하니까요. 매크로를 사용하면 여러분도 이런 일을 똑같이 해낼 수 있습니다. (원한다면 defstruct를 직접 작성할 수도 있지요.)


make-point를 호출하면 항상 새로운 point를 생성하게 됩니다. 이 때 각 필드를 대응되는 키워드 인수를 이용해서 초기화 하는 것이 가능합니다:

> (setf p (make-point :x 0 :y 0))
#S(POINT X 0 Y 0)

point 접근 함수들은 값을 가져오는 용도 외에 setf를 쓸 때에도 사용할 수 있습니다.

> (point-x p)
0
> (setf (point-y p) 2)
2
> p
#S(POINT X 0 Y 2)

구조체를 정의하면 타입의 이름도 지정하는 것이 됩니다. 각 point는 point 타입이며, 구조체이고, 원자이며, 참입니다. 그렇기 때문에 어떤 것이 point인지 테스트할 때에는 point-p 뿐 아니라 다용도로 쓰이는 함수인 typep를 사용할 수도 있습니다.

> (point-p p)
T
> (typep p 'point)
T

구조체 필드의 기본값을 지정할 때에는 필드 이름과 기본값을 나타내는 표현식을 리스트 안에 집어넣는 형태로 바꾸기만 하면 됩니다.

(defstruct polemic
(type (progn
(format t "What kind of polemic was it? ")
(read)))
(effect nil))

make-polemic을 아무런 초기 값 없이 호출하게 되면 미리 지정한 표현식의 값으로 설정됩니다.

> (make-polemic)
What kind of polemic was it? scathing
#S(POLEMIC TYPE SCATHING EFFECT NIL)

구조체가 화면에 출력되는 방법을 제어할 수도 있습니다. 자동으로 만들어지는 접근 함수들의 접두어를 바꿀 수도 있구요. 이 두 가지를 보여드리기 위해 좀 더 장황하게 point를 정의해봤습니다:

(defstruct (point (:conc-name p)
(:print-function print-point))
(x 0)
(y 0))

(defun print-point (p stream depth)
(format stream "#<~A, ~A>" (px p) (py p)))

:conc-name 인수는 접근 함수를 만들 때 필드 이름 앞에 붙는 문자열을 결정합니다. 기본값이 적용되었을 때는 point-가 앞에 붙었습니다만, 지금은 그냥 단순히 p만 앞에 붙었습니다. 이렇게 쓰면 코드를 읽기가 좀 어려워지긴 하지만, 이런 접근 함수를 자주 사용한다면 함수 이름의 길이를 조금이라도 줄이고 싶을테니까요. 


:print-function은 point를 화면에 출력할 때 사용할 함수의 이름을 지정합니다. 이 함수는 반드시 세 개의 인수를 받아야 합니다. 첫번째 인수는 출력될 구조체가 오고, 두번째 인수는 출력될 위치가 옵니다. 세번째 인수는 일반적으로 무시합니다. 나중에 7.1절에서 스트림을 다루게 될텐데요. 지금 당장은 스트림을 받아서 format에 넘기면 된다는 것만 기억해두시면 됩니다.


print-point 함수는 아래와 같이 축약된 형태로 point를 표현하게 됩니다.

> (make-point)
#<0,0>

예제: 이진 검색 트리

sort 함수는 Common Lisp에 내장되어 있기 때문에 직접 정렬 코드를 작성해야 되는 경우는 거의 없습니다. 이 절에서는 당장 가져다가 쓸 것이 없는 문제를 어떻게 풀어내는지 보여드리려고 합니다. 객체를 정렬된 상태로 유지하는 문제인데요. 여기에서 제시하는 코드는 객체를 이진 검색 트리에 저장하게 될겁니다. 이진 검색 트리가 잘 균형이 잡혀있다고 가정하면 요소를 검색, 추가, 삭제하는 시간은 log n에 비례합니다. 여기서 n은 집합의 크기입니다.


이진 검색 트리는 순서를 결정하는 함수 <에 대해 특정 요소보다 작은 요소는 왼쪽 자식 노드에, 특정 요소보다 큰 요소는 오른쪽 자식 노드에 위치하는 이진 트리를 뜻합니다.

(defstruct (node (:print-function
(lambda (n s d)
(format s "#<~A>" (node-elt n)))))
elt (l nil) (r nil))

(defun bst-insert (obj bst <)
(if (null bst)
(make-node :elt obj)
(let ((elt (node-elt bst)))
(if (eql obj elt)
bst
(if (funcall < obj elt)
(make-node
:elt elt
:l (bst-insert obj (node-l bst) <)
:r (node-r bst))
(make-node
:elt elt
:r (bst-insert obj (node-r bst) <)
:l (node-l bst)))))))

(defun bst-find (obj bst <)
(if (null bst)
nil
(let ((elt (node-elt bst)))
(if (eql obj elt)
bst
(if (funcall < obj elt)
(bst-find obj (node-l bst) <)
(bst-find obj (node-r bst) <))))))

(defun bst-min (bst)
(and bst
(or (bst-min (node-l bst)) bst)))

(defun bst-max (bst)
(and bst
(or (bst-max (node-r bst)) bst)))

그림 4.5: 이진 검색 트리: 검색 및 추가

그림 4.5는 이진 검색 트리에 객체를 집어넣거나 찾는 함수를 포함하고 있습니다. 여기에서 가장 간단한 자료 구조는 node인데, 여기에는 세 개의 필드가 포함되어 있습니다. 하나는 노드에 저장된 객체를 위한 것, 나머지 둘은 각각 왼쪽과 오른쪽 자식 노드를 위한 것입니다. 


이진 검색 트리는 항상 nil이거나 node입니다. node는 이진 검색 트리의 l 필드이거나 r 필드입니다. cons를 연속으로 호출하여 리스트를 만드는 것처럼, 이진 검색 트리는 bst-insert를 연속으로 호출하여 만들어내게 됩니다. 이 함수는 객체 하나, 이진 검색 트리, 순서 함수를 받아서 주어진 객체를 포함한 이진 검색 트리를 반환합니다. cons처럼 bst-insert도 두 번째 인수로 주어진 이진 검색 트리 자체를 수정하지는 않습니다. 이진 검색 트리를 만드는데 사용한 방법은 아래와 같습니다:

> (setf nums nil)
NIL
> (dolist (x '(5 8 4 2 1 9 6 7 3))
(setf nums (bst-insert x nums #'<)))
NIL

이 시점에서 nums의 구조는 그림 4.4와 같은 트리 모양을 가지게 됩니다.


bst-find도 bst-insert와 같은 인수들을 받습니다. 이 함수를 사용하면 이진 검색 트리에서 객체를 찾는 것이 가능합니다. node 구조체를 들여다보면 두 개의 cdr을 가진 cons cell과 비슷하다는 것을 알 수 있습니다. 예전에 만들었던 our-member 함수 정의와 bst-find 함수 정의를 비교해보면 더 확실하게 알 수 있지요.


bst-find 함수는 member 함수처럼 찾는 요소만 반환하는 것이 아니라 그 노드가 루트인 트리 전체를 반환합니다:

> (bst-find 12 nums #'<)
NIL
> (bst-find 4 nums #'<)
#<4>

이렇게 nil을 이용하여 검색에 성공했는지 실패했는지 구분할 수 있습니다.


이진 검색 트리에서 가장 작은 것과 가장 큰 것을 찾는 것은 쉬운 일입니다. 가장 작은 것을 찾으려면 bst-min에 쓰인대로 그냥 왼쪽 자식 노드만 타고 내려가면 됩니다. 반대로 가장 큰 것을 찾으려면 bst-max와 같이 그냥 오른쪽 자식 노드만 타고 내려가면 됩니다:

> (bst-min nums)
#<1>

> (bst-max nums)
#<9>

이진 검색 트리에서 임의의 요소를 제거하는 것은 매우 빠르지만, 코드가 꽤 많이 필요합니다. 그림 4.6에 삭제를 수행하는 코드가 쓰여있습니다. bst-remove가 삭제하려는 객체와 이진 검색 트리, 정렬 함수를 받으면 해당 객체만 삭제한 형태의 이진 검색 트리를 반환합니다. 이 함수도 remove 함수처럼 두 번째 인수로 받은 이진 검색 트리 자체를 수정하지는 않습니다:

> (setf nums (bst-remove 2 nums #'<))
#<5>
> (bst-find 2 nums #'<)
NIL

이 시점에서 nums는 그림 4.7과 같은 구조를 가지게 될 것입니다. (2가 있던 자리를 3이 차지하는 대신 1이 차지하는 경우도 가능합니다.)

(defun bst-remove (obj bst <)
(if (null bst)
nil
(let ((elt (node-elt bst)))
(if (eql obj elt)
(percolate bst)
(if (funcall < obj elt)
(make-node
:elt elt
:l (bst-remove obj (node-l bst) <)
:r (node-r bst))
(make-node
:elt elt
:r (bst-remove obj (node-r bst) <)
:l (node-l bst)))))))

(defun percolate (bst)
(cond ((null (node-l bst))
(if (null (node-r bst))
nil
(rperc bst)))
((null (node-r bst)) (lperc bst))
(t (if (zerop (random 2))
(lperc bst)
(rperc bst)))))

(defun rperc (bst)
(make-node :elt (node-elt (node-r bst))
:l (node-l bst)
:r (percolate (node-r bst))))

(defun lperc (bst)
(make-node :elt (node-elt (node-l bst))
:l (percolate (node-l bst))
:r (node-r bst)))

그림 4.6: 이진 검색 트리: 삭제
그림 4.7: 요소를 삭제한 후의 이진 검색 트리

가운데에 껴있던 노드를 삭제하려면 빈 자리를 자식 노드로 메워야 하기 때문에 일이 좀 복잡합니다. percolate 함수가 이런 용도로 만들어진 함수인데요. 이 함수는 이진 검색 트리의 최상위 요소를 자식 노드 중 하나로 대체하는 일을 재귀적으로 수행합니다. 


percolate 함수는 트리의 균형을 유지하기 위해 2개의 자식 노드 중 하나를 랜덤하게 선택합니다. (random 2) 표현식은 0일 수도 있고 1일 수도 있습니다. 그러니 (zerop (random 2)) 표현식은 대충 절반 정도는 참을 반환하게 될 것입니다. 

(defun bst-traverse (fn bst)
(when bst
(bst-traverse fn (node-l bst))
(funcall fn (node-elt bst))
(bst-traverse fn (node-r bst))))

그림 4.8: 이진 검색 트리: 순회

이진 검색 트리에 임의의 순서대로 객체들을 집어넣었더라도, 트리를 중위 순회하면 오름차순으로 정렬된 결과를 얻을 수 있습니다. 그림 4.8의 bst-traverse 함수는 이런 목적으로 만들어진 것입니다:

> (bst-traverse #'princ nums)
13456789
NIL

(princ 함수는 객체를 출력하는 역할만 합니다.)


이 절에서 주어진 코드는 이진 검색 트리의 뼈대만 구현한 것입니다. 여러분의 필요에 따라 여기에 살을 붙일 수 있겠지요. 여기에서는 각 노드에 딱 하나의 elt 필드만 존재하지만, 사실 많은 어플리케이션에서는 key 필드와 value 필드를 가지는 것이 일반적입니다. 그리고, 이 장에서는 이진 검색 트리를 집합처럼 다루었습니다. 같은 요소를 여러번 입력하면 그냥 무시되지요. 그렇지만 약간만 수정하면 중복된 요소들을 처리하도록 할 수 있습니다.


이진 검색 트리가 정렬된 객체 모음을 유지하는 유일한 방법은 아닙니다. 일반적으로 이진 검색 트리는 삽입과 삭제가 거의 비슷하게 발생할 때에 최적으로 동작합니다. 그렇기 때문에 우선순위 큐에 이진 검색 트리를 사용하는 것은 별로 좋지 않습니다. 우선순위 큐에서는 입력이 균형있게 들어올 수 있다고 하더라도, 항상 최소값이나 최대값을 삭제하기 마련입니다. 이렇게 되면 이진 검색 트리의 균형이 깨지게 됩니다. 원래는 삽입과 삭제 모두 O(log n)의 효율을 기대할 수 있어야 하지만, 트리가 한 쪽으로 기울게 되면 O(n)이 되고 맙니다. 이진 검색 트리를 우선순위 큐로 사용할 바에는 그냥 일반적인 리스트를 사용하는 편이 나을 것입니다. 어차피 똑같은 결과를 가져올테니까요.

해시 테이블

3장에서는 리스트를 집합과 매핑을 표현하는데 사용할 수 있음을 보여드렸습니다. 그렇지만 상당한 크기로 데이터가 늘어나면 해시 테이블을 사용하는 것이 더 빠릅니다. make-hash-table을 호출해서 해시 테이블을 생성할 수 있습니다. 이 함수는 필수적으로 넘겨야 할 인수가 없습니다:

> (setf ht (make-hash-table))
#<Hash-Table BF0A96>

해시 테이블은 함수처럼 항상 #<...> 형식으로 표시됩니다.


해시 테이블은 연관 리스트와 같이 객체의 쌍을 표현하는 용도로 사용됩니다. 주어진 키에 대응되는 값을 얻기 위해서는 gethash 함수를 호출하면서 키와 해시 테이블을 넘겨주면 됩니다. gethash는 키에 연관된 값을 찾지 못한 경우 기본적으로 nil을 반환합니다.

> (gethash 'color ht)
NIL
NIL

방금 Common Lisp의 특이한 기능 하나를 처음으로 보셨습니다. 표현식은 여러 개의 값을 반환할 수 있습니다. gethash 함수는 두 개의 값을 반환합니다. 첫번째는 키에 연관된 값입니다. 두번째는 해시 테이블이 그 키에 대응되는 값을 저장하고 있는지 아닌지 알려줍니다. 두번째 값이 nil이기 때문에, 첫번째 값이 명시적으로 color에 연관된 nil이 아니라 기본값으로 반환된 nil이라는 것을 알 수 있습니다.

대부분의 구현체들이 최상위 계층에서 호출한 결과로 나오는 반환값은 전부 다 표시해주지만, 코드는 하나의 반환값만 받으려고 하기 때문에 항상 첫번째 반환값을 얻게 됩니다. 여러 개의 반환값을 받을 수 있도록 코딩하는 방법은 5.5절에서 설명하겠습니다.


키에 연관된 값을 설정하려면 gethash와 함께 setf를 사용하면 됩니다:

> (setf (gethash 'color ht) 'red)
RED

이제 gethash를 다시 호출해보면, 우리가 방금 전에 집어넣은 값을 얻을 수 있습니다:

> (gethash 'color ht)
RED
T

두번째 반환값이 우리가 기본값이 아니라 실제로 저장된 값을 얻어왔다는 사실을 입증해주는군요.


해시 테이블에 저장되는 객체나 키 모두 어떤 타입이든 상관없습니다. 가령 함수에 대한 정보를 유지하고 싶은 경우에는, 함수를 키로 쓰고 문자열을 입력 내용으로 하는 해시 테이블을 사용할 수 있습니다:

> (setf bugs (make-hash-table))
#<Hash-Table BF4C36>
> (push "Doesn't take keyword arguments."
(gethash #'our-member bugs))
("Doesn't take keyword arguments.")

gethash는 기본값으로 nil을 반환하고 push는 setf를 줄여서 사용한 것입니다. 따라서 간단히 새로운 문자열을 push 하는 것으로 함수에 대한 정보를 입력할 수 있습니다. (our-member는 16쪽에서 만든 그것입니다.) 


해시 테이블을 리스트 대신 집합을 표시하는데 사용할 수도 있습니다. 집합이 충분히 큰 경우에는 해시 테이블을 사용하는 편이 검색이나 삭제가 훨씬 빠릅니다. 해시 테이블로 표현된 집합에 새로운 요소를 추가할 때는 해당 요소를 gethash한 결과에 t를 설정하면 됩니다:

> (setf fruit (make-hash-table))
#<Hash-Table BFDE76>

> (setf (gethash 'apricot fruit) t)
T

집합에 임의의 요소가 있는지 확인하려면 그냥 gethash를 호출하세요:

> (gethash 'apricot fruit)
T
T

gethash가 기본값으로 nil을 반환하기 때문에, 새로 만들어진 해시 테이블도 마찬가지로 비어있는 집합으로 취급할 수 있습니다.


집합에서 임의의 객체를 제거할 때는 remhash를 호출하게 됩니다. 이 함수는 해시 테이블에서 지정한 항목을 삭제합니다:

> (remhash 'apricot fruit)
T

반환값을 보고 삭제 대상 항목이 존재했었는지 알 수 있습니다. 이 경우에는 삭제할 항목이 들어있었네요.

해시 테이블에 사용할 수 있는 반복 함수도 있습니다. maphash는 두 개의 인수를 받아들이는 함수 하나와 해시 테이블 하나를 인수로 받습니다. 이 함수는 테이블에 존재하는 모든 키/값 쌍을 대상으로 호출됩니다. 특별한 순서는 없구요:

> (setf (gethash 'shape ht) 'spherical
(gethash 'size ht) 'giant)
GIANT
> (maphash #'(lambda (k v)
(format t "~A = ~A~%" k v))
ht)
SHAPE = SPHERICAL
SIZE = GIANT
COLOR = RED
NIL

위의 경우에는 항상 nil을 반환하게 되지만, 리스트에 값을 쌓아놓도록 하는 함수를 넘겨주면 해시 테이블에 있는 값들을 저장할 수도 있습니다.


해시 테이블은 메모리가 고갈될 때까지 무한정 늘어나기 때문에, 아무리 많은 수가 있어도 문제없이 다룰 수 있습니다. 만약 해시 테이블을 처음 만들 때부터 지정한 갯수만큼 미리 공간을 준비해놓기를 원한다면 make-hash-table을 호출할 때 :size 옵션을 주면 됩니다. 여기에는 두 가지 이유가 있습니다. 어차피 커질거라는 사실을 안다면 불필요하게 나중에 테이블 공간을 늘리는 작업을 피하고 싶을겁니다. 그 반대의 경우에는 어차피 몇 개 안 들어갈테니 쓸데없이 메모리 낭비를 할 필요가 없구요. :size 인수는 해시 테이블의 할당 공간량을 지정하는게 아니라 해시 테이블에 들어갈 요소의 수를 뜻하는 것인데요. 더 구체적으로는 확장되기 전에 다룰 수 있는 평균적인 갯수를 의미합니다. 그렇기 때문에:

(make-hash-table :size 5)

이 코드는 5개까지 담을 수 있는 해시 테이블을 반환하게 됩니다.


검색에 관련된 다른 자료 구조들과 마찬가지로 해시 테이블도 반드시 키의 동등성을 비교하는 방법을 가지고 있어야 합니다. 기본적으로는 eql을 사용하지만 여러분이 원한다면 eq, equal, equalp 같은 비교 함수를 사용하도록 :test 인수로 넘길 수 있습니다:

> (setf writers (make-hash-table :test #'equal))
#<Hash-Table C005E6>
> (setf (gethash '(ralph waldo emerson) writers) t)
T

여기에서 보이는 것처럼 해시 테이블의 효율성에는 그에 상응하는 대가가 필요합니다. 리스트를 사용할 때는 member를 호출하는 시점에서 동등성을 비교하는 함수를 지정할 수 있었습니다만, 해시 테이블을 사용할 때는 미리 어떤 비교 함수를 사용할지 결정해두어야 합니다. 해시 테이블을 생성할 때 지정해야 하니까요. 


리스프 프로그래밍에서 발생하는 대부분의 트레이드 오프는 이런 속성을 가지고 있습니다. 따라서 초기에는 가능하면 유연한 방식을 취하고, 프로그램이 완성되어가는 시점에 속도를 위해 유연성을 희생하는 것이 좋습니다.

요약

  1. Common Lisp는 최소 7 차원의 배열을 지원합니다. 1차원 배열은 벡터라고도 불립니다.
  2. 문자열은 문자의 벡터입니다. 문자는 그 자체로 존재하는 객체입니다.
  3. 시퀀스는 리스트와 벡터를 포함합니다. 많은 시퀀스 함수는 기본 구성에 해당하는 키워드 인수를 받아들입니다.
  4. 리스프에는 굉장히 많은 수의 문자열 조작 함수가 존재하기 때문에 파싱이 간단합니다.
  5. defstruct를 호출하면 명명된 필드로 구성된 구조체를 정의할 수 있습니다. 프로그램을 작성하는 프로그램의 좋은 예라고 할 수 있지요.
  6. 이진 검색 트리는 정렬된 객체 집합을 유지하는데 유용합니다.
  7. 해시 테이블은 집합이나 매핑을 표현하는 효율적인 방법을 제공합니다.

연습 문제

  1. 2차원 배열 (차원이 (n n)인 배열) 을 받아서 시계 방향으로 90도 회전시키는 함수를 정의하세요. 아마 array-dimensions 함수가 필요할겁니다. (361쪽):
    > (quarter-turn #2A((a b) (c d)))
    #2A((C A) (D B))
  2. 368쪽에 있는 reduce 함수 설명을 읽고, 이 함수를 활용해서 아래의 함수를 정의해보세요:
    (a) copy-list
    (b) reverse
  3. 데이터와 세 개의 자식 노드를 지원하는 트리를 표현할 수 있는 구조체와 아래의 함수를 정의하세요.
    (a) 트리를 복사하는 함수 (원본에 있는 노드와 eql로 비교했을 때 같은 것이 나오면 안 됩니다.)
    (b) 트리를 구성하는 노드의 데이터 필드를 대상으로 주어진 객체를 찾을 수 있는 경우 참을 반환하는 함수. (객체 하나와 트리 하나를 인수로 받습니다.)
  4. 이진 검색 트리를 받아서 내림차순으로 정렬된 리스트를 반환하는 함수를 정의하세요.
  5. bst-adjoin을 정의하세요. 이 함수는 bst-insert와 동일한 인수를 받지만 트리에서 eql로 비교했을 때 같은 노드가 없는 경우에만 객체를 삽입합니다.
  6. 모든 해시 테이블의 내용물은 키-값 쌍을 (k . v) 요소로 포함하는 연관 리스트로 표현할 수 있습니다. 아래와 같은 함수를 정의하세요:
    (a) 연관 리스트를 받아서 대응되는 해시 테이블을 반환하는 함수
    (b) 해시 테이블을 받아서 대응되는 연관 리스트를 반환하는 함수


ANSI Common Lisp 3장. Lisp 세계에 오신 것을 환영합니다. 학술

ANSI Common Lisp 3장. Lisp 세계에 오신 것을 환영합니다이 장에서는 여러분이 가능한 빨리 프로그래밍을 시작할 수 있도록 하는데 초점을 맞추고 있습니다. 이 장이 끝날 때쯤이면 Common Lisp로 프로그램 작성을 시작하는데 필요한 지식을 모두 습득하게 될 것입니다.

형식

Lisp에서는 사용하면서 배운다는 말이 사실임을 깨닫게 될 것입니다. Lisp는 대화식 언어입니다. 어떤 Lisp 시스템이라도 최상위 계층이라 불리는 대화식 사용자 인터페이스를 포함하고 있습니다. Lisp 표현식을 최상위 계층에 입력하면 시스템이 평가한 값을 보여줍니다.

Lisp는 프롬프트를 이용해서 입력 대기 상태를 표시합니다. 많은 Common Lisp 구현체가 > 기호를 최상위 계층의 프롬프트로 사용합니다. 이 책에서 우리도 이 기호를 사용할겁니다.

가장 간단한 Lisp 표현식 중 하나는 정수입니다. 만약 우리가 1을 프롬프트 뒤에 입력하게 되면,

> 1
1
>

시스템이 결과값을 출력하고, 다시 프롬프트를 출력해서 다음 입력을 받아들일 준비가 됐다고 표시합니다. 

이 경우에는 우리가 입력한 것과 같은 값이 출력되었습니다. 1 같은 숫자는 평가 결과가 그 자신과 동일합니다. 평가하기 위해 뭔가 좀 일을 해야하는 표현식을 입력한다면 더 재미있겠죠? 다음과 같이 입력해서 두 개의 숫자를 더해봅시다:

> (+ 2 3)
5

(+ 2 3) 표현식에서 +는 연산자, 숫자 2와 3은 인수라고 불립니다.

일상에서 이런 표현식을 쓸 때는 2 + 3이라고 씁니다. 그렇지만 Lisp에서는 + 연산자를 가장 먼저 놓고, 그 뒤에 인수를 씁니다. 전체 표현식은 한 쌍의 괄호로 감싸야 합니다. 연산자가 가장 먼저 오기 때문에 이것을 전위 표기법이라고 부릅니다. 처음에는 이런 표현식을 쓰기가 매우 거북하겠지만, 사실 Lisp에서 가장 훌륭한 것 중 하나가 이 표기법입니다.

예를 들어 세 개의 숫자를 더하고 싶다면, 일반적인 표기법으로는 + 기호를 두 번 써야합니다,

2 + 3 + 4

Lisp에서는 그냥 인수 하나를 추가해주면 됩니다.

(+ 2 3 4)

일반적으로 우리가 +를 쓸 때는 반드시 2개의 인수가 필요합니다: 하나는 왼쪽에, 다른 하나는 오른쪽에 말이죠. 전위 표현식의 유연성은 어떤 갯수의 인수라도 받아들일 수 있다는 점에 있습니다. (아무 것도 안 쓰는 것도 포함됩니다):

> (+)
0
> (+ 2)
2
> (+ 2 3)
5

> (+ 2 3 4)
9
> (+ 2 3 4 5)
14

연산자는 인수를 몇 개라도 받아들일 수 있기 때문에, 표현식이 어디서 시작하고 어디서 끝나는지 구분하기 위해 괄호를 쓰게 되었습니다.

표현식은 중첩될 수 있습니다. 즉, 표현식에 들어가는 인수들은 그 자체로 매우 복잡한 표현식이 될 수 있습니다:

> (/ (- 7 1) (- 4 2))
3

우리말로 읽어보자면 이 표현식은 7 빼기 1을 4 빼기 2로 나눈 것입니다.

Lisp 표기법의 또다른 아름다움은: 이게 전부라는거죠! 모든 Lisp 표현식은 1처럼 원자이거나 괄호 안이 0개 이상의 표현식으로 구성된 리스트입니다. 아래는 모두 유효한 Lisp 표현식입니다:

2   (+ 2 3)   (+ 2 3 4)   (/ (- 7 1) (- 4 2))

여기에서 볼 수 있듯이, 모든 Lisp 코드는 이 형식을 따릅니다. C 같은 언어는 수식은 중위 표기법을 사용하고, 함수 호출은 전위 표기법을 사용하고, 인수들은 쉼표로 구분되는데, 표현식은 세미콜론으로 구분되고, 코드 블럭은 중괄호로 구분된다는 둥 훨씬 더 복잡한 문법을 가지고 있지요. Lisp에서는 단 하나의 표기법이 모든 생각을 표현할 수 있습니다.

평가

바로 앞에서 최상위 계층에 몇 가지 표현식을 입력하고 Lisp가 값을 출력하는 것을 봤는데요. 이 절에서는 어떤 식으로 표현식이 평가되는지 더 자세히 살펴보겠습니다.

Lisp에서는 +가 함수입니다. 그리고 (+ 2 3) 처럼 쓰는 표현식은 함수 호출이구요. Lisp가 함수 호출을 평가할 때는 두 단계를 거치게 됩니다:

  1. 왼쪽에서 오른쪽 방향으로 인수들이 먼저 평가됩니다. 위에 든 예에서는 각 인수의 평가된 값이 그 자신과 동일합니다. 인수로 쓰인 2와 3의 평가 결과값은 각각 2와 3이지요.
  2. 인수들의 값은 연산자로 명명된 함수에게 넘겨집니다. 위에 든 예에서는 + 함수가 5를 반환하게 됩니다.

인수가 함수 호출이라도 같은 규칙에 의해 평가됩니다. 따라서 (/ (- 7 1) (- 4 2)가 평가될 때는 다음과 같은 일이 일어납니다:

  1. Lisp는 (- 7 1)을 평가합니다: 7은 7로 평가되고, 1은 1로 평가됩니다. 이 값들은 - 함수에게 전달되어 6을 반환하게 됩니다.
  2. Lisp는 (- 4 2)를 평가합니다: 4는 4로 평가되고, 2는 2로 평가됩니다. 이 값들은 - 함수에게 전달되어 2를 반환하게 됩니다.
  3. 값 6과 2가 / 함수에게 전달되어 3을 반환하게 됩니다.

Common Lisp의 모든 연산자가 함수인 것은 아닙니다. 그렇지만 대부분이 함수이지요. 그리고 함수 호출은 항상 이런 방식으로 평가됩니다. 인수들은 왼쪽에서 오른쪽 방향으로 평가되고, 그 값들은 함수로 전달됩니다. 함수는 표현식 전체의 값을 반환하구요. 이것은 Common Lisp의 평가 규칙이라 불립니다.


문제가 발생했을 때 빠져나오기

만약 여러분이 Lisp가 이해할 수 없는 무언가를 입력하면, 오류 메시지를 출력하고 최상위 계층에서 브레이크 루프라 불리는 상태로 들어가게 됩니다. 브레이크 루프는 숙련된 프로그래머에게 오류의 원인을 찾아내는 기회를 제공하지만, 처음 이걸 봤을 때는 그냥 빨리 빠져나가고 싶을텐데요. 최상위 계층으로 복귀할 때 입력해야 하는 내용은 Common Lisp 구현체에 따라 다릅니다. 가상의 구현체에서는 :abort가 이런 일을 합니다:

> (/ 1 0)
Error: Division by zero.
       Options: :abort, :backtrace
>> :abort

>

부록 A를 보면 Lisp 프로그램을 디버깅하는 방법과 가장 흔히 발생하는 오류들이 설명되어 있습니다.


Common Lisp 평가 규칙을 따르지 않는 연산자로는 quote가 있습니다. quote 연산자는 그 자신만의 독특한 평가 규칙을 가진 특수 연산자입니다. 그 규칙이란, "아무 것도 하지 않음"입니다. quote 연산자는 하나의 인수를 받아서 그대로 반환합니다:

> (quote (+ 3 5))
(+ 3 5)

편의를 위해서 Common Lisp는 '를 quote의 약어로 정의하고 있습니다. quote 대신 '를 표현식 앞에 붙이는 것으로도 동일한 효과를 얻을 수 있습니다:

> '(+ 3 5)
(+ 3 5)

quote 표현식 전체를 쓰는 일은 거의 없고 약어만 사용한다고 보시면 됩니다. 

Lisp는 quote를 표현식을 평가하지 않도록 막아주는 용도로 제공하는 것인데요. 이런 연산자가 왜 필요한가에 대해서는 다음 절에서 설명드리겠습니다.

자료

Lisp는 다른 언어들에서 찾아볼 수 있는 모든 자료형과, 다른 언어에서는 찾아보기 힘든 자료형들을 제공합니다. 우리가 이미 한 번 봤던 자료형은 256처럼 숫자의 연속으로 만들어지는 정수입니다. 대부분의 언어에서 가장 많이 사용되는 또다른 자료형은 문자열입니다. 문자열은 "ora et labora"처럼 큰 따옴표로 둘러싸인 문자들의 연속으로 만들어집니다. 정수와 문자열은 모두 평가 값이 자기 자신과 같습니다.

다른 언어들에서 찾기 힘든 Lisp 자료형에는 심볼과 리스트가 있습니다. 심볼은 그냥 단어라고 생각하시면 됩니다. 보통은 심볼을 어떻게 입력하든 알파벳이 모두 대문자로 변환됩니다:

> 'Artichoke
ARTICHOKE

심볼의 평가 값은 자기 자신이 아닙니다. 따라서 심볼을 참조하려고 한다면 반드시 위에서 쓴 것처럼 따옴표를 앞에 붙여줘야 합니다.

리스트는 괄호 안에 있는 0개 이상의 요소들로 표현됩니다. 리스트에 포함되는 요소들은 어떤 자료형이든 상관없습니다. 리스트 안에 리스트가 있어도 됩니다. 리스트에는 반드시 따옴표를 붙여야 합니다. 그렇지 않으면 Lisp는 리스트를 함수 호출로 인식하게 됩니다:

> '(my 3 "Sons")
(MY 3 "Sons")
> '(the list (a b c) has 3 elements)
(THE LIST (A B C) HAS 3 ELEMENTS)

따옴표를 하나 앞에 붙이기만 하면 해당 표현식 전체가 평가되지 않습니다.

리스트를 프로그래밍 방식으로 만들어내려면 list 함수를 호출합니다. list가 함수이기 때문에 이 안에 있는 인수들은 모두 평가 대상입니다. 아래의 예에서는 list를 호출하기 전에 +를 호출하게 됩니다:

> (list 'my (+ 2 1) "Sons")
(MY 3 "Sons")

여러분은 지금 Lisp의 가장 뛰어난 특징을 보고 계십니다. Lisp 프로그램은 리스트로 표현됩니다. 앞서 인수를 처리하는 방식이 유연하고 우아하다는 생각에는 동의하지 않으셨을 수도 있지만, 이것만큼은 동의할 수 밖에 없을겁니다. 이것은 Lisp 프로그램이 Lisp 코드를 생성할 수 있다는 것을 의미합니다. Lisp 프로그래머들은 프로그램을 생성하는 프로그램을 쉽게 작성할 수 있습니다. (그리고 실제로 자주 그렇게 하죠)

프로그램을 생성하는 프로그램들은 10장까지 나오지 않겠지만, 표현식과 리스트의 관계를 이해하지 못해서 발생하는 혼란을 피하기 위해서라도 위에서 설명한 내용은 꼭 이해하고 넘어가셔야 합니다. 이것이 따옴표를 써야하는 이유입니다. 만약 리스트 앞에 따옴표가 붙어있으면, 평가 결과는 리스트 자체와 동일합니다. 그렇지만 따옴표가 붙어있지 않으면 리스트는 코드로 취급되고 표현식을 평가한 결과가 반환됩니다:

> (list '(+ 2 1) (+ 2 1))
((+ 2 1) 3)

여기에서 첫번째 인수는 따옴표가 붙어있기 때문에 리스트 자체가 반환됩니다. 두번째 인수는 따옴표가 붙어있지 않기 때문에 함수 호출로 취급되고 숫자를 반환합니다.

Common Lisp에서는 빈 리스트를 표현하는 방법이 두 가지가 있는데요. 한 쌍의 괄호 사이에 아무 것도 쓰지 않은 모양으로 표현할 수도 있고, nil 심볼을 사용할 수도 있습니다. 입력이야 어떻든 상관하지 않지만 출력할 때는 nil로 표시됩니다:

> ()
NIL
> nil
NIL

여기 보면 nil 심볼에는 따옴표를 붙이지 않았는데요. 이것은 nil의 평가값이 그 자신이기 때문입니다.

리스트 연산

cons 함수는 리스트를 만들어냅니다. 두번째 인수가 리스트이면 그 리스트 맨 앞에 첫번째 인수를 추가한 새로운 리스트를 만들어 반환합니다:

> (cons 'a '(b c d))
(A B C D)

cons 함수로 비어있는 리스트에 새로운 요소들을 추가한 리스트를 만들어 낼 수도 있습니다. 앞 절에서 봤던 list 함수는 단지 nil을 대상으로 cons 함수를 호출하는 과정을 좀 편하게 만들어 놓은 것에 불과합니다:

> (cons 'a (cons 'b nil))
(A B)
> (list 'a 'b)
(A B)

리스트에서 요소들을 추출하는 가장 기본적인 함수에는 car와 cdr이 있습니다. car 함수는 리스트의 첫번째 원소를 꺼내오고, cdr 함수는 그 첫번째 원소 뒤에 있는 모든 것을 가져옵니다:

> (car '(a b c))
A
> (cdr '(a b c))
(B C)

리스트에서 임의의 요소를 꺼낼 때는 car와 cdr을 조합해서 사용하면 됩니다. 만약 세번째 요소를 얻고 싶으면 이렇게 할 수 있겠죠:

> (car (cdr (cdr '(a b c d))))
C

그렇지만 이 경우에는 third 함수를 사용하는게 훨씬 편합니다:

> (third '(a b c d))
C

Common Lisp에서는 t 심볼이 참을 표현합니다. nil처럼 t도 평가 결과가 자기 자신입니다. listp 함수는 인수가 리스트이면 참을 반환합니다:

> (listp '(a b c))
T

반환 값이 참이나 거짓인 함수를 술어라고 합니다. Common Lisp 술어들은 이름이 p로 끝나는 경우가 많습니다.

Common Lisp에서 거짓은 빈 리스트인 nil로 표현됩니다. 만약 listp 함수의 인수로 리스트가 아닌 것을 주면 nil을 반환합니다:

> (listp 27)
NIL

nil이 Common Lisp에서 두 가지 역할을 하기 때문에,

> (null nil)
T

> (not nil)
T

인수가 빈 리스트인 경우 참을 반환하는 null과 인수가 거짓일 때 참을 반환하는 not 함수 모두 nil을 인수로 받는 경우 같은 결과를 냅니다.

Common Lisp에서 가장 간단한 조건식은 if 입니다. if는 test 표현식, then 표현식, else 표현식을 인수로 받습니다. test 표현식이 참이면 then 표현식이 평가되어 그 값이 반환됩니다. test 표현식이 거짓이면 else 표현식이 평가되어 그 값이 반환됩니다:

> (if (listp '(a b c))
      (+ 1 2)
      (+ 5 6))
3
> (if (listp 27)
      (+ 1 2)
      (+ 5 6))
11

quote처럼 if도 특수 연산자입니다. if는 함수로 구현되는 것이 불가능합니다. 조건식의 평가 결과에 따라 두 표현식 중 한 쪽만 평가해야 되는데, 함수 호출은 모든 인수가 항상 평가되기 때문입니다. 

if의 마지막 인수는 써도 되고 안 써도 됩니다. 마지막 인수를 생략하면 조건식이 거짓인 경우 nil을 반환합니다:

> (if (listp 27)
      (+ 2 3))
NIL

t가 참을 표현하는데 쓰이기는 하지만, 사실 nil이 아닌 것은 모두 논리적으로 참이라고 인식됩니다:

> (if 27 1 2)
1

and와 or 같은 논리 연산자들도 조건 연산자와 평가 방법이 비슷합니다. 두 연산자 모두 인수를 몇 개라도 받을 수 있기는 하지만, 인수 순서대로 필요한만큼만 가져다가 평가해서 결과를 내버립니다. 만약 모든 인수가 참이면 (nil이 아닌 것) and 함수는 마지막 인수를 반환합니다:

> (and t (+ 1 2))
3

그렇지만 평가 도중 하나라도 거짓인 것이 드러나면, 그 뒤의 인수들은 평가되지 않습니다. or도 비슷한데요, 참에 해당되는 인수를 발견하면 더 이상 평가를 진행하지 않습니다.

이 두 개의 연산자는 매크로라고 불립니다. 특수 연산자처럼 매크로는 평소에 쓰이는 평가 규칙을 우회합니다. 매크로를 작성하는 법은 10장에서 설명합니다.

함수

새로운 함수는 defun으로 정의할 수 있습니다. 보통은 이름, 매개변수 목록, 하나 이상의 표현식으로 구성된 함수 본체를 인수로 받습니다. 아래의 예제는 third 함수를 정의하는 방법을 보여줍니다:

> (defun our-third (x)
    (car (cdr (cdr x))))
OUR-THIRD

첫번째 인수는 함수의 이름이 our-third라는 것을 알려줍니다. 두번째 인수인 리스트 (x) 는 함수가 정확히 하나의 인수 x를 받는다고 알려줍니다. 이렇게 위치 지정자로 쓰인 심볼은 변수라고 불립니다. x처럼 함수의 인수를 표현하는 변수는 매개변수라고 불립니다.

정의의 나머지 부분은 (car (cdr (cdr x))) 인데, 이것을 함수의 본체라고 합니다. 본체는 함수의 반환값을 계산하려면 어떻게 해야하는지 Lisp에게 알려줍니다. 따라서 our-third를 호출하면 어떤 x를 인수로 입력하든지 (car (cdr (cdr x)))를 반환하게 됩니다.

> (our-third '(a b c d))
C

이제 변수도 봤으니 심볼이 뭔지 이해하기가 좀 쉬울겁니다. 심볼은 변수 이름에 해당되지만, 변수와는 별개의 객체로 존재합니다. 그리고 그것이 심볼이 리스트처럼 따옴표를 앞에 붙여야 하는 이유이지요. 따옴표를 붙이지 않은 리스트는 코드로 취급됩니다. 따옴표를 붙이지 않은 심볼은 변수로 취급됩니다.

여러분은 함수 정의를 Lisp 표현식이 일반화된 버전으로 생각할 수 있습니다. 아래 표현식은 1과 4의 합이 3보다 큰지 검사합니다:

> (> (+ 1 4) 3)
T  

특정한 수를 변수로 교체하면, 임의의 두 수의 합이 세번째 인수보다 큰지 검사하는 함수를 작성할 수 있습니다:

> (defun sum-greater (x y z)
    (> (+ x y) z))
SUM-GREATER

> (sum-greater 1 4 3)
T

Lisp는 프로그램, 프로시저, 함수를 구분하지 않습니다. 함수가 모든 일을 수행합니다. 원한다면야 여러분이 작성한 함수 중 하나를 main 함수로 취급할 수도 있습니다만, 기본적으로 최상위 계층에서는 어떤 함수나 호출하는 것이 가능합니다. 따라서 여러분은 프로그램을 작성할 때 각 함수가 제대로 동작하는지 즉각 확인할 수 있습니다.

재귀

앞 절에서 봤던 함수들은 어떤 일을 하기 위해 다른 함수를 호출하는 함수입니다. 아까 봤던 sum-greater 함수는 +와 > 함수를 호출했습니다. 함수는 어떤 함수나 호출할 수 있는데, 자기 자신도 그 대상에 포함됩니다.

자기 자신을 호출하는 함수는 재귀적입니다. Common Lisp의 member 함수는 특정 대상이 리스트에 있는지 검사합니다. 아래의 예는 member를 재귀적인 함수로 간략하게 정의한 버전입니다:

(defun our-member (obj lst)
  (if (null lst)
      nil
      (if (eql (car lst) obj)
          lst
          (our-member obj (cdr lst)))))

술어 eql은 두 개의 인수가 서로 동일한지 확인합니다: 이 외에는 모두 앞에서 설명한 내용입니다. 실행 결과는 아래와 같습니다:

> (our-member 'b '(a b c))
(B C)
> (our-member 'z '(a b c))
NIL

our-member의 정의는 아래의 설명에 대응됩니다. obj 객체가 lst 리스트에 있는지 확인하려면,

  1. lst가 빈 리스트인지 확인합니다. 만약 lst가 빈 리스트라면 obj는 있을 수 없습니다. 끝.
  2. lst의 첫번째 요소가 obj이면, obj는 lst 리스트에 있는 것입니다.
  3. 그렇지 않다면 obj는 lst의 첫 요소를 제외한 나머지 부분에 있어야만 lst 리스트에 있다고 말할 수 있습니다.

재귀적인 함수의 동작을 이해하려고 할 때 이런 식으로 코드가 하는 일을 풀어서 써보는게 도움이 됩니다.

많은 사람들이 처음에는 재귀를 이해하기 어려운 것이라고 생각합니다. 재귀가 어려운 것은 대부분 함수에 대해 잘못된 메타포를 가지고 있기 때문입니다. 함수를 기계의 일종으로 생각하면 이해하기가 어렵습니다. 매개변수에 입력값이 들어오고, 일부분은 다른 함수를 통해 처리하고, 제품이 완성되면 조립해서 반환값을 내보낸다는 식으로 말이죠. 함수를 이런 메타포로 이해하고 있다면 재귀를 설명하려고 할 때 자가당착에 빠집니다. 어떻게 기계가 자기 자신에게 일을 시킬 수가 있나요? 이미 일을 하고 있는데.

더 나은 메타포는 함수를 어떤 일을 처리해나가는 과정으로 생각하는 것입니다. 이렇게 생각하면 재귀가 자연스럽습니다. 재귀적인 처리 과정을 일상생활에서 매일 보니까요. 유럽의 인구 변화 추이에 관심이 있는 역사가를 생각해봅시다. 문헌을 조사하는 과정은 이렇게 되겠죠:

  1. 문헌의 사본을 얻습니다.
  2. 인구 변화에 관련된 정보를 찾아봅니다.
  3. 그 문헌이 언급하는 다른 유용한 문헌들을 조사합니다.

이 과정은 마지막 단계가 한 번 이상의 같은 과정을 반복하게 되기 때문에 재귀적이라고 말할 수 있지만, 이 과정은 쉽게 이해할 수 있습니다.

따라서 our-member 함수를 뭔가가 리스트에 포함되어 있는지 검사하는 기계로 생각하지 않는 편이 좋습니다. 그 대신 어떤 것이 리스트 안에 있는지 결정하는 규칙으로 생각하세요. 함수를 이런 관점에서 생각하면 재귀의 역설이 사라집니다.

Lisp 코드 읽는 법

앞 절에서 봤던 간단한 member 구현은 다섯 개의 괄호로 끝났습니다. 좀 더 복잡한 함수는 일곱 개나 여덟 개의 괄호로 끝날 수도 있습니다. 처음 Lisp를 배우는 사람들은 이렇게 많은 괄호가 늘어져 있는 것을 보고 낙담하곤 합니다. 도대체 어떻게 이런 코드를 읽고 쓸 수가 있습니까? 괄호 쌍이 다 맞는지 어느 세월에 일일이 확인하고 있나요?

답부터 말하자면, 괄호에 신경 쓸 필요가 전혀 없습니다. Lisp 프로그래머들은 괄호를 보는게 아니라, 들여쓰기를 이용해서 코드를 읽고 씁니다. 코드를 작성할 때는 텍스트 편집기가 괄호 쌍이 맞는지 확인시켜 줍니다. 좋은 편집기라면, 특히 Lisp 시스템에 딸려오는 편집기라면 괄호 쌍이 맞는지 입력하면서 바로 확인할 수 있습니다. 혹시 여러분이 쓰고 있는 편집기가 괄호 쌍이 맞는지 보여주지 않는다면, 지금 당장 하던 일을 멈추고 어떻게 이 기능을 켜는지 알아내세요. 이 기능 없이 Lisp 코드를 작성하는 것은 거의 불가능합니다.

좋은 편집기만 있으면 코드를 작성할 때 괄호 쌍을 맞추는 일을 고민할 필요가 없습니다. 그리고 Lisp 코드의 들여쓰기 방식은 만국 공통이기 때문에 코드를 읽을 때도 역시 문제가 되지 않습니다. 누구나 같은 관습을 따르기 때문에 읽을 때는 들여쓰기만 보면서 코드를 읽을 수 있습니다. 괄호는 그냥 무시하세요.

아무리 숙련된 Lisp 해커라도 아까 봤던 our-member 코드가 이런 식으로 써있다면 읽기가 정말 어려울 겁니다:

(defun our-member (obj lst) (if (null lst) nil (if
(eql (car lst) obj) lst (our-member obj (cdr lst)))))

그렇지만 코드가 제대로 들여쓰기가 되어있기 때문에 아무도 곤경에 처하지 않는 것입니다. 괄호의 대부분을 생략해버려도 아무 문제 없이 읽을 수 있습니다. 지금 한 번 확인해보죠:

defun our-member (obj lst)
  if null lst
     nil
     if eql (car lst) obj
        lst
        our-member obj (cdr lst)

사실 종이에 코드를 쓴다면 이런 식으로 접근하는 편이 훨씬 실용적입니다. 나중에 실제로 타이핑할 때에는 편집기의 괄호 맞추기 기능을 이용하면 됩니다.

입력과 출력

지금까지는 최상위 계층을 이용해서 암시적으로 입출력을 수행했습니다. 그렇지만 진짜 대화식 프로그램에서는 이 정도로 충분할 리가 없죠. 이 절에서는 입출력하는 함수 몇 개를 살펴보겠습니다.

Common Lisp에서 가장 일반적인 출력 함수는 format입니다. 보통 두 개 이상의 인수를 받는데요: 첫번째 인수는 출력될 장소, 두번째 인수는 문자열 템플릿, 그리고 나머지 인수들은 템플릿에 입력될 객체입니다. 여기 전형적인 예제를 실었습니다:

> (format t "~A plus ~A equals ~A.~%" 2 3 (+ 2 3))
2 plus 3 equals 5.
NIL

여기서 두 줄이 출력되었다는 점을 주목할 필요가 있는데요. 첫번째 줄은 format에 의해 출력된 것입니다. 두번째 줄은 지금까지 최상위 계층에서 봤던 것처럼 format 호출의 반환값이 출력되었습니다. 보통 format 같은 함수는 최상위 계층에서 바로 불리지는 않고 프로그램 내부에서만 쓰이기 때문에, 이렇게 반환값을 볼 일은 없습니다.

format의 첫번째 인수인 t는 출력을 기본 장소에 보내도록 지시합니다. 기본 장소라고 한다면 보통 최상위 계층입니다. 두번째 인수는 출력할 때 사용할 템플릿을 문자열로 받습니다. 이 문자열을 보면 ~A가 있는데 이것은 문자열로 채워넣을 빈 자리를 의미합니다. ~%는 줄을 나눌 때 쓰입니다. 뒤에 따라오는 인수들의 값은 순서대로 템플릿의 빈 자리를 채워 넣습니다.

입력할 때 쓰이는 표준 함수는 read입니다. 아무런 인수도 주지 않으면 기본 장소에서 읽어들이게 되는데, 읽기에 쓰이는 기본 장소 역시 일반적으로 최상위 계층입니다. 아래 함수는 사용자의 입력을 기다릴 때 쓰이는 프롬프트를 표시하고 입력이 들어왔을 때 반환합니다:

(defun askem (string)
   (format t "~A" string)
   (read))

실행 결과는 아래와 같습니다:

> (askem "How old are you? ")
How old are you? 29
29

여러분이 뭔가를 입력하고 엔터를 치기 전까지 read가 무한히 기다리고 있다는 사실을 잘 기억할 필요가 있습니다. 그러므로 눈에 잘 띄이는 프롬프트 하나 없이 read를 호출하는 것은, 프로그램이 실제로는 사용자의 입력을 기다리고 있음에도 불구하고 그냥 뻗어버린 것처럼 보일 수 있기 때문에 별로 현명한 처사가 못 됩니다.

두번째로 read에 대해 알아둘 것은 이것이 매우 강력하다는 점입니다: read는 완전한 Lisp 해석기입니다. 단순히 문자들을 읽어서 문자열을 반환하는 것이 아닙니다. 읽어들인 것을 해석한 다음 Lisp 객체를 반환합니다. 위의 경우에는 숫자가 반환되었습니다.

askem의 정의를 보면 이전에는 함수에서 보지 못 했던 것이 보입니다. 함수 본체에 하나 이상의 표현식이 들어있는데요. 함수의 본체에는 몇 개의 표현식이라도 들어갈 수 있습니다. 함수가 호출되면 이 표현식들이 위에서부터 순서대로 평가된 다음, 가장 마지막 표현식의 평가 결과가 반환됩니다.

이 시점보다 이전에 배웠던 내용들은 "순수한" Lisp - 부작용이 없는 Lisp - 라고 불립니다. 부작용이란 표현식을 평가하는 도중에 상태가 바뀌는 것을 말합니다. (+ 1 2) 같은 순수한 Lisp 표현식을 평가할 때는 부작용이 없습니다; 단순히 값을 반환할 뿐이지요. 그렇지만 format을 호출한 경우에는 값을 반환하면서 동시에 무언가를 출력합니다. 이것이 일종의 부작용입니다.

우리가 부작용이 없는 코드만 작성한다면 함수를 구성하는 본체 안에 표현식이 2개 이상 들어갈 일이 없습니다. 이런 경우에는 마지막 표현식의 값만 반환되고 그 이전에 있던 표현식의 평가 결과는 다 날아갑니다. 그렇기 때문에 부작용이 없는 표현식이라면 Lisp가 이 표현식들을 평가했는지조차 알 도리가 없습니다.

변수

Common Lisp에서 가장 빈번하게 사용되는 연산자 중 하나가 let입니다. 이 연산자는 새로운 지역 변수를 만들 수 있도록 해줍니다:

> (let ((x 1) (y 2))
    (+ x y))
3

let 표현식은 두 부분으로 나누어집니다. 첫번째 부분은 (변수 표현식)의 형식으로, 생성할 변수들의 목록을 지시합니다. 각 변수는 그에 대응되는 표현식의 값으로 초기화됩니다. 예제에서는 새로운 변수 x와 y를 생성해서 각각 1과 2로 초기화하고 있습니다. 이 변수들은 let의 본체 안에서만 유효합니다.

변수와 값의 목록 뒤에는 순서대로 평가될 표현식들이 위치합니다. 이 경우에는 + 호출만 존재합니다. 마지막 표현식의 값이 let의 반환값입니다. let을 이용해서 숫자만 입력받도록 다시 작성된 askem 예제를 보도록 합시다:

(defun ask-number ()
  (format t "Please enter a number. ")
  (let ((val (read)))
    (if (numberp val)
        val
        (ask-number))))

이 함수는 val 변수를 생성하고 여기에 read에서 반환된 객체를 보관합니다. 함수가 객체를 붙들고 있기 때문에, 이 함수는 여러분이 입력한 값을 보고 반환할 것인지 말 것인지 결정할 수 있습니다. 예상했겠지만 numberp는 인수가 숫자인지 아닌지 검사하는 술어입니다.

만약 사용자가 숫자가 아닌 값을 입력하게 되면, ask-number는 자기 자신을 부르게 됩니다. 숫자를 입력하도록 강요하는 함수가 만들어진 것이죠:

> (ask-number)
Please enter a number. a
Please enter a number. (ho hum)
Please enter a number. 52
52

지금까지 본 변수들은 지역 변수라고 불립니다. 이 변수들은 특정한 문맥에서만 유효합니다. 전역 변수라고 불리는 다른 변수들도 있는데, 이것들은 어디에서나 사용이 가능합니다.

전역 변수는 defparameter에 심볼과 값을 넘겨서 만들 수 있습니다:

> (defparamter *glob* 99)
*GLOB*

이 이름과 동일한 지역 변수를 만들지만 않는다면 어디에서나 이 전역 변수에 접근할 수 있습니다. 실수로 이런 일이 발생하지 않도록 하기 위해서 보통 전역 변수는 앞 뒤에 별표를 붙입니다.

defconstant를 호출하면 전역 상수를 정의하는 것도 가능합니다:

(defconstant limit (+ *glob* 1))

전역 상수는 전역 변수처럼 특별히 구분되게 이름을 지을 필요는 없습니다. 누군가 같은 이름을 변수로 쓰게 되면 오류가 발생할테니까요. 만약 어떤 심볼이 전역 변수이거나 전역 상수인지 확인하고 싶다면 boundp를 사용하세요:

> (boundp '*glob*)
T

배정

Common Lisp에서 가장 일반적으로 사용되는 배정 연산자는 setf입니다. 어떤 종류의 변수라도 배정할 때 setf를 사용할 수 있습니다:

> (setf *glob* 98)
98
> (let ((n 10))
    (setf n 2)
    n)
2

setf의 첫번째 인수로 지역 변수의 이름이 아닌 심볼을 전달하면, 전역 변수로 취급합니다:

> (setf x (list 'a 'b 'c))
(A B C)

즉, 여러분은 단지 값을 특정 심볼에 배정하는 것만으로도 암시적으로 전역 변수를 생성할 수 있습니다. 그렇지만 소스 파일에 코드를 쓸 때에는 명시적으로 defparameter를 써주는 편이 좋습니다.

단순히 변수에 값을 배정하는 것 이상의 일을 할 수도 있는데요. setf의 첫번째 인수는 변수 이름만 될 수 있는게 아니라 표현식이 될 수도 있습니다. 이런 경우에는 첫번째 인수가 참조하는 위치에 두번째 인수가 삽입됩니다:

> (setf (car x) 'n)
N
> x
(N B C)

setf의 첫번째 인수는 특정 위치를 참조하는 거의 모든 표현식이 가능합니다. 이런 연산자들은 부록 D에서 "설정 가능"으로 표시되어 있습니다.

setf에 임의의 수(짝수)의 인수들을 전달하는 것도 가능합니다. 다음과 같은 형식의 표현식은

(setf a b
      c d
      e f)

setf를 3개의 분리된 호출로 나눠놓은 것과 동일합니다:

(setf a b)
(setf c d)
(setf e f)

함수형 프로그래밍

함수형 프로그래밍은 무언가를 변경하면서 동작하는 대신 값을 반환하는 방식으로 동작하는 프로그램을 작성하는 것을 의미합니다. Lisp의 주류 패러다임은 함수형 프로그래밍입니다. Lisp에 내장된 대부분의 함수들은 부작용 없이 값을 반환하도록 만들어져 있습니다.

예를 들어 remove 함수는 객체 하나와 리스트를 받은 다음, 해당 객체를 포함하지 않은 새로운 리스트를 만들어서 반환합니다:

> (setf lst '(c a r a t))
(C A R A T)
> (remove 'a lst)
(C R T)

그냥 remove 함수가 리스트에서 객체를 제거했다고 말하면 되는거 아니냐고 할 수도 있겠지만, 그렇지 않습니다. 원본 리스트 자체는 변경되지 않았습니다:

> lst
(C A R A T)

그럼 정말로 원본 리스트에서 뭔가를 제거하고 싶다면 어떻게 해야되냐고요? Lisp에서는 그럴 때 리스트를 인수로 받는 함수를 호출하여 나온 반환값을 setf로 배정합니다. 특정 리스트 x에서 모든 a를 제거하려면 이렇게 할 수 있습니다:

(setf x (remove 'a x))

함수형 프로그래밍을 아주 간단하게 말하자면, setf 류의 사용을 피하는 것입니다. 처음에는 이렇게 하는 것이 정말 바람직한가를 생각하기 이전에, 이렇게 프로그래밍 하는 것이 가능하다는 사실조차 받아들이기가 어려울 겁니다. 도대체 어떻게 반환값만 이용해서 프로그램을 만들 수가 있다는건지?

부작용을 완전히 배제하고 프로그래밍 하는 것은 불편하겠지요. 그렇지만 계속 읽어나가다보면, 정말로 부작용이 필요한 경우는 그다지 많지 않다는 사실에 놀라게 될 것입니다. 더불어 부작용을 더 많이 제거할수록 프로그래밍이 한결 더 쉬워지는 것을 느끼게 될 것입니다.

함수형 프로그래밍이 가진 가장 큰 장점은 대화식 환경에서 끊임없이 테스트를 수행할 수 있다는 점입니다. 순수한 함수형 코드라면 작성하자마자 즉시 테스트할 수 있습니다. 반환값이 여러분이 기대한 것과 일치한다면 제대로 코딩했다고 확신할 수 있지요. 이런 확신이 모이면 엄청난 차이를 낳게 됩니다. 프로그램의 어느 부분을 변경하더라도 즉시 되돌아 볼 수 있습니다. 편지를 쓰다가 전화를 쓰게 된 것이 의사소통의 혁신을 이루었던 것처럼, 어느 부분이나 원하는대로 검사할 수 있다는 사실이 완전히 새로운 프로그래밍 스타일을 가능하게 합니다.

반복

어떤 일을 되풀이 하고 싶을 때는 재귀를 사용하는 것보다 반복을 사용하는 것이 훨씬 자연스럽습니다. 반복의 전형적인 예는 표를 생성하는 일이죠. 아래 함수는

(defun show-squares (start end)
  (do ((i start (+ i 1)))
      ((> i end) 'done)
    (format t "~A ~A~%" i (* i i))))

start부터 end 범위에 속하는 정수들의 제곱수를 출력합니다:

> (show-squares 2 5)
2 4
3 9
4 16
5 25
DONE

do 매크로는 Common Lisp에서 사용되는 가장 기본적인 반복 연산자입니다. do도 let처럼 첫번째 인수에 변수의 리스트를 받습니다. 리스트의 각 요소는 아래의 형식을 따라야 합니다

(variable initial update)

variable은 심볼이고, initial과 update는 표현식입니다. 처음에 각 variable은 대응되는 initial 표현식을 이용하여 초기화됩니다. 마찬가지로 한 번 반복이 끝날 때마다 각 variable에 대응되는 update 표현식을 이용하여 갱신됩니다. show-squares 함수에 있는 do 매크로는 i라는 단 하나의 변수를 생성하게 됩니다. i의 첫번째 반복을 시작할 때 start의 값으로 초기화되고, 그 이후에는 한 번 반복할 때마다 1씩 증가된 값으로 i를 갱신하게 됩니다.

do의 두번째 인수는 하나 이상의 표현식을 포함한 리스트를 받습니다. 첫번째 표현식은 반복이 중단되어야 할지 테스트할 때 사용됩니다. 위의 경우에서는 테스트 표현식이 (> i end) 입니다. 리스트 안에 있는 나머지 표현식들은 반복이 중단될 때 순서대로 평가됩니다. 마지막 표현식의 평가 결과값을 do 전체의 값으로 반환하게 됩니다. 따라서 show-squares 함수는 언제나 done 심볼을 반환하게 됩니다.

do의 나머지 인수는 루프의 본체를 구성합니다. 이 표현식들도 각 반복을 수행할 때마다 순서대로 평가됩니다. 한 번의 반복을 수행할 때마다 변수가 갱신되고 종료 테스트를 평가한 다음, 테스트가 실패하면 본체를 계속해서 평가합니다.

앞의 예제와 비교할 수 있도록 아래에 show-squares의 재귀적인 버전을 실었습니다:

(defun show-squares (i end)
  (if (> i end)
      'done
      (progn
        (format t "~A ~A~%" i (* i i))
        (show-squares (+ i 1) end))))

이 함수에서 생소한 것이라고는 progn 하나 뿐이네요. progn은 임의의 수의 표현식을 받아서 순서대로 평가하고 마지막 평가 결과값을 반환합니다.

Common Lisp는 특정한 경우에 사용할 수 있는 훨씬 간단한 반복 연산자들이 있습니다. 리스트의 요소들을 대상으로 반복을 수행할 때는 보통 dolist를 사용합니다. 리스트의 길이를 구하는 함수의 예를 봅시다:

(defun our-length (lst)
  (let ((len 0))
    (dolist (obj lst)
      (setf len (+ len 1)))
    len))

dolist는 (variable e-pression) 형식의 인수와 여러 개의 표현식으로 구성된 본체를 인수로 받습니다. 본체는 e-pression에서 반환된 리스트의 각 요소를 variable에 지정한 상태에서 평가됩니다. 따라서 이 예제의 루프는 lst에 들어있는 각 obj를 대상으로 len을 증가시키는 것이라고 말할 수 있습니다.

이 함수의 재귀적인 버전은 아래와 같습니다:

(defun our-length (lst)
  (if (null lst)
      0
      (+ (our-length (cdr lst)) 1)))

리스트가 비어있으면 길이는 0입니다. 그렇지 않으면 리스트의 나머지 부분의 길이에 1을 더할 뿐이지요. our-length는 재귀적으로 작성하는 것이 훨씬 깔끔합니다. 그렇지만 이 코드는 꼬리 재귀 호출이 아니기 때문에 (13.2 절에 나옵니다) 효율적이지는 않습니다.

객체로서의 함수

Lisp에서는 함수가 심볼이나, 문자열이나, 리스트 같은 다른 객체들과 똑같이 취급됩니다. function에 함수 이름을 주게 되면 연관된 함수 객체를 반환합니다. quote처럼 function도 특수 연산자이기 때문에, 인수에 따옴표를 붙일 필요가 없습니다:

> (function +)
#<Compiled-Function + 17BA4E>

이 이상하게 보이는 반환값이 Common Lisp 구현체에서 함수를 표시하는 전형적인 방법입니다.

지금까지는 우리가 입력했던 것과 동일하게 출력하는 객체만 다루었습니다. 이 관습이 함수에는 적용되지 않습니다. 내부적으로 + 같은 내장 함수는 기계어 코드의 특정 구획에 대응됩니다. Common Lisp 구현체는 이런 것들을 어떻게 표시할지 마음대로 선택할 수 있습니다. 

quote의 축약어로 '를 써왔던 것처럼 function의 축약어로 #'를 사용할 수 있습니다:

> #'+
#<Compiled-Function + 17BA4E>

다른 객체들과 마찬가지로 함수도 인수로 넘길 수 있습니다. 함수를 인수로 받아들이는 함수에는 apply가 있는데요. 이것은 하나의 함수와 하나의 리스트를 인수로 받아서, 리스트에 주어진 함수를 적용한 결과를 반환합니다:

> (apply #'+ '(1 2 3))
6
> (+ 1 2 3)
6

마지막 인수가 리스트이기만 하면 몇 개의 인수라도 받을 수 있습니다:

> (apply #'+ 1 2 '(3 4 5))
15

funcall 함수도 똑같은 일을 하지만, 인수가 리스트 안에 들어있을 필요는 없습니다:

> (funcall #'+ 1 2 3)
6

defun 매크로는 함수를 생성하고 이름을 부여합니다. 그렇지만 함수가 꼭 이름을 가져야 하는 것은 아닙니다. 그래서 함수를 정의할 때 꼭 defun을 써야 하는 것은 아닙니다. Lisp의 다른 모든 객체들과 마찬가지로 함수를 참조하는 것 역시 문자로 표현할 수 있습니다.

정수를 문자로 참조하려면 숫자의 나열을 사용합니다. 함수를 문자로 참조하려면 람다 표현식이라고 불리는 것을 사용할 수 있습니다. 람다 표현식은 lambda 심볼, 매개변수 목록, 0개 이상의 표현식으로 이루어진 본체를 가지고 있는 리스트입니다.

두 개의 숫자를 받아서 그 합을 반환하는 함수를 람다 표현식으로 표현하려면 이렇게 합니다:

(lambda (x y)
  (+ x y))

리스트 (x y)는 매개변수 목록이고, 그 뒤에 오는 것은 함수의 본체입니다.

람다 표현식은 함수 이름 대신 사용될 수 있습니다. 일반적인 함수 이름을 쓰는 대신에 람다 표현식을 함수 호출의 첫번째 요소로 집어넣으면 됩니다.

> ((lambda (x) (+ x 100)) 1)
101

LAMBDA는 뭔가요?

람다 표현식 안에 들어있는 lambda는 연산자가 아닙니다. 이것은 그냥 심볼입니다. Lisp의 초창기 구현에서는 lambda를 써야하는 이유가 있었습니다: 함수들이 내부적으로도 리스트로 표현되었기 때문에, 일반적인 리스트가 아닌 함수라는 것을 표시하는 유일한 방법이 첫번째 인수로 lambda 심볼을 집어넣는 것이었습니다.

Common Lisp에서도 함수를 리스트로 표현하지만, 내부적으로 함수 객체 표현에 더 이상 리스트를 쓰지 않습니다. 그래서 사실 lambda가 필수적인 것은 아닙니다. 게다가

(lambda (x) (+ x 100))

이렇게 쓰는 대신

((x) (+ x 100))

이렇게 쓰는 것이 일관성도 있습니다. 그렇지만 Lisp 프로그래머들은 이미 람다 표현식을 쓸 때 맨 앞에 lambda 심볼을 쓰는 것이 습관이 되었기 때문에, Common Lisp에서도 전통적인 이유로 그냥 남겨두고 있습니다.


그리고 #'를 람다 표현식에 접두사로 붙여도 대응되는 함수를 얻을 수 있습니다:

> (funcall #'(lambda (x) (+ x 100))
           1)
101

이 표기법을 사용하면 이름을 붙이지 않고도 함수를 사용할 수 있습니다.

타입

Lisp는 타입에 대해 매우 유연한 접근법을 채택하고 있습니다. 다른 많은 언어들에서는 변수가 타입을 가지고 있기 때문에 타입을 지정하지 않고서는 변수를 사용할 수 없습니다. Common Lisp에서는 변수가 타입을 가지고 있는 것이 아니라, 값이 타입을 가지고 있습니다. 모든 객체가 타입이 적힌 꼬리표를 하나씩 달고 있다고 상상하면 됩니다. 이 접근법을 매니페스트 타입 매기기라고 부릅니다. 어떤 변수라도 타입을 가지고 있는 객체에 연결되기 때문에 변수에 타입을 선언할 필요가 없습니다.

타입 선언이 전혀 필요없다고 할지라도, 효율 때문에 타입을 필요로 할 수도 있습니다. 타입 선언에 관해서는 13.3절에서 다룹니다.

Common Lisp에 내장된 타입들은 상위 타입과 하위 타입으로 나뉘어 계층을 구성합니다. 모든 객체는 항상 하나 이상의 타입을 가지게 됩니다. 예를 들면 숫자 27은, 점점 일반화되는 순서로 나열하자면 fixnum, integer, rational, real, number, atom, t 타입을 가지고 있습니다. t는 모든 타입의 상위 타입이기 때문에, 어떤 것이나 t 타입이라고 말할 수 있습니다.

typep 함수는 객체와 타입 지시자를 받아서 객체가 그 타입인 경우 참을 반환합니다:
> (typep 27 'integer)
T

여기에서 다루지 않은 여러가지 내장 타입들은 나중에 보게 될 때마다 따로 설명하겠습니다.

예고편

이 장에서 우리는 가볍게 Lisp를 살펴보았습니다. 이제 이 특이한 언어의 전체적인 윤곽이 슬슬 떠오를텐데요. 우선 이 언어는 단 모든 프로그램 구조를 표현하는 단 하나의 문법을 가지고 있습니다. 이 문법은 리스트에 기반하고 있는데, 이 또한 Lisp 객체입니다. 함수는 그 자체가 객체로 취급되고, 리스트로 표현할 수 있습니다. 그리고 Lisp 그 자체가 Lisp 프로그램이기 때문에, Lisp 내장 함수의 대부분이 여러분이 정의하는 함수와 차이가 전혀 없습니다. 

지금 당장 이 모든 개념들의 관계가 명확하게 보이지 않더라도 걱정할 필요는 없습니다. Lisp가 소개하는 새로운 개념들을 완전히 이해하고 사용하기까지는 시간이 좀 걸립니다. 일단 여기서는 놀랍도록 우아한 개념들이 좀 있다는 사실 자체만 확실하게 해두면 충분합니다.

Richard Gabriel은 언젠가 반농담조로 C를 유닉스 작성하는데나 쓰이는 언어라고 평한 적이 있습니다. 우리는 그와 비슷하게 Lisp를 Lisp를 작성하는데 쓰이는 언어라고 설명할 수 있습니다. 그렇지만 이 문장은 본질적으로 다릅니다. 자기 자신으로 쓰여질 수 있는 언어는 특정한 종류의 어플리케이션을 작성하는데나 쓸모있는 언어와는 근본적으로 다르지요. 언어로 프로그램을 작성하는 것 뿐만 아니라 언어 자체를 프로그램에 맞도록 개선하는 완전히 새로운 방식의 프로그래밍을 가능하게 하니까요. 여러분이 Lisp 프로그래밍의 정수를 이해하고 싶다면, 이런 사고방식이 좋은 출발점이 될 것입니다.

요약

  1. Lisp는 대화식 언어입니다. 최상위 계층에 표현식을 입력하면 Lisp가 평가 결과값을 표시합니다.
  2. Lisp 프로그램은 표현식으로 구성됩니다. 표현식은 단일 값이나, 연산자와 0개 이상의 인수로 구성된 리스트가 될 수 있습니다. 전위표기법을 사용하기 때문에 연산자가 어떤 갯수의 인수라도 받을 수 있습니다.
  3. Common Lisp 함수 호출의 평가 규칙은 이렇습니다: 인수들을 왼쪽에서 오른쪽 방향으로 평가합니다. 평가된 각 인수들을 지정된 연산자로 넘깁니다. quote 연산자는 인수를 평가하지 않은 채로 반환한다는 고유의 평가 규칙을 가지고 있습니다.
  4. Lisp는 일반적인 자료형 외에도 심볼과 리스트를 가지고 있습니다. Lisp 프로그램이 리스트로 표현되기 때문에, 프로그램을 작성하는 프로그램을 작성하는 것도 매우 쉽습니다.
  5. 가장 기본적인 리스트 함수 3개로는 cons, car, cdr을 꼽을 수 있습니다. cons는 리스트를 만듭니다. car는 리스트의 첫번째 요소를 반환합니다. cdr는 첫번째 요소를 제외한 나머지 부분을 반환합니다.
  6. Common Lisp에서 t는 참을 표현하고 nil은 거짓을 표현합니다. 논리적인 문맥에서 nil이 아닌 것은 모두 참으로 간주됩니다. 기본적으로 조건은 if로 표현합니다. and와 or 논리 연산자는 조건과 동작이 유사합니다.
  7. Lisp는 주로 함수로 구성됩니다. 새로운 함수를 정의할 때에는 defun을 사용합니다.
  8. 자기 자신을 호출하는 함수를 재귀적이라고 합니다. 재귀적인 함수를 기계로 생각하지 말고 처리 과정으로 간주하세요.
  9. 괄호는 문제가 안 됩니다. 프로그래머들은 들여쓰기를 통해 Lisp를 읽고 씁니다.
  10. 기본 I/O 함수에는 완전한 Lisp 파서에 해당하는 read와, 템플릿 기반으로 출력을 생성하는 format이 있습니다.
  11. let으로 지역 변수를 생성할 수 있고, defparameter로 전역 변수를 생성할 수 있습니다.
  12. 배정 연산자는 setf입니다. 첫번째 인수는 표현식이 될 수도 있습니다.
  13. 함수형 프로그래밍은 부작용을 피하는 것을 의미합니다. 이것이 Lisp의 주류 패러다임입니다.
  14. 기본 반복 연산자는 do입니다.
  15. 함수들은 일반적인 Lisp 객체입니다. 인수로 넘겨질 수도 있고 람다 표현식으로 표기할 수 있습니다.
  16. Lisp에서는 변수 대신 값이 타입을 가지고 있습니다.

연습문제

  1. 아래 표현식이 어떻게 평가될지 서술해보세요.
      (a) (+ (- 5 1) (+ 3 7))
      (b) (list 1 (+ 2 3))
      (c) (if (listp 1) (+ 1 2) (+ 3 4))
      (d) (list (and (listp 3) t) (+ 1 2))
    
  2. (a b c)를 반환하는 cons 표현식을 3가지 쓰세요.
  3. car와 cdr을 이용해서 리스트의 네번째 요소를 반환하는 함수를 정의하세요.
  4. 두 개의 인수를 받아서 둘 중 큰 쪽을 반환하는 함수를 정의하세요.
  5. 아래 함수들은 무슨 일을 할까요?
      (a) (defun enigma (x)
            (and (not (null x))
                 (or (null (car x))
                     (enigma (cdr x)))))
      (b) (defun mystery (x y)
            (if (null y)
                nil
                (if (eql (car y) x)
                    0
                    (let ((z (mystery x (cdr y))))
                      (and z (+ z 1))))))
    
  6. x로 표시된 부분에는 무엇이 와야 할까요?
      (a) > (car (x (cdr '(a (b c) d))))
          B
      (b) > (x 13 (/ 1 0))
          13
      (c) > (x #'list 1 nil)
          (1)
    
    
  7. 이 장에서 설명한 연산자만 이용해서, 한 개의 리스트를 인수로 받아 그 안에 리스트인 요소가 있으면 참을 반환하는 함수를 작성해보세요.
  8. 각각 반복적인 버전과 재귀적인 버전의 함수 정의를 쓰세요.
      (a) 양의 정수를 받아서 그 수만큼 .을 찍는 함수
      (b) 리스트를 받아서 그 안에 심볼 a가 몇 개 있는지 반환하는 함수
    
  9. 어떤 친구가 리스트에서 nil이 아닌 요소들의 합을 반환하는 함수를 작성하려고 합니다. 이런 일을 하는 함수를 2개 작성했는데 둘 다 제대로 동작하지 않습니다. 각각 어디가 잘못되었는지 설명하고, 제대로 고친 버전을 만들어보세요:
      (a) (defun summit (lst)
            (remove nil lst)
            (apply #'+ lst))
      (b) (defun summit (lst)
            (let ((x (car lst)))
              (if (null x)
                  (summit (cdr lst))
                  (+ x (summit (cdr lst))))))
    

ANSI Common Lisp 2장. 리스트 학술

ANSI Common Lisp 2장. 리스트리스트는 Lisp에서 가장 기본적인 자료 구조 중 하나입니다. 초기 구현체에서는 유일한 자료 구조이기도 했었죠: "Lisp"라는 이름은 원래 "LISt Processor"에서 유래한 것입니다. 그렇지만 이제 그런 약어로 표현하기에는 Lisp가 너무 거대해졌죠. 오늘날의 Common Lisp는 다양한 자료 구조를 지원하는 범용적인 프로그래밍 언어입니다.


Lisp 프로그램 개발을 하다보면 Lisp 언어의 개발 역사를 그대로 반영하게 되는 경우가 많습니다. Lisp 프로그램을 처음 작성할 때에는 보통 리스트만 엄청나게 써서 코딩하게 됩니다. 그러다가 차츰 버전을 올리면서 리스트를 더 빠르고 특화된 자료 구조로 대체하게 됩니다. 이 장에서는 리스트를 가지고 할 수 있는 다양한 것들을 살펴보는 동시에, 리스트를 이용하여 몇 가지 일반적인 Lisp 개념들을 설명하겠습니다.

Cons

2.4절에서는 cons, car, cdr과 같은 기본적인 리스트 조작 함수를 배웠습니다. cons가 실제로 하는 일은 두 개의 객체를 두 부분으로 나뉘어진 cons라 불리는 객체에 결합하는 것입니다. 개념적으로 cons는 한 쌍의 포인터입니다. 앞쪽은 car이고 뒷쪽은 cdr에 해당됩니다.


cons는 어떤 타입의 쌍을 표현할 때에도 유용하게 사용할 수 있습니다. cons의 각 부분은 어떤 종류의 객체의 위치라도 가리킬 수 있습니다. 물론 또다른 cons를 가리킬 수도 있지요. 리스트를 만들 때는 cons가 다른 cons를 가리키도록 해서 줄줄이 연결하는 방법을 사용합니다.


리스트를 여러 개의 쌍으로 바꿔서 생각하는 것이 쉽진 않겠지만, 어찌되었든 이런 방식으로 정의하는 것이 가능합니다. 비어있지 않은 모든 리스트는 첫번째 요소와 리스트의 나머지 부분으로 구성된 한 쌍으로 간주할 수 있습니다. Lisp의 리스트는 이런 개념을 구체화한 것입니다. cons의 앞 부분은 리스트의 첫번째 요소를 가리키고, 뒷 부분은 리스트의 나머지 부분을 가리킵니다. 나머지 부분은 또다른 cons이거나 nil일 수 있겠지요. Lisp는 관습적으로 첫번째 요소를 가져올 때 car를, 리스트의 나머지 부분을 가져올 때 cdr을 사용해왔습니다. 그래서 지금은 리스트의 첫 번째 요소를 car라고 하고, 리스트의 나머지 부분을 cdr이라고 부르게 되었습니다. 리스트는 특별한 종류의 객체가 아닙니다. 그냥 이런 식으로 cons 여러 개가 묶여있을 뿐입니다.

무엇인가를 nil에 cons 하게 되면,

> (setf x (cons 'a nil))
(A)

그림 3.1처럼 하나의 cons로 구성된 리스트가 만들어집니다. 각각의 cons가 car와 cdr을 가리키는 화살표를 가진 상자로 보이기 때문에, 이렇게 cons를 표현하는 방법을 상자 표기법이라고 부릅니다. car와 cdr를 호출하면 이 화살표들이 가리키는 대상을 가져올 수 있습니다:

> (car x)
A
> (cdr x)
NIL

여러 개의 요소를 가진 리스트를 만든다면 여러 개의 cons가 사슬처럼 연결된 모양을 얻게 됩니다:

> (setf y (list 'a 'b 'c))
(A B C)

이 사슬 모양의 구조가 그림 3.2에 그려져 있습니다. 이제 이 리스트의 cdr은 2개의 요소가 들어있는 리스트입니다.

> (cdr y)
(B C)

여러 개의 요소가 들어있는 리스트에서는 car를 이용해서 각 요소들을 가져올 수 있고, cdr을 이용해서 리스트의 나머지 부분을 가져올 수 있습니다.


리스트는 어떤 종류의 객체라도 저장할 수 있기 때문에, 다른 리스트를 저장할 수도 있습니다:

> (setf z (list 'a (list 'b 'c) 'd))
(A (B C) D)

이런 상황에서는 그림 3.3에 표시된 내부 구조를 가지게 됩니다. 두번째 cons의 car 포인터는 리스트를 가리키게 되는 것이지요:

> (car (cdr z))
(B C)

앞에서 봤던 리스트 y와 지금 보고 계신 리스트 z는 모두 3개의 요소를 가지고 있습니다. 단지 z의 두번째 요소가 리스트인 것 뿐이죠. 이런 리스트를 중첩된 리스트라고 부르고, 다른 리스트를 요소로 포함하지 않는 y 같은 리스트는 평평한 리스트라고 부릅니다.


consp 함수는 주어진 인수가 cons인 경우에만 참을 반환합니다. 그러므로 listp는 아래와 같이 정의될 수 있습니다:

(defun our-listp (x)
(or (null x) (consp x)))

cons가 아닌 것은 모두 원자이기 때문에 술어 atom은 아래와 같이 정의할 수 있습니다:

(defun our-atom (x) (not (consp x)))

nil이 원자이면서 리스트라는 점을 주의하세요.

동등성

Lisp는 cons를 호출할 때마다 두 개의 포인터를 담을 공간을 메모리에 할당합니다. 그렇기 때문에 같은 인수를 주고 cons를 두 번 호출하더라도, 겉으로는 같은 값을 가지고 있는 것처럼 보이지만 별개의 객체로 존재합니다:

> (eql (cons 'a nil) (cons 'a nil))
NIL

만약 두 개의 리스트가 같은 요소들을 가지고 있는지 확인할 수 있다면 편리할텐데요. 이런 경우를 위해 Common Lisp는 동등성을 비교할 때 쓸 수 있는 술어를 내장하고 있습니다. eql은 두 객체가 같은 경우에만 참을 반환하지만,

> (setf x (cons 'a nil))
(A)
> (eql x x)
T

equal은 인수들이 본질적으로 동일한 내용을 출력하면 참을 반환합니다:

> (equal x (cons 'a nil))
T

이 술어는 리스트 외의 다른 자료 구조에도 사용할 수 있지만, 리스트의 동등성만 판별하는 버전은 이렇게 작성할 수 있을겁니다:

(defun our-equal (x y)
(of (eql x y)
(and (consp x)
(consp y)
(our-equal (car x) (car y))
(our-equal (cdr x) (cdr y)))))

이 정의를 보면 알 수 있듯이, 어떤 x와 y를 인수로 넣었을 때 eql이 참이면, equal도 참이라고 말할 수 있습니다.

왜 리스프는 포인터가 없나요

리스프의 비밀 중 하나는, 변수 또한 리스트가 각 요소를 가지고 있는 것과 동일한 방식으로 값을 가진다는 것입니다. cons가 각 요소를 가리키는 포인터를 가지고 있는 것처럼 변수도 값을 가리키는 포인터를 가지고 있습니다.


여러분은 아마도 포인터가 명시적으로 관리되는 언어를 지금껏 써왔을텐데요. 리스프에서는 언어가 포인터를 처리해주기 때문에 명시적으로 포인터를 관리할 일이 없습니다. 이전에 리스트에서 포인터가 어떻게 사용되는지 살펴봤는데요. 변수도 비슷합니다. 두 개의 변수에 같은 리스트를 설정한다고 생각해봅시다:

> (setf x '(a b c))
(A B C)
> (setf y x)
(A B C)

y에 x의 값을 설정하면 무슨 일이 일어날까요? 사실 변수 x가 리스트 자체를 포함하고 있지는 않습니다. 단지 리스트를 가리키는 포인터를 메모리에 저장하고 있는 것 뿐입니다. 그래서 같은 값을 y에 배정하게 되면, 리스트를 복사하는 것이 아니라 포인터를 복사하게 됩니다. (그림 3.4가 그 결과를 보여주고 있습니다.) 따라서 두 개의 변수 모두 같은 객체를 가리키게 됩니다:

> (eql x y)
T

리스프가 포인터를 따로 가지고 있지 않은 이유는, 모든 값이 개념적으로 포인터이기 때문입니다. 어떤 값을 변수에 배정하거나, 자료 구조에 저장한다고 할지라도 실제로 저장되는 것이라곤 그 값을 가리키는 포인터 뿐입니다. 자료 구조에게 내용을 꺼내도록 하거나 변수의 값을 가져오게 하면 리스프는 포인터가 가리키는 객체를 반환합니다. 그렇지만 이 모든 일이 수면 아래에서 일어나지요. 아무 생각 없이 그냥 자료 구조나 변수에 값을 집어넣으면 됩니다.


리스프는 효율 때문에 가끔 포인터 대신 값 자체를 선택할 때가 있습니다. 예를 들면, 작은 정수를 저장하는 경우에는 포인터보다 더 큰 메모리 공간을 쓸 필요가 없습니다. 그래서 리스프 구현체는 포인터를 만들고 이 값을 가리키도록 하는 대신 작은 정수 표현을 그냥 사용합니다. 그렇지만 내부적으로야 어떻게 돌아가든 프로그래머는 귀찮게 생각할 필요없이 무엇이든 아무데나 집어넣을 수 있습니다. 완전히 모순적인 선언을 만들어내지 않는 한 어떤 종류의 객체를 어떤 종류의 자료 구조에 넣는 것도 가능합니다. 특정한 구조를 만들어 낸 다음, 그 안에 같은 구조를 넣어버리는 것도 가능하지요.

리스트 만들기

copy-list 함수는 리스트를 받아서 복사본을 반환합니다. 새 리스트는 같은 요소들을 가지고 있지만, 새로운 cons 안에 담겨있다는 점에서 다릅니다:

> (setf x '(a b c)
y (copy-list x))
(A B C)

그림 3.5를 보면 같은 승객들을 새로운 버스에 태운 것 같은 구조입니다. copy-list는 아래와 같이 정의되었다고 생각할 수 있습니다:

(defun our-copy-list (lst)
(if (atom lst)
lst
(cons (car lst) (our-copy-list (cdr lst)))))

x와 (copy-list x)이 가지고 있는 요소가 같으므로 equal은 참이 되겠지만, x가 nil이 아닌 이상 같은 객체는 아니므로 eql은 거짓이 됩니다.


마지막으로, append 함수는 리스트 여러 개를 받아서 하나의 리스트로 만들어줍니다:

> (append '(a b) '(c d) '(e))
(A B C D E)

append는 여러 개의 리스트를 하나로 합친 새로운 리스트로 만들어 내는 과정에서 마지막 리스트를 제외한 모든 리스트들을 복사합니다. (역주: 부록 B의 append 구현을 참고하세요)

예제: 압축

이 절에서는 리스트를 압축할 때 쓸 수 있는 가장 간단한 기법을 보여드리겠습니다. 이 알고리즘은 Run Length Encoding으로 불립니다. 레스토랑의 예를 생각하면 이 알고리즘의 동작 방식을 알 수 있는데요. 웨이트리스가 주문을 받으러 손님 4명이 앉아있는 식탁으로 왔다고 상상해보지요.


웨이트리스: "뭘 드시겠습니까?"

첫번째 손님: "난 스페셜"

두번째 손님: "어 나도"

세번째 손님: "그게 좋겠네"

모두가 네번째 손님을 돌아보는 찰나, "난 실란트로 수플레 먹을래"라고 들릴락 말락한 작은 목소리로 말합니다.


웨이트리스가 돌아서서 카운터로 또각또각 걸어갑니다. 그러고는 주방에 대고 큰 소리로 주문을 넣죠. "여기 스페셜 세 개랑 실란트로 수플레 하나요!"

그림 3.6: Run-length encoding: Compression

(defun compress (x)
(if (consp x)
(compr (car x) 1 (cdr x))
x))

(defun compr (elt n lst)
(if (null lst)
(list (n-elts elt n))
(let ((next (car lst)))
(if (eql next elt)
(compr elt (+ n 1) (cdr lst))
(cons (n-elts elt n)
(compr next 1 (cdr lst)))))))

(defun n-elts (elt n)
(if (> n 1)
(list n elt)
elt))


그림 3.6을 보시면 이런 압축 기법을 구현한 코드가 있습니다. compress 함수는 원자들로만 이루어진 리스트를 하나 받아서 압축된 형태로 결과를 반환합니다:

> (compress '(1 1 1 0 1 0 0 0 0 1))
((3 1) 0 1 (4 0) 1)

같은 요소가 연속으로 나오게 되면, 이것을 (갯수 요소) 형태의 리스트로 바꿔버립니다.


대부분의 일은 compr 함수가 해결하는데요. 이 함수는 3개의 인수를 받습니다. elt는 우리가 마지막으로 봤던 요소, n은 elt가 연속으로 몇 번 나왔는지, 그리고 lst는 압축할 나머지 부분을 의미합니다. 처리할 것이 더 없으면 n-elts 함수를 호출해서 n개의 elt를 특정한 방식으로 표현하도록 합니다. lst의 첫번째 요소가 여전히 elt이면 그냥 n을 하나 증가시켜서 재귀 호출하면 됩니다. 만약 elt와 같지 않으면 지금까지 봤던 요소들을 압축합니다. 그리고 compr이 나머지 부분들을 처리해서 계속 붙여나가도록 합니다.

그림 3.7: Run-length encoding: Expansion.

(defun uncompress (lst)
(if (null lst)
nil
(let ((elt (car lst))
(rest (uncompress (cdr lst))))
(if (consp elt)
(append (apply #'list-of elt)
rest)
(cons elt rest)))))

(defun list-of (n elt)
(if (zerop n)
nil
(cons elt (list-of (- n 1) elt))))


압축된 리스트를 원래대로 되돌리려면, 그림 3.7에 있는 uncompress 함수를 호출하면 됩니다.

> (uncompress '((3 1) 0 1 (4 0) 1))
(1 1 1 0 1 0 0 0 0 1)

uncompress 함수도 압축된 리스트를 재귀적인 방식으로 처리합니다. 각 묶음의 압축을 풀어서 원래 모양으로 만들어 놓을 때는 list-of 함수를 호출합니다:

> (list-of 3 'ho)
(HO HO HO)

사실 list-of 함수를 만들 필요는 없었습니다. 내장 함수 중에 make-list가 같은 일을 하니까요 - 그렇지만 그 함수는 키워드 인수를 사용하는데, 아직 키워드 인수가 뭔지 설명을 안 했기 때문에 어쩔 수 없이 하나 만들어서 보여드린겁니다.


그나저나 숙련된 Lisp 프로그래머라면 그림 3.6이나 3.7처럼 코딩하지는 않았을겁니다. 비효율적이기도 하고, 압축도 할 수 있는만큼 최대로 한 것이 아니거든요. 게다가 원자로 구성된 리스트만 처리할 수 있다는 것이 문제입니다. 몇 장만 더 넘어가면 지금 언급한 문제들을 모두 해결할 수 있는 기법들을 배울 수 있습니다. 일단 넘어가지요.




프로그램 로드하기

이 절에서 처음으로 실질적인 프로그램을 하나 작성했는데요. 코드가 좀 길어지면 파일에 써넣고 Lisp로 이 파일을 읽어들이는 편이 더 편할 수도 있습니다. 가령 그림 3.6과 3.7에 있는 코드를 "compress.lisp" 파일에 저장했다면, 최상위 계층에서 이렇게 입력하세요:

(load "compress.lisp")

이렇게 하면 최상위 계층에서 입력한 것과 차이가 전혀 없습니다.
주의: 어떤 구현체에서는 Lisp 파일의 확장자로 ".lisp" 대신 ".lsp"를 쓰기도 합니다.



접근

Common Lisp는 car와 cdr 외에 추가적인 접근 함수를 가지고 있습니다. 리스트의 특정 위치에 있는 요소를 찾으려면 nth 함수를 호출하면 됩니다:

> (nth 0 '(a b c))
A

만약 cdr을 n번 적용한 결과를 원한다면 nthcdr 함수를 호출하면 됩니다:

> (nthcdr 2 '(a b c))
(C)

nth 함수나 nthcdr 함수 모두 인덱스가 0부터 시작합니다. 즉, 각 요소에 번호를 매길 때 1부터 시작하는 것이 아니라 0부터 시작하게 됩니다. Common Lisp에서 숫자를 이용하여 자료 구조 안에 있는 요소를 참조하는 경우에는 항상 0부터 시작하는 인덱스를 사용하게 됩니다.


앞에서 본 두 함수는 하는 일이 거의 동일합니다. nth는 nthcdr에 car를 적용한 함수와 같습니다. 오류 처리를 다 빼고 생각해본다면 nthcdr을 이렇게 정의할 수 있습니다:

(defun our-nthcdr (n lst)
(if (zerop n)
lst
(our-nthcdr (- n 1) (cdr lst))))

zerop는 인수가 0인 경우 참을 반환하는 간단한 함수입니다.


last 함수는 리스트를 구성하는 가장 마지막 cons를 반환합니다:

> (last '(a b c))
(C)

이 결과에서 주의해야 할 것은 반환값이 마지막 요소 자체가 아니라는 점입니다. 마지막 요소를 얻어내고 싶다면, last 함수를 호출해서 나온 결과를 car에 넘겨주세요. 


Common Lisp는 first부터 tenth까지 함수를 정의해놓고 있는데, 각각 리스트에서 대응되는 위치에 있는 요소를 가져옵니다. 이 함수들은 인덱스가 0부터 시작하지 않습니다. 따라서 (second x)는 (nth 1 x)와 같습니다.


이 외에도 Common Lisp는 caddr 같은 함수들을 정의하고 있는데요. caddr은 (car (cdr (cdr )))을 축약해놓은 함수입니다. 이렇게 cxr 형태로 만들어진 함수 이름들이 있는데, x에는 a나 d가 4개까지 들어갈 수 있도록 미리 내장 함수들이 만들어져 있습니다. 그렇지만 cadr 같은 간단한 경우를 제외하고는 이런 함수를 쓰지 않는 편이 좋습니다. 그런 이름은 아무도 읽고 싶지 않을테니까요.

매핑 함수

Common Lisp는 리스트 안에 있는 요소를 대상으로 함수를 호출할 수 있도록 해주는 여러가지 함수를 내장하고 있습니다. 가장 자주 사용되는 것은 mapcar인데요, 함수 하나와 한 개 이상의 리스트를 받아서, 각 리스트의 요소들을 대상으로 함수를 호출한 결과를 반환합니다. 두 개 이상의 리스트가 인수로 주어지는 경우 가장 길이가 짧은 리스트에 맞춰서 동작하게 됩니다:

> (mapcar #'(lambda (x) (+ x 10))
'(1 2 3))
(11 12 13)

> (mapcar #'list
'(a b c)
'(1 2 3 4)
((A 1) (B 2) (C 3))

maplist 함수는 똑같은 형식으로 인수를 받긴 하지만, 리스트에 cdr을 연속적으로 적용하면서 함수를 호출한다는 점에서 다릅니다:

> (maplist #'(lambda (x) x)
'(a b c))
((A B C) (B C) (C))

다른 매핑 함수로는 mapc가 있는데요, 88쪽에서 설명드리겠습니다. mapcan도 있는데 이것은 202쪽에서 다루도록 하겠습니다.

트리

Cons를 이진 트리로 생각할 수도 있습니다. car를 왼쪽 하위 트리로 생각하고 cdr을 오른쪽 하위 트리로 생각하는거죠. 예를 들어 리스트 (a (b c) d)는 그림 3.8 같은 트리로 표현될 수 있습니다. (책을 -45도로 돌려보면 그림 3.3과 같은 것을 알 수 있습니다.)


Common Lisp는 트리를 다루는 내장 함수를 몇 개 가지고 있습니다. 예를 들어, copy-tree 함수는 트리 하나를 받아서 복사본을 반환합니다. 이것은 아래와 같이 정의될 수 있습니다:

(defun our-copy-tree (tr)
(if (atom tr)
tr
(cons (our-copy-tree (car tr))
(our-copy-tree (cdr tr)))))

36쪽의 copy-list와 이 코드를 비교해보면, copy-tree는 cdr만 복사하는 것이 아니라 car도 복사한다는 점이 다르다는 것을 알 수 있습니다. 


그런데 사실 트리 노드에 값을 쓸 수 없으면 이진 트리로서 별 가치가 없습니다. Common Lisp에 내장된 트리를 대상으로 동작하는 함수들은 트리가 아니라 중첩된 리스트를 다루기 위해서 존재하는 것이랍니다. 예를 들어 아래와 같은 리스트를 하나 가지고 있다고 합시다:

(and (integerp x) (zerop (mod x 2)))

이걸 x 대신 y로 고치고 싶은데요. 순차열에 있는 요소를 치환하는데 쓰는 substitute를 여기에 대고 호출해봐야 제대로 동작하지 않습니다:

> (substitute 'y 'x '(and (integerp x) (zerop (mod x 2))))
(AND (INTEGERP X) (ZEROP (MOD X 2)))

왜냐하면 이 리스트는 3개의 요소를 가지고 있는데 이 중 어느 것도 x가 아니기 때문입니다. 중첩된 리스트 속에 박혀있는 x를 치환하려면 트리에 있는 요소를 대체할 때 사용하는 subst를 써야합니다:

> (subst 'y 'x (and (integerp x) (zerop (mod x 2))))
(AND (INTEGER Y) (ZEROP (MOD Y 2)))

직접 subst를 구현해보면 copy-tree와 모양이 거의 비슷하다는 것을 알 수 있습니다:

(defun our-subst (new old tree)
(if (eql tree old)
new
(if (atom tree)
tree
(cons (our-subst new old (car tree))
(our-subst new old (car tree))))))

트리를 대상으로 동작하는 함수는 대체로 다 이런 모양을 가지고 있습니다. car와 cdr 양쪽으로 타고 내려가죠. 이런 함수를 가리켜 이중 재귀적이라고 합니다.

재귀 이해하기

학생들에게 재귀를 가르칠 때, 재귀 함수의 모든 호출 과정을 종이에 써보라고 하는 경우를 종종 보는데요. (재귀 함수의 호출 과정을 추적하는 방법은 288쪽에서 다룹니다.) 이렇게 가르치면 안 됩니다. 프로그래머가 그렇게 모든 함수 호출 과정과 반환값을 따져가면서 재귀 함수를 정의하는 것은 아니니까요.


만약 그런 식으로 생각해야 한다면 재귀를 사용하는 것이 도움이 되는게 아니라 오히려 부담만 되겠죠. 사실 재귀의 장점이 알고리즘을 좀 더 추상적인 차원에서 볼 수 있도록 해주는 것이니까요. 함수를 호출했을 때 발생하는 모든 재귀 호출을 따져보지 않아도 이것이 제대로 만들어진 재귀 함수인지 아닌지 충분히 판단할 수 있습니다.


어떤 재귀 함수가 제대로 동작하는지 알고 싶다면 그냥 모든 경우의 수를 처리하는지 확인하기만 하면 됩니다. 가령 리스트의 길이를 구하는 함수가 있으면:

(defun len (lst)
(if (null lst)
0
(+ (len (cdr lst)) 1)))

두 가지만 확인해보면 이 함수가 올바르게 동작하는지 알 수 있습니다:

  1. 리스트의 길이가 0일 때 동작
  2. 길이가 n인 리스트에 대해서 동작하면, 길이가 n+1인 리스트에 대해서도 동작

두 가지 조건이 성립되면 모든 리스트에 대해서 동작한다는 것을 알 수 있지요.


이 함수의 정의는 확실히 첫번째 조건을 만족합니다: lst가 nil인 경우, 함수는 즉각 0을 반환합니다. 자 이제, 길이가 n인 리스트인 경우에 동작한다고 가정하구요. 길이가 n+1인 리스트를 넘겨봅시다. 이 함수의 정의는 리스트의 cdr에 대해 len을 호출한 결과와 1을 더하여 반환합니다. 여기서는 cdr이 길이가 n인 리스트라고 할 수 있겠네요. 처음 가정한 바에 의하면 길이가 n인 리스트를 len에 넘기면 결과가 n입니다. 따라서 리스트의 전체 길이는 n+1이라고 할 수 있지요.


이게 전부입니다. 재귀를 이해하는 방법이 괄호를 다루는 방법과 많이 닮았다는 생각이 들지 않습니까? 아니 어떻게 그 많은 괄호의 쌍이 제대로 맞는지 확인할 수 있습니까? 그럴 필요가 없습니다. 대체 어떻게 재귀 함수의 모든 호출 과정을 머리 속으로 그려낼 수 있죠? 그럴 필요가 없다니까요.


더 복잡한 재귀함수라면 더 많은 경우의 수가 있을테지만 어디까지나 기본 원리는 같습니다. 가령 41쪽의 our-copy-tree는 원자, cons가 1개인 경우, n+1개의 cons로 이루어진 트리의 경우, 이렇게 세 가지 경우를 고려해야 했습니다.


첫번째 경우 (여기서는 길이가 0인 리스트이지요)는 기초 단계라고 부릅니다. 의도한대로 재귀 함수가 동작하지 않는 것은 기초 단계가 잘못되어서 그런 경우가 많습니다. 아예 기초 단계를 생략해버리는게 가장 흔한 실수인데요. member를 잘못 구현한 정의를 한 번 보시죠:

(defun our-member (obj lst)             ; wrong
(if (eql (car lst) obj)
lst
(our-member obj (cdr lst))))

끝까지 못 찾고 리스트의 마지막에 도달했을 때 재귀 호출을 멈추려면 null 테스트가 필요합니다. 이 버전은 원하는 객체를 리스트에서 찾지 못한 경우에 무한 루프에 빠져버립니다. 이런 종류의 문제를 더 자세히 살펴보려면 부록 A를 보세요.


어떤 재귀 함수가 올바르게 구현되었는지 아닌지 판단할 수 있게 되었다고 해도 그건 재귀의 절반을 이해한 것에 불과합니다. 나머지 절반을 이해하려면 여러분이 원하는 것을 재귀 함수로 만들어 낼 수 있어야 합니다. 그 내용은 6.9절에서 다루겠습니다.

집합

리스트는 작은 집합을 표현하는데 쓰기에도 좋습니다. 리스트의 모든 요소는 그 집합의 원소이기도 합니다:

> (member 'b '(a b c))
(B C)

member가 참을 반환할 때는 심볼 t를 반환하는 대신 리스트에서 우리가 찾는 객체로 시작하는 부분을 반환합니다. 논리적으로는 cons가 t와 똑같이 취급되니까 이런 방식으로 추가적인 정보를 전달하는 것이지요.


기본적으로 member는 eql을 이용해서 객체를 비교합니다. 그렇지만 키워드 인수를 쓰면 기본값을 덮어 쓸 수 있습니다. 많은 Common Lisp 함수들은 하나 이상의 키워드 인수를 받도록 되어있습니다. 이 키워드 인수는 위치에 대응되지 않고 키워드라고 불리는 특별한 태그에 대응된다는 점에서 좀 다릅니다. 키워드는 콜론이 앞에 붙은 심볼을 말합니다.


member가 받아들이는 키워드 인수 중 하나는 :test인데요. member를 호출할 때 :test 인수에 함수를 넘기면 동등성을 테스트할 때 eql 대신 그 함수를 사용합니다. 따라서 리스트에서 어떤 요소를 찾을 때 eql 대신 equal을 사용하고 싶은 경우 이렇게 쓸 수 있습니다:

> (member '(a) '((a) (z)) :test #'equal)
((A) (Z))

키워드 인수는 항상 옵션입니다. 키워드 인수가 하나라도 호출에 포함되는 경우에는 끝부분에 써야 합니다. 키워드 인수를 여러 개 쓸 때에는 키워드 인수끼리 순서를 마음대로 바꿔도 상관없습니다.

member가 받는 또다른 키워드 인수로는 :key 인수가 있는데요. 이것으로 각 인수를 비교하기 전에 적용할 함수를 지정할 수 있습니다:

> (member 'a '((a b) (c d)) :key #'car)
((A B) (C D))

이 예제는 car를 적용한 결과가 a인 요소를 검색하는 방법을 보인 것입니다.


키워드 인수를 둘 다 전달할 때에는 어떤 순서라도 가능합니다. 아래의 두 호출은 동등한 것입니다:

> (member 2 '((1) (2)) :key #'car :test #'equal)
((2))
> (member 2 '((1) (2)) :test #'equal :key #'car)
((2))

둘 다 car를 적용한 결과가 2인 요소를 찾아냅니다.


임의의 술어를 만족하는 요소를 찾고 싶을 때 - 홀수인 경우에만 참을 반환하는 oddp 같은 것들 - 사용할 수 있는 비슷한 함수로는 member-if가 있습니다:

> (member-if #'oddp '(2 3 4))
(3 4)

member-if가 이런 식으로 쓰여있다고 생각하면 됩니다:

(defun our-member-if (fn lst)
(and (consp lst)
(if (funcall fn (car lst))
lst
(our-member-if fn (cdr lst)))))

함수 adjoin은 조건부 cons 함수라고 할 수 있습니다. 객체 하나와 리스트 하나를 받아서, 이 객체가 리스트에 없는 경우에만 붙여넣습니다:

> (adjoin 'b '(a b c))
(A B C)
> (adjoin 'z '(a b c))
(Z A B C)

일반적인 경우에는 member와 동일한 키워드 인수를 받아들입니다.


합집합, 교집합, 차집합 연산은 각각 union, intersection, set-difference 함수로 구현되어 있습니다. 이 함수들은 정확히 두 개의 리스트만 받아들입니다. (그렇지만 이 함수들 또한 member와 동일한 키워드 인수를 받을 수 있습니다.)

> (union '(a b c) '(c b a))
(A C B S)
> (intersection '(a b c) '(b b c))
(B C)
> (set-difference '(a b c d e) '(b e))
(A C D)

원래 집합에는 순서라는 개념이 없기 때문에 이 함수들은 연산할 때 원본 리스트에 들어있는 요소들의 순서를 보존하지 않습니다. set-difference 함수를 호출한 결과는 세부 구현에 따라 (d c a)가 될 수도 있다는 뜻입니다.

시퀀스

리스트를 특정한 순서를 지닌 객체들의 나열이라고 생각할 수도 있습니다. Common Lisp에서는 리스트와 벡터, 둘 다 시퀀스에 포함됩니다. 이 절에서는 시퀀스 함수 몇 개를 소개할텐데요, 특히 리스트에 적용 가능한 것들을 알아보겠습니다. 시퀀스에 적용할 수 있는 연산들은 4.4절에서 더 자세하게 다루겠습니다.


length 함수는 시퀀스에 포함된 요소들의 갯수를 반환합니다:

> (length '(a b c))
3

리스트 전용 함수이긴 헀지만 이미 24쪽에서 이 함수를 작성한 적이 있었죠. 


시퀀스의 일부분을 복사할 때에는 subseq를 사용합니다. 두번째 인수(필수)는 어디부터 복사할 것인지 지정하구요. 세번째 인수(옵션)는 어느 지점에서 복사를 멈출 것인지 지정합니다.

> (subseq '(a b c d) 1 2)
(B)
> (subseq '(a b c d) 1)
(B C D)

세번째 인수가 생략되면 지정한 시작 위치부터 원본 시퀀스의 끝까지 복사해버립니다.


reverse 함수는 시퀀스를 인수로 받아서 똑같은 요소를 가진 시퀀스를 내놓기는 하는데, 순서를 뒤집어 버립니다:

> (reverse '(a b c))
(C B A)

(a b b a)처럼 앞에서부터 읽어도, 뒤에서부터 읽어도 똑같은 시퀀스를 회문이라고 부르는데요. 만약 회문이 짝수 개의 요소들을 가지고 있다면, 나머지 절반은 앞 부분을 거울로 비춘 모양이 될 것입니다. length, subseq, reverse 함수를 가지고 아래와 같이 함수를 정의할 수 있습니다:

(defun mirror? (s)
(let ((len (length s)))
(and (evenp len)
(let ((mid (/ len 2)))
(equal (subseq s 0 mid)
(reverse (subseq s mid)))))))

이 함수를 이용해서 아래와 같은 회문을 찾아낼 수 있습니다:

> (mirror? '(a b b a))
T

Common Lisp는 sort라는 정렬 함수를 내장하고 있습니다. 시퀀스 하나와 두 인수를 비교하는 함수를 하나 받습니다. 그 다음, 이 비교 함수를 기준으로 정렬한 시퀀스를 반환합니다:

> (sort '(0 2 1 3 8) #'>)
(8 3 2 1 0)

sort 함수를 사용할 때는 조심해야 합니다. 파괴적이니까요. 효율을 끌어올리기 위해 sort 함수는 인수로 주어진 시퀀스를 임의로 변경할 수 있습니다. 그러므로 원본이 손상되는 것을 원하지 않는다면 복사본을 넘겨줘야 합니다.


sort와 nth를 이용하면 정수 n을 인수로 받아서 리스트에 있는 요소 중 n번째로 큰 요소를 반환하는 함수를 작성할 수 있습니다:

(defun nthmost (n lst)
(nth (- n 1)
(sort (copy-list lst) #'>)))

n번째로 큰 수를 찾으려고 하는데, 순서를 셀 때 0부터 시작하면 직관적이지 않기 때문에 nth를 호출할 때 1을 빼도록 만들었습니다.

> (nthmost 2 '(0 2 1 3 8))
3

조금 더 노력을 기울이면 이보다 효율적인 버전의 함수를 만들어낼 수도 있습니다.


every 함수와 some 함수는 술어와 하나 이상의 시퀀스를 받아들입니다. 단 하나의 시퀀스만 넘겨주는 경우에는 시퀀스의 각 요소가 술어를 만족하는지 테스트합니다:

> (every #'oddp '(1 3 5))
T
> (some #'evenp '(1 2 3))
T

두 개 이상의 시퀀스가 주어지는 경우라면, 테스트하는데 사용되는 술어 또한 주어지는 시퀀스의 갯수만큼 인수를 받아들일 수 있어야 합니다. 테스트를 수행할 때마다 각 시퀀스에서 뽑아낸 요소를 한 번에 여러 개의 인수로 던져넣으니까요:

> (every #'> '(1 3 5) '(0 2 4))
T

길이가 서로 다른 시퀀스가 주어지면, 가장 길이가 짧은 쪽에 맞춰서 테스트를 수행하게 됩니다.

스택

리스트는 여러 개의 cons가 연결된 것으로 표현되는데요. 이것을 그대로 스택처럼 사용할 수도 있습니다. Common Lisp에 보면 이런 목적으로 사용하기 위한 매크로가 두 개 있습니다: (push x y)는 x를 리스트 y의 앞에 밀어넣고, (pop x)는 리스트 x의 첫번째 요소를 제거하고 반환합니다.


둘 다 setf를 써서 정의되어 있습니다. 인수가 상수나 변수라면 호출을 변환하는 것이 매우 간단합니다. 표현식

(push obj lst)

(setf lst (cons obj lst))

와 동등하고, 표현식

(pop lst)

은 아래와 동등합니다.

(let ((x (car lst)))
(setf lst (cdr lst))
x)

아래의 예를 보세요:

> (setf x '(b))
(B)
> (push 'a x)
(A B)
> x
(A B)
> (setf y x)
(A B)

> (pop x)
A
> x
(B)
> y
(A B)

각 표현식의 실행 결과는 이렇습니다. 그림 3.9는 이 표현식들이 모두 수행되고 난 후의 구조입니다.


reverse 함수를 반복적인 버전으로 정의한다면 이런 식으로 push를 활용할 수 있습니다:

(defun our-reverse (lst)
(let ((acc nil))
(dolist (elt lst)
(push elt acc))
acc))

이 버전에서는 빈 리스트를 하나 준비해놓고 lst의 각 요소들을 계속 밀어넣습니다. 다 끝나면 lst의 마지막 요소가 제일 앞에 위치하게 됩니다.


pushnew 매크로는 push의 변종인데요. cons 대신 adjoin을 사용합니다:

> (let ((x '(a b)))
(pushnew 'c x)
(pushnew 'a x)
x)
(C A B)

이 결과를 보면, c는 리스트에 새로 추가되었지만 a는 이미 들어있었기 때문에 추가되지 않은 것을 알 수 있습니다.

점 찍힌 리스트

list 함수를 호출해서 만든 리스트를 좀 더 정확하게는 완전한 리스트라고 부릅니다. 완전한 리스트는 nil이거나 cdr을 취한 결과가 완전한 리스트인 cons로 정의됩니다. 즉, 완전한 리스트인 경우에만 참을 반환하는 술어는 이렇게 정의할 수 있습니다:

(defun proper-list? (x)
(or (null x)
(and (consp x)
(proper-list? (cdr x)))))

지금까지 우리가 만들어왔던 리스트는 모두 완전한 리스트입니다.

그렇지만 cons는 리스트 만드는데만 쓰라고 있는 것은 아닙니다. 딱 두 개의 필드가 필요한 경우에도 cons를 사용할 수 있습니다. car를 첫번째 필드를 가리키는데 사용하고, cdr를 두번째 필드를 가리키는데 사용하면 됩니다.

> (setf pair (cons 'a 'b))
(A . B)

이 cons는 완전한 리스트가 아닙니다. 이런 것은 점 표기법을 이용하여 표시됩니다. 점 표기법에서는 각 cons의 car와 cdr를 점으로 분리하여 표시합니다. 그림 3.10에 이 cons의 구조가 그려져 있습니다.


완전한 리스트가 아닌 cons는 점 찍힌 리스트라 불립니다. 사실 이런 명칭이 썩 좋다고 할 수는 없습니다. 완전한 리스트가 아닌 cons라고 해서 항상 리스트를 표현하는 것은 아니니까요. 예를 들어 (a . b)는 그냥 두 부분으로 나눠진 자료 구조를 의도한 것 뿐이죠.


완전한 리스트를 점 표기법으로 표현하는 것도 가능합니다. 그렇지만 리스프가 완전한 리스트를 표시할 때에는 항상 정규 리스트 표기법을 사용합니다:

> '(a . (b . (c . nil)))
(A B C)

얼핏 보면 그림 3.2에서 봤던 상자 표기법과 점 표기법으로 쓰여진 리스트가 비슷하다는 생각이 들겁니다.


정규 리스트 표기법과 점 찍힌 리스트 표기법 사이에 위치한 어중간한 형태의 표기법도 존재합니다.

> (cons 'a (cons 'b (cons 'c 'd)))
(A B C . D)

여기서 앞부분은 완전한 리스트 형태로 표시되었지만, 점이 찍힌 다음 마지막 cdr이 표시되어 있습니다. 그림 3.11이 이 리스트의 구조를 보여주고 있는데요. 그림 3.2와 이 그림을 비교해보면, 마지막 칸에 nil이 들어있다는 것만 제외하고 똑같다는 것을 알 수 있습니다.


결론적으로 리스트 (a b)를 표시하는데 쓸 수 있는 방법은 사실상 4가지입니다.

(a . (b . nil))
(a . (b))
(a b . nil)
(a b)

그렇지만 리스프가 이 리스트를 표시할 때는 항상 가장 마지막 형태를 사용합니다.

연관 리스트

cons를 연관 관계 표현에 사용할 수도 있습니다. 이런 cons의 리스트를 연관 리스트라고 합니다. 번역문의 집합을 표현할 때를 예로 들어보겠습니다:

> (setf trans '((+ . "add") (- . "subtract")))
((+ . "add") (- . "subtract"))

연관 리스트는 느리지만 프로그램을 처음 작성하는 단계에서 사용하기 매우 편하다는 장점이 있습니다. 게다가 Common Lisp는 키에 연관된 값을 꺼내는데 사용할 수 있는 assoc 함수를 내장하고 있습니다:

> (assoc '+ trans)
(+ . "add)
> (assoc '* trans)
NIL

assoc 함수가 검색에 실패하는 경우에는 nil을 반환합니다.


assoc 함수를 직접 간단하게 만들어본다면 이렇게 쓸 수 있겠죠:

(defun our-assoc (key alist)
(and (consp alist)
(let ((pair (car alist)))
(if (eql key (car pair))
pair
(our-assoc key (cdr alist))))))

member 함수처럼 assoc 함수도 :test나 :key 같은 키워드 인수를 받습니다. Common Lisp는 assoc-if 함수도 내장하고 있는데, 이 함수는 member 대신 member-if를 썼던 것과 동일한 용도로 사용됩니다.

예제: 최단 거리

그림 3.12

(defun shortest-path (start end net)
(bfs end (list (list start)) net))

(defun bfs (end queue net)
(if (null queue)
nil
(let ((path (car queue)))
(let ((node (car path)))
(if (eql node end)
(reverse path)
(bfs end
(append (cdr queue)
(new-paths path node net))
net))))))

(defun new-paths (path node net)
(mapcar #'(lambda (n)
(cons n path))
(cdr (assoc node net))))

그림 3.12의 코드는 네트워크에서 최단 거리를 찾는 프로그램입니다. shortest-path 함수는 시작 노드, 목표 노드, 네트워크를 입력으로 받아서 최단 경로를 반환합니다.


이 예제에서는 각 노드는 심볼로, 네트워크는 연관 리스트의 형태로 표현하고 있습니다. 리스트의 각 요소는 아래와 같은 형식을 사용합니다.

(노드 . 인접 노드들)

따라서 그림 3.13의 네트워크는 아래와 같이 표현됩니다.

(setf min '((a b c) (b c) (c d)))

그리고 어떤 노드에서 도달 가능한 노드를 찾으려면 이렇게 해야겠지요:

> (cdr (assoc 'a min))
(B C)

그림 3.12의 프로그램은 네트워크를 넓이 우선 탐색 알고리즘으로 검색합니다. 넓이 우선 탐색을 수행하기 위해서는 아직 지나가지 않은 노드의 목록을 담는 큐를 하나 유지해야 합니다. 노드를 하나 지나갈 때마다, 이 노드가 목적지인지 확인합니다. 만약 목적지가 아니면 이 노드에서 갈 수 있는 다른 노드들의 목록을 큐의 끝에 추가합니다. 다시 큐의 맨 앞에서 노드를 하나 꺼낸 다음에 검색을 계속 진행합니다. 항상 가장 멀리 있는 위치의 노드를 큐의 끝에 집어넣기 때문에, 네트워크를 한꺼풀씩 벗겨내면서 검색한다는 것을 확신할 수 있습니다.


그림 3.12의 코드는 위에서 말한 아이디어보다 약간 더 복잡한데요. 목적지만 찾는 것이 아니라, 어떻게 목적지까지 도달했는지 기록하기 때문입니다. 따라서 단순히 노드를 담는 큐만 유지하는 것이 아니라, 지나온 길을 담는 큐를 유지하는 것도 필요합니다. 검색을 계속할 때 큐에서 꺼내는 요소는 노드가 아니라 리스트입니다. 이 리스트의 맨 앞에 다음으로 검색할 노드가 있습니다.


bfs 함수는 검색을 수행합니다. 처음에는 큐 안에 단 하나의 요소만 존재합니다. 아직 시작도 안 했기 때문에 지나온 길은 시작 노드만 포함하고 있어야 할 것입니다. 따라서 shortest-path가 bfs를 호출할 때 (list (list start))를 초기 큐로 넘기게 됩니다.


bfs 내에서 제일 먼저 하는 일은 탐색할 노드가 더 남아있느냐를 확인하는 것입니다. 만약 큐가 비어있다면, bfs는 nil을 반환하여 경로를 찾지 못했다는 것을 알려줍니다. 아직 탐색할 노드가 남아있는 경우에는, 큐의 맨 앞에 있는 요소를 하나 꺼내옵니다. 맨 앞에 있는 요소가 목적지이면, 최단 경로를 찾은겁니다. 이걸 그냥 순서를 뒤집어서 반환하면 됩니다. 목적지가 아닌 경우에는 이 노드에서 도달할 수 있는 다른 노드들을 큐에 집어넣어야 합니다. 따라서 현재까지의 경로에 이웃 노드를 추가해서 큐의 끝에 집어넣습니다. 이렇게 bfs를 재귀적으로 호출하면서 큐를 끝까지 검색합니다.


bfs가 넓이 우선으로 검색하기 때문에, 가장 먼저 찾은 경로가 최단 경로이거나, 최단 경로 중 하나입니다:

> (shortest-path 'a 'd min)
(A C D)

bfs를 재귀적으로 호출하면서 큐는 아래와 같이 변화합니다:

((A))
((B A) (C A))
((C A) (C B A))
((C B A) (D C A))
((D C A) (D C B A))

여기에서 주기가 반복될 때마다 큐에서 두번째에 위치한 요소가 앞으로 이동하고, 첫번째 요소는 앞에 노드가 추가된 채로 큐의 끝에 추가된다는 것을 알 수 있습니다.


그림 3.12의 코드가 네트워크를 검색하는 가장 빠른 방법은 아닙니다. 그렇지만 이 예제를 통해 리스트를 얼마나 다양한 방법으로 사용할 수 있는지 깨달았을 것입니다. 이 간단한 프로그램에서는 리스트를 3가지 용도로 사용했습니다. 첫번째, 경로를 표현하기 위해 심볼의 리스트를 사용했습니다. 두번째, 넓이 우선 검색을 수행할 때 큐를 표현하기 위해 경로의 리스트를 사용했습니다. 세번째, 네트워크 자체를 표현하기 위해 연관 리스트를 사용했습니다.

쓰레기

리스트는 여러가지 이유로 느릴 수 있습니다. 리스트는 임의의 위치로 접근하는 것을 지원하지 않습니다. 오직 처음부터 순차적으로 읽는 것만 가능합니다. 따라서 어떤 요소를 가져올 때에는 배열보다 리스트가 더 오래 걸립니다. 디스크보다 테이프에서 뭔가 검색하는 것이 더 오래 걸리는 것과 똑같은 이유이지요. 내부적으로 cons는 보통 포인터로 표현되기 때문에, 리스트를 순회한다는 것은 배열에서처럼 단순히 인덱스만 증가시키면 되는 것이 아니라 일련의 포인터를 타고 내려간다는 것을 의미합니다. 그렇지만 이런 비용은 cons 여러 개를 할당하고 해제하는 것에 비하면 작은 것입니다.


자동 메모리 관리는 리스프의 가장 훌륭한 기능 중 하나입니다. 리스프 시스템은 힙이라고 불리는 메모리 덩어리를 관리합니다. 시스템은 힙에서 사용되지 않고 있는 메모리들을 유지하고 있다가, 새로운 객체가 만들어지면 메모리를 나눠줍니다. 예를 들어 cons 함수는 새로 할당된 cons를 반환합니다. 힙에서 메모리를 할당하는 것은 일반적으로 consing이라고 알려져 있습니다.


이런 메모리들은 리스프가 새로운 객체에게 할당해 줄 메모리가 모자라거나, 리스프 시스템을 종료하는 경우가 아니면 절대로 해제되지 않습니다. 그러므로 시스템은 반드시 주기적으로 힙을 검사하면서 더 이상 사용되지 않는 메모리를 찾아내야 합니다. 더 이상 사용되지 않는 메모리를 쓰레기라고 부릅니다. 그리고 이렇게 쓰레기를 찾아다니는 연산 과정을 쓰레기 수집(garbage collection) 또는 GC라고 부릅니다.


이런 쓰레기는 언제 발생하는 것일까요? 한 번 쓰레기를 만들어봅시다:

> (setf lst (list 'a 'b 'c))
(A B C)
> (setf lst nil)
NIL

초기에 우리는 list를 호출했습니다. list는 힙에 새로 cons를 할당하는 cons 함수를 호출하게 되어있구요. 이 경우에는 cons가 세 개 만들어집니다. lst를 nil로 설정하게 되면, 우리는 더 이상 어떤 방법으로도 lst의 예전 값인 리스트 (a b c)를 참조할 방법이 없습니다. 


이 리스트에 더 이상 접근할 수 있는 방법이 없기 때문에, 아예 존재하지 않는 것으로 취급할 수 있습니다. 어떤 방법으로도 도달 불가능한 객체는 쓰레기입니다. 시스템은 이런 cons들을 안전하게 재활용할 수 있습니다. 


메모리 관리가 자동으로 되기 때문에 프로그래머 입장에서는 굉장히 편합니다. 명시적으로 메모리를 할당하거나 해제할 필요가 전혀 없으니까요. 게다가 이것은 메모리 할당과 해제에 관련된 버그를 완전히 잊어버려도 된다는 말입니다. 메모리가 샌다든지 연결이 끊어진 포인터가 존재한다는 것은 리스프에서 아예 불가능한 일입니다.


그렇지만 어떤 기술적인 진보에 있어서도 마찬가지로, 여러분이 부주의할 경우 자동 메모리 관리가 해가 될 수도 있습니다. 일반적으로 힙 공간을 사용하고 재활용하는데 관련된 비용은 간단히 cons를 만드는 비용이라고 생각할 수 있습니다. 어차피 프로그램이 아무 것도 버리지 않는 경우가 아니라면, 만들어진 cons의 대부분은 언젠가 쓰레기가 되기 때문에 이런 생각은 합리적입니다. cons를 만드는데 있어서의 문제는 메모리 공간을 할당하고 찾아다니는 것이, 프로그램의 일반적인 연산을 수행하는 것보다 훨씬 비싼 댓가를 치루는 일이라는 것입니다. 최근의 연구를 통해 쓰레기 수집 알고리즘을 극적으로 개선하기는 했지만, cons를 만들어내는 것은 여전히 비용이 드는 일이고, 게다가 현존하는 몇몇 리스프 시스템에서는 이것이 정말 큰 비용을 수반합니다.


여러분이 주의하지 않는다면 과도하게 cons를 생성하는 프로그램이 나오기 쉽습니다. 예를 들어 remove는 원하는 요소가 배제된 리스트를 새로 만들어 내는 과정에서 cons를 모두 복사하게 됩니다. 이렇게 불필요하게 cons를 만들지 않으려면 파괴적인 함수를 사용하는 방법을 쓸 수 있습니다. 파괴적인 함수들은 인수로 넘긴 리스트의 구조의 대부분을 재사용합니다. 파괴적인 함수들은 12.4절에서 소개됩니다.


cons를 매우 많이 만들어내는 프로그램을 작성하는 것이 쉬운 반면에, cons를 전혀 사용하지 않는 프로그램을 작성하는 것도 역시 가능합니다. 전형적인 접근법은 일단 프로그램의 초기 버전을 순수하게 함수형 스타일로 리스트를 아주 많이 사용해서 작성하는 것입니다. 그 다음 프로그램을 키워나가면서 코드의 핵심적인 부분을 파괴적인 연산이나 다른 자료 구조로 대체합니다. 그렇지만 consing에 대해 일반적인 조언을 하기는 좀 어렵습니다. 어떤 리스프 구현체에서는 메모리 관리를 아주 잘 하기 때문에, cons를 쓰는 것이 그렇지 않은 코드보다 더 빠른 경우도 있습니다. 이 모든 문제들은 13.2절에서 설명하겠습니다.


어쨌든 실험작이나 프로토타입을 만들 때에 한해서는 부담없이 cons를 만들어도 됩니다. 프로그램의 초기 단계에서 리스트가 주는 유연함을 취한다면 시간이 흘러도 살아남는 코드를 만들 수 있을 것입니다. 

요약

  1. cons는 두 부분으로 나누어진 자료 구조입니다. 리스트는 cons 여러 개를 연결해서 만들어집니다.
  2. equal은 eql보다 느슨한 기준을 가지고 있습니다. 간단하게 말하자면 equal은 주어진 인수들이 서로 같은 것을 출력하면 참을 반환합니다.
  3. 모든 리스프 객체는 포인터처럼 행동합니다. 포인터들을 명시적으로 조작하는 것은 불가능합니다.
  4. 리스트를 복사할 때에는 copy-list 함수를 사용할 수 있고, 리스트에 다른 요소를 붙이려면 append 함수를 사용할 수 있습니다.
  5. Run-length encoding은 레스토랑에서 사용하는 간단한 압축 알고리즘입니다.
  6. Common Lisp는 car와 cdr의 조합으로 구현된 다양한 접근 함수를 지원합니다.
  7. 매핑 함수들은 함수를 리스트의 각 요소에, 혹은 각 꼬리 부분에 적용합니다.
  8. 중첩된 리스트에 대한 연산은 가끔 트리에 행하는 연산처럼 취급됩니다.
  9. 재귀적인 함수가 올바른지 판단하려면 그냥 모든 경우의 수를 처리하는지 확인하세요.
  10. 리스트로 집합을 표현할 수 있습니다. 몇몇 내장 함수가 리스트를 집합의 관점에서 다룹니다.
  11. 키워드 인수는 옵션입니다. 그리고 위치에 의해서 인식되는 대신 앞에 붙은 심볼 태그로 인식됩니다.
  12. 리스트는 시퀀스의 하위 타입입니다. Common Lisp는 아주 많은 수의 시퀀스 처리 함수를 가지고 있습니다.
  13. 완전한 리스트가 아닌 cons는 점 찍힌 리스트라 불립니다.
  14. cons들을 요소로 가지는 리스트는 매핑을 표현하는데 사용될 수 있습니다. 이런 리스트들은 연관 리스트라 불립니다.
  15. 자동 메모리 관리 기능이 있어서 메모리 할당에 관련된 일을 하지 않아도 되지만, 과도하게 쓰레기를 만들어내면 프로그램이 느려집니다.

연습 문제

  1. 아래에 주어진 리스트들을 상자 표기법으로 써보세요:
      (a) (a b (c d))
    (b) (a (b (c (d))))
    (c) (((a b) c) d)
    (d) (a (b . c) . d)
  2. 각 원본 리스트에 들어있는 요소들의 순서를 그대로 보존하는 union 함수를 작성해보세요:
     > (new-union '(a b c) '(b a d))
    (A B C D)
  3. 리스트를 하나 받아서 그 리스트에 같은(eql) 객체가 몇 번 나왔는지 리스트로 반환하는 함수를 작성하세요. 반환하는 리스트는 가장 많이 나온 요소가 앞으로 나오도록 정렬되어 있어야 합니다:
    > (occurrences '(a b a d a c d c a))
    ((A . 4) (C . 2) (D . 2) (B . 1))
  4. 왜 (member '(a) '((a) (b)))가 nil을 반환할까요?
  5. pos+ 함수는 하나의 리스트를 받아서 각 요소에 해당 요소의 인덱스만큼 더한 리스트를 반환합니다. 아래 함수를 (a) 재귀적으로 (b) 반복적으로 (c) mapcar를 써서 다시 작성해보세요:
    > (pos+ '(7 5 1 4))
    (7 6 3 7)
  6. 몇 년의 고심 끝에 정부 위원회는 리스트의 car와 cdr이 바뀌어야 한다고 결정했습니다. 이제 cdr가 첫번째 요소를 가리켜야 하고, car가 리스트의 나머지 부분을 가리켜야 합니다. 아래 함수들을 정부 버전으로 다시 정의해보세요:
      (a) cons
    (b) list
    (c) length (리스트 용)
    (d) member (리스트 용, 키워드 인수 없음)
  7. 그림 3.6의 프로그램을 수정해서 cons를 덜 사용하도록 바꿔보세요. (힌트: 점 찍힌 리스트를 사용하세요)
  8. 리스트를 하나 받아서 점 표기법으로 출력하는 함수를 정의하세요.
    > (showdots '(a b c))
    (A . (B . (C . NIL)))
    NIL
  9. 3.15절에서 표현된 네트워크에서 가장 긴 유한 경로를 찾는 프로그램을 작성하세요. 네트워크는 순환 고리를 포함할 수도 있습니다.


ANSI Common Lisp 1장. 소개 학술

ANSI Common Lisp 1장. 소개첫 Lisp 구현체는 존 맥카시와 그의 학생에 의해 1958년 무렵 작성되었습니다. Lisp는 아직까지 사용되고 있는 언어로는 Fortran 다음으로 가장 오래된 언어입니다. 더 놀라운 것은 이 언어가 프로그래밍 언어 세상에서 아직도 가장 앞서 있다는 사실이죠. Lisp를 잘 아는 프로그래머라면, 어떤 면에서 그토록 Lisp가 다른 언어들보다 앞서있는지 설명할 수 있을겁니다.

Lisp를 두드러지게 하는 것 중 하나는 진화에 적합하도록 설계되었다는 점입니다. 여러분은 Lisp를 사용해서 새로운 Lisp 연산자를 정의할 수 있습니다. 객체 지향 프로그래밍 같은 새로운 추상화 방법이 인기를 끌게 되었을 때, 객체 지향 기능도 역시 Lisp로 쉽게 구현할 수 있었습니다. 이런 언어는 마치 DNA처럼 유행을 타지 않죠.

새로운 도구

왜 Lisp를 배워야 하냐구요? 굳이 이 언어가 아니어도 여러분은 다른 언어로 원하는 일을 얼마든지 할 수 있을텐데 말입니다. 가령 n 보다 작은 숫자들의 합을 구하는 함수를 작성한다고 해봅시다. 이 정도는 Lisp나 C나 별반 차이가 없습니다.

;Lisp
(defun sum(n)                                                                     
  (let ((s 0))                                                                    
    (dotimes (i n s)                                                              
      (incf s i)))) 

/- C *-
int sum(int n) {
  int i, s = 0;
  for(i=0; i < n; i++)
    s += i;
  return(s);
}

여러분이 만약 이렇게 단순한 코드만 만들거라면, 어떤 언어를 쓰든지 별 상관이 없습니다. 그렇지만 만약 숫자 n을 매개변수로 받아서, 인수에 n을 더하여 반환하는 함수를 만들어주는 함수를 작성하려고 한다면 어떨까요?

;Lisp
(defun addn (n)
  #'(lambda (x)
      (+ x n)))

이런 addn 함수를 C로 작성하려면 어떻게 해야될까요? 당장 생각해내기는 꽤 어려울 겁니다.

이런 기능을 대체 누가 원하냐구요? 프로그래밍 언어는 여러분이 언어로 표현할 수 있는 것 외에는 생각하지 못하도록 만드는 힘을 가지고 있습니다. 어떤 언어로 작성할 수 있는 프로그램은 생각해 낼 수 있지만, 반대로 여러분이 설명조차 할 수 없는 대상을 상상하기는 매우 어렵습니다. 제가 베이직으로 처음 프로그램을 작성하는 법을 배울 때를 돌이켜보면, 그 때는 재귀 호출이 없어서 답답하다고 생각하진 않았습니다. 그게 뭔지도 몰랐으니까요. 그냥 베이직 수준으로 생각할 수 있는 범위가 제한되는거죠. 애초에 알고리즘도 반복문으로 구현할 수 있는 것만 떠올릴 수 있는데, 재귀 호출이 왜 필요했겠습니까?

뭐, 여러분이 Lexical Closure가 필요하지 않다고 느낀다면 (이게 앞의 예제에서 다룬 기능입니다.) "지금은 그냥 믿어주세요" 라고 말할 수 밖에 없겠지만, Lisp 프로그래머는 일상적으로 이 기능을 사용합니다. Common Lisp로 작성된 어떤 길이의 프로그램이든 클로저를 이용하지 않은 코드를 발견하는게 어려울 정도입니다. 112쪽을 한 번 보시기 바랍니다.

그리고 클로저는 다른 언어에서 찾을 수 없는 추상화 방법 중 단 하나일 뿐입니다. 훨씬 더 강력한, Lisp의 또다른 특징은 바로 Lisp 프로그램이 Lisp 자료 구조를 통해 표현된다는 점입니다. 이게 의미하는 바가 무엇이냐 하면, 프로그램을 생성하는 프로그램을 작성할 수 있다는거죠. 음, 사람들이 정말 이런 기능을 원하냐구요? 물론이죠! - 그리고 이 기능은 매크로라고 불리며, 숙련된 프로그래머라면 항상 사용하는 기능이죠. 한 번 만들어보려면 173쪽을 보세요.

매크로, 클로저, 실행 시간 타입 계산으로 객체 지향 프로그래밍을 뛰어넘을 수 있습니다. 만약 방금 한 말을 이해하셨다면 이 책을 덮으셔도 됩니다. 왜 그런지 설명할 수 있다면 Lisp를 잘 알고 있을테니까요. 이 내용은 매우 중요하기 때문에 17장 전체를 할애해서 객체 지향 언어를 Lisp로 구현하는 방법을 상세히 설명해두었습니다.

2장부터 13장까지는 17장에 있는 코드를 이해하는데 필요한 개념들을 하나씩 소개하겠구요. 여러분이 앞으로 쏟아부을 노력으로 받게 될 보상은 이렇게 말할 수 있겠네요. 숙련된 C++ 프로그래머가 Basic으로 프로그래밍할 때 느끼는 것만큼이나 앞으로는 C++로 프로그래밍할 때 질식할 것 같은 기분을 느끼게 될겁니다. 그런 의미에서 Lisp는 그냥 새로운 언어를 하나 더 배우는 정도가 아니죠. 프로그램을 작성하는데 필요한 새롭고 강력한 사고의 틀을 배우게 되는 겁니다.

새로운 기법

앞에서도 이야기했지만, Lisp는 다른 언어들이 주지 못하는 도구들을 제공합니다. 그런데 여기에서 그치지 않습니다. Lisp와 같이 딸려오는 것들 - 자동 메모리 관리, 동적인 타입 계산, 클로저, 기타 등등 - 이 프로그래밍을 훨씬 쉽게 만들어줍니다. 이 모든 것들이 합쳐져서 새로운 프로그래밍 방법을 만들어 냅니다.

Lisp는 확장 가능하도록 설계되었습니다: 연산자를 마음대로 정의할 수 있죠. Lisp 언어 자체가 여러분의 프로그램에 있는 함수나 매크로들이랑 똑같은 방식으로 만들어져 있기 때문에 가능한 일입니다. 프로그램을 작성하는 것과 Lisp를 확장하는 것이 전혀 차이가 없지요. 사실 언어를 확장하는 일이 대단히 일상적입니다. 프로그램을 언어로 변환해가면서 작성하는 것과 반대로 언어를 발전시켜서 프로그램으로 만드는 것이 가능합니다. 위에서 아래로 내려가는 방식이나, 아래에서 위로 올라가는 방식이나 모두 쓸 수 있습니다.

어떤 프로그램이든지 그 자신의 목적에 딱 맞는 언어를 가질 수 있으면 좋지만, 특히 프로그램의 복잡도가 증가할수록 아래에서 위로 프로그래밍하는 것이 더 가치가 있습니다. 아래에서 위로 작성된 프로그램은 여러 개의 계층으로 구성될 수 있는데, 각각은 그 위쪽 계층에서 사용하는 일종의 언어라고 말할 수 있습니다. TeX이 이런 방식으로 작성된 초창기 프로그램들 중 하나입니다. 어떤 언어로도 아래에서 위로 프로그램을 작성할 수 있기는 하지만, Lisp는 매우 자연스럽게 이런 스타일을 지원한다는 점에서 차이가 있습니다.

아래에서 위로 프로그래밍하는 것은 소프트웨어를 자연스럽게 확장하는 길입니다. 만약 아래에서 위로 프로그래밍하는 원칙을 프로그램의 최상위 계층까지 지켜나간다면, 그 마지막 계층은 사용자를 위한 프로그래밍 언어가 될겁니다. 이런 사고방식 자체가 Lisp에서 기원했기 때문에, Lisp가 확장성 있는 소프트웨어를 작성하는데 최적의 언어라고 말할 수 있습니다. Gnu Emacs, Autocad, Interleaf를 아십니까? 1980년대에 Lisp를 확장 언어로 사용했던 가장 성공적인 프로그램들입니다.

아래에서 위로 작업하는 것은 재사용 가능한 소프트웨어를 얻어내는 가장 좋은 방법이기도 합니다. 재사용 가능한 소프트웨어의 핵심은 특정한 것에서 일반적인 것을 분리해내는 것에 있습니다. 그리고 아래에서 위로 프로그래밍하게 되면 자연히 그런 분리를 만들어내게 됩니다. 여러분의 모든 노력을 단 하나의 통짜 어플리케이션을 작성하는데 들이붓는 대신에, 그 노력의 일부를 언어를 만들어내는데 쓰고, 나머지는 그 위에 올라가는 상대적으로 더 작은 어플리케이션을 만드는데 사용할 수 있습니다. 하위 계층이 어플리케이션을 만드는데 필요한 언어를 구성하게 되는겁니다. 프로그래밍 언어보다 더 재사용성이 높은게 있나요?

Lisp는 더 복잡한 프로그램을 만들 수 있게 할 뿐만 아니라, 더 빨리 작성할 수 있게 합니다. Lisp 프로그램은 짧은 경향이 있는데요. Frederick Brooks가 지적했듯이, 프로그램을 작성하는 시간은 대체로 프로그램의 길이에 비례합니다. Lisp 프로그램은 작성하는데 시간이 덜 든다는 소리죠. 여기에 Lisp의 동적인 속성이 더해지면 엄청나게 작성 속도가 빨라집니다. Lisp의 편집-컴파일-테스트 주기는 아주 짧기 때문에 프로그래밍이 실시간으로 이루어집니다.

강력한 추상화 능력과 동적인 개발 환경은 조직이 소프트웨어를 개발하는 방법을 바꿔놓을 수 있습니다. 고속 프로토타이핑은 Lisp와 함께 시작된 프로그래밍 방법입니다: Lisp에서는 스펙을 작성하는데 걸리는 시간보다 적은 시간을 들이면서도 프로토타입을 작성하는 것이 가능합니다. 게다가 이런 프로토타입은 충분히 추상적으로 만들 수 있기 때문에 영문으로 기술하는 것보다 더 나은 스펙을 작성할 수 있습니다. 그리고 Lisp를 사용하면 프로토타입에서 출시 가능한 소프트웨어로 부드럽게 옮겨가도록 만들 수 있습니다. 성능에 주의를 기울여서 코드를 작성하고 최신 컴파일러를 사용하면, Common Lisp로 작성된 프로그램도 다른 언어로 작성된 프로그램들만큼 빠르게 동작합니다.

Lisp를 충분히 알고 있지 못한 상태에서 보면, 지금까지 소개한 내용들이 뭔가 대단해 보이기는 하지만 별 쓸모없는 얘기로 들릴 수도 있습니다. Lisp가 객체 지향 프로그래밍을 초월하든, 프로그램을 아래에서 위로 작성하든, 실시간으로 프로그래밍을 하든, 그게 대체 뭔 상관이냐고요? 지금 당장은 의미가 잘 다가오지 않겠지만 Lisp의 특징들을 하나하나 배워가면서 동작하는 실제 프로그램들을 보게 되면, 경험이 쌓이면서 점점 명확하게 전체적인 윤곽을 볼 수 있을 것입니다.

새로운 접근법

이 책의 목적은 단지 Lisp 언어를 설명하는 것에 그치지 않습니다. Lisp가 가져다 줄 수 있는 새로운 프로그래밍 접근법에도 초점을 두고 있지요. 새로운 접근 방식은 여러분이 미래를 더 내다볼 수 있도록 해줍니다. 프로그래밍 환경이 점점 강력해지고, 언어가 더 강력한 추상화를 지원하게 되면서 Lisp 스타일로 프로그래밍하는 것이 점점 "계획한 다음 구현하기"라는 낡은 모델을 갈아치우고 있습니다.

오래된 프로그래밍 모델에서는 버그라는 것이 절대로 발생해서는 안 되는 것이었습니다. 아주 철저하게 스펙을 작성하고, 무지막지하게 공을 들여서, 한 번에 프로그램이 제대로 동작한다는 것을 보장한다는건데요. 이론적으로야 그럴듯하죠. 스펙이나 코드나 사람이 작성하는게 처음부터 완벽할 수 없습니다. 그렇다보니 실전에서는 계획한 다음에 구현하는 방법이 잘 안 먹힙니다.

OS/360 프로젝트의 관리자였던 Frederick Brooks는 이런 전통적인 접근법에 대해 정통했습니다. 그리고 전통적인 접근법을 따라 개발했던 프로젝트의 결과를 이렇게 말했습니다.:

OS/360 사용자라면 누구라도 이 물건이 얼마나 형편없는지 금방 알아차릴 것이다.. 게다가, 제품은 늦게 출시되었고, 메모리는 계획했던 것보다 많이 먹었으며, 비용은 예산을 몇 배나 초과했다. 그나마도 첫번째 릴리즈 이후 몇 번의 릴리즈를 더 거칠 때까지 제대로 동작하지도 않았다.

그 당시 가장 성공적인 시스템 중 하나였다는 물건이 이 정도입니다.

낡은 모델은 사람의 한계를 간단히 무시해버립니다. 이 낡은 모델에서는 "스펙에는 심각한 결함이 없다, 스펙을 코드로 변환하는 것은 아주 간단한 일이다"라고 가정하는데요. 경험을 되돌아보면 아주 말도 안 되는 소리라는 것을 알 수 있죠. 차라리 처음부터 스펙이 잘못 만들어졌을 수도 있다고 가정하고, 코드에는 버그가 가득할거라고 생각하는 편이 더 안전할겁니다.

이게 새로운 프로그래밍 모델이 가정하는 것들입니다. 사람들이 실수를 하지 않을거라고 예상하는게 아니라, 실수로 인해 발생하는 비용을 최소화하려고 시도합니다. 실수로 인해 발생하는 비용은 그걸 고치는데 들어가는 시간이지요. 강력한 언어와 훌륭한 프로그래밍 환경이 있으면 이 비용은 엄청나게 줄일 수 있습니다. 이렇게 되면 계획에는 덜 의존하고 탐색에는 더 의존하는 프로그래밍 스타일로 바꿀 수 있습니다.

계획은 필요악입니다. 위험에 대한 반응이지요: 떠안고 있는 위험 부담이 클수록, 사전에 계획하는 것이 더 중요해집니다. 강력한 도구는 위험을 감소시킬 수 있고, 따라서 계획의 필요성을 줄입니다. 프로그램의 설계에는 가장 유용한 정보를 써먹을 수 있습니다: 실제로 구현해 본 경험 말입니다.

Lisp 스타일은 1960년대부터 이런 방향으로 발전해왔습니다. 옛날 방식대로라면 스펙 작성을 막 완료했을 시간에, 실제로 설계와 구현을 몇 번 반복하면서 프로토타입을 만들어낼 수 있습니다. 실제로 해보면서 문제가 될 수 있는 요소들을 꽤 많이 찾아낼 수 있기 때문에 설계 결함에 대해 크게 걱정할 필요가 없지요. 버그에 대해서도 별로 걱정할 필요가 없습니다. 함수형 스타일로 프로그램을 작성하면, 버그가 발생해도 아주 부분적으로만 영향을 주니까요. 아주 추상적인 언어를 사용하면 어떤 버그의 발생 가능성은 원천적으로 봉쇄되고 (이를테면 무효화된 포인터라든가), 다른 버그들은 프로그램 코드의 길이가 짧기 때문에 찾는데 그리 오래 걸리지 않습니다. 여기에 추가로 대화식 개발 환경을 가지고 있으면 편집, 컴파일, 테스트하는 오랜 주기를 견디지 않고도 즉각 버그를 제거할 수 있습니다.

Lisp 스타일이 이런 식으로 진화해 온 것은 그냥 이런 방식이 제대로 결과를 내줬기 때문입니다. 좀 이상하게 들릴 수도 있는데, 계획을 덜 하는게 더 좋은 설계를 만들어낸다는거죠. 기술의 역사를 보면 서로 비슷한 사건들을 접할 수 있는데, 15세기의 화법을 한 번 예로 들어보겠습니다. 유화가 인기를 끌기 전에는 화가들이 템페라 물감을 썼는데 이건 색을 혼합할 수도 없고 덧칠할 수도 없었습니다. 실수 한 번 했다간 처음부터 다시 그려야하기 때문에, 이 물감은 화가들을 보수적으로 만들었지요. 그런데 유화 물감이 발명되고 나서는 스타일에 일대 혁명이 일어났습니다. 순간적으로 떠오르는 것을 그냥 그려버려도 됩니다. 특히 초상화 같은 어려운 주제를 다룰 때에는 아주 결정적인 장점이라고 말할 수 있습니다.

새로운 착색제의 발견은 단순히 화가들의 삶을 더 편하게 해준 정도로 끝나지 않았습니다. 새롭고 대담한 회화 기법들을 선보일 수 있게 되었으니까요. Janson은 이렇게 기록하고 있습니다:

유화 물감이 없었다면, 플랑드르의 거장들이 이룩한 초현실성의 정복은 더 많이 제한될 수 밖에 없었을 것이다. 기술적인 관점으로만 보더라도 이들은 유화 물감을 화가의 필수 재료로 쓰이게 하였으므로 "현대 미술의 아버지"라고 부르기에 손색이 없다.

재료 자체만으로 따지자면 템페라가 유화 물감보다 덜 아름답다거나 한 것은 아닙니다. 그렇지만 유화 물감의 유연성이 상상의 폭을 넓혀준 것이지요 - 이것이 결정적인 요인이었습니다.

프로그래밍도 비슷한 변화를 겪고 있습니다. 변화를 주도하는 새로운 매체는 동적인 객체 지향 언어 - 한 마디로, Lisp - 라 할 수 있습니다. 물론 몇 년 후에는 모든 소프트웨어가 Lisp로 작성될 것이라고 이야기하는 것은 아닙니다. 템페라에서 유화 물감으로 넘어가는 과정도 하루 아침에 일어난 것은 아니죠. 처음에는 선도적인 일부 예술관에서만 인기있다가, 시간이 흐르자 템페라와 조합해서 쓰이는 양상을 보였습니다. 우리는 지금 전환기를 보고 있습니다. Lisp는 대학, 연구소, 그리고 선도적인 일부 회사에서만 사용되고 있습니다. 그렇지만 이와 동시에, Lisp에서 차용된 특징들은 급속하게 주류로 편입되고 있습니다: 간단히 몇 가지만 예로 들자면 대화식 프로그래밍 환경, 메모리 자동 관리, 실행 시간 타입 계산이 있겠네요.

더 강력한 도구는 탐색의 부담을 떠안아줍니다. 프로그래머에게는 아주 좋은 소식이지요. 어떻게 될지 모르는 프로젝트라도 시작할 수 있으니까요. 유화 물감의 사용이 확실히 이런 효과를 가지고 있었습니다. 유화 물감을 받아들인 시기 직후에는 미술의 황금기가 이어졌는데요. 프로그래밍 세계도 그렇게 될 조짐이 이미 보이고 있습니다.


1 2 3 4 5 6 7 8 9 10 다음