하스켈 학교

1990년대 후반부터 많은 대학들이 프로그래밍 입문에 코스에 자바를 채택하기 시작하였습니다. 2001년 텍사스 오스틴 대학도 프로그래밍 입문 코스에 기존에 사용하던 언어인 Haskell을 자바로 대체하는 방안을 검토 중이었습니다. 당시 이 대학에 재직 중이던 저명한 컴퓨터 과학자 다익스트라(Edsger W. Dijkstra)는 공개 서한을 통해 이런 움직임을 비판하며 Haskell의 장점을 강조하였습니다.

하지만 텍사스 오스틴 대학은 결국 상업적으로 성공한 자바를 채택하였고, 이후 MIT를 비록한 많은 대학들이 Scheme, ML, Haskell 같은 함수 언어 대신 Java나 Python을 가르치기 시작하여 그 추세는 지금까지도 이어지고 있습니다.

저도 대학에서 C, C++, Java를 배웠고 졸업 후에도 10년 이상을 C, C++, JavaScript를 사용해 코드를 작성하였습니다. 자바가상머신(JVM), 웹킷(WebKit) 등의 프로젝트에 참여하며 복잡한 소프트웨어를 작성하기 위한 객체지향프로그래밍 방법론, 디자인 패턴, 테스트 케이스 작성 방법 등도 배웠습니다.

하지만 동시에 한계도 느꼈습니다. 어제 작성한 코드가 오늘 작성한 코드와 크게 다르지 않고 오늘 발생한 버그가 지난 번에 해결한 버그와 크게 다르지 않았습니다. 프로그래밍은 점점 기계적인 활동이 되었고 흥미도 잃게 되었습니다. 코드 재활용이나 쉬운 유지보수는 영원히 달성할 수 없는 이상향으로만 느껴지기도 했습니다.

그러던 와중에 지푸라기라도 잡는 심정으로 학부 때 잠깐 공부했던 Haskell을 다시 꺼내서 공부하기 시작하였습니다. 그리고 프로그래밍에 대해 다시 생각하게 되었습니다. 함수 합성(function composition)을 통한 코드 재활용, 지연 연산(lazy evaluation)을 통한 모듈화, 펑터(functor)와 모나드(monad) 등 카테고리 이론을 이용한 추상화 등은 그간 프로그래밍에 대해 가졌던 여러 고정관념들을 허물고 익숙한 코드를 낯선 관점에서 새롭게 바라볼 수 있는 시각을 제공해 주었습니다.

흔히 Haskell은 배우기 어려운 언어라고 합니다. 저도 꽤 오랜 시간 많은 시행착오를 하며 람다 대수, 타입 이론, 카테고리 이론 등 Haskel 이해에 필요한 이론들을 공부하였습니다. 하지만 돌이켜 생각해보면 Haskell은 단순히 어려운 언어가 아니라 “다른” 언어였다는 생각이 듭니다. 특히, 이미 C++이나 Java 같은 언어로 오랜 시간 프로그래밍을 해왔다면 Haskell이 더욱 다르고 낯설게 느껴질 수 있습니다.

국내에는 Haskell 관련 자료가 별로 없습니다. Haskell을 주된 프로그래밍 언어로 사용하는 회사도 없습니다. 그래서 Haskell은 배우기 어려운 언어라는 악순환이 반복되고 있습니다. 하지만 많은 분들이 Haskell을 배우고 싶어합니다. 대체 무엇이 있는지 알 수 없지만, 뭔가 흥미로운 게 있을 것 같다는 기대 때문입니다. 이 사이트가 Haskell 세상으로 탐험을 나서는 분들에게 미약하나마 작은 도움이 되어드릴 수 있으면 좋겠습니다.

하스켈 “Hello World” 프로그램

프로그래밍 언어 입문서는 화면에 “Hello World”를 출력하는 프로그램을 보여주는 것으로 설명을 시작하는 전통이 있습니다. 이 전통에 따라 하스켈 “Hello World” 프로그램을 소개하겠습니다.

main = putStrLn "Hello World!"

이 프로그램을 실행하려면 먼저 코드를 복사해서 HelloWorld.hs 파일을 만듭니다. 그런 다음에 runhaskell 명령을 이용해 프로그램을 실행합니다. runhaskell은 GHC를 설치하면 함께 설치되는 프로그램으로 인자로 주어진 하스켈 프로그램을 곧바로 실행시켜 주기 때문에 하스켈을 스크립트 언어로 쓸 수 있게 해줍니다.

$ runhaskell HelloWorld.hs
Hello World!

ghc 컴파일러를 이용해서 실행 파일을 생성하는 것도 가능합니다.

$ ghc --make HelloWorld.hs

컴파일 후에 HelloWorld(윈도에서는 HelloWorld.exe)라는 실행 파일이 생성된 것을 확인할 수 있습니다. 실행하면 runhaskell HelloWorld.hs를 했을 때와 마찬가지로 화면에 “Hello World!”를 출력하는 것을 보실 수 있습니다.

$ ./HelloWorld
Hello World!

추가로 같은 디렉토리에 HelloWorld.hiHelloWorld.o 파일도 생성된 것을 볼 수 있는데, .hs 파일은 각 모듈을 따로 컴파일(separate compilation)을 하기 위한 인터페이스 파일이고, .o 파일은 해당 모듈을 컴파일한 오브젝트 파일입니다. 이에 대해서는 하스켈 모듈 시스템을 설명할 때 좀 더 자세히 설명하겠습니다.

이제 코드 내용을 살펴보겠습니다. 우선 main은 하스켈 코드가 실행되는 시작점(entry point)입니다. 모든 하스켈 프로그램은 main에서 시작해서 main으로 끝나게 됩니다.

ghci을 이용해서 main을 타입을 확인하면 다음과 같습니다. 참고로 :l 명령을 이용하면 외부 파일을 ghci로 로드할 수 있습니다. HelloWorld를 인자로 주면 HelloWorld.hs파일을 컴파일하여 ghci로 로드합니다.

$ ghci
> :l HelloWorld
[1 of 1] Compiling Main             ( HelloWorld.hs, interpreted )
Ok, modules loaded: Main.
> :t main
main :: IO ()

