2007년 12월 12일 수요일

2. 웰컴 투 리습 - ANSI Common Lisp 번역

2. 웰컴 투 리습

이 장의 목표는 당신으로 하여금 최대한 빨리 프로그래밍을 시작할 수 있게 하는 것이다. 이 장을 다 읽게 되면 바로 리습으로 프로그램을 작성할 수 있을 정도의 지식을 갖추게 될 것이다.


2.1 폼(Form)

리습은 인터랙티브한 언어이기 때문에, 리습을 사용해 봄으로써 리습에 대해서 배울 수 있다. 어떤 리습 시스템이던지 탑레벨(toplevel)이라 불리는 상호작용 가능한 프론트엔드를 가지고 있다. 탑레벨에 리습 표현(expression)을 치게 되면, 시스템은 그 결과를 표시하게 된다.

리습은 프롬프트를 표시해서 사용자의 입력을 기다리고 있음을 표시한다. 많은 커먼 리습 구현들은 '>'를 사용해서 탑레벨 프롬프트를 표시한다. 이 책에서도 '>'를 사용해서 프롬프트를 표시하겠다.

리습의 가장 간단한 표현 중 하나는 integer이다. 우리가 프롬프트에 1을 치면

> 1
1
>

시스템은 그 값을 표시한 후에, 새로운 프롬프트를 표시하여 또 다른 입력을 기다리고 있음을 알린다.

이 경우에는 우리가 친 값과 표시되는 값이 같다. 1과 같은 숫자는 평가의 결과가 그 자신이 된다. 이것보다는 뭔가 더 평가할 여지가 있는 것을 입력해보면 재밌을 것이다. 예를 들어, 우리가 두 개의 숫자를 더하고 싶다고 하자, 우리는 다음과 같이 칠 수 있다:

> (+ 2 3)
5

(+ 2 3) 이라는 표현에서 +는 오퍼레이터(operator)라고 불리며, 2와 3은 인자(argument)라고 불린다.

우리는 일상에서는 이 같은 표현을 2+3 과 같이 쓴다. 하지만 리습에서는 + 오퍼레이터가 맨 처음에 오고 그 뒤에 인자들이 따라오고, 전체 표현식으 괄호에 의해 둘러싸여진다: (+ 2 3). 이런 표시 방식을 오퍼레이터가 맨 앞에 오기 때문에 전위 표기 방식(prefix notation)이라고 한다. 이런 방식은 처음 보면 이상해 보이지만, 사실 이 표기 방식은 리습의 가장 뛰어난 면 중 하나이다.

예를 들어, 우리가 세 개의 숫자를 더하려고 한다고 하자. 일반적으로 우리는 + 를 두 번 사용해야 한다.

2 + 3 + 4

리습에서는 그저 하나의 인자를 추가할 뿐이다.

(+ 2 3 4)

우리가 일상적으로 + 를 사용하는 방식에서는, + 좌우에 정확히 두 개의 인자만이 와야 한다. 리습에서 사용하는 전위 표기 방식은 + 오퍼레이터가 몇 개의 인자든(인자가 없는 경우도 포함해서) 받을 수 있도록 유연성을 제공한다.

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

오퍼레이터가 받을 수 있는 인자의 개수에 제한이 없기 때문에 우리는 괄호를 사용해서 표현식이 시작하고 끝나는 것을 표시할 필요가 있다.

표현식은 중첩될 수 있다. 다시 말하면, 표현식의 인자 자체가 또 하나의 복잡한 표현식이 될 수도 있다는 것이다.

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

한글로 말하면 '칠 빼기 일'을 '사 빼기 이'로 나눈 값이 된다.

리습의 또 다른 아름다운 점은 모든 리습 표현식은 아톰(atom)이거나 리스트(list) 둘 중에 하나라는 것이다. 다음은 모두 문법에 맞는 리습 표현식이다:

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

앞으로 보게 되겠지만, 모든 리습 코드는 이같은 형태를 띤다. C같은 언어는 보다 복잡한 형식을 가지고 있다: 수식 표현은 중위 표기(infix) 방식을 사용하고; 함수 호출은 콤마로 구분된 인자와 함께 전위 표기 방식의 일종을 사용한다; 표현식은 세미콜론으로 끝나야 하며, 코드의 블록은 중괄호 { } 를 사용해서 표시한다. 리습은 단 하나의 표현 방법으로 이 모든 것들을 표현할 수 있다.


2.2 평가(Evaluation)

이전 섹션에서, 우리는 탑레벨에서 표현을 입력했고, 리습은 그 값을 표시했다. 이 섹션에서 우리는 리습이 표현식을 어떻게 평가하는지를 좀 더 자세히 살펴볼 것이다.

리습에서 + 는 함수이다. 그리고 (+ 2 3)과 같은 표현식은 함수의 호출이 된다. 리습이 함수의 호출을 평가하는 것은 두 단계로 이루어진다:

1. 먼저 인자들이 왼쪽에서 오른쪽 방향으로 차례대로 평가된다. 이 경우에는 각 인자들을 평가하면 그 자신이 되기 때문에 각 인자의 값은 차례대로 2와 3이 된다.

2. 각 인자의 값이 오퍼레이터가 나타내는 함수로 넘겨진다. 이 경우에는 + 함수로 인자가 넘겨지고 함수는 5를 리턴한다.

