4. 아름다운 언어 Haskell 프로그래밍 (2)

 
김재우 kizoo@bluette.com
블루엣 인터내셔널의 기술 이사로 재직중이며 개발 환경과 온라인 교육 시스템을 결합한 소프트웨어를 설계하고 있다. 소프트웨어 공학 기술이나 관련 이론을 실천하도록 만드는 것이 개발자로서의 목표. 현재 정보기술원과 함께 분야별 표준 교육 과정. 전문 개발자 양성 및 인증을 위한 교육 시스템‘Theory Into Practice’를 설계하고 있다.
 
Haskell만의 독특하고 아름다운 기능을 소개한다. Haskell을 통해 그 동안 프로그래밍 분야에서 어떤 노력이 있었고, 그 결과 어떤 성과를 이뤘는지 살펴볼 수 있는 좋은 계기가 될 것이다.
 
지난 호에 이어 이번에도 Haskell의 뛰어난 표현력을 살펴볼 것이다. 물론 지금까지 소개한 기능만으로도 Haskell이 시중의 다른 언어와 어떻게 다른지 충분히 알 수 있다. 하지만 비슷한 표현 수단을 공유하는 언어(Miranda, ML, 파이썬, Clean 등)도 많기 때문에 Haskell만의 고유한 특징을 보여줬다고 할 수 없다. 그래서 이번에는 Haskell이 Haskell이라 불리는 이유에 해당하는 기능을 소재로 삼아 나머지 얘기를 이어가고자 한다.
이번에 다룰 Haskell 기능 중 형 클래스(Type class)와 모나드(Monad), 이 두 기법은 아주 독창성이 뛰어난 성과물이다. 그저 한 언어의 고유한 기능이라기보다 그동안 축적된 프로그래밍 분야의 연구 성과를 알차게 반영한 결과라 할 수 있다. 따라서 다른 프로그래밍 방식에 능통한 사람이라 해도 눈여겨봐야 할 만한 가치가 충분하다.
특히 객체지향성의 거품(?) 속을 헤매던 사람이라면 형 클래스 기능을 공부하면서 ‘클래스(Class)’란 단어의 참뜻을 새롭게 할 계기가 될 것이고, 형의 본질에 다가가는 기능을 맛볼 수 있을 것이다. 서브타이핑의 효과를 맹신하고 있었던 프로그래머라면 ‘덮어쓰기(overriding)’로 동적 결합을 허용하는 방식만이 점진 비파괴 확장(Incremental nondestructive extension)의 유일한 답이 아니라는 점도 깨닫게 될 것이다.
또한 모나드는 기존 기계식 프로그래밍(Imperative Programming)을 다른 시각에서 다른 가치로 새롭게 비추어 볼 수 있도록 시야를 넓혀 줄 것이고, ‘시간에 따른 상태 변화(State-oriented history-sensitive computation)’를 계산에 이용하는 것이 유리한 경우라 할지라도(폰 노이만 식 하드웨어 구조로부터 내려받은 배정문(Assignment)이 없어도), 순수함을 지켜가며 상태 변화를 표현하는 새로운 기법을 경험하게 될 것이다.
 

Arity로부터의 자유, 커링

 
지금까지 Haskell 코드를 세심하게 살펴본 독자라면 함수를 정의하는 문법이 좀 유별나다는 점을 눈치 챘을 것이다.예를 들어 다른 언어라면 f(x, y, z)라고 쓸 것을 Haskell에서는 다음과 같은 문법을 쓴다.
 
f x y z = x + y + z
 
함수 이름과 인자를 구분하기 위해 묶어두는 괄호도 없고, 인자 사이를 구분하는 쉼표도 없다. 언뜻 별 차이도 없어 보이는데, 왜 익숙한 문법을 놔두고 이런 표현을 강요하는 것일까? 물론 그 까닭을 모른다고 해도 코드를 쓰는 데는 아무런 지장이 없지만, 그 뒤에 숨은 깊은 뜻과 이론의 배경을 알아두면 프로그래밍이 한층 즐겁고, 문법의 유연함에서 오는 표현의 자유를 만끽할 수 있다.
Haskell 문법에서는 맨 앞에 나오는 이름이 저절로 함수의 이름이 되고, 뒤에 오는 것을 인자의 이름으로 취급한다. 그리고 각 인자는공백문자로구분한다. 이때 우리는 보통 함수 f가 세 개의 인자 x y z를 모조리 건네주는 것으로 해석하지만, Haskell에서는 함수를 그렇게 다루지 않는다. Haskell의 모든 함수는 인자를 하나만 받아들이는 단항 연산이다. 함수 f는 그저 x 하나만 받아들이는 함수이며, yz는 차례로 그 결과 값(함수)에 적용된다. 즉 앞에서 함수 f의 정의는 다음과 같은 람다(Lambda) 식의 약식 표현이다.
 
f = (\x -> (y -> (\z -> x + y + z)))
 
자, 그러면 식 f x의 값은 무엇일까? 앞의 람다식에서 인자를 오른 편으로 하나씩 넘겨보자.
 
f x = (\y -> (\z -> x + y + z)) (f x) y = (\z -> x + y + z) ((f x) y) z = x + y + z
 
마지막 식에서 번거로운 괄호를 없애버리면 맨 처음 보았던 문법이 나온다. 이때 주의 깊게 봐야 할 점은 (f x)((f x) y)의값이 다시 단항 연산 함수가 된다는 것이다. 즉 마지막 식을 풀어 읽어보면 다음과 같다.
 
“함수 fx를 건네준 결과는 y라는 인자 하나를 받아들이는 함수 값이 되고, 다시 이 함수에 y를 적용한 결과는 z라는 하나의 인자를 받아 x y z 값을 모두 합하는 함수가 된다.”
 
이와 같이 어떤 연산에서 n개의 인자를 한번에 받아야 할 경우, 이를 n개의 단항 연산을 이어붙이는 것으로 대체하는 기법을 커링(Currying)이라고 한다(커링이란 이름은 수학자 Haskell B. Curry의 성을 따라 붙인 것이다. Haskell 이름을 빌려준 사람이기도 하다. Alonzo Church 등과 함께 이 분야의 기초가 되는 이론 연구에 지대한 공헌을 한 학자다).
괄호나 쉼표가 없어져서 문법이 더 깔끔해진다는 장점도 있겠지만,그 느낌은 사람마다 다를 것이고 진짜 효과는 표현의 자유에 있다. 일단 모든 함수를 단항 함수로 취급하기 때문에 언어 내부에서 함수 값을 일관되게 다룰 수 있다. Haskell에서 모든 값이 함수 표현이라는 점을 고려한다면 커링 방식을 도입하면서 얻게 되는 단순함의 이점은 무시할 수 없을 만큼 크다. 세상에 어떤 언어가 모든 값을 하나의 꼴로 취급할 수 있는가.
특히 커링은 고차함수(함수를 주고받을 수 있는 함수) 표현과 잘 어울린다. 커링이 함수 문법에 결합되면 불필요한 코드를 줄일 수 있고, 엉성한 문법의 속박을 제거해 버릴 수 있다. 하지만 이 글의 성격상 이론의 의의와 장점을 논하지는 않겠다.
 
cadd x y = x
 
앞의 함수를 만일 커링으로 표현하지 않는다면 다음과 같다.
 
sadd(x, y) = x
 
saddcadd와 비교해서 불필요한 제약(반드시 두 값을 동시에 줘야 한다)을 포함하는 함수다. 만일 우리가 5를 더하는 함수가 필요하다면 sadd를 둘러싸는 또 하나의 함수를 만들어야 한다.
 
add5(x) = sadd(5, x)
 
그러나 cadd와 같은 커링 표현에서는 이미 분할된 여러 함수를 한 번에 정의한 것과 같은 효과를 얻을 수 있다.
cadd5 x= (cadd 5)x
양편의 x는 불필요하다. 그저 첫번째 값에다 5를 넣는 것으로 충분하다.
 
cadd5 = cadd 5
 
별도로 함수 이름을 만들어야 할 필요가 없을 때, 람다식을 굳이 쓰지 않아도 좋다(물론 간편한 표기법에 지나지 않지만).
 
map (cadd 5) [2, 3, 4, 5]
 