main의 타입은 IO ()라는 것을 알 수 있습니다. 여기서 () 타입은 유닛(unit) 타입으로()가 유일한 값입니다. 주로 유용한 리턴 값이 없을 때 사용하는 타입인데, C++이나 Java의 void와 유사한 역할을 합니다. 다만, 하스켈의 () 타입은 void와 달리 리턴 값이 없는 것이 아니라 () 타입의 유일한 값인 ()을 리턴합니다. 타입과 값 모두 ()이라는 심볼을 사용하는 것에 유의하시기 바랍니다.

> () -- 여기서 ()는 값입니다.
()
> :t () -- 여기서 ()는 타입입니다.
() :: ()

main의 리턴 값은 ()이 아니라 IO ()인데, 여기서 IO는 사이드 이펙트가 있는 함수라는 뜻입니다. 쉽게 말해, 화면에 입출력을 할 수 있는 함수는 ()가 아닌 IO () 타입으로 표시합니다. IO 타입에 대한 자세한 설명은 입출력(IO)에 대한 설명을 할 때 자세히 이야기하겠습니다.

putStrLn의 타입은 String -> IO ()입니다. 타입에 ->가 포함되어 있으므로 함수임을 알 수 있습니다. 인자 타입은 String이고 리턴 타입은 IO ()입니다. putStrLn은 문자열을 하나 받아서 화면에 출력하는 함수입니다. 화면에 문자열을 출력하는 것도 사이드 이펙트이기 때문에 리턴 타입이 ()가 아닌 IO ()임을 알 수 있습니다.

> :t putStrLn
putStrLn :: String -> IO ()

putStrLn "Hello World!"의 타입은 String -> IO () 타입 함수에 String 인자를 호출하였기 때문에 putStrLn의 리턴 타입인 IO ()가 됩니다. main의 타입인 IO ()putStrLn "Hello World!"의 타입이 같기 때문에 이 프로그램을 컴파일 에러 없이 정상적으로 컴파일이 됩니다.

하스켈 시작하기

설치

하스켈을 설치하는 가장 쉬운 방법은 하스켈 플랫폼(Haskell Platform)을 이용하는 것입니다. 하스켓 플랫폼은 하스켈 컴파일러 GHC, 빌드 시스템 Cabal, 자주 사용되는 35개의 하스켈 패키지를 한 번에 설치할 수 있게 도와주는 하스켈 배포판이라고 생각하시면 됩니다. Mac OS X, Windows, Linux를 모두 지원합니다.

GHC와 GHCi

ghc는 하스켈 컴파일러입니다. 다음과 같이 --version 옵션을 주고 실행했을 때 버전 정보가 출력되면 정상적으로 설치된 것입니다.

$ ghc --version
The Glorious Glasgow Haskell Compilation System, version 7.10.3

추가로 ghci라는 REPL도 함께 설치되었을 겁니다. 여기서 REPL은 Read–eval–print loop의 약자로 프로그램 코드를 입력하면 수행 결과를 화면에 보여주는 프로그램을 통칭하는 말입니다.

쉘에서 ghci 명령을 입력하면 다음과 같이 Prelude>라는 프롬프트가 뜨는 것을 확인하실 수 있습니다.

$ ghci
GHCi, version 7.10.3: http://www.haskell.org/ghc/  😕 for help
Prelude>

프롬프트에 간단한 하스켈 프로그램 1+2를 입력하면 계산 결과인 3이 화면에 표시됩니다.

Prelude> 1 + 2
3
Prelude>

이제 하스켈 프로그래밍을 시작할 준비가 완료되었습니다.

하스켈 함수

함수 언어

하스켈은 함수 언어입니다. 함수 언어의 대표적인 특징은 함수를 다른 함수에 인자로 넘기거나 함수가 다른 함수를 리턴하는 일이 가능하다는 것입니다. 이런 함수를 고차 함수(higher order function)라고 부릅니다. 이 글에서는 이런 특징을 가진 하스켈의 함수에 대해 살펴봅니다.

함수 호출

하스켈에서 함수 호출(function application)은 f a입니다. 여기서 f는 함수, a는 인자를 의미합니다. 함수 호출이 빈도가 높기 때문에 가장 간결한 문법을 사용하고 있습니다. 명령형 언어가 대개 f(a)와 같이 괄호를 사용하는 것과는 대비됩니다.

간단한 함수 호출의 예는 아래와 같습니다. isLower 함수는 인자 'a'를 입력으로 받아True를 값으로 돌려줍니다. 인자 'A'을 입력하면 False를 값으로 돌려줍니다. isLower함수는 Data.Char 모듈에 선언되어 있기 때문에 함수를 사용하기 위해서는 먼저 import구문을 사용해 임포트를 해주어야 합니다.

> import Data.Char
> isLower 'a' 
True
> isLower 'A'
False

또 다른 예로 fstsnd 함수가 있습니다. 둘 다 튜플을 인자로 받아 fst는 튜플의 첫 번째 요소를 리턴하고, snd는 튜플의 두 번째 요소를 리턴합니다.

> fst ('a', 1)
'a'
> snd ('a', 1)
1

함수의 인자 개수

하스켈 함수의 인자 개수는 항상 하나입니다. fstsnd 함수는 인자 'a'1 두 개의 인자를 받는 함수가 아니라 ('a', 1)이라는 튜플 값 하나를 인자로 받는 함수입니다.

하스켈에는 인자를 2개 이상 받는 함수가 존재하지 않습니다. 2개 이상의 인자가 필요하면 튜플을 넘기거나, 조금 있다 살펴볼 커리 함수(curried function)을 사용합니다.

함수의 타입

ghci에서 :t 명령을 이용하면 주어진 값의 타입을 확인할 수 있습니다. 아래 예를 보면,'a'의 타입은 Char임을 확인할 수 있습니다.

> :t 'a'
'a' :: Char

마찬가지 방법으로 함수의 타입도 확인할 수 있습니다. 앞서 살펴본 isLower 함수의 타입을 확인해보면 다음과 같은 결과가 출력됩니다.

> :t isLower
isLower :: Char -> Bool