만일 인자 중 하나가 함수 호출이라면, 그것들 역시 똑같은 방식으로 평가된다. 따라서 (/ (- 7 1) (- 4 2)) 표현식의 평가는 다음과 같이 이루어진다:

1. (- 7 1)이 평가된다: 7과 1은 각각 7과 1로 평가된다. 이 값들이 - 함수로 넘겨지고 함수는 6을 리턴한다.

2. (- 4 2)가 평가된다: 4와 2는 각각 4와 2로 평가된다. 이 값들이 - 함수로 넘겨지고 함수는 2를 리턴한다.

3. 6과 2가 / 함수로 넘겨지고 함수는 3을 리턴한다.

커먼 리습에 존재하는 오퍼레이터(operator)가 모두 함수(functions)인 것은 아니지만, 대부분은 함수이다. 그리고 함수 호출은 모두 이 같은 방식으로 평가된다. 인자들은 왼쪽에서 오른쪽으로 차례대로 평가되고, 그 값들이 함수에게 넘겨지면, 함수는 전체 표현식의 값을 리턴하게 된다. 이것이 커먼 리습에서의 평가 규칙(evaluation rule)이다.


곤란에서 벗어나기

만일 당신이 리습이 이해할 수 없는 것을 입력했다면, 에러 표시가 나타나면서 break loop라고 불리는 탑레벨의 일종으로 떨어지게 될 것이다. break loop는 리습 프로그래머가 무엇이 에러를 발생시켰는지 알아볼 수 있도록 도와주는 것이지만, 일단 지금으로서는 break loop에서 빠져나오는 방법만 알면 될 것이다. 어떻게 해야 다시 탑레벨로 돌아올 수 있는지는 사용하는 리습 구현에 따라 조금씩 다르다. 다음의 가상적인 리습 구현에서는 :abort 가 break loop 로부터 빠져나오게 해준다. (역주: 저같은 경우에는 q를 치면 디버거에서 빠져 나오더군요)

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

부록 A 에서는 전형적인 에러들의 예를 들고, 어떻게 리습 프로그램을 디버그하는지를 설명하고 있다.


커먼 리습의 평가 규칙을 따르지 않는 오퍼레이터로는 인용부호(quote)가 있다. 인용부호 오퍼레이터는 자신만의 고유한 평가 규칙을 갖는 특수 오퍼레이터(special operator)이다. 그리고 그 룰은 '아무 처리도 하지 말라'이다. 인용부호 오퍼레이터는 하나의 인자를 받아서 그것을 쓰여진 문자 그대로 리턴한다.

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

편의를 위해서, 커먼 리습에서는 ' 기호를 quote의 약어로 사용할 수 있도록 하고 있다. 어떤 표현식 앞에 ' 기호를 붙임으로써 quote를 호출하는 것과 똑같은 일을 할 수 있다.
> '(+ 3 5)
(+ 3 5)

' 기호를 써서 약어로 표시하는 것이 quote를 써서 표시하는 것에 비해 훨씬 일반적이다.

quote는 표현식이 평가되지 않게 보호하는 역할을 한다. 다음 섹션에서 이 같은 보호가 어떤 쓸모가 있는지 알아볼 것이다.


2.3 데이터

리습은 우리가 다른 언어에서 찾아볼 수 있는 모든 종류의 데이터 타입과 함께, 다른 언어에서 찾아볼 수 없는 데이터 타입들도 제공한다. 먼저, 우리가 이미 사용했던 데이터 타입으로 정수형(숫자들의 연속으로 이루어진)이 있다: 256. 또 다른 데이터 타입으로는 다른 언어들과 비슷하게 겹따옴표로 둘러싸서 표현할 수 있는 문자열이 있다: "ora et labora". 정수형과 문자열이 평가되면 그대로 그들 자신이 된다.

다른 언어에서 찾기 어려운 리습만의 데이터 타입으로는 심볼(Symbol)과 리스트(list)가 있다. 심볼은 그저 문자 그대로의 단어들을 말한다. 일반적으로 심볼은 어떻게 치던간에 모두 대문자로 바뀌어서 나타나게 된다.

> 'Artichoke
ARTICHOKE

심볼은 대체로 평가되지 않고 그대로 쓰이는 경우가 대부분이다. 따라서 심볼을 참조하고 싶다면 위에서 보는 것처럼 quote를 붙여야 한다.

리스트는 0 개 이상의 원소들을 괄호로 둘러싼 것으로 표현된다. 각각의 원소들은 어떤 데이터 타입도 될 수 있으며, 리스트도 될 수 있다. 리스트에는 quote를 붙여야 하는데, 그렇지 않으면 리습은 그 리스트를 함수 호출로 볼 것이기 때문이다:

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

하나의 quote가 표현식 전체와 그 표현식 안의 표현식까지도 보호하고 있다는 점에 주목하라.

list 를 호출해서 리스트를 만들 수도 있다. list 는 함수이기 때문에 그 인자들은 평가된다. 다음 예를 보자:

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

여기서 우리는 리습을 그토록 특별하게 만드는 것이 무엇인지 볼 수 있다. 그것은 바로 리습 프로그램은 리스트로 표현된다는 것이다. 인자를 취하는 데 있어서의 유연성과 우아함은 당신으로 하여금 리습 표기 방식의 뛰어남을 충분히 납득시키지 못할 수 있다. 하지만 리습 프로그램이 리습의 데이터 타입 중 하나인 리스트로 표현될 수 있다는 것은 리습의 표기 방식이 얼마나 가치 있는 것인가를 보여준다. 그것은 즉, 리습 프로그램이 리습 코드를 만들어 낼 수 있다는 것이다. 리습 프로그래머는 프로그램을 짜는 프로그램을 만들어 낼 수 있다. 그리고 그들은 종종 그렇게 한다.

