LISP으로 함수형 프로그래밍 맛보기

ch02. 함수가 뭘까

리습으로 프로그램을 만든다는 것은 함수들을 만들어서 모아놓는다는 것과 같습니다. 리습언어 자체도 함수를 모아서 만든 것입니다. 대부분의 언어에서 + 연산자와 사용자가 정의한 함수는 완전히 다른 것입니다. (역자주: 객체지향 언어들은 연산자 override를 지원해서 연산자도 함수처럼 사용할 수 있도록 해줍니다. LISP은 언어 자체에 연산자와 함수의 구분이 없습니다.) 하지만 리습은 프로그램에서 실행되는 모든 연산을 함수로 표현하는 단일 모델을 가지고 있습니다. 리습에서 + 연산자는 함수입니다. 사용자가 정의해서 사용하는 함수와 같은 함수입니다.

 

리습 언어 전체가 완벽히 함수일 수는 없습니다. 특별형식(special form)이라고 불리는 몇몇 연산자가 있고, 그 외의 리습의 내부구현은 리습 함수들의 모음일 뿐입니다. 우리가 이 모음에 뭔가 추가하지 못할 이유가 없지요. 뭔가 리습이 했으면 하고 생각하신게 있다면, 직접 구현하세요. 직접 만든 함수들이나 리습에 있는 함수들이나 똑같이 사용할 수 있습니다.

 

여기서 프로그래머에게 중요한 사실이 하나 나타납니다. 새로운 함수를 만든다는 것은 리습에 함수를 추가한다는 것도 되고, 또 자신이 만드는 프로그램에 함수를 추가하는 것도 된다는 것입니다. 보통 숙련된 리습 프로그래머는 두가지를 다 구현하면서 만들고자하는 프로그램과 언어가 서로 완벽히 맞아떨어지도록 만들어나갑니다. 이 책은 언어와 응용 프로그램이 어떻게하면 잘 어울리게될지를 알려주고자 합니다. 가장 중요한게 함수이므로, 함수에서부터 이야기를 시작하겠습니다.

 

2.1 함수는 곧 데이터입니다.

 

리습에서 함수를 특별하게 만드는게 2가지가 있습니다. 리습 자체가 함수의 모음일 뿐이라는걸 가장 먼저 알아야합니다. 그래서 우리가 만든 새로운 연산자를 리습 언어에 추가할 수 있습니다. 또 우리가 꼭 알아야 할 것은 함수가 리습의 객체라는 것입니다.

 

리습은 다른 언어들이 제공하는 데이타 타입 대부분을 가지고 있습니다. 정수와 부동소수점 수부터 문자열, 배열, 구조체 등등이 있습니다. 하지만 리습에는 누구나 처음 보면 놀랄만한 데이터 타입이 있습니다. 바로 함수입니다. 거의 모든 프로그래밍 언어들이 함수나 프로시저같은 형식을 지원합니다. 그런데 리습이 함수를 데이터 타입으로 제공한다는게 무슨 의미일까요? 

리습에서 우리는 함수를  가지고 데이터로 할 수 있는 일들을 할 수 있다는 것입니다. 예를 들면 정수와 마찬가지로 새로운 함수를 실행중에 만들고, 변수나 구조체안에 저장할 수 있습니다. 또한 다른 함수에 인자로 전달할 수도 있고, 결과값으로 반환할 수도 있습니다. 실행중간에 새로운 함수를 만들거나 결과값으로 함수를 반환할 수 있다는 것이 바로 다른 언어와 차별되는 것입니다.

(역자주: 저는 C언어만 해본 사람이라 C언어와 비교를 해보겠습니다. C언어는 일단 런타임에 새로운 함수를 만들 수 없습니다. 모든 함수는 컴파일되기전에 소스상에서부터 정의되어있어야합니다. 또 함수 자체가 데이터가 될 수 없습니다. 함수의 위치를 가르키는 포인터는 변수가 저장됩니다만 함수라는 데이터 타입은 없습니다. 함수의 반환값도 함수를 가르키는 포인터가 될 수 있지만 함수 자체를 반환할 수 없습니다. 그래서 결국 함수(즉 코드)와 데이터는 항상 분리될 수 밖에 없습니다. 하지만 리습은 함수와 변수, 코드와 데이터의 구분이 없습니다.)

 

처음 들으신 분들은 그게 왜 장점이라는 것인지 의아하실 것입니다. 마치 어떤 사람들이 사용한다고 들어본 자기 자신을 수정할 수 있는 기계 코드와 비슷한것 같이 생각될 것입니다. 하지만 동적으로 새로운 함수를 만드는 것은 리습 프로그래밍에서 흔하게 사용되는 테크닉입니다.

 

2.2 함수를 만들어봅시다

 

이미 아시는 분은 함수를 정의하는 것이 defun이라고 배우셨을 겁니다. 다음이 인자값의 두배를 반환하는 double이라는 함수를 정의하는 표현입니다.

 

> (defun double (x) (* x 2))

DOUBLE

 

이렇게 리습에 함수를 추가하고나면 다른 함수안에서 double을 호출할 수 있고, 최상위 레벨에서도 호출할 수 있습니다.

 

> (double 1)

2

 

리습 코드를 보면 대부분 이런  defun들로 이루어졌다는걸 알 수 있습니다. 마치 C나 파스칼 언어에서 프로시저 정의들과 비슷합니다. 하지만 확연히 다른 점이 존재합니다. 이 defun들은 단순히 프로시저를 정의하는게 아닙니다. 그것들은 리습 호출 그 자체입니다. defun의 내부에  어떤 일이 벌어지는지를 알면 보다 명확해집니다.

 

 

함수는 객체와 동일합니다. defun이 실제로 하는 일은 하나의 객체를 만들어서 첫번째 인자로 전달된 이름으로 저장하는 것입니다. 그래서 double을 호출하는 것 뿐만 아니라 그것을 정의하는 함수 자체를 맘대로 다룰 수도 있습니다. 바로 #’ (샵-따옴표) 연산자를 이용하면 됩니다. 이 연산자는 전달된 이름에 해당하는 함수의 객체를 얻을 수 있게 해줍니다. double 이라는 이름에 #'을 써보면

 

> #’double

#<Interpreted-Function C66ACE>

> (eq #’double (car (list #’double)))

T

 

이렇게 함수를 정의할 때 생성된 객체를 얻게 됩니다. 리습 구현마다 출력 양식이 다를 수 있습니다만 커먼 리습에서 함수는 일급객체입니다. 다른 숫자나 문자열같은 좀더 흔한 객체들과 같습니다. 그래서 우리는 함수를 함수 인자로 전달할 수도 있고, 반환값으로 반환하거나 데이터 구조에 저장하는 등의 일들을 할 수 있게됩니다.

 

함수를 만들 때 꼭 defun만 사용하는 것은 아닙니다. 다른 리습 객체들처럼 우리는 함수 그 자체에 접근할 수 있습니다. 우리가 정수를 참조하고 싶을 때 정수를 그대로 사용하는 것과 같습니다. 문자열을 표현하기 위해서 우리는 쌍따옴표로 둘러싸인 문자들이 나열을 사용합니다. 함수를 표현하기 위해서는 람다 표현식이라는걸 사용하면 됩니다. 람다 표현식은 3개의 항목으로 구성된 리스트입니다. lambda라는 심볼과, 매개변수 리스트, 0개 이상의 표현식으로 구성된 몸체입니다. 람다 표현식으로 double과 동일한 함수를 참조할 수 있습니다.

 

(lambda (x) (* x 2))

 

하나의 인자값 x 를 받아서 2x를 반환하는 함수를 표현해봤습니다. 람다라는 표현을 함수의 이름이라고 생각할 수 있습니다. double가 이름이고 (lambda (x) (* x 2))라는 표현은 이름에 대한 설명이라고 생각해봅시다. “미켈란젤로"가 이름이고 “시스틴 예배당의 천장에 그림을 그린 사람"이 설명인것과 같습니다.

 

> #’(lambda (x) (* x 2))

#<Interpreted-Function C674CE>

 

이 함수는 double과 같은 일을 하지만 서로  다른 객체입니다.

함수를 호출할때는 이름을 먼저 쓰고, 그 다음에 인자를 씁니다.

 

> (double 3)

6

 

람다 표현도 함수 이름이므로 lambda를 가장 먼저 써야합니다.

> ((lambda (x) (* x 2)) 3)

6

 

커먼 리습에서 double이라는 함수가 있을 때 동시에 double이라는 변수도 있을 수 있습니다.

> (setq double 2)

2

> (double double)

4

 

함수 호출에서 첫번째로 나타나는 이름이나 샤프-따옴표가 붙은 이름은 함수를 나타내는 것입니다. 그 외에는 변수 이름입니다. 이걸보면 함수와 변수가 분리된 이름 공간을 사용한다는걸 알 수 있습니다. foo라는 변수와 foo라는 함수가 있는데 동일한게 아니라니. 혼란스러울 수 있습니다. 코드가 이상해보일 수도 있습니다. 하지만 커먼 리습 프로그래머라면 감당해야합니다.

 

 

커먼 리습에는 하나의 심볼을 필요에 따라 함수로 쓸건지 변수로 쓸건지 고르게 해주는 2개의 함수를 제공하고 있습니다. symbol-value 함수는 심볼을 받아서 그에 해당하는 변수의 값을 반환합니다.

(역자주: 리습에서 '(작은 따옴표 quote)를 쓰면 해당 이름을 변수나 함수의 이름으로 해석하지않고, 써진 그대로를 사용한다는 의미입니다.)

 

> (symbol-value ’double)

2

 

반면에 symbol-fundtion은 전역 함수를 반환합니다.

 

> (symbol-function ’double)

#<Interpreted-Function C66ACE>

 

함수가 곧 보통의 데이터 객체이므로 변수의 값으로 함수를 저장할 수도 있습니다.

(역자주: 'append라고하면 append라는 변수나 함수를 찾지말고, append라는 이름을 그대로 사용하라는 의미입니다. #'append라고하면 append라는 함수로 해석하라는 의미입니다. 아무것도 없이 그냥 append라고하면 변수를 의미합니다. 결국 '와 #'는 이름 공간을 지정하는 연산자입니다.)

 

> (setq x #’append)

#<Compiled-Function 46B4BE>

> (eq (symbol-value ’x) (symbol-function ’append))

T

 

내부를 들여다보면 defun이 하는 일은 첫번째 인자로 symbol-function을 호출하고, 나머지인자들로 함수를 만들어서 연결하는 것입니다. 다음 2개의 표현식은 서로 동일하다고 볼 수 있습니다.

 

(defun double (x) (* x 2))

(setf (symbol-function ’double)

#’(lambda (x) (* x 2)))

 

결국 겉으로보기에 defun은 다른 언어들의 프로시저 정의와 같이 여러개의 코드를 하나의 이름으로 묶는 일을 합니다. 하지만 내부를 들여다보면 같지 않습니다. 함수를 만들기위해 꼭 defun을 써야할 필요가 없습니다. 함수가 특정 심볼에 저장될 필요도 없습니다.  겉으로는 다른 언어들의 프로세저 정의와 같아보이지만 내부에는 좀더 범용적인 메카니즘이 들어있습니다. 함수를 만드는 것과 이름을 붙이는 것이 서로 분리될 수 있기 때문입니다. 우리가 리습에서 정의하는 함수의 범용성을 모두 사용할게 아니라면 defun을 다른 언어들과 같이 단순히 함수를 정의하는데만 사용해도 됩니다.

(역자주: 함수에 이름을 붙이는 것은 defun이 하는 일이고, 함수를 만들기만 하는 것은 lambda가 하는 일입니다. 그래서 lambda로 만든 함수를 이름없는 함수라고도 부릅니다.)

 

2.3 함수의 인자로 함수를 전달해봅시다.

 

함수가 데이터 객체라는 것은 -다른 것들도 있겠지만- 다른 함수에 인자로 넘길 수 있다는것입니다. 리습의 상향식 프로그래밍이 가능한 이유중에 하나가 이것입니다.

 

어떤 언어가 함수를 데이터 객체와 같게 만들었다면 당연히 그에 맞는 호출 방식도 제공해야합니다. 리습에서는 apply라는 함수가 그런 역할을 합니다. 보통 apply에 2개의 인자를 전달하면 됩니다. 호출할 함수와 함수에 전달할 인자 리스트입니다. 다음 4개의 표현식들은 모두 같은 일을 합니다.

 

(+ 1 2)

(apply #’+ ’(1 2))

(apply (symbol-function ’+) ’(1 2))

(apply #’(lambda (x y) (+ x y)) ’(1 2))

 

커먼 리습에서 apply는 인자의 갯수가 제한돼있지 않습니다. apply에서 호출된 함수는 두번째부터 마지막까지 전달된 인자들의 cons 리스트를 인자로 전달받습니다.

(역자주주: cons 리스트라는게 나오는데 나중에 설명이 나옵니다. 간단하게 미리 설명하면 cons 함수로 만든 리스트라는 것입니다.)

(apply #’+ 1 ’(2))

 

위의 표현은 그 위의 4개의 표현들과 완전히 동일한 것입니다. 함수 인자로 리스트를 넘기는게 불편하시면 funcall을 쓰면 됩니다. 인자가 리스트가 아니라는 것 외에는 apply와 다른게 없습니다.

 

(funcall #’+ 1 2)

 

위의 5개 표현들과 같은 일을 하는 표현입니다.

 

커먼 리습에 있는 많은 빌트인 함수들은 함수를 인자로 받습니다. 자주 사용되는 함수들 중에 맵핑 함수가 있습니다. 그중에 mapcar라는게 있는데 두개 이상의 인자를 받습니다. 인자는 함수 하나와 한개 이상의 리스트 (각각이 함수에 전달됩니다.)인데, 각각의 리스트마다 한번식 함수가 호출됩니다.

 

> (mapcar #’(lambda (x) (+ x 10)) ’(1 2 3))

(11 12 13)

> (mapcar #’+ ’(1 2 3) ’(10 100 1000))

(11 102 1003)

 

리습 프로그램에서는 리스트에 있는 각각의 항목을 가지고 어떤 일을 하는 경우가 많습니다. 위의 예중에 첫번째 것이 바로 그런 일을 간편하게 실행하는 방법을 보여줍니다. 내가 원하는 일을 하는 함수를 하나 만들고, 리스트의 각 항목에 한번씩 맵핑을 합니다. (역자주:이렇게 리스트에 있는 항목들을 하나씩 꺼내서 함수에 전달하는걸 mapcar라고 부릅니다. 많은 고급언어들이 동일하게 지원하는 함수입니다.)

 

함수를 데이터처럼 다룰 수 있다는게 얼마나 편리한 것인지를 봤습니다. 다른 많은 언어들에서는 mapcar같은 함수에 함수를 인자로 전달할 수 있다해도 미리 소스 파일 어딘가에 정의된 함수만을 전달할 수 있습니다. 하나의 리스트 안에 있는 항목들 각각에 10을 더하는 단순한 일을 하는 함수를 만드려고 해도 우리는 함수를 정의하고 호출해야합니다. 한번만 사용하는 함수일때도 그렇습니다. 람다 표현식이 있으면 이럴때 함수를 곧바로 사용할 수 있습니다. (역자주: 위에 mapcar 예제 두개중 첫번째 것이 바로 lambda를 사용하는 예제입니다.)

 

커먼리습과 커먼리습에서 갈라져나온 다른 언어들의 다른 점중 하나가 커먼 리습에는 함수를 인자로 받는 빌트인 함수가 많다는 것입니다. 많은 곳에서 사용되는 mapcar 다음으로 또 흔하게 사용되는 것들을 두개만 더 말하자면 sort와 remove-if가 있습니다. sort는 범용적으로 사용할 수 있는 정렬 함수입니다. 리스트와 판별자를 받아서 판별자에 의해 정렬된 리스트를 반환합니다.

 

> (sort '(1 4 2 5 6 7 3) #'<)

(1 2 3 4 5 6 7)

 

sort가 어떻게 동작하는지를 잘 기억하려면 중복된 항목이 없는 리스트를 <로 정렬했을 때, 정렬된 리스트에 다시 <를 적용했을 때 참이 반환된다는 것을 기억하면 됩니다.

(역자주: mapcar 와 #'< 를 이용해서 정렬된 리스트가 제대로 정렬된 것인지 확인하는 함수를 만들 수 있습니다. (mapcar #'< (sort '(1 4 2 5 6 7 3) #'<) 를 실행해보면 T로만 이루어진 리스트가 반환된 것입니다.)

 

remove-if가 커먼 리습에 이미 포함되어있지 않았다면 우리가 만들어야 할 첫번째 라이브러리 함수가 remove-if였을 겁니다.(역자주: 그만큼 자주 쓰이는 함수입니다.)  함수와 리스트를 인자로 받아서 함수가 거짓을 반환한 항목들을 리스트로 만들어서 반환해줍니다. (역자주: 반대로말하면 함수가 참을 반환하면 리스트에서 제거합니다.)

> (remove-if #’evenp ’(1 2 3 4 5 6 7))

(1 3 5 7)

 

인자를 함수로 받는게 어떤 것인지 예를 보여드리겠습니다. 아래처럼 간단한 remove-if를 만들어보겠습니다.

 

(defun our-remove-if (fn lst)
  (if (null lst)
      nil
      (if (funcall fn (car lst))
      (our-remove-if fn (cdr lst))
	  (cons (car lst) (our-remove-if fn (cdr lst))))))

 

이렇게 정의한 함수를 보면 인자로 전달된 함수 fn에 샵-따옴표를 붙이지 않은걸 볼 수 있습니다. 함수가 곧 데이터 객체이기 때문에 변수의 값이 함수일 수 있습니다. 그래서 샵-따옴표가 없는 것입니다. 샵-따옴표는 심볼과 같은 이름의  함수에 접근할 때 쓰는 것입니다. 보통 그런 함수는 defun으로 전역적으로 정의된 함수입니다.

 

4장에서 보겠지만 인자로 함수를 받는 함수를 구현하는 것은 상향식 프로그래밍에서 중요한 부분입니다. 커먼 리습에는 여러분이 필요로하는 함수들이 대부분 이미 구현되어있을 겁니다. sort같은 빌트인 함수를 쓰거나 직접 만든 함수를 쓰건건에 원칙은 동일합니다. 어떤 기능을 함수 내부에서 구현하는 것보다는 함수에 인자로 전달하는게 우선입니다.

 

2.4 속성을 나타낼때도 함수를 사용할 수 있습니다

 

함수가 리습 객체라는 것 덕분에 갑작스러운 기능 추가 등의 프로그램 확장에 유연하게 대처할 수 있습니다. 동물의 종류를 인자로 받아서 해당하는 동물의 행동을 나타내는 함수를 만든다고 생각해보겠습니다. 다른 언어들은 case문을 사용해서 처리할 것입니다. 리습으로하면 아래처럼 될 것입니다.

 

(defun behave (animal)
  (case animal
    (dog (wag-tail)
     (bark))
    (rat (scurry)
	 (squeak))
    (cat (rub-legs)
	 (scratch-carpet))))

 

새로운 타입의 동물을 추가하려면 어떻게 해야할까요? 새로운 동물이 추가될 수 있다는 계획이 있었을 때부터 아래와같이 동물 행동을 구현했다면 더 좋았을 것입니다.

 

(defun behave (animal)
    (funcall (get animal ’behavior)))

 

그리고 각 동물의 행동을 구현할 때, 다음 예처럼 동물 이름으로된 속성 리스트에 함수로 저장할 수 있습니다. (역자주: dog가 속성 리스트이고 behavior가 속성입니다. 이 책은 이미 lisp의 기본을 아는 사람을 대상으로 한 책이기 때문에 일일이 새로나온 함수를 설명하지 않습니다.)

 

(setf (get ’dog ’behavior)
      #’(lambda ()
      (wag-tail)
	  (bark)))

 

이렇게하면 새로운 동물을 추가하기 위해 우리가 할 일은 새로운 속성을 정의하는 것밖에 없게됩니다. 함수를 고칠 필요가 없습니다.

 

두번째 방법은 좀더 유연하지만 더 느려보입니다. 실제로도 그렇습니다. 속도가 중요한 상황이라면 속성 리스트 대신에 구조체를 사용했을겁니다. 또 인터프리터에서 함수를 정의할게 아니라 함수를 컴파일해서 사용하는 것도 중요합니다.

 

이렇게 함수를 사용하는 것은 객체지향 프로그래밍에서 메소드의 개념과 같습니다. 일반적으로 메소드라는 것은 객체의 속성이 함수인 것을 말합니다. 바로 우리가 만든 그대로입니다. 이 방식에 상속 개념을 더하면 객체지향 프로그래밍의 모든 요소를 가지게 됩니다. 25장에서 아주 적은 코드만으로 이런 개념들을 구현하는 것을 보게됩니다.

 

객체지향 프로그래밍의 가장 큰 장점이 프로그램을 확장하기 쉽게 만들어준다는 것입니다. 그런 장점이 리습에는 그리 특별한게 아닌게  리습에서는 확장성이 항상  당연한 것이었기 때문입니다. (역자주: 객체지향 프로그래밍이 나오기 한참 전에 리습이 만들어졌습니다.) 꼭 상속으로만 구현되야하는 속성들이 아니라면 기본적인 리습만으로도 충분히 확장성을 제공할 수 있습니다.

 

2.5 유효범위 (Scope)

 

(역자주: scope와 lexical scope/dynamic scope를 어떻게 번역해야될지 아무리 조사해도 알 수가 없어서 그냥 scope는 유효범위, lexical scope는 렉시컬 유효범위, dynamic scope는 다이나믹 유효범위로 썼습니다.)

 

커먼 리습은 렉시컬 유효 범위를 지원하는 언어입니다. 스킴이 첫번째로 렉시컬 유효 범위를 지원하는 리습의 방언이었고, 스킴이 생기기 전에는 리습의 중요한 특징이 다이나믹 유효 범위였습니다.

 

 

렉시컬 유효범위와 동적 유효범위의 차이는 값이 정해지지않은 변수를 어떻게 처리하느냐에 달려있습니다. 하나의 심볼은 함수 매개 변수에 사용되거나 let, do 같은 변수 바인딩 연산자를 이용한 표현식에서 변수로 정의됨으로서 자기가 속한 표현식에 바인딩됩니다. (역자주: 이름과 속성이 연결되는 것을 binding이라고 부르는데 많은 자료들이 바인딩이라고 쓰고 있어서 저도 바인딩이라고 써봤습니다.) 그렇게 특정하게 연결된 값이 없다면 값이 정해지지 않은 것입니다. 다음 예제를 보면 유효범위가 어떻게 정해지는지를 알 수 있습니다.

 

(let ((y 7))
    (defun scope-test (x)
    (list x y)))

 

defun 표현식 내부에서보면 x는 값이 정해졌고 y는 값이 정해지지 않았습니다. 값이 정해지지 않은 변수는 그 값이 뭐가 될지 알 수 없습으므로 주의해야합니다. 값이 지정된 변수는 명확합니다. scope-test 함수가 호출될 때 x의 값은 함수 인자로 전달된 값 그대로입니다. 그럼 y의 값은 뭘까요? 각 리습의 방언마다 유효범위를 어떻게 규정하느냐에 따라 달라집니다.

 

동적 유효범위를 지원하는 리습 방언에서는 scope-test를 실행할 때 값이 정해지지 않은 변수의 값을 찾을 때, 함수 호출 고리를 거슬러 올라가면서 값을 찾습니다. 그렇게 올라가다가 y의 값이 정해지는 환경을 만나면 그때 y의 값이 scope-test에서 사용할 값입니다. 만약 값을 찾지 못하면 y의 값이 전역 변수인지 확인합니다. 그래서 동적 유효 범위를 가진 리습에서 y는 y를 사용하는 표현식이 호출될 때의 값을 가집니다.

 

> (let ((y 5))(scope-test 3))
(3 5)

 

동적 유효범위에서 scope-test가 정의될 때 y의 값이 7이라는 것은 상관이 없습니다. scope-test가 호출될 때 y의 값이 5라는 것만이 중요합니다. (역자주: 정의될 때와 호출될 때의 차이가 있습니다.)

 

렉시컬 유효범위(역자주: 어떤 자료에서는 동적 유효범위의 반대라고해서 정적 유효범위라고 부르기도 합니다.)는 함수 호출 고리를 거슬러 올라가는 대신 함수가 정의될 당시의 환경이 어떠했는지를 찾아봅니다. 렉시컬 유효범위를 지원하는 리습에서 위의 예제를 실행하면 scope-test가 정의되는 곳에서 y의 값을 찾습니다. 그래서 커먼 리습에서는 아래처럼 실행이 됩니다.

 

> (let ((y 5))(scope-test 3))
(3 7)

 

특별한 방법을 써서 동적 유효 범위를 가지는 변수를 만들 수는 있습니다. 하지만 커먼 리습에서는 렉시컬 유효 범위가 기본입니다. 리습 커뮤니티는 동적 유효 범위를 버린 것에 대해 별로 후회하지 않는것 같습니다. 그것 때문에 정말 찾기 어려운 버그가 생기기도 했었으니까요. 반면에 렉시컬 유효 범위는 버그를 방지하기 쉽습니다. 또 다음 섹션에서 보듯이 새로운 프로그래밍 테크닉이 생겨나기도 했습니다.

 

2.6 클로저를 써봅시다

(역자주: closure를 뭐라고 번역할 수가 없었습니다. 그냥 클로저라고 부르겠습니다.)
 
 
커먼 리습이 렉시컬 유효범위를 가진다고 말씀드렸습니다. 그래서 함수가 정의될 때 내부에서 정의되지 않은 변수를 가지고 있다면 시스템은 함수가 정의될 때 어떤 변수들이 어떤 값을 가지고 있는지를 기억해놔야합니다. 그렇게 함수와 변수들의 묶음을 클로저라고 부릅니다. 클로저는 다양한 분야의 응용 프로그램에서 유용하게 사용되고 있습니다.
 
클로저는 워낙 커먼 리습 프로그램에 흔하게 사용되고 있어서 클로저인지 모르면서도 사용하기도 합니다. mapcar를 호출 할 때 샤프-따옴표를 이용해서 람다 표현식을 넘길때가 있습니다. 그때 람다 표현식 안에 람다 표현식 밖에서 정의된 변수가 있다면 클로저를 사용한 것입니다. 예제를 만들어보겠습니다. 숫자로 이루어진 리스트를 받아서 특정 수를 더하는 함수를 만드려고 합니다. 함수 이름은 list+ 입니다.
 
(defun list+ (lst n)
    (mapcar #’(lambda (x) (+ x n)) lst))

 

위와같이 만들어서 아래와같이 사용할 수 있습니다.
> (list+ ’(1 2 3) 10)
(11 12 13)
 
list+ 함수를 보면 mapcar에 사용된 람다 표현식이 클로저라는 것을 알 수 있습니다. n의 값이 람다 표현식 밖에서 정의된 것입니다. 렉시컬 스코프에서는 맵핑 함수를 사용할 때마다 클로저가 생성된다고 보면 됩니다. 
(저자주:1. Under dynamic scope the same idiom will work for a different reason—so long as neither of
mapcar’s parameter is called x.)
저자주1: (해석못함)
 
 
클로저는 Abelson과 Sussman 이 쓴 유명한 고전 Structure and Interpretation of Computer Programs 에서 자주 사용하는 프로그래밍 스타일입니다. 클로저는 자신만의 상태를 가지는 함수라고 할 수 있습니다. 이 상태를 사용하는 방법은 다음과 같은 상황에서 잘 나타납니다.
 
(let ((counter 0))
  (defun new-id ()
    (incf counter))
  (defun reset-id () (setq counter 0)))

 

카운터 역할을 하는 변수를 두개의 함수가 공유하고 있습니다. 첫번째 함수는 카운터를 증가시키고 두번째 함수는 0으로 초기화합니다. 전역 변수로 선언된 카운터로 같은 일을 할 수 있지만 위와 같이 구현하면 의도치않은 곳에서 카운터에 접근하는 것을 막을 수 있습니다. 
 
개별 상태를 가진 함수를 반환하는 것도 유용할 때가 있습니다. 다음과 같은 make-adder 함수를 보면
 
(defun make-adder (n)
    #’(lambda (x) (+ x n)))

 

숫자를 받아서 클로저를 반환합니다. 이 클로저는 호출될때마다 인자로 전달된 숫자만큼 값을 더합니다. 결국 아래처럼 여러가지의 덧셈기를 만들 수가 있게 됩니다.
 
 
> (setq add2 (make-adder 2) add10 (make-adder 10))
#<Interpreted-Function BF162E>
> (funcall add2 5)
7
> (funcall add10 3)
13

 

make-add로 만들어진 클로저들은 내부 상태가 고정되어있습니다. 하지만 내부 상태를 바꿀 수 있게 만들 수도 있습니다.
 
(defun make-adderb (n)
  #’(lambda (x &optional change)
      (if change
      (setq n x)
	  (+ x n))))

 

새로 만든 make-adder는 인자가 하나일때 이전 클로저와 동일하게 동작하는 클로저를 반환합니다.
 
> (setq addx (make-adderb 1))
#<Interpreted-Function BF1C66>
> (funcall addx 3)
4
 
그런데 두번째 인자가 nil값이 아닐때의 동작이 다릅니다. 내부 상태에서 n의 값이 첫번째 인자의 값으로 바뀝니다.
 
> (funcall addx 100 t)
100
> (funcall addx 3)
103
 
같은 데이터 객체를 공유하는 여러개의 클로저를 만드는 것도 가능합니다. 그림 2.1에는 아주 단순한 데이터베이스를 만들기위한 함수가 있습니다. db에 연관 리스트를 받아서 3개의 함수로 이루어진 리스트를 반환합니다. 그 함수는 쿼리, 추가, 삭제 기능을 하는 함수입니다.
 
make-dbms를 호출할 때마다 새로운 데이터베이스가 만들어지고, 연관 리스트를 공유하는 함수들의 집합이 생성됩니다.
 
> (setq cities (make-dbms ’((boston . us) (paris . france))))
(#<Interpreted-Function 8022E7>
#<Interpreted-Function 802317>
#<Interpreted-Function 802347>)
 
(defun make-dbms (db)
(list
    #’(lambda (key)
        (cdr (assoc key db)))
    #’(lambda (key val)
        (push (cons key val) db)
        key)
    #’(lambda (key)
        (setf db (delete key db :key #’car))
        key)))

그림2.1: 하나의 리스트를 공유하는 세개의 클로저

 
데이터 베이스 내부에 있는 연관리스트는 외부에서는 볼 수 없습니다. 밖에서는 그게 연관리스트인지도 알 수 없습니다. cities를 구성하는 함수들을 통해서만 접근할 수 있습니다.
 
> (funcall (car cities) ’boston)
US
> (funcall (second cities) ’london ’england)
LONDON
> (funcall (car cities) ’london)
ENGLAND
 
car를 가지고 list에 접근하는 것은 불편합니다. 실제 프로그램에서는 리스트 구조체에 접근하기 위한 전용 접근 함수를 만들어서 사용하게 될 것입니다. 그렇게하는게 훨씬 깔끔합니다. 즉 다음과 같이 데이터베이스에 간접적으로 접근하게해주는 함수가 있을 수 있습니다.
 
(defun lookup (key db)
    (funcall (car db) key))

 

어쨌든 클로저의 기본 동작은 그런 추가작업과는 상관이 없습니다. 실제 프로그램에서 사용되는 클로저와 데이터 구조는 make-adder나 make-dbms에서 보는 것보다는 더 정교할 것입니다. 하나의 공유 변수대신에 여러개의 변수들을 공유할 수 있고, 각 변수들은 각기 다른 데이터 구조일 수도 있습니다.
 
클로저는 리습만의 고유하고 중요한 특징입니다. 어떤 리습 프로그램들은 덜 강력한 언어로도 노력만하면 만들 수 있는 것들일수 있습니다. 하지만 위와 같이 클로저를 사용하는 프로그램을 다른 언어로 만들어보려고 해보면, 클로저를 이용한 추상화가 얼마나 많은 일들을 간략하게 만들어주는지 알 수 있게 됩니다. 나중에 다른 챕터에서 클로저를 좀더 자세하게 다룰 것입니다. 5장에서 클로저를 이용해서 여러가지 기능을 가진 함수들을 만들어볼 것이고, 6장에서는 전통적인 데이터 구조 대신에 클로저를 사용하는 방법을 알아보겠습니다.
 

2.7 지역함수를 알아봅시다

 
람다 표현식으로 함수를 만들면 defun으로 함수를 만들 때에는 없던 제약이 있습니다. 람다 표현식으로 함수를 만들면 함수의 이름이 없기 때문에 자기 자신을 호출할 방법이 없습니다. 즉 커먼 리습에서 람다를 이용한 재귀 함수를 만들 수 없다는 이야기가 됩니다.
 
리스트에 있는 각 항목에 어떤 함수를 적용하고 싶을 때 보통 아래처럼 합니다.
 
 
 
> (mapcar #’(lambda (x) (+ 2 x)) ’(2 5 7 3))
(4 7 9 5)
 
map의 첫번째 인자로 재귀 함수를 지정하고 싶을 때는 어떻게 해야할까요? defun으로 정의된 함수라면 다음처럼 간단하게 해결됩니다.
 
> (mapcar #’copy-tree ’((a b) (c d e)))
((A B) (C D E))
 
mapcar가 실행될 때의 환경을 기억하는 클로저로 함수를 만드는걸 생각해보겠습니다. 다음 예제의 list+ 함수를 보겠습니다.
 
(defun list+ (lst n)
    (mapcar #’(lambda (x) (+ x n))
    lst))

 

 
mapcar의 첫번째 인자는 #’(lambda (x) (+ x n))입니다. 이 람다 함수는 list+ 안에서만 정의될 수 있습니다. n 변수를 list+함수에서 받아야하기 때문입니다. 여기까지는 문제가 없습니다. 그런데 만약 mapcar에 지역 변수와 재귀가 필요한 함수를 전달하고 싶을 때는 어떻게 해야할까요?
 
defun 밖에서 정의된 함수를 쓸수 없습니다. defun 내부의 환경을 사용하기 때문입니다. 그리고 람다로 재귀 함수를 만들 수 없습니다. 람다 함수 내부에서 다시 자기 자신을 호출할 방법이 없기 때문입니다. 커먼 리습은 이런 문제의 해결책으로 labels을 제공하고 있습니다. labels는 함수에게 let과 같은 역할을 합니다.
 
labels 표현식에 뭔가를 선언하려면 다음과 같은 형태로 하면 됩니다.
(<name> <parameters> . <body>)
labels 표현식 내부에서 <name>은 함수 이름입니다.
 
#’(lambda <parameters> . <body>)
 
예를 들면 이렇습니다.
> (labels ((inc (x) (1+ x))) (inc 3))
4
 
let과 labels 사이에 중요한 차이가 있습니다. let은 같은 let에서 선언된 변수에 접근할 수 없습니다. 다음같은 코드를 보면
 
(let ((x 10) (y x))
y)
 
y가 x의 값을 그대로 갖을 수 없습니다. 하지만 반대로 labels 표현식에서 정의된 함수 f는 자기 자신을 포함해서 그 안에 정의된 다른 함수들을 호출할 수 있습니다. 그래서 재귀함수가 가능해집니다.
 
labels을 써서 list+와 비슷하지만, mapcar의 첫번째 인자로 재귀함수를 전달하는 예를 만들어보겠습니다.
 
(defun count-instances (obj lsts)
  (labels ((instances-in (lst)
         (if (consp lst)
		 (+ (if (eq (car lst) obj) 1 0)
		    (instances-in (cdr lst)))
		 0)))
    (mapcar #’instances-in lsts)))

 

이 함수는 객체와 리스트를 인자로 받아서 리스트의 각 항목안에 해당 객체가 몇개 들어있는지를 리스트로 반환합니다.
 
> (count-instances ’a ’((a b c) (d a r p a) (d a r) (a a)))
(1 2 1 2)
 

2.8 꼬리재귀를 알아봅시다

 
재귀 함수는 자기 자신을 호출하는 함수입니다. 그중에서 자기 자신을 호출하는 것이 가장 마지막에 하는 일인 함수를 꼬리재귀 함수라고 부릅니다. 다음 함수는 꼬리 재귀함수가 아닙니다.
 
(defun our-length (lst)
    (if (null lst)
        0
    (1+ (our-length (cdr lst)))))

 

재귀 호출이 끝나서 받은 결과에 1을 더해서 반환하기 때문입니다. 다음 함수가 꼬리재귀함수입니다.
 
(defun our-find-if (fn lst)
  (if (funcall fn (car lst))
      (car lst)
      (our-find-if fn (cdr lst))))

 

재귀 함수의 결과값이 곧바로 반환되기 때문입니다.
 
꼬리재귀함수가 좋은 이유가 많은 커먼 리습 컴파일러들이 꼬리재귀함수를 루프로 바꿀 수 있기 때문입니다. 그런 컴파일러를 이용하면 재귀 호출을 써서 코드가 명료해지면서도 함수 호출에 들어가는 성능저하가 없어집니다. 그렇게 얻어지는 성능향상이 꽤 커서 프로그래머들은 함수를 만들 때 최대한 꼬리 재귀로 만들려고 노력합니다.
 
꼬리재귀가 아닌 많은 함수들을 누적계산을 하는 지역함수를 이용한 다른 함수로 변환할 수 있습니다. 누적계산기는 지금까지 계산된 결과를 가지는걸 말합니다. 예를 들어 our-length 함수도 아래처럼 바꿀 수 있습니다.
 
(defun our-length (lst)
  (labels ((rec (lst acc)
         (if (null lst)
		 acc
		 (rec (cdr lst) (1+ acc)))))
    (rec lst 0)))
예제를보면 리스트에 있는 항목들이 갯수가 두번째 매개변수 acc에 저장됩니다. 재귀호출이 리스트의 끝을 만나면 acc의 값인 리스트의 길이가 됩니다. 값을 따로 저장하는게 아니라 함수 호출이 진행되면서 값을 누적해나가는 방식을 사용하면 rec 함수를 꼬리재귀함수로 만들 수 있습니다.
 
많은 커먼 리습 컴파일러들은 꼬리재귀 최적화를 할 수 있습니다만 디폴트 동작이 아닌 경우도 있습니다. 그래서 꼬리재귀함수를 만들었다면 다음과 같은 내용을
 
(proclaim ’(optimize speed))
 
파일의 가장 윗부분에 써줘서 컴파일러가 확실하게 알도록 해주어야합니다.
(저자주2 (optimize speed)라는 선언은 (optimize (speed 3))을 줄여서 쓴 것입니다. 그런데 어떤 커먼 리습 구현체는 (optimize speed)라고 쓰면 꼬리재귀 최적화를 하는데 (optimize (speed 3))라고 쓰면 하지 않는것도 있습니다.) 
 
꼬리재귀와 타입선언을 이용하면 현재의 커먼 리습 컴파일러로 C보다 더 빠른 코드를 만들어낼 수 있습니다. Richard Gabriel은 다음가 같이 1부터 n까지의 정수를 더하는 함수를 그 예로 들었습니다.
 
 
(defun triangle (n)
  (labels ((tri (c n)
         (declare (type fixnum n c))
	     (if (zerop n)
		 c
		 (tri (the fixnum (+ n c))
		      (the fixnum (- n 1))))))
    (tri 0 n)))
 
이로서 빠른 커먼 리습 코드가 어떻게 생겼는지 알 수 있습니다. 처음 봤을 때는 함수를 이런식으로 만든다는게 이상해보일것입니다. 일반적인 함수를 만드는 걸로 시작했다가  필요에 따라서 동일한 일을 하는 꼬리재귀 함수로 바꿔나가는게 좋은 접근 방식입니다.
 

2.9 함수를 컴파일하기

 
리습의 함수는 개별적으로도 컴파일할 수 있고, 파일 단위로도 컴파일할 수 있습니다. 최상위 레벨에서 다음처럼 defun으로 함수를 만들 수 있습니다. (역자주: 리습의 evaluator를 이 책에서는 최상위 레벨 toplevel이라고 부르고 있습니다. 리습을 사용자가 가장 직접 사용하는 레벨이라는 뜻인것 같습니다.)
 
> (defun foo (x) (1+ x))
FOO
 
많은 구현체에서 인터프리터에서 함수를 사용하는 것이 컴파일된 함수와는 다른 경우가 있습니다. compiled-function-p 에 함수를 전달하면 컴파일된 함수인지를 알려줍니다.
 
> (compiled-function-p #’foo)
NIL
 
아직 컴파일된게 아닌 foo 함수를 다음처럼 compile을 이용해서 컴파일할 수 있습니다.
 
> (compile ’foo)
FOO
 
compile은 foo의 정의에 따라 함수를 컴파일하고 인터프리터에서 사용중인 함수를 컴파일된 함수로 대체합니다.
 
> (compiled-function-p #’foo)
T
 
컴파일되었건 인터프리터에서 사용중이건 모두 리습의 객체입니다. 완전히 같은 동작을 하기때문에 compiled-function-p에 전달했을 때만 구별할 수 있습니다. 함수를 문자열로 표현해놓은 것도 컴파일할 수 있습니다. compile의 첫번째 인자에는 이름을 써야하는데 nil로 지정하면 두번째 인자로 전달된 람다 표현식을 컴파일하라는 의미가 됩니다.
 
> (compile nil ’(lambda (x) (+ x 2)))
#<Compiled-Function BF55BE>
 
이름과 함수를 모두 전달하면 compile은 defun으로 정의된 함수를 컴파일하는 것과 같습니다.
 
> (progn (compile ’bar ’(lambda (x) (* x 3)))
(compiled-function-p #’bar))
T
 
(역자주:개별 툴이 아니라) 언어 자체에 컴파일 명령이 들어있다는 것은 실행 중에 새로운 함수를 생성해서 컴파일할 수도 있다는 것입니다. 그렇지만 그렇게 실행 중에 컴파일을 한다는 것은 정상적으로 eval에서 하는 것에 비해 아주 예외적인 경우이므로 주의해서 살펴봐야합니다. (저자주3)

(저자주3: 원서 278 페이지에 왜 eval을 직접 호출하는게 나쁜지 설명이 있습니다.

 
2.1장에서 런타임에 새로운 함수를 생성하는 것이 일상적으로 사용되는 프로그래밍 테크닉이라고 설명했었는데, 그건 make-adder에서 본 것과 같은 클로저를 만드는 것을 말한 것이었습니다. 문자열을 가지고 compile을 호출해서 함수를 만드는 경우를 말하는게 아닙니다. compile을 호출하는 것은 일상적으로 사용되는 테크닉이 아닙니다. 정말 드문 경우입니다. 꼭 필요한 경우인지 잘 생각해야합니다. 리습을 기반으로 다른 언어를 만드려는 경우가 아닌 이상, (그외의 어떤 경우에도 대부분) 매크로만으로도 충분할 것입니다.
 

 

compile에 인자로 전달할 수 없는 함수가 2종류있습니다. CLTL 2(677페이지)(역자주: Common Lisp The Language 2nd를 말합니다)에 따르면 “렉시컬 환경을 가지고 인터프리터 환경에서 정의된” 함수는 컴파일할 수 없습니다. 그게 무슨 말이냐면 다음처럼 최상위 레벨에서 let으로 foo를 정의한 경우를 말합니다.

 

> (let ((y 2))

(defun foo (x) (+ x y)))

 

(compile ‘foo)가 동작하지 않을 수도 있습니다.(저자주4) 이미 컴파일된 함수에서 compile을 호출하는 것도 안됩니다. CLTL2에서는 “결과를 보장할 수 없음"이라고만 설명하고 있습니다.

(저자주4: It’s ok to have this code in a file and then compile the file. The restriction is imposed on interpreted code for implementation reasons, not because there’s anything wrong with defining functions in distinct lexical environments.)

(역자주: 해석도 안되고 이해도 안되는 설명입니다.)

 

리습 코드를 컴파일할때는 보통 compile을 써서 함수 하나씩 컴파일하는게 아니라 compile-file을 써서 전체 파일을 컴파일하게됩니다. compile-file은 파일 이름을 전달받아서 컴파일된 파일을 생성합니다. 보통 파일 이름은 같고 확장자만 다른 이름을 가집니다. 컴파일된 파일을 읽어들인 다음 파일에 정의된 모든 함수들에 대해 compiled-function-p를 호출하면 참을 반환합니다.

 

컴파일을 함으로써 생기는 결과가 하나 더 있습니다. 어떤 함수가 다른 함수를 포함할 때, 그 함수가 컴파일된다면, 포함된 함수도 컴파일된다는 것입니다. CLTL2에서 이것을 명시하고 있지는 않습니다만, 최신 구현체들에은 지원하고 있는 사항입니다.

 

내부 함수들도 자동으로 컴파일된다는 것은 함수를 반환하는 함수를 만들어보면 알 수 있습니다. make-adder가 컴파일되면 반환되는 함수들도 컴파일된 함수들일 것입니다.

 

> (compile ’make-adder)

MAKE-ADDER

> (compiled-function-p (make-adder 2))

T

 

나중에 다른 챕터에서 보겠지만, 내장 언어를 만드는데 있어서 이것이 중요한 역할을 하게됩니다. 새로운 언어를 만들어내는 변환기를 구현했는데, 이 변환기 코드를 컴파일한다면, 그 결과물도 컴파일된 것일거고, 결국 자동으로 새로운 언어의 컴파일러가 될 것입니다. (원서 81페이지에서 간단한 예제를 만들어 설명합니다.)

 

매우 작은 함수를 만들 때, 인라인 함수로 만들고 싶을 때가 있습니다. 함수가 너무 작아서 함수를 호출하는 시간이 함수의 실행보다 더 길 수 있기 때문입니다. 아래처럼 50th라는 함수를 만들고

 

(defun 50th (lst) (nth 49 lst))

 

아래처럼 선언하면

 

(proclaim ’(inline 50th))

 

컴파일된 함수안에서 50th를 호출한다면 함수 호출을 실행하지 않게됩니다. 다음처럼 50th를 호출하는 함수를 만들어서 컴파일하면

 

(defun foo (lst)

(+ (50th lst) 1))

 

foo를 컴파일할 때 50th에 해당하는 코드가 함수 호출하는 자리에 들어가게되서, 결과적으로 다음처럼 만든것과 동일하게 됩니다.

 

(defun foo (lst)

(+ (nth 49 lst) 1))

 

부작용도 있는데 50th를 다시 만들게되면 foo를 다시 컴파일해야한다는 것입니다. 다시 컴파일하지 않으면 이전의 50th를 계속 호출하게됩니다. 인라인 함수를 사용하는데 있어서 제약사항들이 있는데 매크로의 제약사항과 기본적으로는 같습니다. (7.9장을 참고하세요)

 

2.10 리스트로 함수를 만들기

 

초기 리습의 방언들은 함수를 리스트로 표현했었습니다. 그덕분에 리습 프로그래머들은 그들만의 리습 프로그램을 만들고 실행할 수 있었습니다. 커먼 리습에서는 함수가 더이상 리스트로 만들어지지 않습니다. 좋은 구현체들은 네이티브 머신 코드로 컴파일해버립니다. 하지만 여전히 프로그램을 만드는 프로그램을 만들 수 있습니다. 컴파일러가 리스트를 입력으로 받기 때문입니다.

리습 프로그래머에게 그런 사실은 너무나 중요합니다. 하지만 자주 간과되는 것도 사실입니다. 리습의 이런 기능을 이용해서 얼마나 좋은 일들을 할 수 있는지를 경험많은 리습 사용자들도 알지 못하는 경우가 많습니다.  매크로가 강력한 이유가 바로 프로그램을 만드는 프로그램을 만들 수 있다는 것입니다. 이 책에서 설명하는 대부분의 프로그래밍 테크닉들이 리습이 리습 표현식을 만들어내는 프로그램을 만들 수 있기 때문에 가능한 것들입니다.

 
 
 
 

댓글

댓글 본문
작성자
비밀번호
버전 관리
gurugio
현재 버전
선택 버전
graphittie 자세히 보기