isLower 함수의 타입이 Char -> Bool임을 확인할 수 있는데, 여기서 ->는 함수 타입을 나타냅니다. ->의 왼쪽에 있는 타입은 함수의 인자의 타입이고, ->의 오른쪽에 있는 타입은 함수의 리턴 타입입니다. 즉, isLower의 인자 타입은 Char, 리턴 타입은 Bool입니다.

같은 방법으로 fst의 타입도 확인할 수 있습니다.

> :t fst
fst :: (a, b) -> a

fst의 인자 타입은 (a, b), 리턴 타입은 a입니다. 튜플 타입은 (,)로 표현하는데, a타입을 첫 번째 요소로, b 타입을 두 번째 요소로 가지는 튜플 타입을 뜻합니다.

앞서 isLower 함수와 달리 구체적인 타입이 아닌 ab 같은 소문자로 표시된 타입은 다른 타입으로 대체될 수 있기 때문에 Char와 같은 타입과 구분하여 타입 변수(type variable)라고 합니다.

예를 들어, 튜플 ('x', 1::Int)(Char, Int) 타입을 가지는데 이 튜플로 fst 함수를 호출하면 fst 함수의 인자 타입 aChar로, bInt로 대체되어 fst의 타입은(Char, Int) -> Char가 됩니다. 같은 방식으로 (False, 'y') 튜플을 호출하면 aFalse의 타입인 Bool, b'y'의 타입인 Char가 되어 fst의 타입은(Bool, Char) -> Bool이 됩니다. 이처럼 하나의 함수가 인자에 따라 여러 타입을 가질 수 있는데, 이런 함수를 다형 함수(polymorphic function)라고 합니다.

연산자

하스켈은 +, -, &&, ||와 같이 특수 문자로 된 함수도 제공합니다. 이런 함수들을 연산자(operator)라고 부르는데, 연산자와 일반 함수의 차이점은 연산자는 infix로 일반 함수는 prefix로 사용한다는 점입니다. 즉, + 1 2가 아니라 1 + 2와 같은 형태로 함수를 호출하게 됩니다.

> 1 + 2
3

하지만 + 연산자를 prefix로 호출할 수도 있습니다. (+)와 같이 연산자를 괄호로 싸주면 일반 함수와 마찬가지로 prefix로 호출이 가능합니다.

> (+) 1 2
3

커리 함수(curried function)

앞서 하스켈 함수는 항상 인자를 하나만 받는다고 이야기하였습니다. 2개 이상의 인자가 필요할 경우, 튜플을 넘길 수 있다는 사실도 배웠습니다. 하지만 논리 연산자 (&&)의 예를 보면 인자를 2개 받는 것처럼 보입니다.

> False && True
False

분명 하스켈 함수는 인자를 1개만 받을 수 있다고 이야기했는데, (&&)는 어떻게 2개의 인자를 받을 수 있는 걸까요? :t 명령을 이용해서 (&&) 함수의 타입을 확인해 보겠습니다.

> :t (&&)
(&&) :: Bool -> Bool -> Bool

(&&)의 타입은 Bool -> Bool -> Bool입니다. 이 타입을 어떻게 해석해야 할까요? 앞서->는 함수 타입이고 ->의 왼쪽은 함수의 인자, ->의 오른쪽은 함수의 리턴 타입이라고 이야기했었습니다. 하지만 ->가 2개 있기 때문에 어디가 인자 타입이고, 어디가 리턴 타입인지 구분하기가 어렵습니다.

->은 우결합(right-associative)하기 때문에 Bool -> (Bool -> Bool)와 같이 괄호로 묶을 수 있습니다. 이 타입을 놓고 다시 해석을 해보면, (&&) 함수는 인자가 Bool 타입이고, 리턴 값은 함수 타입인 Bool -> Bool임을 알 수 있습니다.

바꿔 말해, (&&) 함수는 인자 2개를 받는 함수가 아니라 Bool 타입의 인자를 받아서Bool -> Bool 타입의 함수를 리턴하는 함수입니다. 하스켈은 고차 함수를 지원하는 언어이기 때문에 함수가 함수를 인자로 받거나 함수를 리턴하는 것이 가능합니다.

실제로 인자를 하나만 넘겨보겠습니다.

> (&&) False
Prelude> (&&) False

<interactive>:5:1: No instance for (Show (Bool -> Bool)) (maybe you haven't
applied enough arguments to a function?) arising from a use of ‘print’ In a stmt
of an interactive GHCi command: print it

에러 메시지는 ghci가 함수는 화면에 제대로 출력할 수 없기 때문에 발생합니다. 정말 함수를 리턴한 것이 맞는지는 :t 명령으로 확인할 수 있습니다.

> :t (&&) False
(&&) False :: Bool -> Bool

이처럼 리턴된 값이 Bool -> Bool 타입을 가진 함수라는 사실을 확인할 수 있습니다. 이 함수를 f라는 이름으로 바인딩하여 다시 True를 호출해 보겠습니다.

> let f = (&&) False
> f True
False

False && True(&&) 함수에 2개의 인자 FalseTrue를 넘긴 것이 아니라, 먼저False를 호출하고 그 결과로 함수가 리턴되면 다시 True를 인자로 리턴된 함수를 호출한 것입니다. 이 과정을 좀 더 명시적으로 보기 위해 괄호를 추가하면 다음과 같습니다.

> ((&&) False) True
False

이렇게 함수가 함수를 리턴하는 방식으로 여러 개의 인자를 받을 수 있게 만든 함수를 커리 함수(curried function)라고 부릅니다. 이와 달리 여러 값을 하나의 튜플로 묶어 넘기는 것을 언커리(uncurried function) 함수라고 부릅니다. (&&)는 커리 함수의 예이고, 앞서 살펴본 fstsnd 함수는 언커리 함수의 예입니다.

섹션

(+), (-)와 같은 연산자들은 모두 커리 함수이기 때문에 인자를 하나만 줘서 호출하면 함수를 리턴합니다. 예를 들어, (+) 1은 주어진 인자에 1을 더해서 리턴하는 함수가 됩니다.

> let f = (+) 1
> f 2
3

이렇게 바이너리 연산자에 인자 하나만 줘서 새로운 함수를 만드는 경우가 흔하기 때문에 하스켈은 (+1)과 같은 특별한 문법을 제공합니다. 이렇게 연산자에 인자 하나만 줘서 새로운 함수를 만드는 방법을 섹션(section)이라고 부릅니다.