Haskell은 이와 같은 커링의 장점을 살려 고차함수를 여러 가지로 쉽게 만들어낼 수 있는 표현법을 제공한다. 다음은 섹션(section)이라 부르는 재미있는 문법이다.
 
(+) = \x y -> x + y (x+) = \y -> x + y (+y) = \x -> x + y
 
또한, 연산 기호를 새로 만들어 쓸 수 있기 때문에(연산자 + 등도 특수한 기호가 아니라, Prelude에 정의된 함수 이름이다. Haskell에는 별도로 달리 취급해야 할 기능이 거의 없다) 다음과 같은 표현이 가능하다.
 
-- 함수를 이항 연산처럼 중간에 놓는다. -- 어떤 함수나 작은따옴표로 싸주기만 하면 된다. merge :: Ord a => [a] -> [a] -> [(a,a)] xs‘merge’ ys = [(max x y, min x y) | x <- xs,y <- ys]
-- 새로운 기호를 연산자로 곧바로 정의해서 쓸 수 있다 (=!) :: Eq a => a -> [a] -> [a] x =! ys = [y | y <- ys, x /= y]
 
만일 새로 만든 연산자의 결합 방향이나 우선순위를 세부 조정하고 싶다면 fixty 선언을 사용한다. 별다른 조정이 없다면 함수는 가장 높은 순위(10)로 결합한다.
 
-- 우선순위가 5이고 오른편 결합 규칙을 사용한다는 선언. -- 왼편 결합에는 infixl infixr 5 merge, (=!)
 
 
Haskell에는 이와 비슷하게 여러 가지 방법으로 불필요한 문법의 속박으로부터 해방시켜 놓았다. 간결한 표현을 좋아하기 때문에 파스칼과 같이 구문 분석에 최적화된 문법에 젖어있던 프로그래머는 Haskell 코드가 암호처럼 보일지도 모른다.
이런 문법의 유연성은 함수를 제대로 지원하는 언어라면 당연한 것일 수도 있다. 그리고 코드를 편하게 쓰도록 만들어 주는 작은 배려일 뿐이라고 취급해도 크게 틀린 말은 아니다. 하지만 유행하는 언어는 말할 필요도 없고, 비슷한 기법을 지원하는 다른 어떤 언어도 Haskell만큼 표현의 자유(?)를 허락하지는 않는다. 그리고 Haskell을 한참 쓰다가 다른 언어를 써보면 그 갑갑함이 단순히 문법의 차이에서 오는 것이 아니라는 것을 실감할 수 있다. 고차함수, 형 클래스에 의한 오버로딩과 이런 저런 조그만 문법의 자유가 결합됐기에 Haskell은 메타 프로그래밍 언어의 특성을 띄게 된다. 이런 의미와 문법의 자유로움 때문에 Haskell은 특정 응용 분야를 위한 더 작은 언어를 정의하는 호스트언어로도 많이 활용되고 있다.
 

혁신은 언제나 기본으로부터

혁신은 흐르는 강물의 표면에서는 찾아볼 수 없다. 언제나 밑바닥에서 나온다. 한 분야의 획을 긋는 이론과 기술은 우리가 중고등학교 시절에 가볍게 다루었던 기초 중의 기초, 그래서 우리가 익숙해서 당연하다고 고정해 버린 어떤 틀을 깨면서 시작된다. Haskell의 두 가지 두드러지는 기법, 형 클래스와 모나드는 바로 그런 혁신의 산물이다.
 

형 클래스, 재사용을 위한 또 다른 생각

Haskell은 함수식 다형성(Parametric Polymorphism) 말고도 또 한 가지 멋진 다형성 기능을 제공한다. 우리가 오버로딩(overloading)이라 부르는 기능으로, 원래는 그저 ‘같은 이름’으로 여러 개의 프로시저를 정의하는 단순한 기법에 불과했다. 다형 표현을 지원하는 기능 중에서 가장 완성도가 떨어지고 방만하다고 할 수 있다. 그러나 Haskell에서의 오버로딩은 자바나 C++ 등에서 보 던것과는 크게 다르다.
다른 언어에서의 오버로딩은 프로그래머가 절제해 사용하기를 기대할 수 밖에 없는 주먹구구식(?) 기능이다. 어떤 두 프로시저가 같은 함수 이름을 쓰도록 만들고 싶을 때, ‘왜 그래도 되는가?’를 제약하는 조건이나 길잡이가 정해져 있지 않다.
먼저 C++를 예로 살펴보자. 잘 알다시피 C++에서는 +라는 연산자를 오버로딩할 수 있다. 특히 + 와 같은 기호에 오버로딩을 적용하는 기능을 ‘오퍼레이터 오버로딩’이라고 구분해서 부른다. C++ 같은 언어에서는 +와 같은 기호가 예약된 문법이고, 이를 여러 가지 뜻으로 달리 해석할 수 있게 만드는 기능인 만큼 잘못 사용했을 때의 대가가 만만치 않을 수 있다(오퍼레이터 오버로딩에서는 그나마 받아들일 수 있는 인자의 개수(Arity)를 제한해 두었다).
연산자 +는 고전 수학에서부터 수치 연산의 덧셈을 뜻하는 것으로 사용된 특수 기호다. 그러므로 만일 전혀 다른 기능, 즉 의미상의 공통점을 발견할 수 없는 프로시저에 +란 이름을 준다면 전체 언어에서‘문법과 의미’의 연계 고리가 부서져 버릴 가능성이 있다. 예를 들어 +의 교환법칙과 같은 것들이 여기에 해당한다. 물론 이런 극단의 경우는 잘 일어나지 않는다. 성숙한 프로그래머는 연산자의 관례나 전통을 쉽사리 무시해 버리지 못할 것이고, 자기 혼자서 쓰는 경우가 아니라면 그 응용 분야의 사용자들이 거부감을 불러일으키지 않도록 노력할 것이기 때문이다. 그러나 문제는 처음부터 무방비의 자유로움을 허락해도 괜찮을까 하는 데 있다. 오용을 피하기 위해서 섬세하게 신경을 쓴다고 해도 어떤 문법 요소에 부여하는 의미는 다분히 주관에 의존하며 응용분야 마다 다른 취향으로 사용할 수 있다. 그래서 오버로딩을 체계있게 쓰느냐 마느냐는 그 언어를 사용하는 집단의 경험 ∙ 관례 ∙ 표준화 작업에 달려있지, 검증된 이치를 바탕으로 체계있게 사용할 수 있게 만드는 무엇인가가 없다(오버로딩을 ‘Ad Hoc Polymorphism’이라고 부르는 이유가 이 때문이다).
 

오버로딩의 장단점

먼저 오버로딩의 장단점을 다른 기법과 비교해서 알아보자. 사실 다형성이란 접근 방식이 달라도 다형 연산을 쓰는 사람 입장에서 보면 한 가지 효과(동일한 의미를 공유하는 연산을 같은 문법으로 쓸 수 있다)를 노리고 쓰는 기능이다.
 
add :: Num a => a -> a -> a -- Parametric polymorphism add x y = ...
class Thing { public static Number add(Number a, Number b) { ... } } // Subtyping
int add(int x, int y); float add(float x, float y); double add(double x, double y); ...
 
앞의 세 가지 다른 기법은 목적을 달성하는 방법만 다를 뿐이다. 즉 개발하는 입장에서 많은 차이가 있을 뿐이지, 쓰는 사람은 연산자 add가 어떤 식으로든 여러 형의 데이터를 받아들일 수만 있으면 그만이다. 그러나 오버로딩의 경우라면 다음과 같은 불청객이 끼어들면서부터 다른 기법과 달리 체계 자체가 아예 깨지기 시작한다.
 
boolean add(int x, int[] a); // 정수 x를 배열 a에 집어넣는다(?).
 
이와 같은 연산을 여러 개발자가 각자의 취향에 맞게 배열에, 스택에, 리스트에, 심지어 네트워크 채널에 마구 적용하기 시작한다면 도대체 새로운 연산자 이름 add가 그 언어 표현의 세계에서 어떤 원칙으로 사용하는 지를 제어할 수 없다. 단순히 사용하는 사람도 문법과 의미의 결합이 깨지는 데서 오는 혼란의 대가를 피할 수 없다.
오버로딩의 단점은 또 있다. 새로운 타입 시그너처가 필요할 때마다 프로그래머는 같은 코드를 중복해서 써야만 한다.
 
complex add(complex a, complex b);
 
연산자의 이름을 달리 쓰지 않아도 되기 때문에 네임스페이스 오염을 막을 수 있다는 점만 제외하면, 프로그래머에게 그다지 매력 없는 기능이 오버로딩이다(다형성의 해결책이 오로지 오버로딩 밖에 없었다면 코딩의 부담과 바이너리의 팽창은 피할 수 없는 숙명이었을 것이다). 그런데 이런 저런 불평에도 불구하고 왜 이 같은 주먹구구식 기능을 많은 언어에서 기본으로 제공하고 있는 것일까? 심지어 Haskell 같이 의미 체계를 중요시하는 언어에서 조차 오버로딩을 지원한다.
물론 오버로딩은 꼭 필요한 기능이 아니다. 그러나 다른 다형성 기법으로 해결할 수 없는 문제, 즉 문법의 제약(인자의 개수와 형을 달리하면서)을 벗어나서 같은 목적의 여러 가지 인터페이스를 제공해야 할 때 무척 편리한 표현 수단이다. 예를 들어 다음과 같다.
 
drawLine(Color rgb, LineStyle lstyle); //선을 그리는데 색과 굵기를 모두 조정하고 싶다면 drawLine(Color rgb); // 색만 지정하고 굵기는 기본으로 정한 값을 쓴다. drawLine(LineStyle lstyle); ...
 
또 어떤 경우에는 언어의 한계로 인해 오버로딩 말고는 다형 연산을 만들 방법이 없을 때가 있다. 자바의 경우를 예로 들어 보자. 자바에서는 값 객체와 참조 객체를 동일한 형 체계 안에 묶어 둘 방법이 없으므로 이 두 형 체계를 효과 있게 엮어서 다형 연산을 구현하려면 오버로딩이 유일한 해결책이다.
 
int add(int x, int y); Number add(Number x, Number y);
 
이 기법은 상당히 번거로울 뿐만 아니라 명백한 한계가 있어 근본 해결책이라 할 수는 없다(더 좋은 해결책은 아예 이런 시도를 하지 않는 것이다).
 

서브타이핑과 함수식 다형성

오버로딩에 비해 서브타이핑은 상당히 체계를 갖추게 된 기법이다. 1990년대에 들어 형 검사 기능과 메시지 패싱은 비교적 멋지게 결합했다.형 검사를 미리 하지 않는 언어에서 메시지 패싱은 클래스 네임스페이스를 넘나드는 객체 수준의 오버로딩과 별로 다를 게 없었지만, 형 검사와 합쳐지면서 대수 체계를 흉내내게 된다.즉, 바로 앞의 예에서`Number`는 x란 자리에 허용할 수 있는 객체의 기능상 한계를 명시하고 있다. ‘형이 하나의 대수 체계를 이루는 연산의 집합’이라는 점을 되새기면, 이는 분명히 어떤 객체가 만족해야 할 최소 구비 요건을 표현하는 문법과 의미의 제약이다. 물론 프로그래밍 언어만으로는 완전한 의미의 제약을 가하는 것이 불가능하지만, 객체지향 언어에서 서브타이핑이 객체 간의 행위 관계(Behavioral Relationship)를 규정짓는 역할을 하고 있다는 사실은 명백하다(말하자면 서브타이핑은 유연성과 안전성의 적절한 결합으로 비교적 최근에 완성된 객체지향 프로그래밍 분야의 가장 큰 성과라 할 수 있다).
또한 함수식 다형성은 어떻게 발전해 왔을까? 형을 함수처럼 인자로 받아들이는 방법은 아주 튼튼하며 어떤 기법보다도 먼저 이론을 통해 타당성이 검증되어왔다는 점은 여러 차례 밝힌 바 있다. 다시 말해 함수식의 다형성은‘가능한 많은 오류를 미리 잡아낸다’는 프로그래밍 분야의 원칙을 준수하며 발전한 기능이다. 서브타이핑과 비교했을 때 명백한 장점은 유연성을 충분히 제공하면서도 RTTI(Runtime Type Information)와 같은 불안한 형검사 기능이 아예 필요도 없다는 점이다.
그런데 정말 함수식 다형성만으로 충분한 자유가 보장되는가? 필수 불가결한 기능이라고 우길 수는 없겠지만 그대로 두었을 때 재사용의 효과가 떨어지는 경우가 있다. 예를 들어‘(Gentle Introduction to Haskell’에서 찾아볼 수 있는) 우리가 어떤 원소가 주어진 집합에 속해 있는지 알아보는 연산을 다음과 같이 구현한다고 하자.
 
elem x [] = False elem x (y:ys) = (x == y) || (elem x ys)
 
앞의 연산을 다음과 같이 쓰는 데는 아무런 문제가 없어 보인다.
 
elem 10 [1..]
 
elem 함수를 그대로 써도 될까?
 
pair2point (x, y) = Point x y answer = elem (Point 1 2) (map pair2point [(x, y)| x <- xSpace, y <- ySpace]) where xSpace = [2..10]; ySpace = [-10..4]
 
앞의 식은 Point2D 형 데이터 간의 연산 == 이 정의돼 있지 않기 때문에 elem 식을 계산 할 수 없어 에러가 날수 밖에 없다. 그러므로 elem을 그대로 쓰려면 Point2D에 맞게 == 연산을 정의해야 할 필요가 있다(드디어 오버로딩이 동원된다). Point2D 데이터의 동등성은 다음과 같이 정의할 수 있겠다.
 
(Point x y) == (Point u v) = (x == u) && (y == v)
 
연산 elem을 그대로 쓰기 위해, 만일 이와 같은 해결책으로 만족했다면 다른 언어의 오버로딩과 별다른 차이가 없었겠지만 Haskell은 주먹구구식 기능을 가능한 용서하지 못하는 언어다. Haskell이 찾아낸 해결책을 이해하기 위해 일단 다음의 질문에 답해보자.
“모든 데이터 형에 == 연산이 의미가 있는가? 다시 말해서 아무 데이터 형이나 마구 ==를 쓰도록 해도 좋은가?”
 
완벽한 해결책은 아니라고 해도 Haskell은 오버로딩의 자유를 타당하게 속박할 수 있는 체계가 필요했다. 일단 elem의 타입 시그너처를 짐작해보자. 다음과 같은 결과를 짐작하는 게 보통이다.
 
elem :: a -> [a] -> Bool
 
그런데 오버로딩의 횡포(?)를 막으려면 아무나 이 elem을 쓸 수 없도록 해야 한다는 문제가 남아 있다. 이때 어떤 형 aelem의 정의에 꼭 필요한 연산 ==를 정의하고 있어야 한다는 점을 이용할 수 있다. 형 a는 최소한 동등성을 비교할 수 있는 데이터의 집합이라야 앞뒤가 맞기 때문이다. Haskell에서는 이 제약조건을 다음처럼 표현한다.
 
EQ a
 
 
여기서 이름 Eq는 Equal의 약칭으로(프로그래머가 마음대로 정의 할 수 있는 이름이지만, 이미 표준에 포함돼있어 그대로 쓸 수밖에 없다) ‘형 aEq라는 제약을 만족해야 한다’는 뜻이다. 이 때 Eq와 같은 이름을 Haskell에서 형 클래스(Type Class)라 하고, 바로 이 기능이 Ad Hoc 다형성이라는 불리는 오버로딩을 전혀 Ad Hoc하지 않게 만들어 주는 Haskell만의 독특한 특징이다. 형 클래스 기능을 확실하게 알아보기 위해 일단 Prelude에 있는 형 클래스 Eq의 정의를 살펴보자.
 
class Eq a where (==), (/=) :: a -> a -> Bool x == y = not (x /= y) x /= y = not (x == y)
 
앞의 정의에서 맨 처음에 나오는 class는 새로운 형 클래스를 정의하는 키워드다. 객체지향 언어에서와 달리 Haskell의 class는 객체를 나타내지 않는다. 오로지 연산의 집합에 이름을 부여하고, 순수하게 여러 데이터 형이 만족해야 할 자격만을 기술하는 기능이다. 자바의 interface나 C++의 virtual class가 이와 비슷한 역할을 하지만 둘 다 형과 그 형을 만족하는 객체 정의를 엄격하게 구분하지 않고 있다. 앞의 Eq 정의에서 ==, /= 연산만 떨어뜨려 보면 그 차이점을 눈으로 확인할 수 있다.
 
