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

ch03. 함수형 프로그래밍 소개

이전 장에서는 리습 자체뿐 아니라 리습으로 만든 프로그램을 구성하는 가장 기본 재료인 함수에 대해서 설명했습니다. 무엇을 만들던지 재료에 따라 완재품의 품질이 큰 영향을 받을 뿐 아니라, 완재품을 어떻게 만들지에 대해서도 영향을 줄 것이 분명합니다. 함수 또한 리습에 비슷한 영향을 미칩니다.

 

이번 장에서는 리습 세계에서 많이 사용하는 설계 방법들에 어떤 것들이 있는지를 설명하겠습니다. 이 설계방법덕분에 우리는 더 거대한 프로그램을 계획할 수 있게 됐습니다. 옛날에 계획을 먼저 세우고 그대로 구현하던 시절에 만들어진 프로그램들도, 이번장에서 설명할 설계방법덕분에 진화할 수 있었습니다. 다음 장에서는 그런 프로그램중에 중요한 한가지를 설명하도록 하겠습니다.

 

3.1 함수형 프로그래밍에 맞게 디자인하기

 

물체의 특성은 그것을 이루는 재료에 영향을 받습니다. 나무 건물이 돌로만든 건물과는 다르게 생겼듯이 말입니다. 아주 멀리에서 바라봤을 때 나무인지 돌인지 구별이 안가더라도, 건물의 전체적인 모양만으로도 재료가 무었인지를 알 수도 있습니다. 리습 함수도 비슷하게 리습 프로그램의 구조에 영향을 주었습니다.

함수형 프로그래밍은 사이드 이팩트를 이용하지않고 함수의 반환값만을 가지고 동작하는 프로그램을 만드는 것을 의미합니다. 사이드 이팩트에는 객체의 상태를 바꾸는 부작용(rplaca를 사용하는 경우)이나 값을 할당하는 (setq를 이용하는 경우) 동작들이 있습니다. 사이드 이팩트를 최소화하고 한곳에 몰아놓을 수 있다면 프로그램어느 더 읽기 쉽고 테스트하고 디버깅하기 쉬워질 것입니다. 리습 프로그램이 항상 함수형으로 구현되는 것은 아니지만, 리습과 함수형 프로그래밍은 오랫동안 점점 더 뗄 수 없는 관계를 가지게 되었습니다.

 

(defun bad-reverse (lst) 
  (let* ((len (length lst)) 
     (ilimit (truncate (/ len 2)))) 
    (do ((i 0 (1+ i)) 
	 (j (1- len) (1- j))) 
	((>= i ilimit)) 
      (rotatef (nth i lst) (nth j lst))))) 

그림 3.1: 리스트를 뒤집는 함수

 

이 예제를 보면 함수형 프로그래밍이 지금까지 써오던 다른 언어들과 어떻게 다른건지 아실 수 있을 겁니다.  어떤 이유에서건 리스트를 뒤집을 필요가 있다고 해보겠습니다. 리스트 자체를 바꾸는 함수가 아니라, 리스트의 원소들을 그대로 사용하되 순서만 바꿔서 반환하는 함수를 만들어보겠습니다.

그림 3.1에는 리스트 자체를 뒤집는 함수가 있습니다. 리스트를 배열로 생각하고, 그 자체를 뒤집어 버립니다. 반환된 값은 완전히 새로운 값입니다.

 

> (setq lst ’(a b c)) 

(A B C) 

> (bad-reverse lst) 

NIL 

> lst 

(C B A) 

 

이름만 봐도 알수있듯이 좋은 리습 프로그래밍 스타일이 아닙니다. 단순히 보기에만 나쁜게 아닙니다. 사이트 이팩트를 가지고 있기 때문에 이런 함수를 쓰게되면 함수형 프로그래밍의 이상과는 멀어지게 만듭니다.

bad-reverse는 나쁜 예를 보여주는 것이지만 한가지 배울게 있습니다. 커먼 리습에서 두 값을 서로 바꿀 때 어떻게 해야하는지를 보여줍니다. rotatef 매크로를 쓰면 갯수에 상관없이 변수들의 값을 옆 변수로 옮길 수 있습니다. 인자가 두개일때는 서로 맞바꾸는 효과가 나게됩니다.

그림 3.2에서는 다른 방식으로 리스트를 뒤집는 함수를 보여줍니다. good-reverse라는 함수는 반환값으로 뒤집어진 리스트를 반환하는데 원래의 리스트는 그대로 남게됩니다.

 

> (setq lst ’(a b c)) 

(A B C) 

> (good-reverse lst) 

(C B A) 

> lst 

(A B C)

 

 

(defun good-reverse (lst) 
  (labels ((rev (lst acc) 
         (if (null lst) 
		 acc 
		 (rev (cdr lst) (cons (car lst) acc))))) 
    (rev lst nil))) 