그와 같은 프로그램은 10장에 가서야 살펴볼 것이지만, 혼란을 피하기 위해서 표현식과 리스트 사이의 관계를 확실히 이해할 필요가 있다. 만일 리스트에 quote가 붙어 있다면, 그 리스트는 그대로 리턴된다; 만일 quote가 붙어 있지 않다면, 그 리스트는 코드로 취급되고, 평가한 값이 리턴된다.

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

여기서 첫 번째 인자는 quote가 붙어 있기 때문에 리스트가 된다. 두 번째 인자는 quote가 붙어 있지 않기 때문에 함수 호출로 취급되고 그 결과는 숫자가 된다.

커먼 리습에서 빈 리스트를 표현하는 방법은 두 가지가 있다. 빈 괄호로 표시하거나 nil 심볼을 사용하는 것이다. 어떤 식으로 표현하는가는 문제가 되지 않는다. 두 경우 모두 nil 로 표시된다:

> ()
NIL
> nil
NIL

nil을 평가하면 그 자신이 되기 때문에 nil 에 quote를 붙일 필요는 없다.(붙인다고 해도 문제가 되진 않겠지만)


2.4 리스트 연산

cons 함수는 리스트를 만든다. 두 번째 인자가 리스트이면, 첫 번째 인자를 맨 앞에 붙여서 만든 새로운 리스트를 리턴한다:

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

빈 리스트에 cons를 사용해서 새로운 원소를 추가함으로써 리스트를 만들 수도 있다. 앞 섹션에서 본 list 함수는 cons를 사용하여 여러 개의 원소를 nil에 추가함으로써 리스트를 만드는 방법의 편리 버전일 뿐이다:

> (cons 'a (consb 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


2.5 참과 거짓

커먼 리습에서는 심볼 t가 참을 나타내는 기본 표현이다. nil처럼 t는 평가하면 자기 자신이 된다. listp 함수는 인자가 리스트이면 참을 리턴한다.

> (listp '(a b c))
T

참이나 거짓을 리턴하도록 되어 있는 함수를 predicate이라고 한다. 커먼 리습의 predicate들은 대체로 p로 끝나는 이름을 가지고 있다.

커먼 리습에서의 거짓은 nil 또는 빈 리스트로 표현된다. listp에 리스트가 아닌 것을 인자로 주면 listp는 nil을 리턴한다:

> (listp 27)
NIL

nil은 거짓을 표현하기도 하고 빈 리스트를 표현하기도 하기 때문에, 빈 리스트를 인자로 주면 참을 리턴하는 함수 null과,

> (null nil)
T

거짓 값을 인자로 주면 참을 리턴하는 함수 not은,

> (not nil)
T

같은 결과를 보인다.

커먼 리습의 가장 간단한 조건문을 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 역시 특수 오퍼레이터(special operator)이다. 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도 유사한데, 어떤 인자가 참으로 판명되면 거기서 평가를 멈춘다.

and와 or, 이 두 가지 오퍼레이터는 매크로(macro)이다. 특수 오퍼레이터(special operator)와 같이 매크로도 일반적인 평가 규칙을 따르지 않을 수 있다. 10장에서 어떻게 매크로를 작성하는지에 대해서 설명할 것이다.


2.6 함수

새로운 함수를 defun 을 통해 정의할 수 있다. defun은 일반적으로 세네개의 인자를 받는다: 함수 이름, 인자들의 리스트, 함수의 몸체가 될 하나 이상의 표현식. 예를 들어 third 함수는 다음과 같이 정의될 수 있을 것이다:

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

첫 번째 인자는 이 함수의 이름이 our-third라는 것을 나타낸다. 두 번째 인자는 리스트 (x)인데, 이 함수가 정확히 하나의 인자 x 를 받는다는 것을 나타낸다. 값을 저장하기 위한 심볼을 변수(variable)라고 하는데, 여기서 사용된 x 와 같이 변수가 함수의 인자를 표현하는데 사용되면 매개 변수(parameter)라고 한다.

정의의 나머지 부분인 (car (cdr (cdr x))) 는 함수의 몸체(body)부분으로 함수의 값을 리턴하기 위해 무엇을 계산해야 할지 말해준다. 따라서 우리가 x 에 어떤 것을 인자로 주던지 our-third 함수는 (car (cdr (cdr x)))를 리턴할 것이다.

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

이제 변수가 무엇인지 알았으니, 심볼이 무엇인지 이해하는 것은 어렵지 않다. 심볼은 변수의 이름이면서, 또한 그 자신만의 권리를 갖고 있는 객체이다. (역주: 커먼 리습에는 Common Lisp Object System이 포함되어 있어서 객체지향 프로그램을 기본적으로 지원하지만, 특별한 언급 없이 일반적으로 언급되는 객체는 클래스의 인스턴스로서의 객체가 아니라 그저 하나의 object를 말한다. 여기서와 이 장 전체에서 객체라는 말은 모두 하나의 object를 가리키는 표현으로 사용되었다.) 리스트와 마찬가지로, 심볼도 quote를 붙여야 한다. 리스트에 quote를 붙이지 않으면 그 리스트는 코드로 다뤄질 것이다; 심볼에 quote를 붙이지 않으면 그 심볼은 변수로 취급될 것이다.

리습 표현식의 일반화된 버전이 함수라고 생각할 수 있다. 다음 표현식은 1과 4의 합이 3보다 큰지를 검사한다:

> (> (+ 1 4) 3)
T

특정한 숫자를 변수로 바꿈으로써, 두 숫자의 합이 세 번째 숫자보다 큰지를 검사하는 함수를 만들 수 있다.

> (defun sum-greater (x y z)
(> (+ x y) z))
SUM-GREATER
> (sum-greater 1 4 3)
T

리습에서는 프로그램, 프로시져(procedure), 함수의 구별이 없다. 함수는 모든 것을 처리할 수 있다.(함수는 리습 언어의 대부분을 구성하고 있다.) 만일 당신이 특정한 하나의 함수를 main 함수로 삼고 싶다면 물론 그렇게 할 수 있다. 하지만 탑레벨에서 어떤 함수든 호출하는 것이 가능한데, 이것은 프로그램을 함수 단위로 작성해 나가면서 그것들을 바로바로 테스트 할 수 있다는 것을 의미한다.


2.7 재귀

이전 섹션에서 우리가 정의한 함수들은 함수 내에서 다른 함수를 호출해서 자신이 하려는 일을 거들게 했다. 예를 들어, sum-greater 함수는 + 함수와 > 함수를 호출했다. 함수는 어떤 함수든 호출할 수 있다. 그 자신까지도.

자기 스스로를 호출하는 함수를 재귀적(recursive)이라고 한다. 커먼 리습의 member 라는 함수는 어떤 객체가 리스트의 원소인지 아닌지를 검사한다. member를 재귀적으로 정의한 간단 버전은 다음과 같다:

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

predicate eql 은 두 인자가 같은지를 검사한다; 코드에서 eql 외에 모든 것은 우리가 이미 아는 것이다. our-member 를 실제로 호출해보자:

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

our-member의 정의를 말로 풀어보면 다음과 같다. 객체 obj 가 리스트 lst 의 멤버인지를 검사하기 위해,

1. 먼저 lst가 비어있는지를 검사한다. 만일 비어있다면, obj는 lst의 멤버가 아니며, nil이 리턴된다.
2. lst가 비어있지 않고, obj가 lst의 첫 번째 원소라면, obj는 lst의 멤버라고 할 수 있다.
3. 위의 경우가 둘 다 아니라면, obj가 lst의 멤버이기 위해서는 obj는 lst의 첫 번째 원소를 제외한 나머지의 멤버여야만 한다.

재귀적 함수가 어떤 식으로 동작하는지를 알기 위해서는, 위와 같이 말로 풀어보는 것이 도움이 된다.

많은 사람들이 처음에는 재귀를 이해하기 어려운 것이라고 생각한다. 이런 어려움은 대부분 함수에 대한 잘못된 비유에서 생겨나는 것이다. 사람들은 함수를 일종의 기계처럼 생각하는 경향이 있다. 원재료가 인자로서 기계에 넘겨지면; 그 중 몇몇 일들은 다른 함수에게 맡겨지고; 그렇게 해서 만들어진 각각의 부품이 조립되어 리턴 값으로 선적된다는 식으로 생각하는 것이다. 우리가 함수에 대해서 이런 식으로 생각한다면, 재귀는 패러독스로 다가올 수밖에 없다. 기계가 자기 스스로에게 일을 맡긴다는 게 무슨 의미가 있는가? 그렇다고 일이 덜어질 것도 아닌데 말이다.

이보다 나은 비유는 함수를 일종의 프로세스로 생각하는 것이다. 무엇인가를 진행하는 프로세스에서 재귀는 자연스러운 것이다. 우리는 매일 재귀적인 프로세스를 보면서 살아간다. 예를 들어, 유럽의 인구 변화에 관심이 있는 역사가가 있다고 하자. 관련된 문서를 찾아보는 프로세스는 다음과 같다:

1. 어떤 문서를 얻는다.

2. 인구 변화에 관련된 정보가 있는지 살펴본다.

3. 만일 그 문서가 다른 유용한 문서를 언급하고 있다면 그 문서를 찾아본다.

이 프로세스는 재귀적이지만 이해하기에 아무 어려움이 없다. 이 프로세스가 재귀적인 이유는 세 번째 단계에 이르러 다시 전체 프로세스를 반복할 수 있기 때문이다.

our-member를 어떤 것이 리스트의 멤버인지를 테스트하는 기계로 생각하지 말자. 대신에 어떤 규칙으로 생각하자. 우리가 함수를 이런 식으로 생각한다면, 재귀가 패러독스처럼 느껴지지 않을 것이다.


2.8 리습 코드 읽기

앞 섹션에서 정의했던 our-member 코드는 다섯개의 괄호를 닫으며 끝난다. 더 복잡한 함수의 정의는 더 많은 개수의 괄호로 끝날 수도 있다. 리습을 처음 배우는 사람들은 수많은 괄호들을 보고 좌절하게 된다. 어떻게 이렇게 많은 괄호들을 구별하며 코드를 읽고, 또 쓸 수가 있을까? 이 괄호가 어떤 괄호와 쌍을 이루는지 어떻게 알까?

답은, 알 필요가 없다는 것이다. 리습 프로그래머들은 괄호가 아니라 들여쓰기를 보고 코드를 읽고 쓴다. 코드를 작성할 때는 텍스트 편집기가 괄호의 쌍을 체크해준다. 리습 코딩을 하기 위한 텍스트 편집기는 반드시 괄호의 쌍을 체크하는 기능을 가지고 있어야 한다. 만일 사용하는 편집기가 괄호 매칭 기능이 없다면, 코딩을 중단하고 방법을 찾아야 한다. 괄호 매칭 기능 없는 편집기로 리습 코딩을 한다는 것은 불가능하기 때문이다.

적당한 에디터를 사용한다면, 코딩할 때 괄호를 매칭하는 것은 더 이상 문제가 되지 않는다. 그리고 리습 코드의 들여쓰기 역시 일정하게 정해져 있기 때문에 코드를 읽는 것 역시 어렵지 않다. 모두가 똑같은 들여쓰기를 하기 때문에, 괄호를 신경쓰지 않고 들여쓰기만으로 코드를 읽을 수 있다.

아무리 숙련된 리습 해커라 하더라도 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)

종이에 코드를 적을 때는 이렇게 들여쓰기만 지키면서 괄호를 생략하는 방식도 유용하다. 그러다 에디터로 코드를 옮겨 적게 되면, 에디터의 기능에 힘입어 괄호 매칭은 쉽게 할 수 있다.


2.9 입력과 출력

여태까지는 탑레벨에서 입출력 비슷하게 해 왔지만, 실제 프로그램에서는 이것으로 충분하지 않다. 이 섹션에서는 입출력과 관계된 함수들을 살펴보려고 한다.

커먼 리습의 대표적인 출력 함수는 format 이다. format은 두 개 이상의 인자를 받는다: 첫 번째 인자는 어디에 출력할 것인지를 가리키고, 두 번째 인자는 문자열 템플릿이 되며, 나머지 인자들은 템플릿 속에 들어갈 표현들이 된다. 예를 들면:

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

결과는 두 행에 걸쳐 나타난다. 첫 번째 행은 format이 출력한 것이다. 두 번째 행은 format을 호출함에 따라 리턴된 값이 표시된 것이다. 일반적으로 format 같은 함수는 탑레벨에서 불리기보다 프로그램 안에서 호출된다. 따라서 format의 리턴 값을 볼 일은 별로 없다.

format의 첫 번째 인자인 t는 출력의 방향을 기본 출력으로 하라는 것을 나타낸다. 일반적으로 기본 출력은 탑레벨로 지정되어 있다. 두 번째 인자는 출력을 위한 템플릿으로 기능하는 문자열이다. 문자열 안의 ~A는 채워져야 할 위치들을 나타내고, ~%는 새로운 행으로 개행하라는 것을 나타낸다. ~A로 표시되는 위치에 나머지 인자들의 값이 차례대로 채워지게 된다.

입력을 위한 대표적인 함수로 read 가 있다. 아무 인자도 주어지지 않으면 read는 일반적으로는 탑레벨이 되는 기본 위치에서 입력값을 읽어온다. 예를 들어, 사용자의 입력을 받아서 그대로 출력하는 다음과 같은 함수가 있다고 하자:


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

이 함수를 호출한 결과는 다음과 같다:

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

read는 사용자가 타이핑하고 엔터를 칠 때까지 계속해서 기다린다. 따라서 명시적으로 프롬프트를 출력하지 않은 채로 read를 호출해서 계속 입력을 기다리는 것은 프로그램이 멈췄다는 오해를 불러 일으킬 여지가 있다.

read에 관해 또 하나 알아야 할 것은 read는 굉장히 강력한 도구라는 것이다: read는 완전한 하나의 리습 파서(parser)이다. 단지 문자들을 읽어서 문자열로 리턴하기만 하는 것이 아니다. read는 읽어들인 내용을 파싱해서 리습 객체를 결과로 리턴한다. 위의 예에서는 숫자를 리턴한 것이다.

askem 코드를 보면 함수의 몸체가 여러 개의 표현식으로 구성되어 있는데, 이는 이전에 살펴본 함수들에서는 보지 못했던 것이다. 함수의 몸체는 몇 개의 표현식이든 가질 수 있다. 함수가 호출되면 각 표현식이 차례대로 평가되고, 맨 마지막 표현식을 평가한 값이 리턴된다.

이전까지 우리가 봐 온 것은 모두 부효과(side-effect)가 없는 순수한 리습 표현식이었다. 부효과란 표현식을 평가한 결과로 무언가의 상태가 변화되는 것을 말한다. 우리가 (+ 1 2)와 같은 순수한 리습 표현식을 평가했을 때는 아무런 부효과도 발생하지 않는다; 그저 값을 리턴할 뿐이다. 하지만 format을 호출하면, 값을 리턴하는 것과 동시에, 무언가가 출력된다. 이것은 부효과라고 할 수 있다.

우리가 부효과가 없는 코드를 작성한다고 하면, 함수의 몸체가 하나보다 많은 표현식으로 구성되는 일이 없을 것이다. 왜냐하면, 함수에서 리턴되는 값은 마지막 표현식의 값 뿐이고, 나머지 표현식들의 값은 버려지는데, 부효과가 전혀 없는 함수를 작성한다면, 부효과도 없고 리턴되지도 않을 표현식들을 함수에 넣어서 평가할 이유가 없기 때문이다.


2.10 변수

커먼 리습에서 가장 많이 쓰이는 오퍼레이터 중 하나는 새로운 지역 변수를 생성하는 let 이다:

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

let 표현은 두 부분으로 이루어져 있다. 첫 번째는 변수를 생성하는 부분인데 각각이 (변수, 표현식) 형태로 오게 되며, 각각의 변수는 표현식으로 초기화된다. 위의 예에서는 변수 x, y가 각각 1과 2로 초기화되었다. 이 변수들은 let의 몸체 안에서만 유효하다.

변수와 값의 목록 다음에는 몸체 부분이 나오고, 차례대로 평가된다. 몸체 부분의 표현식이 (+ x y) 하나 밖에 없다. 마지막 표현식의 값이 let 전체의 최종 값으로 리턴된다. let을 사용해서 askem을 조금 고쳐보자:

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

이 함수는 val이라는 변수를 만들어서 read가 리턴하는 객체를 저장한다. val이 이 객체를 가지고 있기 때문에 함수가 값을 리턴하기 전에 그 값이 무엇인지 살펴보는 것이 가능하다. p로 끝나는 이름에서 알 수 있듯이 numberp는 인자가 숫자인지를 판단하는 predicate이다.

사용자가 입력한 값이 숫자가 아니라면, ask-numer는 자기 자신을 다시 호출한다. 결과적으로 함수는 숫자값을 얻을 때까지 계속해서 입력을 요구한다:

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

여태가지 우리가 본 변수를 지역 변수라고 한다. 지역 변수는 특정한 문맥 하에서만 유효하다. 다른 형태의 변수도 있는데, 그것은 어디에서나 참조할 수 있는 전역 변수이다.

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

> (defparameter *glob* 99)
*GLOB*

전역 변수는 똑같은 이름으로 지역 변수가 생성되어 있는 표현식 내부를 제외하고, 어디서든 참조될 수 있다. 우연히 지역 변수가 전역 변수와 같은 이름을 가져, 전역 변수의 참조를 무효화시키는 일이 일어나는 것을 막기 위해서, 일반적으로 전역 변수의 이름은 별표(*)로 시작하고 끝난다. 우리가 방금 생성한 변수의 이름은 “star-glob-star”라고 읽는다.

defconstant를 사용하면 전역 상수 역시 정의할 수 있다:

(defconstant limit (+ *glob* 1))

상수의 이름을 특별히 구분해서 만들 필요는 없는데, 정의된 상수와 똑같은 이름의 변수를 사용하려고 하면 에러가 생기기 때문이다. 어떤 심볼이 전역 변수나 전역 상수의 이름으로 사용되고 있는지를 시험해 보려면 bounp 를 사용하면 된다:

> (boundp '*glob*)
T


2.11 대입

커먼 리습의 대표적인 대입 오퍼레이터는 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의 첫 번째 인자는 특정한 장소를 가리키는 것이면 거의 무엇이든 될 수 있다. setf와 유사한 성격을 가진 오퍼레이터들을 “settable”하다고 하는데 이런 오퍼레이터들은 부록 D에 나와 있다.

setf에 넘길 수 있는 인자의 개수에는 제한이 없다. 다음 표현식은

(setf a b
c d
e f)

세 개의 setf를 따로 쓴 다음과 동일하다:

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


2.12 Functional Programming

functional programming 이란 상태를 변화시키기 보다는 값을 리턴하는 식으로 프로그램을 작성하는 것을 말한다. 이것은 리습의 중요한 패러다임이다. 리습에 구현되어 있는 대부분의 함수들은 부효과를 만들어 내는 것이 아니라 값을 리턴한다.

예를 들어, 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)

만일 정말로 리스트에서 해당 객체를 제거하고 싶다면 어떻게 해야 할까? 리턴된 값을 setf를 사용해서 저장하면 그렇게 할 수 있다. 리스트 x에서 모든 a를 제거하고 싶다면 다음과 같이 하면 된다:

(setf x (remove 'a x))

functional programming이란 setf와 같은 것들을 사용하는 것을 되도록이면 피하는 것이다. 처음에는 ‘그런 식으로 프로그래밍을 하는 것이 과연 가능한가’ 라는 생각이 들 수도 있다. 어떻게 값을 리턴하는 것만으로 이루어진 프로그램을 만들 수 있을까?

부효과를 전혀 이용할 수 없다면 꽤나 불편할 것이다. 하지만 이 책을 계속해서 읽어나간다면, 부효과가 정말로 필요한 순간이 얼마나 드문가를 깨닫고 놀라게 될지도 모른다. 그리고 부효과를 덜 사용할수록, 프로그램은 더 나아진다.

functional programming의 가장 큰 장점 중 하나는 인터랙티브 테스팅을 가능하게 한다는 것이다. 순수하게 functional한 코드라면, 각각의 함수를 작성하자마자 따로따로 테스트하는 것이 가능하다. 테스트에서 함수가 기대했던 결과값을 리턴한다면, 그 함수가 올바로 작성됐다고 자신할 수 있다. 이런 자신감이 쌓이고 쌓이면 큰 차이를 만들어 낸다. 프로그램의 어디를 변경하던지 즉각적인 피드백을 받을 수 있다. 그리고 이런 즉각적인 피드백은, 마치 전화가 편지에 비해 즉각적인 메시지 전달이 가능하기 때문에 완전히 새로운 의사 소통 스타일을 만들어 내듯이, 새로운 스타일의 프로그래밍을 가능하게 한다.


2.13 반복 (Iteration)

무엇인가를 되풀이해서 하길 원할 때, 때때로 재귀보다는 반복을 이용하는 것이 나을 때가 있다. 반복을 이용해서 표를 만드는 예를 보자. 이 함수는

(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 매크로는 커먼 리습의 가장 기본적인 반복 오퍼레이터이다. let과 마찬가지로, do 역시 변수들을 만들어내는데, 첫 번째 인자로 변수를 선언하는 리스트가 온다. 이 리스트는 다음과 같이 이루어진다.

(변수 초기값 갱신값)
(variable initial update)

변수 자리에는 심볼이, 초기값과 갱신값 자리에는 표현식이 온다. 처음에는 변수가 초기값을 가진다. 매 반복마다 변수는 갱신값에 따라 값이 변한다. show-squares에서 do 문은 i 변수 하나만을 사용하는데, 시작할 때 i는 start 값이 되고, 계속적인 반복마다 1씩 증가한다.

do의 두 번째 인자는 하나 이상의 표현식을 가진 리스트가 되어야 한다. 첫 번째 표현식은 반복을 멈출 것인지를 여부를 결정하는 테스트가 된다. 이 경우에는 (i > end)가 만족되면 반복을 멈추게 된다. 나머지 표현식은 반복이 멈출 경우에 평가되고 그 결과가 do 전체의 값으로 리턴된다. 따라서 show-squares는 언제나 done을 리턴한다.

나머지 인자들은 루프의 몸체 부분을 구성하며 매 반복마다 순서대로 평가된다. 정리하면, 매 반복마다 변수가 갱신되고, 종료 조건이 테스트 된 후에, (테스트가 실패했다면) 몸체 부분이 평가된다.

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은 표현식들을 받아서 차례대로 평가한 후, 마지막 값을 리턴한다.

커먼 리습에는 덜 일반적인 경우를 위한 보다 단순한 반복 오퍼레이터도 있다. 리스트의 원소들을 차례대로 방문하기 위해서는 dolist 같은 것을 사용할 수 있다. 다음은 리스트의 길이를 리턴하는 함수이다:

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

dolist는 (변수, 표현식)과 같은 형태의 인자와 함께, 몸체 부분을 받는다. 표현식이 리턴하는 리스트의 원소가 차례대로 변수의 값이 되고 그때마다 매번 몸체 부분이 평가된다. 따라서 위의 루프는 lst에 있는 매 obj마다 len을 증가시키라는 내용이 된다.

이 함수의 재귀적 버전을 다음처럼 작성할 수 있을 것이다:

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

리스트가 비어있다면, 길이는 0이 된다. 그렇지 않다면, 리스트의 길이는 cdr의 길이에 1을 더한 것이다. 이 함수는 보기에 명확하지만, tail-recursive (섹션 13.2)하지는 않기 때문에, 속도 면에서 효율적이지는 않다.


2.14 함수는 객체이다

리습에서 함수는 심볼이나, 문자열, 리스트가 그렇듯이 일반적인 객체일 뿐이다. 우리가 function 에 어떤 함수의 이름을 주면, function은 그 함수에 해당하는 객체를 리턴할 것이다. quote처럼, function 역시 특수 오퍼레이터이다. 따라서 function의 인자에 quote를 붙일 필요는 없다:

> (function +)
#

이 이상해 보이는 리턴값은 일반적인 커먼 리습 구현에서 함수 객체를 표시하는 방식이다.

여태까지는 우리가 타이핑한 글자와 리습이 표시하는 값이 같은 객체들만 보아왔다. 함수는 다르다. 내부적으로, + 같은 함수는 기계어 코드의 세그먼트이다. 커먼 리습 구현에 따라 이와 같은 함수의 외부적 표현을 어떤 식으로 할 것인지가 달라질 수 있다.

우리가 quote의 약어로서 '를 사용하였듯이 function의 약어로 #'를 사용할 수 있다:

> #'+
#

이 약어는 sharp-quote 라고 읽는다.

다른 객체들과 마찬가지로, 함수 역시 인자로 넘길 수 있다. 함수를 인자로 받는 함수로 apply 가 있다. apply는 함수와 인자의 리스트를 받아서 인자들에 함수를 적용한 결과를 리턴한다:

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

apply는 몇 개의 인자든 받을 수 있다. 마지막 인자가 리스트이기만 하면 된다:

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

funcall 함수는 apply와 똑같은 일을 하지만 인자들이 리스트로 싸여져 있지 않아도 된다는 점에서 다르다.

> (funcall #'+ 1 2 3)
6

사실 defun 매크로가 하는 일은 함수를 생성하고 거기에 이름을 붙이는 것이다. 하지만 이름이 없는 함수를 만들 때는 defun이 필요없다. 다른 리습 객체들과 마찬가지로 우리는 함수를 문자 그대로 참조할 수 있다.

정수를 문자 그대로 참조하기 위해서는, 숫자의 연속을 사용한다; 함수를 문자 그대로 참조하기 위해서는, 람다 표현식(lambda expression)이라 불리는 것을 사용할 수 있다. 람다 표현식은 ‘lambda 심볼, 매개변수의 리스트, 하나 이상의 표현식으로 이루어진 함수의 몸체’로 이루어져 있는 리스트이다.

두 개의 숫자를 받아서 그 합을 리턴하는 함수를 람다 표현식으로 표현해 보면 다음과 같다:

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

(x y)는 매개변수의 리스트이고 함수의 몸체가 그 다음에 따라온다.

람다 표현식 자체를 함수의 이름으로 생각할 수 있다. 일반적인 함수의 이름과 마찬가지로, 람다 표현식은 함수 호출에서 첫 번째 원소가 될 수 있다,

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

그리고 #'를 람다 표현식에 붙임으로써, 그 함수를 얻어낼 수 있다,

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

이 같은 표기 방식은 이름 없는 함수를 사용할 수 있게 해 준다.


Lambda가 무엇인가요?

람다 표현식의 lambda는 오퍼레이터가 아니다. 그것은 그저 심볼일 뿐이다. 리습의 초기 구현들에서는 lambda가 하는 역할이 있었다: 함수는 내부적으로 리스트로 표현되었고, 어떤 것이 그저 리스트인지 함수인지 구별하는 방법은 첫 번째 원소가 lambda 심볼인지를 보는 것이었다.

커먼 리습에서, 함수는 리스트로 표현되지만, 내부적으로는 리스트와 구별되는 함수 객체로서 표현된다. 따라서 lambda는 더 이상 실제적으로 필요하지 않다. 다음과 같이 표현하는 대신에

(lambda (x) (+ x 100))

다음과 같이 표현할 수도 있을 것이다.

((x) (+ x 100))

하지만 리습 프로그래머들이 함수의 시작을 lambda 심볼로 해왔기 때문에 커먼 리습에서는 그 같은 전통을 고수하고 있다.


2.15 타입

리습은 타입에 대해 유연한 접근방식을 가진다. 많은 언어에서, 변수들은 타입을 가져야 하고, 타입을 명세하지 않고는 변수를 사용할 수 없다. 커먼 리습에서는 변수가 아니라 값이 타입을 가진다. 모든 객체에 그 객체가 어떤 타입인지 나타내는 이름표가 붙어 있다고 생각해 보자. 이런 방식을 manifest typing이라고 한다. 커먼 리습에서는 변수의 타입을 선언할 필요가 없다. 왜냐하면 변수가 어떤 타입의 객체든 참조할 수 있기 때문이다.

타입의 선언이 반드시 필요하지는 않음에도 불구하고, 실행 속도를 빠르게 하기 위해서 타입을 명시적으로 선언할 수 있다. 타입 선언에 대해서는 섹션 13.3에서 다룬다.

내장되어 있는 커먼 리습의 타입들은 하위 타입과 상위 타입의 계층 구조를 이룬다. 모든 객체는 하나 이상의 타입을 가지고 있다. 예를 들어, 숫자 27은 fixnum, integer, rational, real, number, atom, t의 타입(뒤로 갈수록 일반적인 타입이다.)에 속한다. (수 타입에 대해서는 9장에서 설명한다.) 타입 t는 모든 타입의 상위 타입이다. 따라서 모든 것은 타입 t에 속한다.

typep 함수는 객체와 타입 지정자를 받아서 그 객체가 해당 타입이면 참을 리턴한다:

> (typep 27 'integer)
T

이후에 여러 가지 내장형 타입들에 대해서 살펴보게 될 것이다.


2.16 계속해서 나아가기

이 장에서 우리는 리습을 겉핥기 식으로 살펴봤을 뿐이다. 매우 이상한 언어라는 느낌을 받았는지도 모르겠다. 처음에는, 리습이 단일한 프로그램 형태를 가지고 있다는 것을 설명했다. 이 형태는 리습의 객체 중에 하나인 리스트에 기반을 두고 있다. 리습 자체도 사용자가 정의할 수 있는 함수와 아무 차이가 없는 리습 함수로 이루어진 리습 프로그램이다.

이 같은 설명이 명확하게 이해되지 않는다고 해서 걱정할 필요는 없다. 리습은 그것들에 익숙해지고 사용하기까지 다소 시간이 걸릴 수 있는 고급 개념들을 많이 가지고 있다. 적어도 하나는 확실하다: 리습에는 무언가 놀랄만한 굉장한 것이 있다.

리처드 가브리엘이 반 농담조로 C를 유닉스를 작성하기 위한 언어로 묘사한 적이 있다. 우리도 그와 비슷하게 리습을 리습을 작성하기 위한 언어로 정의할 수 있다. 하지만 두 문장 사이에는 차이점이 있다. 자기 스스로를 작성하기 위한 언어는 무언가 특정한 종류의 어플리케이션을 작성하기 위한 언어와 근본적으로 다르다. 여기에 새로운 프로그래밍의 길이 있다: 언어를 사용해서 프로그램을 작성하는 것과 동시에, 언어 자체를 개선시켜서 당신이 원하는 프로그램에 적합하게 만들어 나갈 수 있다. 프로그램을 언어가 표현할 수 있는 데까지 끌어 내리는 것이 아니라, 언어를 쌓아 올려서 만들고자 하는 프로그램을 간단하게 표현할 수 있게 하는 것, 그것이 리습 프로그래밍의 정수이다.


translated by 찬우

댓글 없음: