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

5.1 ~ 5.4

 

 

5.1 커먼 리습은 진화를 해왔습니다

 

커먼 리습은 예전부터 서로 대체되는 함수들을 제공했었습니다. remove-if와 remove-if-not가 서로 그런 관계를 가지고 있습니다. pred가 하나의 인자를 받아서 조건에 맞는지 확인하는 함수이라고하면

(remove-if-not #’pred lst)

이것은 다음과 동일합니다.

(remove-if #’(lambda (x) (not (pred x))) lst)

 

위와같이 인자로 전달된 함수가 반대의 일을 하는 함수라도 같은 결과를 만들 수 있습니다. CLTL2에서는 이렇게 반대로 동작하기 위해 complement라는 함수를 제공합니다. 이 함수는 판단할 조건식 p를 받아서 그 반대되는 결과를 반환합니다. 만약 p가 참이라면 complement는 거짓을 반환하는 식입니다. 그래서 이렇게도 만든 식이

(remove-if-not #’pred lst)

아래와 동일하게 됩니다.

(remove-if (complement #’pred) lst)

 

complement가 있으므로 -if-not같은 함수를 쓸 이유가 없어집니다. 사실 CLTL2(페이지 391)에서 그런 함수를 더 이상 지원하지 않는다고 말하기도 했습니다. 커먼 리습에 남아있는 이유는 단지 호환성때문입니다. complement연산자는 함수를 반환하는 함수라는 거대한 빙산의 일각일 뿐입니다. 이 함수는 스킴(역자주: Scheme을 스킴이라고 부르겠습니다. Lisp을 리습이라고 부르는 것처럼 그냥 소리나는대로 썼습니다.) 언어에서 가장 많이 사용되는 함수중 하나입니다. 스킴은 함수를 렉시컬 클로저로 만든 첫번째 리습 방언입니다. 그리고 스킴으로인해 함수를 반환하는 것이 중요하게 여겨지기 시작했습니다.

동적 유효범위를 가지는 리습에서 함수를 반환하지 못하는건 아닙니다. 다음 함수는 동적요휴범위에서나 렉시컬유효범위에서나 동일하게 동작합니다.

(defun joiner (obj)
  (typecase obj
    (cons #'append)
    (number #'+)))

위 함수는 객체를 받아서 객체의 타입에 따라 그런 객체를 더하는 함수를 반환합니다. (역자주: 참고로 이걸 실행해보시려면 (apply fff '(1 2)) 이렇게 하시면 됩니다. (funcall (joiner 3) 1 4) 이렇게도 가능합니다.)이걸 이용해서 숫자나 리스트를 받아서 타입에 따라 결합하는 함수를 만들 수 있습니다.

 

(defun join (&rest args)
  (apply (joiner (car args)) args))

 

하지만 고정된 함수를 반환하는 것은 동적 유효범위를 가지고 할 수 있는 일들에 비해 제한적입니다. 런타임에 함수를 만들 수 없으므로 joiner는 미리 정해진 두개의 함수 중 하나를 반환할수밖에 없습니다. 18페이지에서 우리는 함수를 반환하면서 렉시컬 유효범위의 함수를 하나 봤습니다.
 
(defun make-adder (n)
  #'(lambda (x) (+ x n)))

 

(저자주1: remove-if-not는 제외합니다. remove-if보다 더 많이 쓰이니까요.)
 
make-adder를 호출하면 인자로 전달된 값에 따라서 동작하는 closure를 얻게됩니다.
 
> (setq add3 (make-adder 3))
#<Interpreted-Function BF1356>
> (funcall add3 2)
5
 
렉시컬 유효범위를 사용하면 고정된 함수들 중에서 선택할 필요없이 새로운 클로저를 런타임에 생성할 수 있습니다. 동적 유효범위로는 이렇게 할 수 없습니다. (저자주2) complement 함수를 만드는 것도 다음처럼 클로저를 반환하게 만들면 됩니다.
(defun complement (fn)
    #’(lambda (&rest args) (not (apply fn args))))
 
 
complement에서 반환되는 함수는 complement가 호출될 때 인자로 전달된 fn를 활용합니다. 정해진 함수들중에서 고르는게 아니라 어떤 함수와도 반대로 동작하는 함수를 만들어냅니다.
 
> (remove-if (complement #’oddp) ’(1 2 3 4 5 6))
(1 3 5)
 
함수를 인자로 전달할 수 있다는 것은 추상화을 높이는데 강력한 밑바탕이 됩니다. 함수를 반환하는 함수를 만들면 그 효과를 더욱 높일 수 있습니다. 다음에는 함수를 반환하는 유틸리티의 예를 몇가지 보겠습니다.
 

5.2 직교성

 
언어가 직교성을 가진다는 것은 적은 수의 연산자를 다양하게 조합해서 많은 표현을 만들어 낼 수 있다는걸 의미합니다. 작난감 블럭은 높은 직교성을 가집니다. 프라모델 키트는 직교성의 없다고 볼 수 있구요. complement의 주요 장점은 언어를 좀더 직교성있게 만든다는 것입니다. complement가 없을때는 리습에 remove-if와 remove-if-not, subst-if와 subst-if-not 같은 함수들을 한쌍씩 모두 포함시켜야 했습니다. complement가 생겨나면서 포함시켜야할 함수가 반으로 줄었지요. setf 매크로도 리습의 직교성을 높이고 있습니다. 초기의 리습 방언들은 데이터를 읽기용과 쓰기용의 함수를 둘다 가지고 있었습니다. 속성리스트의 경우에 속성을 만드는 함수와 속성에 따라 찾는 함수가 있어야 했습니다. 커먼 리습에서는 속성에 따라 찾아내는 get만 있으면 됩니다. (저자주2: 동적 유효범위에서 make-adder와 같은 함수를 만들 수 있지만, 잘 활용하기는 어렵습니다. n의 값이 함수가 호출된 순간의 환경에 따라 달라진다면, 그 값이 언제 어떻게될지 통제할 수가 없습니다.)
 
 
(defvar *!equivs* (make-hash-table))

(defun ! (fn)
  (or (gethash fn *!equivs*) fn))

(defun def! (fn fn!)
  (setf (gethash fn *!equivs*) fn!))

그림 5.1: 같은 일을 하지만 유해한 함수 찾기

 
get과 setf을 같이 사용해서 속성을 만들 수 있습니다.
 
(setf (get ’ball ’color) ’red)
 
우리가 커먼 리습을 작고 단순하게 만들 수는 없겠지만, 커먼 리습의 일부분만 사용할 수 있다면 커먼 리습을 작게만드는 것과 다를게 없겠지요. complement와 setf를 만들어썼듯이 새로운 연산자를 만들면 그렇게 할 수 있을까요? 많은 함수들이 유해한 버전과 아닌 버전의 쌍으로 존재합니다. remove-of와 delete-of가 그렇고 reverse와 nreverse, appned와 nconc가 같은 일을 하면서 유해한 버전과 아닌 버전의 쌍으로 존재합니다. 우리가 만약 어떤 함수의 유해한 버전을 반환해주는 연산자를 만든다면 유해한 함수를 직접 호출하지 않아도됩니다. (역자주: 유해한 버전을 직접 호출하지 않으면 비유해한 버전만 사용하면되니까 사용할 함수의 양이 줄어들게됩니다. 그래서 커먼 리습을 작게 만든다는 의미입니다. 뭔가 조삼모사같지만 어쨌든 기억해야할 함수 이름은 줄어들긴하겠네요.) 그림 5.1에는 유해한 함수를 찾기위한 코드가 있습니다. 전역변수로 선언된 *!equivs*라는 해시 테이블은 유해한 함수와 유해하지 않은 함수를 연결해줍니다. ! 은 특정 함수의 유해한 버전을 반환합니다. (역자주: 커먼 리습에서 이름에 대한 규정이 없다고 말씀드렸습니다. 특수 문자 한글자만으로된 함수를 만들 수도 있습니다.) def!는 어떤 함수와 그 함수의 유해한 버전의 함수를 저장합니다. !(느낌표)로 연산자 이름을 만드는 것은 스킴에 사이드 이팩트가 있는 함수 이름에 !을 붙이는 규칙이 있는데 여기에서 따온 것입니다. 이제 다음 처럼 한번 지정을 해놓으면
 
(def! #’remove-if #’delete-if)
 
이렇게 delete-if를 호출할 필요없이
 
(delete-if #’oddp lst)
 
이렇게 remove-if만 호출해서 같은 일을 할 수 있습니다.
 
(funcall (! #’remove-if) #’oddp lst)
 
다음은 같은 일을 하는 스킴코드인데 리습에 비해 더 보기 쉽습니다.
 
((! remove-if) oddp lst)
 
!연산자를 쓰면 직교성도 좋아지는것뿐 아니라, 다른 장점들도 있습니다. 프로그램을 좀더 이해하기 쉽게 만듭니다. (! #'foo)은 foo 함수와 같은 일을 하는 유해한 함수를 나타냅니다. 또 소스 코드를 보기만 해도 유해한 함수인지 확연하게 알게됩니다. 그래서 버그를 잡을 때 해당코드를 확인하기 좋습니다. 어떤 함수와 어떤 함수가 서로 같은 일을 하지만 어떤 함수는 유해하고 어떤 함수는 유해하지 않은지 런타임 이전에 알 수 있습니다. 따라서 !을 매크로로 정의하는게 가장 효과적일 수 있습니다. 아니면 짝이되는 함수를 찾아내는 기능만이라도 매크로로 만드는 것도 좋습니다.
 

5.3 메모이제이션 

 
(역자주: 메모라이징 memorizing이 아닙니다. 기억한다는 의미에서는 비슷한 말이긴 하지만 어원이 다르다고 합니다. 위키페이지 https://en.wikipedia.org/wiki/Memoization 참고하세요.) (역자주: 원문은 memoizing, memoize 등으로 사용하지만 번역은 "메모이제이션하기"로 쓰겠습니다. "메모이제이션"이라는 용어가 이미 많이 사용되고 있으니까요.)
 
(defun memoize (fn)
  (let ((cache (make-hash-table :test #'equal)))
    #'(lambda (&rest args)
    (multiple-value-bind (val win) (gethash args cache)
      (if win
          val
        (setf (gethash args cache)
          (apply fn args)))))))

그림 5.2: 메모이제이션 유틸리티

 
(역자주: gethash 함수는 2개의 값을 반환합니다. 하나는 해시 테이블에서 특정 키를 찾아서 키 값이 있으면 그 키에 해당하는 데이터을 반환합니다. 두번째 값은 그 키가 해시 테이블에 있었나 없었나를 나타내는 T혹은 nil입니다. 해시 테이블에 있는 데이터 값 자체가 nil일 수 있는데, nil만 반환하면 키에 해당하는 데이터가 없다는 의미인지, 데이터가 있는데 데이터의 값이 nil인지 알 수 없기 때문에 2개의 값을 반환합니다. multiple-value-bind는 gethash가 반환한 2개의 값을 각각 val과 win에 저장합니다. 그래서 win이 참이면 찾는 키 값이 있다는 의미이고, val이 함수 호출의 결과값이 됩니다.)
 
어떤 함수가 무겁고 여러번 호출된다면 메모이제이션을 할만합니다. 이전에 호출될때 얻은 반환값을 저장해놓고, 다음에 함수가 호출되면 이전에 같은 호출이 있었는지 저장된 반환값들을 찾아보는 것입니다.
그림 5.2는 범용적인 메모이제이션 유틸리티를 보여줍니다. memoize에 함수를 전달하면 그 함수와 같은 일을 하지만 내부적으로 해시 테이블을 가지고 있는 클로저를 반환합니다. 반환된 함수는 호출되었을 때의 결과값을 해시 테이블에 저장합니다.
 
> (setq slowid (memoize #’(lambda (x) (sleep 5) x)))
#<Interpreted-Function C38346>
> (time (funcall slowid 1))
Elapsed Time = 5.15 seconds
1
> (time (funcall slowid 1))
Elapsed Time = 0.00 seconds
1
 
메모이제이션을 하는 함수는 해시 테이블을 검색하는 동작이 추가로 가지게됩니다. 첫 호출때마다 함수 자체의 동작 시간외에 해시 테이블 검색에 필요한 시간이 더해질 수밖에 없습니다. 하지만 원래 함수가 하려는 동작이 매우 긴 시간이 필요한 연산임을 생각한다면 그정도의 시간이 더해진다고해서 크게 차이가 나지 않을것입니다. memoize함수를 범용적으로 만들다보니 몇가지 제약이 생길수밖에 없었습니다. 함수 호출에 사용된 인자가 같다면 같은 함수 호출로 판단하는데, 함수가 keyword 매개변수를 가지고 있다면 사용할 수 없습니다. 그리고 반환값이 한개인 함수만 사용할 수 있습니다. 반환값이 여러개인 함수는 사용할 수 없고, 반환값을 저장할 수도 없습니다.
 

5.4 합성 함수

 

(defun compose (&rest fns)
  (if fns
      (let ((fn1 (car (last fns)))
        (fns (butlast fns)))
    #'(lambda (&rest args)
        (reduce #'funcall fns
            :from-end t
            :initial-value (apply fn1 args))))
    #'identity))

그림 5.3: An operator for functional composition. 함수 합성을 위한 연산자

 
함수 f의 보함수는 ~f로 표시됩니다. (역자주: 고등학교 수학 시간에 배운 용어들이네요) 5.1장에서 클로저를 이용해서 ~역할을 하는 리습 함수를 만들었었습니다. 또 하나의 일반 연산자가 합성입니다. 연산자 ◦로 표시합니다. f와 g가 함수일때 f◦g도 함수이고 f◦g(x) = f(g(x)) 관계를 만족합니다. (역자주: 정석을 읽는것 같습니다.) 클로저를 이용하면 ◦를 리습 함수로 만들 수 있습니다. 그림 5.3이 여러개의 함수를 전달받아서 합성 함수를 반환하는 compose 함수를 보여줍니다. 예를 들면
 
(compose #’list #’1+)
 
이렇게 호출하면 다음과 같은 함수를 반환해줍니다.
 
#’(lambda (x) (list (1+ x)))
 
합성하게될 함수들은 하나의 인자만을 가져야합니다. 마지막 함수는 예외로 인자 갯수에 제한이 없습니다. 마지막 함수의 인자가 바로 합성된 함수가 처음으로 전달받는 인자입니다.
 
> (funcall (compose #’1+ #’find-if) #’oddp ’(2 3 4))
4
 
not도 리습의 함수이므로 compose를 활용하면 여집합을 만들 수 있습니다. (역자주: A집합의 여집합이란 전체 집합중에 A에 속하지 않는 원소들의 집합을 말합니다.) 다음처럼 만들면됩니다.
 
(defun complement (pred)
  (compose #’not pred))

 

합성 외에도 함수를 결합하는 방법이 더 있습니다. 아래에 있는 흔히 볼 수 있는 표현식을 생각해보겠습니다.
 
 
(mapcar #'(lambda (x)
        (if (slave x)
        (owner x)
          (employer x)))
    people)

 

위와같은 함수를 자동으로 생성하는 연산자를 만들어보겠습니다. 그림 5.4에 있는 fif를 이용하면 동일한 일을 하는 다음 표현식을 만들 수 있습니다.

(mapcar (fif #’slave #’owner #’employer)
        people)

(defun fif (if then &optional else)
  #'(lambda (x)
      (if (funcall if x)
      (funcall then x)
    (if else (funcall else x)))))

(defun fint (fn &rest fns)
  (if (null fns)
      fn
    (let ((chain (apply #'fint fns)))
      #'(lambda (x)
      (and (funcall fn x) (funcall chain x))))))

(defun fun (fn &rest fns)
  (if (null fns)
      fn
    (let ((chain (apply #'fun fns)))
      #'(lambda (x)
      (or (funcall fn x) (funcall chain x))))))

그림 5.4: More function builders. 함수를 생성하는 함수들의 추가 예제

 
그림 5.4는 자주 쓰이는 형태의 함수들을 생성해내는 함수들이 있습니다. fint는 다음과 같은 형태에 사용됩니다.
 
(find-if #'(lambda (x)
         (and (signed x) (sealed x) (delivered x)))
     docs)

 

find-if에 전달되는 함수는 3가지 판별식의 교집합을 찾는 함수입니다. fint는 "function intersection 교집합함수"의 약자인데, 이 함수를 이용하면 다음과 같이 만들 수 있습니다.
 
(find-if (fint #’signed #’sealed #’delivered) docs)

 

여러 판별식의 합집합을 구하는 연산자도 만들 수 있습니다. fun이라는 함수가 fint와 유사하지만 and 대신에 or를 사용해서 합집합을 찾도록 만들어졌습니다.

댓글

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