그림3.2: 뒤집어진 리스트를 반환하는 함수

 

사람 헤어 스타일만 보고 그 사람의 성격에 대해 선입견을 가져본 적이 있으실겁니다. 그렇게 외모로 사람을 짐작하면 맞을 때도 있고 틀릴 때도 있지만, 리습 프로그램은 겉으로만 봐도 거의 특성을 알 수 있습니다. 함수형 프로그램은 명령형 프로그램과 모양부터 다릅니다. 함수형 프로그램의 구조는 각 표현에서 함수 인자를 어떻게 만들어나가느냐에따라 결정됩니다. 함수 인자들마다 한 줄을 차지하거나 들여쓰기가 되므로, 함수형 프로그램을 보면 들여쓰기의 모양이 다양합니다. 함수형 프로그램의 코드가 유동적이라면(저자주1), 명령형 프로그램의 코드는 정적이고 고정된 형태입니다. 베이직이 그렇습니다.

(저자주1 그 특성을 잘 보여주는 예가 242페이지에 있습니다.)

대강 모양만봐도 bad-reverse와 good-reverse중에서 어떤게 더 좋은 프로그램인지 알 수 있습니다. good-reverse가 더 짧기도하고 더 효율적이기도합니다. good-reverse는 O(n)이고 bad-reverse는 O(n^2)이니까요.

리습에 reverse 함수가 있으니 굳이 새로 만드는 수고를 할 필요가 없습니다. 하지만 함수형 프로그래밍에 대해 오해를 줄 수 있는 요지가 있으므로 간단하게 보고 넘어가겠습니다. good-reverse와 같이 리습에 포함된 reverse 함수는 인자로 전달된 리스트를 수정하지 않고, 반환된 값을 이용하게돼있습니다. 리습을 배우는 사람들은 reverse가 bad-reverse와 같이 사이드 이팩트를 가지고 있을거라고 생각할 수 있습니다. lst라는 리스트가 있을 때, 이걸 뒤집고 싶다면

 

(reverse lst) 

 

이렇게 호출하게 됩니다. 그리고는 왜 lst의 값이 바뀌지 않는지 의아해합니다. 생각한 대로 lst의 값을 바꾸고 싶다면 우리가 직접 값을 바꾸도록 작성해야합니다.

 

(setq lst (reverse lst)) 

 

이렇게 말입니다. reverse와 같은 연산자들은 연산자의 반환값을 활용하도록 만들어진 것이지 사이드 이팩트를 활용할 목적으로 만들어진 것이 아닙니다. 프로그램을 만들 때 이런 방식으로 값을 바꾸도록 합시다. 어떤 특별한 장점이 있어서가 아니라 그렇게하는게 리습 언어의 철학에 맞기때문입니다.

good-reverse와 bad-reverse를 비교하면서 아직까지 생각못한게 하나 있는데, 바로 bad-reverse가 cons를 실행하지 않는다는 것입니다. (역자주: cons는 리스트를 만드는 명령입니다. 이 책은 리습 초보를 대상으로하는 책이 아닙니다. 앞으로도 새로 나오는 명령들에 대한 설명이 없을 수 있습니다.) 새로운 리스트 구조체를 만드는게 아니라 원래 있던 리스트의 데이터를 수정합니다. 그 리스트가 다른데서도 쓰이는 리스트일 수도 있으므로, 프로그램이 위험해질 수도 있습니다. 하지만 반대로 성능을 생각한다면 필요한 것일 수도 있습니다. 그래서 커먼 리습에서는 그렇게 성능을 필요로 하는 경우에 사용하도록 객체의 값을 바꾸지만 O(n) 성능을 내는 nrever라는 함수를 제공하고 있습니다.

유해함수(역자주: 이상하긴 하지만 어쨌든 부르는 이름이 필요하긴 하므로 사이드이팩트가 있는 함수를 억지로 번역해서 유해함수라고 부르겠습니다.)는 인자로 받은 객체를 수정합니다. 그런데 유해함수라해도 보통은 반환값을 활용하도록 만들어져있습니다. nreverse 함수가 인자로 받은 리스트를 재활용하는걸 알고있겠지만, 뒤바뀐 리스트도 저장할 거라고 생각했다면 틀린 것입니다.

 

(nreverse lst) 

 

위와같이 호출해놓고, lst에 뒤바뀐 리스트가 저장될거라고 생각하면 안됩니다.

 

> (setq lst ’(a b c)) 

(A B C) 

> (nreverse lst) 

(C B A) 

> lst 

(A) 

 

이렇게 만들었어야합니다.

lst를 뒤집기 위해서 reverse를 쓸때와 같이 nreverse 함수의 반환값을 lst에 저쟁했어야합니다.

어떤 함수가 유해함수라고 써있다고해서 반드시 그 함수의 결과가 사이드이팩트를 발생시키는 것은 아닙니다. 문제는 어떤 함수들은 그렇게 결과가 사이드이팩트를 발생시키는 것처럼 보인다는 것입니다.

 

(nconc x y) 

 

위의 코드는 거의 대부분 아래 코드와 같은 결과를 가집니다.

 

(setq x (nconc x y)) 

 

항상 첫번째 코드처럼 사용한다면 보통 잘 동작하는 것처럼 보일 것입니다. 하지만 x가 nil일때는 제대로 동작하지 않을 것입니다.

몇개의 리습 연산자만이 결과값을 사이드이팩트로 발생시킵니다. 보통은 내장 연산자라 하더라도 반환값을 이용하도록 돼있습니다. sort나 remove나 substute를 이름만 보고 판단하면 안됩니다. 사이드이팩트를 원한다면 반환값을 setq로 저장해야합니다.

이로서 사이드이팩트가 완전히 없을 수는 없다는걸 알 수 있습니다. 함수형 프로그래밍을 지향한다고해서 사이드이팩트를 완전히 없앤다는 것은 아닙니다. 필요이상으로 쓰지 않는다는 것을 말합니다.

사이드이팩트를 직접 쓰지않고 위에서처럼 반환값을 할당하는 방식에 적응하려면 시간이 좀 걸릴 수 있습니다. 처음에는 아래 연산자들을 쓸때마다 뭔가 더 처리해야할게 있다는걸 계속 생각하면서 시작하면 좋습니다.

그리고 명령형 프로그램에서 자주 쓰는 let* 같은 연산자들을 최대한 줄이도록 해야합니다. 이런 연산자들의 사이드이팩트를 주의해서 사용하는게 반드시 해야할 규칙이라기보다는 좋은 리습 스타일이라고 생각하시면 됩니다. 이런 습관 하나로도 많은 발전을 하실 수 있습니다.

어떤 언어들은 사이드 이팩트를 여러개의 반환값을 반환하는 함수를 위해 사용합니다. (역자주: C언어는 반환값이 한개뿐이므로 인자로 전달된 객체의 포인터를 이용해서 객체의 값을 바꾸고, 에러값만 반환하는게 절대적입니다.) 함수가 1개의 값만 반환할 수 있다면, 나머지 반환값들은 인자의 값을 수정해서 반환할 수 밖에 없습니다. 다행히 커먼 리습에서는 그럴 필요가 없습니다. 커먼 리습에서는 여러개의 값을 반환할 수 있으니까요.

내장함수 truncate는 두개의 값을 반환할 수 있습니다. 정수 부분과 나머지 부분으로요. 대부분의 리습 구현체에서는 최상위에서 truncate를 호출하면 다음처럼 두 값을 모두 출력해줍니다.

 

> (truncate 26.21875) 

26 

0.21875 

 

다음처럼 한개의 값만 사용하게되는 코드에서는 첫번째 값이 사용됩니다.

 

> (= (truncate 26.21875) 26) 

 

두개의 값을 모두 사용해야된다면 multiple-value-bind를 사용하면 됩니다. 이 연산자는 값들의 리스트와 호출부분, 함수 바디를 인자로 받습니다. 호출부분에서 반한된 값들이 리스트의 각 요소들에 저장되고, 함수바디에서 사용합니다.

 

> (multiple-value-bind (int frac) (truncate 26.21875) 

(list int frac)) 

(26 0.21875) 

 

그리고 values 연산자를 써서 여러개의 값을 반환할 수 있습니다.

 

> (defun powers (x) 

(values x (sqrt x) (expt x 2))) 

POWERS 

> (multiple-value-bind (base root square) (powers 4) 

(list base root square)) 

(4 2.0 16)

 

함수형 프로그래밍은 다른 언어들에서도 활용할만한 좋은 아이디어입니다. 특히 리습에서 잘 적용될 수 있는데, 리습이 함수형 프로그래밍에 맞게 진화되어왔기 대문입니다. reverse나 nreverse같은 내장 연산자들은 함수형 프로그래밍 기법에 맞게 사용될 수 있습니다. values나 multiple-value-bind같은 다른 연산자들은 함수형 프로그래밍을 좀더 쉽게 사용하기위해 제공되는 연산자들입니다.

 

3.2 명령형 프로그래밍의 반대를 생각해봅시다

 

명령형 프로그래밍과 비교를 해보면 함수형 프로그래밍이 뭘 지향하는지를 더 잘 보여줄 수 있을것 같습니다. 함수형 프로그래밍은 뭘 원하는지가 중점인데, 명령형 프로그래밍을 뭘 해야하는지가 중점입니다. 함수형 프로그래밍으로 “a의 값과 x의 첫번째 요소의 제곱 값을 리스트로 묶어서 반환하라"를 표현해보면 아래처럼 됩니다.

 

(defun fun (x) 
    (list ’a (expt (car x) 2))) 

 

명령형 프로그래밍으로 “x에서 첫번째 요소를 꺼내서 제곱한 후 a와 리스트로묶어서 반환해라"를 표현하면 아래처럼 됩니다.

(defun imp (x) 
  (let (y sqr) 
    (setq y (car x)) 
    (setq sqr (expt y 2)) 
    (list ’a sqr)))

 

리습 유저는 프로그래밍을 할 때 두가지 방식을 다 사용할 수 있습니다. 어떤 언어는 명령형 프로그래밍만 가능합니다. 베이직이나 대부분의 기계어가 그렇습니다. 리습 컴파일러로 재미삼아 기계어를 출력해보면 위에서만든 imp하고 모양이 비슷할 것입니다.

컴파일러가 만들수있는 코드를 굳이 직접 만들 필요가 없겠지요. 그런데 많은 프로그래머들은 거기에 의문도 가지지 않고 있습니다. 언어라는 것은 생각에 패턴을 만듭니다. 명령형 프로그래밍 언어에 익숙해진 사람은 프로그램을 명령형 방식으로만 생각하게될 것입니다. 그러다보면 함수형 프로그래밍보다 명령형 프로그래밍이 더 쉽다고 생각할 것입니다. 이렇게 명령형 프로그래밍으로만 생각하는 습관이 생기는 것은 새로운 방식의 언어를 접해서 깨뜨려야합니다.

다른 언어들에 익숙한 사람들에게 리습을 시작하는 것은 아이스 링크에 처음으로 발을 내딛는것과 같습니다. 스케이트를 신기만 하면 땅에서보다 얼음위에서 이동하는게 더 쉽습니다. 그 전까지는 얼음에서 뭘하는건가 궁금해하기만 했었지요.

얼음과 스케이트가 잘 맞는것처럼 리습도 함수형 프로그래밍에 잘 어울립니다. 둘다 잘 활용하기만하면 더 우아하고 더 쉽게 어행할 수 있습니다. 하지만 다른 방식에 너무 익숙해져있다면 처음부터 잘하기는 어렵습니다. 다른 언어를 알고있는 상태에서 리습 언어를 배우는데 넘어야할 산중에 하나가 함수형 스타일로 프로그래밍을 배우는 것입니다.

다행히 명령형 프로그램을 함수형으로 바꿀 방법이 있습니다. 처음에는 일단 명령혀으로 프로그램을 만들고나서 이 방법대로 바꾸면 됩니다. 점차 혜안이 생기면 코드를 쓰면서 동시에 함수형으로 바꿀 수 있게 될겁니다. 그리고 얼마안가서 시작부터 함수형 프로그래밍 관점에서 프로그램을 바라볼 수 있게 될겁니다. 이 방법이라는 것은 명령형 프로그램이 함수형 프로그램을 뒤집은 것이라는걸 깨닫는 것입니다. 명령형 프로그램에서 함수형 프로그램을 찾아보려면 마찬가지로 뒤집으면 됩니다. 이 방법을 imp를 만드는데 써먹어보겠습니다.

처음으로 볼 것은 첫번째 let에의해 y와 sqr가 만들어지는 것입니다. 이것만봐도 다음에 뭔가 나쁜 코드가 있을거라는 신호가 옵니다.  eval을 런타임에 쓰는게 나쁜 것처럼, 초기화가 안된 변수는 거의 쓸일이 없기 때문에, 이게 나타났다는 것은 뭔가 나쁘게 돌아가고 있다는 증거가 됩니다. 그런 변수들은 프로그램이 자연스럽게 실행되지 못하게 방해하는 방해물이 됩니다. (역자주: 이 문장은 의미가 제가 해석이 안되서 완전히 의역을 했습니다. 원문은 Such variables are often used like pins which hold the program down and keep it from coiling into its natural shape. 입니다.)

 

지금은 일단 그냥 무시하고 함수 끝을 한번 보겠습니다. 명령형 프로그램의 끝 부분에서 하는 일과 함수형 프로그램의 긑부분에서 하는 일은 같습니다. 그러므로 우리가 첫번째로 해야할 일은 리스트에 대한 호출 중에 마지막 것을 찾아내서, 프로그램의 나머지 부분들을 거기에 쑤셔넣는 일입니다. 우리가 셔츠를 뒤집을 때 처럼말이지요. 셔츠의 양쪽 팔을 뒤집어서 소매를 똑바로 꺼내는 것처럼, 뒤집는걸 반복하면 됩니다.

함수의 끝부분부터 바꿔나가라면 sqr을 (expt y 2)로 바꿔서 다음과 같이 만들면 됩니다.

 

(list ’a (expt y 2))) 

 

그다음 y를 (car x)로 바굽니다.

 

(list ’a (expt (car x) 2)) 

 

필요한건 다 집어넣었으니 이제 나머지 코드는 버릴 수 있습니다. 이 과정에서 우리는 변수 y와 sqr을 지웠습니다. 또 let도 버릴 수가 있습니다.

결과적으로 처음보다 훨씬 짧아졌습니다. 이해하기도 더 쉬워졌습니다. 원래 코드에서는 (list ‘a sqr) 문장을 봐도 sqr이 어디서 어떻게 만들어진 변수인지 바로 알 수가 없었습니다. 지금은 지도를 보는 것처럼 어떤 변수가 어디에서 왔는지를 알 수 있습니다.

여기서 본 예제는 짧은 것이지만, 거기에 들어간 테크닉은 얼마든지 확장할 수 있습니다. 더 큰 함수에 적용될 수록 더 좋겠지요. 사이드 이팩트가 있는 함수라고 할지라도, 사이드이팩트가 없는 부분을 개선하는데 적용할 수 있을 것입니다.

 

3.3 함수형 인터페이스가 뭘까

 

어떤 사이드이팩트는 다른 사이드이팩트보다 더 나쁘거나 덜 나쁠 수 있습니다. nconc를 호출하는 이 함수를 보세요.

 

(defun qualify (expr) 
    (nconc (copy-list expr) (list ’maybe))) 

 

비록 nconc를 호출하지만 참조 투명성(역자주: 같은 인자를 주면 항상 같은 값을 반환하는 함수를 참조 투명성이 있다고 말합니다.)을 가지고 있습니다. (저자주2)(역자주: (nconc x y)는 x와 y를 연결한 리스트를 반환하는데 x의 값이 바뀝니다. 그런데 qualify처럼 만들면 expr의 값이 바뀌지 않고도 연결된 리스트를 반환할 수 있습니다.) 이 함수를 호출할 때 같은 인자를 전달하면 항상 같은 값을 반환할 것입니다. 호출하는 입장에서 보면 qualify함수는 순수한 함수형 코드입니다. 분명히 bad-reverse처럼 인자로 넘어온 객체를 수정하지않으니까요.

모든 사이드이팩트를 다 똑같이 나쁘다라고 하지말고, 경우에 따라 나눌 수 있는 방법이 있다면 좋을것 같습니다. 나름대로 표현해보면 누구의 것도 아닌 것을 수정하는 함수는 별로 나쁠게 없다고 봐도 좋을것 같습니다. 그렇게 따지면 qualify에서 사용하는 nconc는 해가 없는 것이지요. nconc가 첫번째 인자로 받은 리스트는 새롭게 만들어진 리스트였으니까요. 그것은 누구의 것도 아니었습니다.

보통 어떤 객체가 어디에 속해있냐를 따질때, 어느 함수 안에 있냐를 보고 판단할게 아니라, 함수가 호출되는 상황에서 판단해야합니다. 아래를 보면 x라는 변수는 어디에도 속하지 않습니다.

 

(let ((x 0)) 
  (defun total (y) 
    (incf x y)))

 

그렇지만 한번 함수가 호출되면 다음 함수가 호출될때마다 이전 함수의 결과가 영향을 줍니다. (역자주: x의 값이 계속 바뀌니까 다음 함수 호출때 이전 함수가 바꾼 x의 값에 영향을 받겠지요. 그게 바로 2장에서배운 클로저의 내용입니다. total 함수에 같은 인자를 전달한다고 해서 같은 반환 값을 받을 수 없습니다. 따라서 total은 순수 함수형 코드가 아닙니다.) 결국 (역자주: 함수형 코드가 되기 위해서는) 항상 함수가 호출될 때 자기가 독점하는 객체만 수정하도록 해야한다는 법칙이 있음을 알 수 있습니다. (역자주: x는 total함수가 소유하는 객체가 아닙니다. 하지만 total함수가 x의 값을 바꿉니다. 즉 여기에서 말하는 법칙을 어기고 있고, 그래서 함수형 코드가 아닙니다.)

그럼 함수 인자와 반환값은 누구의 소유일까요? 리습에서는 함수를 호출하는 측이 함수에서 반환되는 객체를 소유하는걸로 규정했습니다. 자기 자신이 호출될 때 인자로 전달받은 객체는 자기 소유가 아닙니다.인자로 받은 객체를 수정하는 함수를 “유해함수"로 분류하는데반해, 반환값을 받아서 수정하는 함수에 대해서는 따로 부르는 이름이 없습니다. (역자주: 반환받은 객체는 자기 소유이므로 수정하든 말든 상관없으니 따로 부를 필요가 없습니다. 인자로 받은 객체는 자기 소유가 아니므로 수정하면 안되니, 수정을 하는지 안하는지에 따라 이름을 붙여서 구분합니다.)(역자주: A함수에서 B함수를 호출하면서 어떤 객체를 인자로 넘겼습니다. 그런데 B함수가 끝나고보니 객체가 수정되었습니다. 객체의 소유는 A입니다. B는 자기 소유가 아닌 객체를 수정한 것입니다.)

아래 함수가 그런 규정을 보여주는 함수입니다.

(defun ok (x) 
    (nconc (list ’a x) (list ’c))) 

 

nconc가 호출되긴하지만 nconc가 수정하는 리스트는 새롭게 생성된 리스트이지  ok함수의 인자로 전달된 리스트가 아닙니다. 따라서 ok함수는 이름 그대로 ok입니다. (역자주: x는 ok 함수의 인자이므로 ok 함수의 것이 아닙니다. ok안에서 x를 수정하면 안되고, 안됐습니다. nconc함수에 전달되는 인자는 (list ‘a x) 문장으로 새로 생성된 리스트이고, 그 소유주는 ok입니다. nconc는 (list ‘a x)로 생성된 새로운 리스트를 받아서 수정했습니다. nconc는 자기것이 아닌 객체를 수정했고, 함수형 코드가 아닙니다. 하지만 괜찮습니다. ok함수를 호출하는 입장에서 ok함수는 함수형 코드이므로, 아래에서 말하는 지역성을 만족합니다. 따라서 ok함수를 ok하다고 하는 것입니다.)

 

(defun not-ok (x) 
    (nconc (list ’a) x (list ’c))) 

(저자주2 A definition of referential transparency appears on page 198.)

 

위처럼 조금만 다르게 만들어도 nconc 호출이 not-ok에게 전달된 인자를 수정하게됩니다. 많은 리습 프로그램들이 규정을 위반합니다. 작은 코드안에서만 위반할 때도 있고, 그 이상일 때도 있습니다. 하지만 어떤 함수가 내부에서만 규정을 위반한다면 그 함수을 호출하는게 반드시 유해해지는건 아닙니다. (역자주: 의역을 했습니다.) 어떤 함수가 위의 조건들을 만족한다면 순수 함수형 코드가 가지는 장점들중 많은 것들을 가지게됩니다. (역자주: 함수형 코드가 아니지만 지역성을 가지고있으므로 함수형 코드의 장점을 가진다는 것입니다.)

겉으로봐서 함수형 코드와 분간이 안되는 프로그램을 만들려면 한가지 더 지켜야할 조건이 있습니다. 함수형 코드를 위한 규칙을 위반하는 다른 코드와 객체를 공유하면 안된다는 것입니다. 아래의 함수는 사이드이팩트가 없습니다. (역자주: 인자로받은 객체를 수정하지 않으니까요.)

(defun anything (x) 
    (+ x *anything*)) 

 

위 함수의 반환값은 전역변수 *anything*에 따라 달라집니다. 그러니 다른 함수가 *anything*의 값을 바꾼다면 anything이라는 함수는 뭘 반환할지 모르게됩니다. 어떤 함수가 호출될때마다 자기가 소유한 것만을 수정하도록 만들면, 거의 순수한 함수형 코드가 가지는 장점들을 다 가지게됩니다. 위에서 말한 조건들을 다 만족하는 함수가 함수형 인터페이스를 제공하기까지하면, 같은 인자를 전달하면 항상 같은 결과값을 얻을 수 있게 됩니다. 이것이 다음장에서 설명한 상향식 프로그래밍에 중요한 조건이 됩니다.

유해한 연산의 문제는 바로 프로그램의 지역성을 해친다는 것입니다. 전역변수를 쓰는 것이나 마찬가지입니다. 함수형 코드를 작성하면 신경써야하는 범위가 최소화할 수 있게됩니다. 내 함수에서 어떤 함수를 호출하는지, 내 함수가 어디에서 호출되는지만 신경쓰면 됩니다. 뭔가를 유해한 방법으로 수정하고 싶다면 이런 장점을 얻을 수 없게됩니다. 그게 어딘가 다른 곳에서도 사용될 것이기 때문입니다.

위에서 이야기한 조건들이 순수 함수형 코드에서 얻을 수 있는 완벽한 지역성을 보장해주지는 않습니다. 하지만 거기에 가까워질 수는 있습니다. 다음은 f가 g를 호출하는 예제입니다.

 

(defun f (x) 
    (let ((val (g x))) 
    ; 여기서 val을 수정해도 안전할까요?
)) 

 

nconc를 써서 val이라는 변수에 뭔가를 추가해도 f함수는 안전할까요? g가 만약 x를 그대로 반환한다면 안전하지 않게됩니다. f에 전달된 인자를 수정하게되기 때문입니다.

규정을 지키고 있는 코드에서 f함수를 사용한다고해도, f함수 안에서 뭔가를 수정하려고한다면 단순히 f 만을 고려해서는 안됩니다. 어쨌든 아직은 전체 프로그램을 생각할 필요는 없습니다. 지금 당장은 f에서 시작하는 하위 트리만을 생각하겠습니다.

지금까지 설명한 규정은 함수가 반환하는 것들이 수정해도 안전한 것들이었기 때문에 가능한 것이었습니다. 그래서 quote로 만들어진 객체가 포함된 반환값을 가지는 함수는 만들어서는 안됩니다. 반환값에 quote로 만들어진 리스트가 포함된 exclaim함수를 만들어보겠습니다.

 
(defun exclaim (expression) 
    (append expression ’(oh my))) 

 

함수 호출 다음에 반환값을 수정하게되면
 
> (exclaim ’(lions and tigers and bears)) 
(LIONS AND TIGERS AND BEARS OH MY) 
 
> (nconc * ’(goodness)) 
(LIONS AND TIGERS AND BEARS OH MY GOODNESS) 
 
함수 내부의 리스트까지 수정하게됩니다.
 
(역자주: 다음 예제를 보세요. nconc는 내부적으로 첫번째 인자의 뒷부분에 두번째 인자를 연결합니다. (append x y)에서 y의 뒤에 (goodness)를 연결해버립니다. 그래서 x값은 바뀌지않아도 y값은 바뀌게 됩니다.)
CL-USER> (setq x '(1 2 3))
CL-USER> (setq y '(a b c))
CL-USER> (append x y)
(1 2 3 A B C)
CL-USER> x
(1 2 3)
CL-USER> y
(A B C)
CL-USER> (nconc (append x y) '(goodness))
(1 2 3 A B C GOODNESS)
CL-USER> x
(1 2 3)
CL-USER> y
(A B C GOODNESS)

 

 
 
> (exclaim ’(fixnums and bignums and floats)) 
(FIXNUMS AND BIGNUMS AND FLOATS OH MY GOODNESS) 
 
exclaim에서 이런 문제가 없으려면 다음처럼 만들어야합니다.
 
(defun exclaim (expression) 
    (append expression (list ’oh ’my))) 
 
quote로 만든 리스트를 반환하지 않아야한다는 규정에서 한가지 중요한 예외가 있습니다. macro를 확장해주는 함수들이 있는데, 이 함수의 반환값들에는 quote로만든 리스트가 들어가도 괸찬습니다. 이 반환값들은 곧바로 컴파일러로 들어가기 때문입니다.
 
그 외에 quote로 만든 리스트들은 의심해봐야할 것들입니다. 그런것들 대다수는 (원서 152페이지)에 나온것 처럼 매크로를 써서 해야할 것을 함수로 잘못 처리하는 경우입니다.
 
 

3.4 인터랙티브 프로그래밍

 
위에서는 함수형 스타일이 좋은 프로그램 설계 방법이라는걸 이야기했습니다. 사실은 더 많은 장점이 있습니다. 리습 프로그래머들이 단지 코드가 예쁘니까 함수형 스타일을 도입한게 아니겠지요. 그게 개발을 더 쉽게 해주기 때문에 사용하는 것입니다. 리습의 동적 개발 환경을 이용하면 함수형 프로그램은 놀라운 속도로 개발될 수 있고, 또 놀라울 정도로 안정적일 수 있습니다.
리습에서는 디버깅도 더 쉬워집니다. 동적 환경에서도 많은 정보를 확인할 수 있기 때문에 에러의 원인을 찾기 쉬워집니다. 게다가 더 중요한건 프로그램을 테스트하기가 더 쉬워진다는 것입니다. 테스트를 위해서 전체를 다 컴파일하고 한꺼번에 테스트를 실행할 필요가 없습니다. 최상위 루프에서 함수를 하나씩 호출해가면서 개별적으로 테스트할 수 있습니다.
작은 단위부터 큰 단위로 테스트할 수 있다는 것은(역자주:증분테스트, 점증적 테스트라고합니다. 자주 나오는 말이 아니라서 굳이 어려운 용어를 쓰지않고 간단하게 풀어서 번역했습니다.) 너무나 중요한 것이라서, 리습이 거기에 맞춰서 진화해왔다고 봐도 될정도입니다. 프로그램이 함수형 스타일로 작성되었다면 함수 하나만 따로 봐도 그 함수를 이해할 수 있어야 합니다. 또 그렇게 코드를 읽는 사람이 함수 하나씩도 이해할 수 있다는 것이 함수형 스타일의 가장 큰 장점입니다. 그런데 함수형 스타일은 단위를 증가시켜가며 테스트하는 경우에도 잘 들어맞습니다.  한번에 하나의 함수만을 테스트할 수 있기 때문입니다. 하나의 함수가 외부 상태를 참조하거나 바꾸지 않으므로, 어떤 버그라도 빨리 발견됩니다. 그런 함수는 반환값으로만 바깥 세상에 영향을 줍니다. 내가 원하는 반환값이 나오기만 한다면, 그런 반환값을 만드는 코드는 신뢰할 수 있습니다.
경험많은 리습 프로그래머라면 프로그램을 테스트하기 쉽게 설계합니다.
 
1. 그들은 몇몇 함수만 사이드 이팩트를 가지고 나머지 대부분의 프로그램은 순수한 함수형 스타일로 구현하려고 합니다.
2. 어떤 함수가 꼭 사이드 이팩트를 가져야한다면, 최소한 함수형 인터페이스를 가지도록 만듭니다. (역자주:사이드 이팩트를 한 지역안으로만 한정하도록 만드는걸 3.3장에서 다뤘습니다.)
3. 각 함수가 하나의 명확한 목적만을 가지도록 만듭니다. 함수를 구현할 때 꼭 필요한 상황들만 테스트를 하고, 다음 함수를 만들기 시작합니다. 작은 블럭들이 하고자하는 바가 명확해지면, 만들고자하는 건물은 쉽게 완성됩니다.
 
리습을 쓰면 건물의 디자인도 더 좋아집니다. 소리의 전송 시간이 일분이나되는 아주 먼거리에 있는 사람과 대화를 하게되었다고 상상해봅시다. 그리고 바로 옆방에 있는 사람과 대화하는 것을 상상해봅시다. 그냥 같은 대화가 좀더 빨리 이뤄질뿐 아니라 대화하는 내용도 달라질수 있습니다. 리습을 써서 소프트웨어를 만든다는 것은 얼굴을 보고 대화하는 것과 같습니다. 코드를 쓰는 동시에 테스트도 할 수 있습니다. 얼굴을 보고 대화하면 대화가 달라지는 것처럼 개발을 하면서도 바로바로 응답을 받을 수 있으므로 엄청난 변화가 일어날 것입니다. 단순히 같은 프로그램을 더 빨리 만들 수 있는것만이 아닙니다. 만들 수 있는 프로그램이 달라질 것입니다.
테스트가 빠르다면 좀더 자주 테스트를 할 수 있습니다. 리습에서도 다른 언어와같이 코드 작성과 테스트를하는 개발 사이클이 있습니다. 그런데 리습에서는 그 사이클이 매주 짧아집니다. 하나의 함수를 개발 및 테스트할 수도 있고,  함수의 부분별로도 할 수 있습니다. 만약 코드를 작성하면서, 작성된 모든 코드를 테스트한다면 에러가 발생했을 때 어디를 봐야할지도 바로 알 수 있습니다. 바로 마지막에 만들었던 코드겠지요. 말로는 간단하지만, 이런 원칙이 상향식 프로그래밍을 가능하게 만든 중요한 바탕입니다. 또 그렇게 테스트가 잘 된다면 프로그램에 신뢰성이 높아질 것이고, 옛날의 기획하고 그대로 개발하던 시대에 비해 조금이라도 더 쉴시간을 가질 수 있게 됩니다.
1.1장에서 상향식 설계는 발전적인 개발 프로세스라고 강조했습니다. 프로그램을 만들다보면 그 프로그램을 돌아가게하는 하나의 언어가 만들어집니다. 하위 레벨 코드를 신뢰할 수 있어야만 이런 방식의 개발을 할 수 있습니다. 특정 레이어를 언어로 활용하고 싶다면 어떤 버그가 생겼을 때 그 버그가 언어의 버그가 아니라 그 언어로 만든 프로그램의 버그라는 것을 확신할 수 있어야 합니다. 어떤 언어든지 마찬가지입니다. 
어떤 새로운 추상화 레이어를 만들려면 그 하위 레이어는 이렇게 막중한 책임을 감당해야합니다. 그런 하위 레이어가 있다고해도, 그걸 새로운 요구사항이 생길때마다 새롭게 바꿔나갈 수 있을까요? 리습을 쓰면 둘다를 만족시킬 수 있습니다. 함수형으로 프로그램을 만들고 점증적으로 테스트할때, 갑작스러운 변화에 대응할 유연성을 가질 수 있고, 신뢰성 또한 더할 수 있게 됩니다. 그런 신리성은 보통 주의깊게 계획해야만 얻을 수 있지만, 우리는 자연스럽게 얻어집니다.
 

댓글

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