(==), (/=) :: a -> a -> Bool x == y = not (x /= y) x /= y = not (x == y)
 
앞에 붙어있는 클래스 선언이 없다면 그저 평범한 함수 정의를 붙여 쓴 것에 지나지 않는다. 클래스 선언이 하는 일은두 연산을 묶어서 Eq이라는 이름을 붙여 준 것밖에 없다. 어쨌든 전체를 읽어보면 “이 두 연산 집합이 바로 동등성(Equality) 관계를 의미한다. 그러므로 어떤 형 a가 이 카테고리에 들려면 반드시 두 연산의 정의를 제공해야 한다”는 규칙을 정의하고 있다. 그렇다면 elem의 정확한 형은 무엇일까? 놀랍게도 Haskell의 형 체계는 한 연산의 문맥(context)에 필요한 조건을 형 클래스의 정의에 따라 정확하게 잡아낸다.
 
🗣
:t elem elem :: Eq a => a -> [a] ->Bool
 
 
즉, 앞에서 elem 타입 시그너처는“어떤 형 aelem이란 연산을 쓰고자 하면, 반드시 형 클래스 Eq의 조건을 만족해야 한다(Eq a =>)”는 뜻을 간결하게 표현하고 있다. 이제 어떤 데이터 형도 class Eq에 속하지 못하면 함부로 == 연산을 정의할 수 없으며 당연히 elem도 쓸 수 없다. 그러므로 Point2Dclass Eq를 만족하는 데이터 형으로 만들어야 한다.
 
Eq (Point2D a) where (Point x y) == (Point u v) = (x == u) && (y == v)
 
앞에서 /=의 정의는 반드시 필요하지 않다. 이미 class Eq를 정의할 때 기본 정의를 만들어 두었기 때문에, (==)(/=) 중의하나만 정의하면 된다. 물론 필요에 따라 두 연산을 모두 재정의할 수도 있다(효과는 객체지향성의 추상 클래스(템플릿 패턴)와 비슷하지만, 서브클래싱의 단점을 공유하지 않는 해결기법이다).
자, 모든 작업이 마무리된 것일까? 아직 허점을 눈치채지 못했다면‘제대로’에 덜 익숙해진 탓이다. 앞의 인스턴스 선언에는 또 다시 뭔가가 빠져 있다. 위에서 Point2D의 == 정의를 살려 보자. 두 점의 ==는 각 차원 요소 x, y, u, v의 동등성을 빌어 정의된다. 다시 말해 Point2D의 형 변수 a 또한 `class Eq의 인스턴스여야 말이 된다. 이런 경우 instance 선언에도 문맥에 필요한 형의 조건을 명시하는 기능이 필요하게 되고, 함수의 문맥을 정의하 는 방식과 같이 다음과 같은 표현을 쓸 수 있다.
 
instance Eq => Eq (Point2D a) where ....
 
앞의 문장을 말로 풀어 가면서 지금까지의 내용을 정리해 보자.
 
Point2D는 생성함수 Point로 형 a의 데이터를 두 개 받아서 새로운 원소를 만드는 데이터형이다. Point2D의 데이터끼리 ==, /= 연산으로 서로의 동등성을 비교할 수 있도록 하기 위해, Point2D를 class Eq의 인스턴스로 선언한다. 이 때 동등성을 정의하려면 형 a도 class Eq의 인스턴스여야 한다.”
 
문맥에 따른 형 a의 자격 조건을 반드시 instance 선언에만 쓸 수 있는 것은 아니다. 만일 a==연산을 써야 할 때 뿐만 아니라, 언제나Eq a여야 한다면 아예 새로운 형을 선언할 때도 문맥을 명시해 형 변수 a의 조건을 지정할 수 있다.
 
data Eq a => Set a = Set[a] -- 형 생성함수 Set a와 데이터 생성함수 Set [a]는 같은 이름을 써도 -- 구분하는 데 아무 문제가 없다.
 
마찬가지로 엄격한 순서 관계를 필요한 경우라면 다음과 같이 선언한다.
 
data Ord a => OrderedSet a = OrdSet [a] -- class Ord는 strict ordering을 뜻하며, -- Prelude에 정의된 표준 클래스다.
 

형 검사와 서브클래싱

지금까지 살펴본 바와 같이 Haskell의 오버로딩에 대한 해결책은 그저 체계를 잡는 정도가 아니라, 서브타이핑의 단점을 일부 보완하면서 장점을 수용한 새로운 기법이라 할 수 있다. 서브타이핑에 비해 가장 두드러진 장점은 바로 형 검사 기능이다. 서브타이핑이 다형성(유연성)을 위해 안정성을 포기했다면, Haskell은 모든 형을 스크립트 해석 시간에 검사해낸다. 게다가 상속에 대응하는 ‘서브클래싱’효과도 지원한다. 실제로 앞의 예제에서 보았던 OrdEq의 서브클래스다. 그러므로 OrdEq가 의미하는 문맥을 포함하며, 더 까다로운 조건을 표현한다(수학 이치로 따쳐보면 “아, 그렇구나!”하고 맞장구를 칠 것이다. 원소간의 순서를 비교하려면 동등성을 비교하는 것은 당연히 기본 조건으로 따라줘야만 한다). Ord 클래스의 정의는 옮겨보면 다음과 같다(Eq처럼 단순하지는 않다).
 
class (Eq a) => Ord a where compare :: a -> a -> Ordering (<), (<=), (>=), (>) :: a -> a -> Bool max, min :: a -> a
 
aOrd의 인스턴스가 되려면 먼저 Eq의 인스턴스여야 한다. 즉, OrdEq보다 더 섬세한 조건이므로 Ord에는 Eq 문맥이 함축되고 서브타이핑과 같은 클래스 연관성이 성립되어 형간 대치성이 보장된다. 쉬운 말로 Eq을 요구하는 자리에 Ord가 조건을 만족하는 데 형의 원소를 써도 당연히 문제가 없다는 얘기다.
 
compare x y | x == y = EQ | x <= y = LT | otherwise = GT
 
compare는 꼭 필요한 함수는 아닌데, 복잡한 type의 순서 관계 연산을 정의할 때 효율적이다. 여러 개의 조건을 다음과 같이 적는 문법을 Guarded Expression이라고 하는 데, if 등을 여러번 겹쳐 쓰는 것보다 깔끔하다.
 
-- 왜 굳이 compare를 만들어 쓰는 지 알만하다. x <= y = compare x y /= GT x < y = compare x y == LT x >= y = compare x y /= LT x > y = compare x y == GT
-- max, min 연산도 조건에 포함시켜 편리함을 더한다. max x y | x <= y = y | otherwise = x min x y | x <= y = x | otherwise = y
 
Ord는 자체의 정의는 다소 복잡해 보이지만, 인스턴스 만들기는 (쓸 때는) 아주 간편하다.
 
instance Ord a => Ord (Point2D a) where compare (Point x y) (Point u v) = compare (x, y) (u, v) -- tuple 정렬 기능을 살짝 빌어서 간단하게
 
나머지 연산은 Ord에서 제공하는 기본 정의를 그대로 써도 되고,마음에 들지 않는다면(그런 경우가 거의 없지만) 필요에 따라 새로 정의해도 된다.
때로는 제약 조건이 하나 이상 일 수도 있다. 이 경우가 마치 객체지향성의 ‘여러번 상속(multiple inheritance)’과 비슷한 기법인데, 필요한 문맥을 튜플로 엮어 표현한다.
 
class (Eq a, Show a) => Num a where (+), (-), (*), :: a -> a -> a -> ...생략...
 
Prelude에서 정의하는 표준 Num 클래스다. 즉 수(Number)로 분류할 수 있는 데이터 형의 조건을 명시하고 있는데, (+)등의 연산자 기호를 쓰려면 이 클래스의 인스턴스가 돼야 한다. (Eq aShow a)는 Num a가 되기 위해 a가 먼저 만족해야 하는 여러 개의 형 클래스를 명시하고 있다. 하나 이상의 클래스 조건을 나타낼 때 (Class1 a, Class2 a, ...) 식으로 쓴다. 여기서 클래스 Show는 화면에 값을 어떻게 출력하는 지를 명시하라는 제약이다. 마치 자바에서 toString 메쏘드와 같은 기능을 말하는 것인데 좀 더 엄격하다는 것 말고는 큰 차이가 없다.
인스턴스 관계를 더 간편하게 처리하는 방법도 알아두면 편리하다. 예를 들어 Point2D의 정의가 다음과 같았다고 하자.
 
data Point2D a = Point (a, a)
 
Point2D의 동등성은 (a, a) 데이터 형의 동등성과 동일하고, (a, a) 같은 튜플 데이터는 이미 Eq를 만족하도록 정의했기 때문에 그냥 (a, a) 형의 기본 정의를 그대로 빌어 채우는 것으로 충분하기 때문에, 이런 경우라면 다음과 같이 처리할 수 있다.
 
data Point2D a = Point(a, a) derriving Eq
 
Eq로 부터 직접 내려받는 기능은 다음의 instance 선언과 같은 뜻의 약식 표현이다.
 
instance Eq (Point2D a) where (Point this) == (Point that) = this == that
 
같은 방법으로 다음처럼 Ord를 구현했다고 하자.
 
instance Ord a => Ord (Point2D a) where compare (Point this) (Point that) = compare this that
 
앞의 인스턴스 선언을 다음과 같이 한번에 표시할 수 있다.
 
data Point2D a = Point (a,a) deriving (Eq, Ord)
 
이와 같은 표현은 Show 클래스를 구현해야 할 때 습관처럼 쓰게 되는 경우가 많다. 앞에서 소개한 대로 Show 클래스는 어떤 형의 데이터를 그 특성에 맞게 출력하고 싶을 때 구현하는 것으로 인터프리터에서 그 효과를 바로 확인할 수 있다.
 
🗣
Point (2, 2) ERROR - Cannot find "show" function for: *** Expression : Point (2, 2) *** of type : Point2D Integer
 
이와 같은 결과가 나오는 이유는 Point2D가 Show 클래스를 구현하지 않아 생성된 원소를 찍어낼 방법을 제공하지 않았기 때문이다. 에러 메시지에 나온 show 함수는 다음처럼 정의되어 있다
 
class Show where show :: a -> String ...생략...
 
그러므로 Point2D 형을 Show 클래스의 인스턴스로 만들면 해결된다.
 
instance Show a => Show (Point2D a) where show (Point x y) = “Point“ ++ show (x, y) -- (a, a) 데이터는 이미 Show의 인스턴스다.
 
이제 다시 Point2D 데이터를 출력하면 다음과 같다.
 
🗣
[ Point (x,y) | x <- [1,3..], y <- [-50..], x + y < 30] [Point (1,-50),Point (1,-49),Point (1,-48),Point (1,-47),Point (1,-46),Point (1,-45),Point (1,-44), ...생략...
 
그런데 Point2D 데이터를 앞에서 정의한 show 함수로 출력하면 데이터 형 구조를 그대로 찍는 셈이라서, 애써 show 함수를 구현할 필요가 없다. 생성함수는 이름 그대로 출력하면 될 일이고, 튜플이야 본래 생긴대로 찍어주면 된다. 이럴 때 deriving을 쓰면 편리하다. 출력결과는 물론 다음에 정의한 show와 같다.
 
data Point2D a = Point (a,a) deriving Show
 
지금까지 Haskell의 형 클래스 기능을 간단히 살펴보았다. 앞에서 언급한 바와 같이 형(type)이란 것이 수학의 대수 체계에서 비롯됐다는 점을 고려한다면, Haskell의 클래스야말로 우리가 무심코 쓰는 클래스(형, 집합)란 말의 본뜻에 더 가까운 기능이란 점을 부인할 수 없을 것이다. 굳이 필자의 부족한 글로 찬사를 늘어놓지 않더라도 얼마나 섬세하고 잘 정돈된 기법인지는 직접 써보면 느낄 수 있을 것이다.
‘A Gentle Introduction to Haskell’에는 다른 언어 기능(특히 객체지향성)과 Haskell의 형 클래스 기능을 7가지 항목으로 잘 비교 정리해 놓았다. 읽어보면 이 기능을 이해하는 데 큰 도움이 될 것이다.
 

I/O 모나드의 문법과 의미

프로그래밍 언어의 입출력 시스템은 하부 플랫폼(운영체제의 입출력 서비스 층)의 힘을 빌어서 구현된다. 기계식 계산 모델을 따르는 언어에서는 바탕 플랫폼의 입출력 기능을 끌어안고, 언어의 표현 수준에 맞는 적절한 인터페이스를 제공하는 데 힘이 덜 드는 편이다. 한 언어의 프로그래밍 인터페이스를 개발하는 일이 결코 쉬운 일은 아니겠지만, 자바나 C# 같은 언어에서는 적어도‘계산 방식’의 충돌 같은 난해한 문제는 없다.
Haskell 같은 언어에서는 입출력 시스템이 처리하기 상당히 어려운 부분이다. 그저 하부 시스템과 단순히 데이터를 교환하는 수준이거나, 어떤 기능을 일방적으로 빌어쓰는 것이 아니기 때문에, 하부의 전혀 다른 계산 방식을 써야 할 뿐만 아니라 서로 간의 대화 방식을 어떤 식으로 결정해야 한다.
입출력은 일괄 처리 방식을 따르기 보다 대화형 계산(interactive computation)에 가깝다. 이는 수학에서와 같이 식을 조합해서 계산으로 값을 얻어내기 보다, 주어진 작업을 해내는 데 필요한 ‘순서 있는 행동(action)의 연속’에 가까운 작업이다. 거기다 입출력 기능을 제어하는 쪽에서는 매번 계산의 상태를 참고해서 앞으로 진행해야 할 작업의 흐름을 결정해야 하기 때문에 끊임없는 상호 간섭이 필요 불가결하다.
또한 일괄처리와 달리 대화형 계산에서는 이어지는 행동의 순서에 큰 의미가 있다. 어떤 시점에서 요소 행동(elementary)은 바로 전까지 진행된 행동의 결과를 바탕으로 계산의 방향을 다르게 이끌어 낼 수 있기 때문이다. 대화형 계산에서 축적된 행동의 결과는‘상태’로 표현되며, 상태는 이어지는 계산의 흐름을 제어하므로 ‘상태 변화를 바탕으로 하는 계산방식(History-sensitive computation 또는 stateful computation)’이 된다. 계산의 결과가 상태를 바탕으로 하는 제어의 흐름에 따라 매번 바뀔 수도 있고, 이런 성질은‘값의 변형을 인정하지 않는 수학식 계산 모델’과 부딪힌다. Haskell과 같은 언어에서 발생하는 모든 문제는 바로 이런 철학의 차이에서 시작된다.
앞에서 설명한 계산 모델은 C++ 같은 기계식 언어(Imperative Language)가 사용하는 기법이며, 폰 노이만 기계의 구조를 그대로 흉내낸 것이다. 특히 배정문(Assignment)이 ‘상태의 변화’를 표현하는 핵심 기능이기 때문에, 배정문 없이는 어떤 계산을 한다는 것 자체가 불가능하다. 그런데 Haskell처럼 순수한 언어에는이 배정문이 아예 없다. 수학식 계산에서는 모든 것이 오로지 값이고 그 값은 시간에 따라 절대로 바뀌지 않아야 하기 때문에(Value transparency를 보장하기 때문에) Haskell은 이 원칙을 철저하게 고수하며 발전해 왔다. 그리고 그‘순수함’을 바탕으로 여러 가지 독특한 특징을 자랑한다. ‘필요할 때 계산하는 기법(Lazy Evaluation)’의 표현력과 효율성을 이용할 수 있었고, 수학식 추론(equational reasoning, 서로 같다는 이치를 따르는 논리)의 간결하고 명료함을 프로그래밍에 도입할 수 있었다. 그러므로 Haskell과 같은 언어에서 만일 값의 투명함을 포기하고 기계식 계산을 답습할 수 밖에 없다면, 단순히 어떤 기능 하나를 편리하게 사용하기 위해 너무 비싼 대가를 치뤄야 하는 것이다. ML이나 Scheme과 같이, Haskell 이전의 언어들은 비싼 대가를 지불하며 타협하는 방법을 선택했으며 그 대신(Haskell이 자랑하는) 여러 가지 우수한 기법을 포기했다.
프로그래밍 분야의 저명한 학자인 Philip Wadler의 논문을 읽어보면 이 같은 고민의 흔적을 찾아볼 수 있다. 그의 관련 논문은 보통 다음과 같은 글귀로 시작된다.
Shall I be pure or impure?
(편집자 주 : ‘신이 나를 순수하게 해 줄 것인가?’라는 정도로 해석할 수 있겠다. Philip Wadler는 프로그래밍 언어의 순수성에 대해 많은 고민을 했으나, 결국 그것이 자신의 의지대로 되는 것이 아니라는 것, 즉 주변 환경에 의해 순수성을 해칠 수도 있다는 사실을 깨달아 이와 같은 말을 한 것 같다)
 
간단하지만 그동안 고민한 문제의 본질과 결국 Haskell에서 어떤 해결책을 제시했는지 잘 짐작할 수 있을 문장이다.답부터 말하자면 Haskell은 역시 순수를 버리지 못했다. 그리고 그 해답의 실현이 (지금 Standard Prelude에 포함되어 있는) 지금부터 소개하게 될 I/O 모나드다. 먼저 어떤 기능인지 살펴보자.
Standard Prelude에는 표준 입력으로부터 문자열(String)을 읽는 입력함수, getLine이 정의되어 있다. 그런데 이 함수의 코드를 보면 지금까지 보아 온 프로그래밍 기법과 많은 차이를 발견할 수 있다.
 
getLine :: IO String getLine = do c <- getChar if c == '\n' then return "" else do cs <- getLine return (c:cs)
 
이 코드는 Haskell 식이라기 보다, 오히려 C 방식의 프로그래밍과 가깝다. 동일한 기능의 C 코드와 비교해 봐도 별 다른 차이를 느낄 수 없을 것이다. 그렇다면 과연 Haskell은 순수함을 포기하고, 기계식 프로그래밍 기법과 완전히 타협한 것일까? 그렇다고 할 수도 있고 아니라고 할 수도 있다.
함수 getChar의 타입 시그너처는 다음과 같다.
 
getChar :: IO Char
char getchar(); // in C++ or Java
 
여기서 형 생성자 IO에 주목하자. getChar는 외부에서 문자를 하나 읽어 들이는 기능이지만, 그 값이 그냥 Char가 아니라 IO Char라는 것이 다르다. 다시 말해 getChar는 값을 표현하는 게 아니라 ‘값을 읽어오는 입출력 기능(IO action)’으로, 이런 점에서 Haskell의 보통 함수와는 구별된다. 즉 IO는 Haskell 식으로 표현한 ‘명령(Command)’의 일종이며, 기계식 언어의 함수가 이 정의에 가깝다. 이번에는 문자를 하나 출력하는 함수, putChar의 시그너처를 보자.
 
putChar :: Char -> IO ()
void putchar(char c); // In C++ or Java
 
putChar는 문자를 하나 받아들여서 출력 명령을 수행하는 함수다. 여기서 ()는 C 언어의 void에 비슷한 표현으로 명령을 수행한 결과에 값이 따라오지 않는다는 것을 의미한다. 이 함수를 이용해서 putLine이란 함수를 만들면 다음과 같다.
 
putLine :; String -> IO () putLine [] = putChar '\n' putLine (c:cs) = putChar c >> putLine cs
void putLine(char* s) { ... putchar(*s); ... }
 
putLine은 문자열을 받아 각 문자를 putChar로 출력하고 마지막에 새 줄문자를 출력하는 명령(즉, 모나드) 함수다. 위의 정의에서 (>>)는 모나드 명령을 이어서 하나의 명령으로 엮어주는 연산으로, C에서 여러 문장을 연속으로 묶어내는 {};에 해당한다(C에서는 특수한 문법이지만, Haskell에서는 평범한 함수일 뿐이다).
 
(>>) :: Monad a => a b -> a c-> a c
 
식(putChar c)의 시그너처는 IO ()이므로 IOa에, b()과 대응한다. 앞의 시그너처를 해석해 보면, (>>) 연산이 두 개의 모나드값(a b),(a c)를 받아 (a c )형 모나드 값을 계산하는 함수다. 식에서 a는 문맥에 요구하는 대로 Monad 클래스를 구현해야 하는 어떤 형이므로(Monad a =>), 반드시 모나드, 즉 명령 (action)을 나타내는 데이터라야 한다(IO는 출력을 나타내는 모나드 명령 데이터이기 때문에, 문맥이 요구하는 조건에 맞아 떨어진다). 그리고 인자로 받는 모나드나 결과치 모나드 같은 형이어야 하는데, 이는 (>>) 연산이 같은 일을 하는 명령 같은 모나드 데이터만을 결합할 수 있다는 말이다. 이 경우에는 모두 IO, 입출력 명령만 연결하므로 이 조건에 일치한다.
이제 putLine을 좀 더 깔끔하게 고쳐보자. putLine의 코드는 문자열, 즉 리스트를 이루는 모든 문자에 putChar를 적용한 다음, (>>) 연산으로 그 결과를 왼편에서 오른편으로 묶어버리면 되고, mapfoldr을 사용하면 된다. 먼저 map을 적용하면 IO 명령의 리스트를 얻을 수 있다.
 
🗣
map putChar “abc” [<<IO action>>,<<IO action>>,<<IO action>>]
 
각각의 문자를 출력하는 명령 세 개가 모인 것을 확인할 수 있다. 다음 (>>)으로 전체를 묶으면 다음과 같다.
 
🗣
foldr (>>) (putChar‘\n’) (map putChar“abc”) abc
 
두 함수가 잘 동작한다는 것을 확인했기 때문에 다음과 같이 고쳐 쓸 수 있다
 
putLine s = foldr (>>) (putChar '\n') (map putChar s)
 
이를 다시 함수의 합성으로 고쳐 쓰면 다음과 같은 식을 얻을 수 있다.
 
putLine s = foldr (>>) (putChar '\n')) . (map putChar)) s
 
앞의 식에서 불필요한 괄호를 없애고 식의 양편에 있는 인자 s를 생략하면 다음과 같다.
 
putLine = foldr (>>) (putChar '\n' . map putChar
 
이제 putLine 함수를 완성했다. 일부러 길게 늘어서 식을 전개하고 더 나은 형태를 유도하는 과정을 보여줬는데, 익숙해지면 곧 바로 맨 마지막의 표현을 얻어낼 수 있다. 일단 이렇게 프로그래밍한 결과를 getLine과 비교해 보자.
둘 다 IO 모나드를 이용하는 함수인 점은 같다. 그러나 프로그래밍하는 방식이 아주 다르다. putLine 방식의 프로그래밍은 Haskell 답지만, getLine은 그렇지 않다. getLine에서는 어떻게 해서 마치 기계식 프로그래밍 같은 문법을 흉내낼 수 있는 것일까? 비밀은 바로 do에 숨어 있다.
Haskell에는 (>>) 연산 같은 류의 명령을 결합하는 기능을 한층 더 일반화한 연산 (>>=)이 있다. 이 함수는 모나드의 바인드(bind)에 해당하는데 (>>)과 비슷하게 여러 유형의 명령을 묶어 하나의 명령으로 만드는 역할을 한다. 다시 말하자면 개개의 요소 명령을 이어서 일련의 작업을 해내는 단일 명령으로 연계한다. (>>=) 연산의 형은 다음과 같다.
 
(>>=) :: Monad a => a b -> (b -> a c) -> a c
 
이를 다시 입출력, IO 모나드에만 맞추어 보면 다음과 같다.
 
(>>=) :: IO a -> (a -> IO b) -> IO b
 
앞의 시그너처는 다음과 같은 뜻으로 해석 가능하다.
 
“먼저 a형의 값을 입력하는 명령을 받는다(IO a). 그 다음 같은 형의 데이터를 받아 출력하는 명령을 받고(a -> IO b), 문제가 없으면 그 결과 출력 명령이 수행된다(IO b). 이때 입력 데이터와 출력 데이터는 반드시 같을 필요는 없다(출력 명령에서 데이터 변환 작업이 당연히 있을 수 있고, 그 결과가 같은 형의 데이터라는 보장은 없다).”
 
가장 간단한 응용 예는 다음과 같이 입력과 출력을 바로 이어버리는 경우다.
 
🗣
etChar >>= putChar
 
getCharIO Char형이고, putChar(Char -> IO Char)이므로 그 결과 IO Char, 즉 문자 출력 명령이 실행된다.
앞의 예는 아주 간단하기 때문에 (>>=) 연산을 쓰는 게 별로 어렵지 않아 보이지만, 조금만 더 복잡한 입출력 함수를 만들다 보면(이런 방식에 익숙하지 않은 사람이 금방 파악하기 힘든) 컨티뉴에이션과 스트리밍을 합쳐 놓은 것과 유사한 기법을 써서 프로그램을 작성해야만 한다. 이런 난점을 해결하고, 다른 언어와 비슷한 문법으로 입출력 프로그래밍을 할 수 있도록 고안한 표기법이 do 구문이다. 다시 말해 do(>>=) 연산을 써야 하는 모나드 방식 프로그램을 좀더 익숙한 문법으로 가려놓은 문법 트릭이다. 문법 do를 쓰면 getLine에서와 같이 기계식 언어로 프로그래밍하는 흉내를 낼 수 있다.
이제 do를 (억지로) 사용해서 putLine을 고쳐 써보면 다음과 같다.
 
putLine2 s = do putString s putChar '\n' putString s = if s == "" then return "" else do putChar c putString cs where (c:cs) = s
 
사실 Haskell 식을 따르자면 상태를 이용해서 프로그래밍하는 경우에도(즉 모나드 데이터를 써야 하는 경우라도 putLine2보다는 putLine과 같은 프로그래밍이 더 바람직하다) 간결하고 깔끔하며 재사용 효과도 뛰어나기 때문이다. 그러나 getLine을 가만히 살펴보면 putLine에서는 볼 수 없는 표현이 한 줄 있다.
 
do c <- getChar ...
 
앞의 식에서 c는 마치 getChar 명령의 결과를 저장하는 변수와 같은 역할을 한다. 이 때 cIO Char(getChar) 값에서 모나드(명령)의 껍질을 벗겨낸 값 그 자체를 담아내기 때문에, 그 형은 Char가 된다. 이 문법을 사용하면 이어지는 두 명령을 서로 떼어 내서 열거하는 효과를 얻을 수 있다.
 
getChar (>>=) putChar
do c <- getChar result <- putChar c
 
이와 같이 do 구문 안에서는 모든 명령이 var <- Command 문법을 따라 열거된다. 만일 출력 명령처럼 값이 ()인 경우라면 () <- Command 형태를 취하게 되는데, () <-는 생략하고 간단하게 Command만 쓰는 약식 문법을 쓸 수 있다. 예를 들어 앞에서 resultIO ()에서 IO를 떼어낸 값이므로 ()이 된다. 이런 이치로 result <- 는 생략할 수 있으면 결과는 다음과 같다. 이제 어느 정도 비밀의 흔적을 느낄 수 있을 것이다.
 
do c <- getChar putChar c
 
어쨌든 이와 같은 이유로 do 안에서는 마치 C 프로그래밍을 하는 것과 같이 순서대로 명령어를 열거할 수 있는 것이다.
우리가 앞에서 본 Haskell 식 C 언어 문법 뒤에는 이보다 많은 비밀이 숨겨져 있다. 그러나 의심하지 말아야 할 사실은 드러나는 문법과 다르게, 배정문을 허용한 것이 전혀 아니라는 사실이다(변수 c(>>=)을 대신해서 두 모나드 연산을 연결하는 역할을 하고 있을 뿐이다). 다시 말해서 실제 기억 장소 내의 값을 바꿔 계산 환경을 오염시키는 효과는 전혀 없으며, 값의 투명성을 바탕으로하는 수학적 순수성은 조금도 변질되지 않는다. 앞에서 보았듯이 상태를 표현하기 위해 모나드를 도입했지만, 프로그래밍 기법은 여전히 예전처럼 할 수 있다(putLine의 예를 통해 확인했다).
깔끔한 함수식 프로그래밍 기법을 그대로 쓸 수 있는 데도 불구하고, do와 같은 과잉 친절(?)을 베푼 것에는 그만한 이유가 있다. 모나드 이전에도 스트림(stream), 컨티뉴에이션(continuation)이라 불리는 표현 기법이 있었다. 모두 순수성을 보전하면서도 대화형 계산을 표현하고자 만들어진 것들이다. 그러나 어느 하나 만족스럽지 못했다. 쓰기가 쉽다면 기능을 구현하기 어렵고, 언어 기능을 구현하기 쉽다면 반대로 사용하기가 어려웠으며 언어에 더해진 복잡도만큼 실제 제 가치를 발휘하며 널리 쓰인 기법이 없었다. 그에 비하면 배정문은 그 부정적인 효과를 무시하면 훨씬 구현하기도 간단하고, 쓰기도 편하기 때문에 많은 언어들은 타협점을 찾아 순수성 일부를 포기할 수 밖에 없었다.
언어 밖의 계산 세계와 대화형 작업을 진행하려면 단순히 상태를 표현할 수 있어야 할 뿐만 아니라, 그 세계의 문제를 쉽게 풀어낼 수 있는 인터페이스도 같이 따라가 줘야 제 가치를 발휘한다. 이 말은 최소한 시간에 따라 바뀌는 상태를 참고해 대화형 계산을 진행할 때는 명령식 패러다임이 쉽다는 점을 부분적으로나마 인정할 수 밖에 없다는 말이다. 그러므로 do 표기법을 남용해 Haskell을 마치 C처럼 혼란스럽게 쓰지만 않는다면, 프로그래머는 오히려 ‘패러다임 섞어쓰기’ 효과를 맘껏 활용할 수 있고, 표현력을 더 한층 상승시킬 기회를 얻게 된다.
Haskell이 잃어버릴까 걱정했던 것은 의미의 순수성이지 겉으로 드러나는 문법이 아니었으며, 모나드는 그동안 두 계산 기법 사이의 갈등에 대한 해결책을 훌륭하게 제시한 것이다. 이 덕분에 이제 Haskell에서는 다음처럼 두 패러다임을 합쳐 놓은 것과 같은 프로그램을 작성할 수 있게 됐다
 
-- 대화형 입출력 palindrome :: IO () palindrome = do putString "Input a String: " cs <- getLine if palin cs then putLine "Ye!" else putLine "Joking?" -- 함수형으로 일괄 처리 palin :; String -> Bool palin xs = (cs == reverse cs) where cs = map toUpper (filter isLetter xs) isLetter = not . isControl -- [Introduction to Functional Programming using Haskell, p331에 있는 코드를 약간 고침]
 

굴레를 벗어나

필자는 이 글을 써 오면서 일부러 ‘함수형’이란 꾸밈말을 피했다. 이른바 각 패러다임을 ‘무슨 무슨 형’ 프로그래밍으로 나누어 따지는 게 적어도 필자에게는 아무런 의미가 없기 때문이다. 대신 ‘함수를 제대로 쓰는 언어’ 또는 ‘함수를 잘 지원하는 언어’라는 식으로 표현했다. 프로그래밍 언어가 함수를 잘 지원하는 것은 기본 요구사항이고 ‘형’이란 단어를 붙여가면서까지 따로 취급해야 할 별다른 이유가 없기 때문이다.
조금 더 욕심을 부려본다면, ‘프로그래밍 언어’ 앞에 이런 저런 불필요한 수식어가 아예 사라졌으면 좋겠다. 프로그래밍 언어나 기법을 쓸데 없이 구분하는 것은 소프트웨어를 더 잘 만드는 데 별 도움이 되지 않을 뿐더러, 그저 기술에 대한 편견과 두려움만 불러일으킬 수 있다.
Haskell을 보자. 이 언어는 그야말로 프로그래밍의 정석을 스스로 증명해 온 언어다. 함수야말로 수학에서 빌어 지금까지 써온 표현 수단이고, 함수를 쓰는 프로그래밍은 이미 타당성이 검증된 기법이어서 완성도가 매우 높다. 그런데도 우리 주변에서 이런 언어로 프로그래밍을 가르치거나 진지한 소프트웨어 작업을 쉽게 찾아볼 수 없는 이유는 무엇일까? 아무리 따져 봐도 배우고 가르치지 않을 핑계를 찾아볼 수 없는 기법이다.
필자는 최근 유닉스를 바탕으로 개발된 새로운 운영체제를 사용하고 있다. 지금까지 보아 온 운영체제 중에 가장 튼튼할 뿐만 아니라, 수많은 응용 프로그램과 편리한 인터페이스로 매번 감동 받는다. 그런데 우리나라에서는 이런 제품을 권할 만한 상황이 아니다. 전자상거래나 인터넷 뱅킹은 고사하고, 그저 단순한 홈페이지 하나도 표준 기술로 만들지 않아서 들어가지조차 못하는 경우가 아주 많다. 이에 반해 다른 나라의 여러 사이트, 그 중에서 아주 복잡한 컨텐츠 구성으로 유명한 웹 사이트만 골라서 방문했을 때는 아무런 문제도 없었다.
기술이 아주 좁은 시장에서 한쪽으로 몰려 균형을 갖추지 못하면 이와 같은 상태가 된다. 그 시장에 갖혀 있는기 술자들은 자신들이 잘못하고 있는지조차 인식하지 못한다. 그 밖을 벗어나 본 경험도 없고, 그럴 필요를 느끼지도 못하며, 자신이 쓰는 기술을 진지하게 돌아볼 여유조차도 없기 때문이다.
프로그래밍도 이와 같다. 시중에서 유행하는 언어는 지금 당장 먹어야 제맛을 내는 아이스크림과도 같다. 물론 우리나라처럼 좁은 시장과 성숙하지 못한 기술 문화 수준에서라면 이런 언어로 프로그래밍을 공부하고 기술을 연마하는 것이 사치일지도 모른다. 그러나 필자가 경험한 바로는 그렇지 않다.
Haskell과 같은 언어로 프로그래밍을 배우는 데서 오는 효과는 지금 당장 써야하는 기술의 질과 생산성에 아주 큰 영향을 준다. 아마도 최소한 다음과 같은 효과를 무시할 수는 없을 것이다.
“자바를 비롯한 많은 객체지향 언어에서 프로시저(함수)를 객체처럼 자유롭게 쓰지 못한다. 특히 프로시저를 자료구조 안에 저장해두거나, 인자로 건네주고 값으로 받아오는 일을 쉽게 처리할 수 없다. 그러나 때때로 이런 기법이 필요할 때가 있다. ...중략... 프로시저 객체가 없어서(함수를 제대로 쓰지 못해서) 이를 보상하려고 생겨난 두 가지 패턴이 있다. Strategy 패턴은 한 프로시저가 어떤 식의 작업을 해주리라 기대하는 문맥에서 사용된다(역자 주: 함수의 형과 이름을 인터페이스로 표현하고, 그 인터페이스의 의미를 충실하게 구현하는 프로시저 객체를 만들어 쓰는 기법. Comparator 인터페이스가 여기에 해당한다). ...중략... command 패턴은 한 프로시저가 어떻게 동작하느냐에 대한 기대가 없을 때 사용된다(역자 주: 즉 인터페이스는 의미를 제약하는 수단이 아니라 문법을 강요하는 수단일 뿐이다. Runnable 인터페이스가 여기에 해당한다).
 

프로그래명 공부를 위한 좋은 자료

Haskell 프로그래밍 공부에 도움이 될만한 좋은 자료를 소개한다. 다음 목록은 필자가 지금껏 보아온 자료 중에서 꽤 쓸만하다고 생각하는 것들만 소개한 것인데, 아무쪼록 필자의 부족한 글에 대한 보상이 됐으면 하는 바람이다.
  1. Introduction to Functional Programming - Richard Bird와 Phlip Wadler, Prentice Hall, 1988
    1. Miranda(Haskell과 비슷)라는 언어의 문법을 빌어 함수로 프로그래밍하는 이론과 기법을 친절하게 설명한 책이다. 이 책에서 소개하는 많은 기능이 Haskell 언어의 Standard Prelude에 포함되어 있다. 즉, Standard Prelude 그 자체가 아주 좋은 자료다(http://www.cse.unsw.edu.au/~paull/cs1011/inbuilt.html 에서 Prelude에 있는 각 함수에 대한 설명을 찾아볼 수 있다).
  1. Introduction to Functional Programming using Haskell
    1. Richard Bird 내용은 1과 많은 부분이 비슷하지만 Haskell 언어에 보다 충실하다. 현재 2판까지 나와있으며, 1을 구하기 어려울 때 대신해서 쓸 수 있는 좋은 교재다.
  1. http://www.haskell.org/classes/ 의 많은 교육용 슬라이드
    1. Haskell을 가르치는 데 사용하는 여러 가지 자료를 얻을 수 있다. 초보자나 시간이 부족한 사람들이 이런 방식의 프로그래밍을 쉽고 빠르게 접하는 데 도움을 줄 수 있는 것들이 많다.
  1. The Haskell School of Expression: Learning Functional Programming through Multimedia(http://haskell.cs.yale.edu/soe/) - Paul Hudak
    1. 고전 자료구조나 알고리즘, 수학문제를 사용하는 기존의 서적과는 달리 배우는 이가 흥미를 더할 수 있도록 여러 멀티미디어 자원을 사용해서 프로그래밍을 소개하는 독특한 책이다. http://www.haskell.org에서 금새 찾아갈 수 있는 곳이지만, 다가가는 방식이 남달라서 다시 한번 소개한다. 준비할 시간이 더 많았다면 이 책에 있는 예제나 내용을 빌어 글을 썼을 것이다.
  1. Why Functional Programming Matters - John Hughes (http://www.md.chalmers.se/~rjmh/Papers/whyfp.html)
    1. The Computer Journal에 나온 유명한 글이다. 글의 제목대로 왜 함수를 쓰는 프로그래밍에 중요한 의미가 있으며, 그런 기법을 익혀야 하는지 잘 설명하고 있다.
  1. Algorithms: A functional programming approach- Fethi Rabhi and Guy Lapalme (http://www.iro.umontreal.ca/~lapalme/Algorithms- functional.html)
    1. 고전 알고리즘은 대부분 폰 노이만 기계식 언어에 가장 알맞게 만들어 놓았고, 전공 과정에서 배우는 내용도 매한가지다. 이 책은 함수를 쓰는 언어에 맞추어 알고리즘을 보다 이해하기 쉽게 꾸며 놓았을 뿐만 아니라, 프로그램을 개발하는 방법에 초점을 맞추고 있기 때문에 입문 단계를 지난 사람에게 적합한 책이다.
  1. Structure and Implementation of Computer Programs (SICP) Harold Abelson, Gerald Jay Sussman with Julie Sussman
    1. 정말 유명한 MIT의 프로그래밍 교재다. 내용과 예제가 방대하며, 여러 가지 프로그래밍 기법이 총 망라되어 있다. Scheme이라는 언어를 쓰고 있지만, 함수로 프로그래밍하는 기법을 자연스럽게 사용하고 있으며 다른 기법과 어떻게 섞어 쓸 수 있는지 아이디어가 반짝이는 수많은 고전 예제를 사용해 충실하게 소개하고 있다.
 
 
정리 : 강경수 elegy@sbmedia.co.kr

참고 문헌

  • Herold Abelson, Gerald Jay Sussman with Juile Sussman,“Structure and Interpretation of Computer Programs,” 2nd Ed. MIT Press, 1996
  • Richard Bird, “Introduction to Functional Programming using Haskell,” 2nd Ed. Prentice Hall, 1998
  • Richard Bird, Philip Wadler, “Introduction To Functional Programming,”Prentice Hall, 1988
  • Anthony J. Field, Peter G.Harrison “Functional Programming,” Addison Wesley, 1988
  • Timothy Budd , ”Multiparadigm Programming in Leda,” Addison Wesley, 1995
  • Paul Hudak John Peterson,Joseph Fasel, ”The Gentle Introduction to Haskell,” http://www.haskell.org/tutorial/, 2000
  • Simon Peyton Jones, About Haskell,” http://www.haskell.org/aboutHaskell.html, 2001
  • Andrew Hunt, David Thomas, “The Pragmatic Programmer : From Jorneyman to Master,”Addison-Wesley 1999.
  • Why Functional Programming Matters by John Hughes, The Computer Journal, Vol. 32, No. 2, 1989, pp. 98 - 107. Also in: David A. Turner (ed.): Research Topics in Functional Programming, Addison-Wesley, 1990, pp. 17 - 42.
 
Copyright (c) 2002 김재우. All rights reserved.