> (+1) 2
3

피연산자의 위치에 따라 섹션을 만드는 방법은 2가지가 있습니다. 예를 들어 (1-)는 1에서 주어진 값을 빼는 함수이고, (-1)는 주어진 값에서 1을 빼는 함수입니다.

> (-5) 3
-2
> (5-) 3
2

하스켈 입문서

Learn You a Haskell for Great Good!

보통 줄여서 LYHGG라고 부르는 Learn You a Haskell for Great Good!는 가장 쉬운 하스켈 입문서입니다. 원서는 무료로 공개되어 있어 온라인으로 읽으실 수 있고, 국내에는가장 쉬운 하스켈 책 느긋하지만 우아하고 세련된 함수형 언어이란 제목으로 번역 출간되었습니다.

책 전반부에서는 하스켈 타입, 함수, 문법, 재귀, 고차 함수, 모듈 등 하스켈 언어의 기본적인 내용을 쉽고 간결하게 설명하고 있고, 후반부에는 타입클래스, 입출력, Functor, Applicative Functor, Monoid, Monad 등 고급 주제들을 다루고 있습니다.

참고로 쉬운 입문서라고는 하지만 책 후반부의 난이도가 전반부에 비해 상대적으로 높아서 독학하시는 분들이 대개 Functor나 Monad에 막혀서 그만 두는 경우가 많습니다.

Real World Haskell

LYHGG가 주로 하스켈 언어의 특징에 대해 소개한 책이라면, Real World Haskell은 하스켈을 실제 업무에 사용할 수 있도록 다양한 예제를 통해 하스켈의 특징을 설명하고 있습니다. 저자인 Bryan O’Sullivan과 Don Stewart는 각각 페이스북과 스탠다드 차타드 은행에서 하스켈 팀을 이끌고 실제로 하스켈로 개발 업무를 수행하고 있습니다.

덕분에 이 책에는 JSON 처리, 파서 작성, 시스템 프로그래밍, 데이터베이스, GUI, 네트워크 프로그래밍 등의 하스켈을 실제 업무에 사용하기 위해 필요한 구체적인 지식들이 포함되어 있습니다.

한 가지 아쉬운 점은 2007년 이 책이 출간된 이후 하스켈 라이브러리에도 여러 변경 사항이 생기면서 일부 챕터의 코드는 더 이상 제대로 동작하지 않는다는 점입니다. 특히, 챕터 19 “Error handling”의 경우 하스켈의 예외 처리 라이브러리가 완전히 개정되면서 더 이상 유효하지 않게 되었습니다.

원서는 온라인에서 무료로 읽으실 수 있고, 아쉽게도 국내에 번역 출간된 적은 없습니다.

Haskell Book

Haskell Book은 요즘 하스켈 커뮤니티에서 가장 핫한 책입니다. 이 책은 Christopher Allen과 Julie Moronuki이 공동 집필하였는데, 특이한 점은 Julie Moronuki은 이 책을 쓰기 전까지 개발자가 아니었다는 사실입니다. 대학에서 철학을 전공하고 교사 및 사서로 일하던 Julie는 Christopher의 설득으로 하스켈을 생애 첫 프로그래밍 언어로 배웠고 그 내용을 함께 정리하여 책으로 출간하게 되었습니다. 덕분인지 기존의 하스켈 책보다 훨씬 이해하기 쉽게 잘 작성되었다는 평가를 받고 있습니다.

이 책의 1장은 하스켈이 아닌 람다 대수(lambda calculus)로 시작합니다. 람다 대수는 하스켈을 비롯한 모든 함수 언어의 이론적 배경이 되는 수학으로 함수 언어를 제대로 사용하려면 언젠가는 배워야 하는 내용인데, 이 책은 과감하게 1장에서 람다 대수를 설명하고 2장부터 하스켈에 대한 소개로 이어집니다.

이 책은 LYHGG와 Real World Haskell의 장점을 고루 섞어놓았습니다. 하스켈 언어에 대한 기본 설명도 충실하고, 배운 내용을 실제로 사용할 수 있게 여러 실례도 소개하고 있습니다. 새로 하스켈을 공부하시는 분이라면 제가 가장 추천 드리는 책이기도 합니다.

이 책은 아직 완간되지 않습니다. 대신 홈페이지에서 $59에 Early Access 버전을 구매하실 수 있습니다. 당연히 아직 한글 번역서도 존재하지 않습니다. 국내 출판사에서 번역 출간 계약을 하면 좋겠습니다.

하스켈 무한 리스트

하스켈은 유한한 리스트뿐만 아니라 무한히 긴 리스트를 정의할 수 있습니다. 예를 들어,[m..]은 m에서 시작하는 모든 정수의 리스트가 됩니다.

> [1..]
[1,2,3,4,5,6,7,8Interrupted.
>

ghci[1..]을 입력하면 이 리스트를 화면에 출력하는데, 무한히 긴 리스트이므로 끝이 나지 않고 계속해서 숫자를 출력하는 것을 보실 수 있습니다. Ctrl+C 키를 눌러서 종료하면 Interrupted 메시지와 함께 출력이 종료됩니다.

리스트 기초에서 살펴본 take 함수를 이용하면 무한히 긴 리스트에서 일부 원소만 뽑아낼 수 있습니다.

> take 10 [1..]
[1,2,3,4,5,6,7,8,9,10]

같은 리스트를 [1..]과 같은 표기법을 이용하지 않고 재귀적으로 정의할 수도 있습니다. 이렇게 정의한 ints 리스트에서 take 함수로 10개 처음 10개 원소를 꺼내보면 [1..]과 마찬가지로 1부터 10까지 정수의 리스트를 리턴하는 것을 볼 수 있습니다.

> let ints = 1 : map (+1) ints
> take 10 ints
[1,2,3,4,5,6,7,8,9,10]

ints가 어떻게 동작하는지를 이해하기 위해 원소를 차례대로 하나씩 꺼내보겠습니다.ints는 머리 1과 꼬리 map (+1) ints로 구성된 리스트이기 때문에 ints의 첫 번째 원소는 1입니다. 두 번째 원소는 map (+1) ints의 머리입니다. map (+1)은 주어진 리스트의 모든 원소에 1을 더해주는데, 여기서는 다시 ints를 인자로 넘겼습니다. 이 ints의 첫 번째 인자는 1이였으므로 map (+1) ints의 첫 번째 원소는 11을 더한 2가 됩니다. 따라서 전체 ints의 두 번째 인자도 2가 됩니다. 같은 방법으로 세 번째 원소를 꺼내면 3이 되는 것을 확인할 수 있습니다.

이런 무한 리스트도 유한한 리스트와 마찬가지로 map이나 filter 같은 함수들을 사용해 원소를 원하는 대로 바꿀 수 있습니다. 아래 예제에서 evensints 리스트의 각 원소에2를 곱한 짝수의 리스트입니다. take 함수로 10개를 뽑아보면 2부터 20까지 짝수 10개가 리턴되는 것을 볼 수 있습니다.

> let ints = 1 : map (+1) ints
> let evens = map (*2) ints
> take 10 evens
[2,4,6,8,10,12,14,16,18,20]

조금 더 간단한 예로 1이 무한히 반복되는 리스트를 정의해 볼 수도 있습니다.

> let ones = 1 : ones
take 10 ones
[1,1,1,1,1,1,1,1,1,1]

ones의 머리는 1이고 꼬리는 다시 ones입니다. 따라서 ones의 두 번째 원소는 ones의 머리이므로 1이고, 세 번째, 네 번째 원소도 모두 1이 되는 것을 알 수 있습니다.

하스켈은 무한 리스트를 정의하는 경우가 많으므로 Prelude 모듈에서는 편의를 위해iterate를 함수를 제공합니다. iterate 함수는 함수 f를 인자 x에 0번, 1번, 2번, … 반복해서 호출하여 리스트를 만들어줍니다.

iterate f x == [x, f x, f (f x), ...]

iterate 함수를 이용해서 ints를 정의할 수도 있습니다.

> let ints = iterate (+1) 1

iterate (+1) 1[1, (+1) 1, (+1) $ (+1) 1, (+1) $ (+1) $ (+1) 1, ...]와 동일하고,(+1) $ (+1) 1은, (+2) 1과 같으므로 위 리스트는 [1..]과 같다는 사실을 쉽게 확인할 수 있습니다.

iterate 함수로 ones도 정의할 수 있습니다.

> let ones = iterate id 1

여기서는 iterate 함수의 인자로 id를 넘겼습니다. idSKI 콤비네이터에서 살펴보았던 항등 함수(identity function)로 인자를 받아 그대로 리턴합니다. iterate id 1[1, id 1, id $ id 1, id $ id $ id 1, ..]이므로 모든 원소가 1이 되는 것을 확인할 수 있습니다.

이런 경우에는 repeat 함수를 이용하면 더 간단합니다. repeat 함수는 주어진 인자 x를 무한히 반복합니다.

> let ones = repeat 1

이런 무한 리스트는 zip 함수와 같이 사용하는 경우가 많습니다. zip 함수는 리스트 두 개를 인자로 받아 각 리스트이 원소를 하나씩 뽑아 묶은 튜플의 리스트를 리턴합니다. 간단한 예를 보면,

> zip [1,2,3,4,5] ['a', 'b', 'c', 'd', 'e']
[(1,'a'),(2,'b'),(3,'c'),(4,'d'),(5,'e')]

두 리스트 중 한 쪽이 짧으면 먼저 끝나는 쪽을 기준으로 끝이 납니다.

zip [1,2,3,4,5] ['a', 'b', 'c']
[(1,'a'),(2,'b'),(3,'c')]

zip 함수를 이용하여 리스트 각 원소의 인덱스를 매기고 싶은데, 리스트이 길이를 미리 알 수 없다면 어떻게 해야 할까요? 이런 경우 [1..]와 같이 무한히 긴 인덱스의 리스트를 넘기면 됩니다.

> let ls = ['a', 'b', 'c', 'd', 'e']
> zip [1..] ls
[(1,'a'),(2,'b'),(3,'c'),(4,'d'),(5,'e')]

하스켈 검색 엔진 Hoogle

구글(Google)과 유사한 발음의 Hoogle은 하스켈 API 검색 엔진입니다. API의 이름뿐만 아니라 타입으로도 검색할 수 있다는 점이 가장 큰 특징입니다.

예를 들어 (a -> b) -> [a] -> [b] 타입의 함수를 찾고 싶으면 검색 창에(a -> b) -> [a] -> [b]라고 입력해주면 됩니다. 그러면 Hoogle은 검색 결과로 PreludeData.List 모듈에 정의된 map 함수를 보여줍니다.

map :: (a -> b) -> [a] -> [b]

cabal-installhoogle 패키지를 설치하면 커맨드라인에서도 검색이 가능합니다.

$ cabal install hoogle

->가 포함된 타입을 검색할 때는 검색어를 ""로 묶어줍니다.

$ hoogle "(a -> b) -> [a] -> [b]"
Prelude map :: (a -> b) -> [a] -> [b]
Data.List map :: (a -> b) -> [a] -> [b]
...

~/.ghc/ghci.conf 파일에 다음 내용을 추가해주면 ghci 내에서도 검색이 가능합니다.

:def hoogle \s -> return $ ":! hoogle --count=15 \"" ++ s ++ "\""

ghci에서 검색할 때는 :hoogle 뒤에 검색어를 입력하면 됩니다.

>:hoogle (a -> b) -> [a] -> [b]
 Prelude map :: (a -> b) -> [a] -> [b]
 Data.List map :: (a -> b) -> [a] -> [b]
 ...

하스켈 리스트와 고차함수

앞서 리스트 기초 장에서는 리스트 타입의 정의와 head, tail, length 등 기본적인 리스트 함수들을 살펴보았습니다. 리스트는 이런 기본적인 함수 외에도 map, filter, foldr처럼 함수를 인자로 받는 함수들을 제공합니다. 하스켈에서는 함수가 다른 함수를 인자로 받거나 함수를 리턴할 수 있는데 이런 특성을 가진 함수들을 고차 함수(higher-order function)라고 부릅니다.

map 함수

우선 대표적인 고차 함수의 예로 map을 살펴보겠습니다. map의 타입은(a -> b) -> [a] -> [b]입니다. 타입 a를 받아 b를 리턴하는 함수 하나와 a 타입의 리스트 하나를 인자로 받아 b 타입의 리스트를 리턴하는 함수입니다. map은 주어진 함수를 리스트 각각의 원소에 적용하여 새로운 리스트를 리턴합니다. 간단한 사용 예는 다음과 같습니다.

> map (\x -> x + 1) [1,2,3]
[2,3,4]

여기서 \x -> x + 1은 하스켈에서 람다 함수(lambda function)을 정의하는 방법입니다.\를 사용하는 이유는 아스키 코드 중에서 그리스 알파벳 람다(λ)를 가장 닮았기 때문입니다.

이 람다 함수는 인자 x를 받아서 x + 1을 리턴합니다. ->를 기준으로 왼쪽은 인자, 오른쪽은 함수 몸통(function body)입니다. 인자가 2개 이상인 경우는 공백 문자로 구분해 주면 됩니다. 따라서 인자 xy를 받아 합을 리턴하는 함수는 \x y -> x + y로 정의할 수 있습니다.

앞서 하스켈 함수에서 배운 섹션(section)을 사용하면 같은 함수를 좀 더 간결히 표현할 수 있습니다. \x -> x + 1은 2개의 인자를 받는 (+) 연산자에 인자 하나만 먼저 적용하여 새로운 함수를 만들었다고 볼 수 있기 때문에 (+1)과 같이 표현할 수 있습니다.

> map (+1) [1,2,3]
[2,3,4]

filter

filter 함수는 각 원소에 대해 Bool을 리턴하는 테스트 함수(predicate)를 인자로 받아 각 원소 중에서 테스트를 통과한 원소들만 모아 새로운 리스트로 리턴하는 함수입니다.filter의 타입은 (a -> Bool) -> [a] -> [a]인데 첫 번째 인자 타입 a -> Bool을 보면 테스트 함수는 리스트의 원소 타입 a를 인자로 받아 Bool을 리턴하는 것을 확인하실 수 있습니다.

> filter odd [1,2,3,4,5]
[1,3,5]

filter와 비슷한 함수로는 partition 함수가 있는데, filter와 마찬가지로 테스트 함수를 받아 리스트 각 원소에 대해 테스트를 실시하는데, filter는 테스트를 통과한 원소의 리스트를 리턴하는 반면 partition은 테스트를 통과한 원소와 통과하지 못한 원소를 따로 모아 각각을 리스트로 리턴합니다.

리턴값이 2개의 리스트이므로 튜플을 사용합니다. 튜플의 첫 번째 원소는 테스트를 통과한 원소들의 리스트이고, 튜플의 두 번째 원소는 테스트를 통과하지 못한 원소들의 리스트입니다. partition 함수는 filter와 달리 Prelude 모듈에 정의되어 있지 않으므로 사용하려면 먼저 Data.List 모듈을 임포트해주어야 합니다.

> import Data.List
> partition odd [1,2,3,4,5]
([1,3,5],[2,4])

foldr

마지막으로 살펴볼 함수는 foldr입니다. 리스트의 경우 foldr의 타입은(a -> b -> b) -> b -> [a] -> b입니다. 알서 살펴본 map이나 filter와는 달리 리턴 타입이 [a]가 아니라 b임을 볼 수 있습니다. map처럼 리스트 각 원소를 가지고 새로운 리스트를 만들거나, filter처럼 리스트의 각 원소 중 일부를 추려서 새로운 리스트를 리턴하는 함수는 아님을 짐작할 수 있습니다.

이해를 돕기 위해 먼저 사용 예를 살펴보겠습니다. 다음은 foldr을 이용해 리스트 원소들의 합을 구하는 코드입니다.

> foldr (+) 0 [1,2,3,4,5]
15

(+)는 인자 두 개를 받아 합을 리턴하는 함수이고, 0은 초기값입니다. 위 foldr 함수를 풀어써보면 다음과 같습니다.

(1 + (2 + (3 + (4 + (5 + 0 )))))

리스트 기초에서 살펴본 리스트 [1,2,3,4,5]의 정의와 비교해보면 모양이 상당히 유사하다는 사실을 알 수 있습니다.

(1 : (2 : (3 : (4 : (5 : [])))))

리스트 정의에서 (:)(+)로 바뀌었고, []0으로 바뀌었습니다. 즉, foldr f acc의 의미는 리스트를 펼쳐놓고 (:)f[]acc로 바꾼 것과 같다고 생각할 수 있습니다.

이번에는 각 리스트의 곱을 구해보겠습니다.

foldr (*) 1 [1,2,3,4,5]

마찬가지로 리스트를 정의에 따라 풀어 쓰고 (:)(*)[]1로 바꿔쓰면 됩니다.

(1 : (2 : (3 : (4 : (5 : [])))))
(1 * (2 * (3 * (4 * (5 * 1 )))))

foldr을 이용하면 리스트의 길이를 리턴하는 length 함수도 구현할 수 있습니다. 아래 리스트 정의에서 (:)[]를 어떻게 바꾸면 각 원소 대신에 리스트의 길이를 계산하게 될지를 생각해 봅시다.

(1 : (2 : (3 : (4 : (5 : [])))))

금방 답이 떠오르지 않으신가요? 이해를 돕기 위해 이번에는 (:) 연산자를 infix가 아닌 prefix로 표기해보겠습니다.

(:) 1 ((:) 2 ((:) 3 ((:) 4 ((:) 5 []))))

(:)를 보기 좋게 임의의 함수로 f로 대체하면 다음과 같습니다.

f 1 (f 2 (f 3 (f 4 (f 5 []))))

이제 함수 f를 어떻게 정의하면 리스트의 길이를 구할 수 있을까요? 길이를 구할 때 리스트의 원소가 무엇인지는 중요하지 않으므로 첫 번째 인자를 무시합니다. 그리고 f 함수가 매번 호출될 때마다 리스트의 길이가 1만큼 늘어난다는 사실에 착안하여 두 번째 인자로 넘어온 값에 1을 더해주면 어떨까요? 그렇게 f를 정의하면 \x y -> y + 1가 되는데, 실제로 테스트해보면 리스트의 길이를 리턴하는 것을 볼 수 있습니다.

> foldr (\x y -> y + 1) 0 [1,2,3,4,5]
5

foldr 함수는 맥가이버 칼로 불릴 만큼 굉장히 강력한 함수입니다. foldr 함수를 이용하면 map이나 reverse 함수도 구현할 수 있습니다.

숙제: foldrmapreverse를 구현해 봅시다.

하스켈 리스트 기초

리스트는 하스켈에서 가장 범용적으로 사용되는 데이터 타입입니다. 리스트의 타입은[a]로 표시하는데, a는 요소 타입을 뜻합니다. 예를 들어 Char의 리스트는 [Char],Int의 리스트는 [Int]와 같이 표시합니다.

리스트 [a]머리(head)꼬리(tail)로 구성되고 머리는 타입 a, 꼬리는 또 다른 리스트 [a] 타입을 가집니다. 구성 요소에 자기 자신의 타입을 반복되기 때문에 리스트와 같은 타입을 재귀 타입(recursive type)이라고 부릅니다.

머리와 꼬리를 이용하여 이용하여 리스트를 만드는 연산자는 (:)입니다. []는 아무 요소도 없는 빈 리스트를 의미합니다. 리스트를 새로 만들려면 일단 리스트가 하나 있어야 하기 때문에 맨 처음에는 빈 리스트를 이용합니다. 1[]을 합하면 리스트 [1]이 됩니다.

> 1 : []
[1]
> 1 : 2 : []
[1,2]
> 1 : 2 : 3: []
[1,2,3]

여기서 (:) 연산자는 우결합(right-assocative)하기 때문에 1:2:3:[]의 의미는(1:(2:(3:[])))과 같습니다. 먼저 3[]를 합하여 [3]을 만들고 다시 여기에 2를 합하여 [2,3]를 만들고 다시 여기에 1을 합하여 [1,2,3]을 만든다고 이해하시면 됩니다.

매번 이렇게 리스트를 생성하면 불편하기 때문에 조금 더 쉽게 리스트를 생성하는 방법이 있습니다. [] 안에 ,로 구분하여 요소들을 나열해주면 됩니다.

> [False, True, False]
[False,True,False]
> [1,2,3]
[1,2,3]
> []
[]

length 함수는 리스트의 요소 개수를 리턴합니다.

> length [1, 2, 3]
3
> length [1]
1
> length []
0

참고로 우리가 쓰고 있는 String 타입은 실제로는 Char 타입의 리스트인 [Char]로 정의되어 있습니다. 따라서 리스트를 인자로 받는 함수인 lengthString을 줘도 문자열의 길이를 리턴합니다.

> length "abc"
3
> length ""
0

head 함수는 리스트의 머리를 돌려줍니다. 빈 리스트를 인자로 받았을 경우에는 돌려줄 머리가 없으므로 에러가 납니다.

> head [1, 2, 3]
1
> head  []
*** Exception: Prelude.head: empty list

반대로 tail 함수는 리스트의 꼬리를 돌려줍니다. 마찬가지로 빈 리스트를 인자로 받았을 때는 돌려줄 꼬리가 없으므로 에러가 납니다.

> tail [1, 2, 3]
[2,3]
> tail []
*** Exception: Prelude.tail: empty list

(++) 연산자는 리스트 두 개를 합친 새로운 리스트를 리턴합니다.

> [1, 2, 3] ++ [4, 5, 6]
[1,2,3,4,5,6]
> [1, 2, 3] ++ []
[1,2,3]

reverse 함수는 리스트의 순서를 거꾸로 뒤짚어서 리턴합니다. 빈 리스트는 뒤짚을 요소가 없으므로 그대로 빈 리스트를 리턴합니다.

> reverse [1, 2, 3]
[3,2,1]
> reverse []
[]

sum이나 product 함수를 이용하면 리스트 요소들의 모두 더한 값이나 모두 곱한 값을 얻을 수도 있습니다.

> sum [1, 2, 3, 4, 5]
15
> product [1, 2, 3, 4, 5]
120

takedrop 함수를 이용하면 서브 리스트를 얻을 수 있습니다. take는 주어진 개수만큼의 요소를 새로운 리스트로 만들어 리턴합니다. drop은 주어진 개수만큼의 요소를 빼거 나머지 요소들을 새로운 리스트로 만들어 리턴합니다.

> take 3 [1, 2, 3, 4, 5]
[1,2,3]
> drop 3 [1, 2, 3, 4, 5]
[4,5]

이 외에도 Data.List 문서를 보시면 리스트에 정의된 수많은 함수들을 확인하실 수 있습니다. 리스트는 매우 자주 사용되는 데이타 타입이므로 리스트 함수들은 여러 번 공부하고 익혀서 숙지하고 계시면 좋습니다.

SKI 콤비네이터

때는 2030년, 2016년 바둑으로 이세돌을 이긴 알파고는 더욱 진화하여 인류를 지배하기 시작하였다. 저항군은 알파고를 파괴할 컴퓨터 바이러스 개발에 나선다. 알파고는 이를 막고자 개발자들이 사용할 수 있는 함수들을 파괴하기 시작했다. 오랜 싸움 끝에 이제 인류에게 남은 함수는 단 3개뿐, 과연 인류는 3개의 함수로 알파고를 물리칠 바이러스를 만들 수 있을까?

2030년 인류가 바이러스 개발에 사용할 수 있는 함수는 단 3개가 남았습니다. 남은 함수가 3개밖에 없다는 사실을 알고 이제 알파고를 이기는 것을 불가능하다며 많은 사람들이 절망하고 있습니다.

s x y z = x z (y z)
k x y = x
i x = x

각각 s, k, i라고 이름 붙여진 함수들은 보시는 바와 같이 아주 간단한 일을 하는 함수입니다.

  • i: 인자 x를 받아서 그대로 x를 리턴하는 함수입니다. 항등 함수를 뜻하는 영어 identity function의 i에서 따온 이름입니다.
  • k: 2개의 인자 xy를 받아서 항상 첫 번째 인자 x를 리턴하는 함수입니다. 상수 함수를 뜻하는 영어 constant function을 c에서 따온 이름인데, 개발자들은 c를 k라고 부르는 습성이 있어 k 함수가 되었습니다.
  • s: 3개의 인자 x, y, z를 받는 함수입니다. 먼저, z를 인자로 x를 호출합니다. 그리고 나서 리턴된 함수에 z를 인자로 y 함수를 호출한 결과를 다시 인자로 넘겨서 호출합니다. s라는 이름은 대체 연산자를 뜻하는 substitution operator의 s에서 왔습니다.

i 함수와 k 함수는 크게 쓸모가 없어 보입니다. 인자를 그대로 돌려주는 함수나 두 개의 인자 중 항상 첫 번째 인자만 리턴하는 함수 모두 그다지 유용한 일을 하는 것 같지는 않습니다.

그나마 s 함수는 뭔가 유용한 일을 하는 것 같습니다. s가 대체 연산자라는 이름이 붙은 이유는 함수 호출은 변수를 대체하는 것과 같기 때문입니다. 간단한 예로, 인자 x를 받아서 튜플 (x, x)를 리턴하는 함수 \x -> (x,x)가 있다고 하면 이 함수에 1을 인자로 호출한 (\x -> (x, x)) 1의 값은 변수 x를 1로 대체한 (1, 1)과 같기 때문입니다.

많은 사람들이 절망하고 있던 그 순간 그간 비주류 언어라며 천대 받던 하스켈 개발자가 나섭니다. 이 세 함수만 있으면 알파고를 무찌를 바이러스를 충분히 개발할 수 있다고 자신감 있게 주장합니다. 사람들은 동요하기 시작합니다. 누군가가 묻습니다. “하지만 우린TrueFalseif 문도 없는데 없다고! 조건문 없이 어떻게 프로그램을 만들 수 있단 말이야?”

하스켈 개발자가 답합니다. “가능합니다. Truek, Falsesk로 정의하면 됩니다.k 함수는 인자 x, y를 받아서 x를 리턴합니다. sk 함수는 반대로 인자 x, y를 받아서 y를 리턴합니다. True일 때는 x, False 일 때는 y를 리턴하는 값을 만들었으므로 if 문처럼 사용할 수 있습니다.”

k x y = x
s k x y = k y (x y) = y

또 다른 개발자가 외칩니다. “하지만 루프가 없다고! 루프 없이는 아무런 유용한 프로그램을 짤 수가 없다고. 튜링 머신이 아니란 말이야.” 하스켈 개발자가 단호한 표정으로 다시 답합니다. “우리에게 남은 함수는 세 개 밖에 없고, 말씀하신 것처럼 이제 인류는 더 이상 루프를 사용할 수가 없습니다. 하지만 우리에게 재귀 함수가 있습니다. 루프에 비해 느리고, 어렵다며 천대 받던 재귀 함수이지만 이제 우리에게 남은 유일한 희망입니다. 재귀 함수를 정의하는 원리는 간단합니다. 먼저 sii를 보시기 바랍니다.”

sii a = (i a) (i a) = a a

“이처럼 sii는 인자 a를 받아서 a를 인자로 자기 자신을 다시 호출합니다. 네. 맞습니다. 여러분이 사랑하고 동시에 증오하던 무한 루프입니다. 이런 방식을 이용하면 재귀 함수 y를 만들어낼 수 있습니다. y 함수는 다음과 같이 정의합니다.”

y = s(c(s(ks)k)(sii))(c(s(ks)k)(sii))

또 다른 개발자가 소리칩니다. “그럼 정수는? 루프나 if가 있다고 하도 정수 없이 무슨 코딩을 하란 말이야?” 하스켈 개발자는 동요하지 않고 여전히 자신감 있게 설명을 이어갑니다. 오랜 설명 끝에 결국 모든 개발자들이 s, k, i 단 3개의 함수만으로 알파고를 물리칠 바이러스 개발이 가능하다는 말을 납득합니다. 이후 더 이상 하스켈 개발자를 천대하지 않고 함께 열심히 바이러스를 개발하여 알파고를 무찌르고 행복하게 살았답니다.

여기서 설명한 s, k, i 함수는 컴퓨터 과학자, 논리학자에게는 이미 잘 알려진 SKI 콤비네이터입니다. 콤비네이터라는 용어 자체가 생소할 수 있는데, 콤비네이터는 free 변수가 없는 함수를 뜻합니다. 여기서 free 변수가 없다는 뜻은 함수에 주어진 인자만 사용하는 순수 함수를 말합니다. 다음은 콤비네이터의 예제입니다.

\a -> a
\a -> \b -> a
\f -> \a -> \b -> f b a

콤비네이터가 아닌 함수의 예제는 다음과 같습니다. x라는 변수가 함수의 인자로 정의되지 않았습니다. 이런 변수 x를 free 변수라고 부릅니다.

\a -> \b -> x

재미있는 사실은 계산가능성(computability)를 봤을 때 SKI 콤비네이터는 람다 대수(lambda calculus)와 동급이고, 람다 대수가 튜링 머신과 동급이기 때문에 결국 SKI 콤비네이터가 정의한 3개의 함수만 가지고 우리가 컴퓨터를 가지고 할 수 있는 모든 계산을 할 수 있습니다. 인류가 이 세 개의 함수만 가지고 알파고를 무찌를 바이러스를 만들 수 있었던 이유도 세 개의 함수가 튜링 머신과 동급이기 때문입니다.

사실 엄밀하게 이야기하면 sk 2개만 함수만 있어도 됩니다. i조차도 sk의 조합인 skk로 만들어 낼 수 있기 때문입니다.

skk a = k a (k a) = a

s, k, i 세 함수를 장황하게 소개한 이유는 각 함수가 하스켈 프로그래밍에도 실제로 아주 자주 사용되는 필수 함수이기 때문입니다. 이들 함수는 하스켈에서 각각((->) r) Applicative 인스턴스의 (<*>) 함수, const 함수, id 함수로 정의되어 있습니다. 앞으로 설명할 내용에도 자주 등장하는 함수들이 꼭 기억해 두시기 바랍니다.