[책] “가장 쉬운 하스켈 책”을 읽고 연습해보았다.

telnet turtle·2022년 11월 30일
0
post-thumbnail

책 “가장 쉬운 하스켈 책”(미란 리포바카, 옮긴이 황반석)을 읽고 하스켈을 다시 연습해 보았다. 하스켈은 “순수한 함수형 프로그래밍 언어”다.

여기 있는 예제는 일부 내가 작성한 것도 있지만 대다수는 책에서 발췌하였다.

Mac에 GHC 설치하기

Haskell 코딩을 위해선 먼저 컴퓨터에 GHC를 설치해야 한다. GHC는 컴파일러이다. 이외에도 Stack이라는 플랫폼이 있지만 필수적으로 설치할 필요는 없다.

$ brew install ghc

조금 오래걸린다.

설치가 됐다면 ghci를 실행해서 하스켈 인터프리터가 실행되는지 확인해보자.

$ ghci
GHCi, version 9.2.4: https://www.haskell.org/ghc/  :? for help
ghci> 
ghci> 3 + 5
8
ghci> 2 == 4
False
ghci> 'H' == 'H'
True
ghci> sum [1..10]
55

하스켈 모듈을 작성하고 (소스 파일을 작성하고) 인터프리터에서 불러와 실행할 수 있다. 예를 들어 파일 a.hs를 다음과 같이 작성하고,

isEven :: Int -> Bool
isEven x
    | mod x 2 == 0  = True
    | otherwise     = False

ghci에서 사용할 수 있다.

ghci> :l a.hs
[1 of 1] Compiling Main             ( a.hs, interpreted )
Ok, one module loaded.
ghci> isEven 10
True

:r을 쓰면 모듈을 다시 불러올 수 있다.

:q를 쓰면 ghci에서 나갈 수 있다.

연습

책을 본격적으로 읽기 전에 연습문제를 풀어서 예전에 WikibooksHaskell을 읽었던 기억을 되살려보자. 이 부분이 이해가 안 가도 괜찮다. 하스켈의 느낌만 느껴보자.

zip함수의 구현은 다음과 같다. (source)

zip :: [a] -> [b] -> [(a,b)]
zip []     _bs    = []
zip _as    []     = []
zip (a:as) (b:bs) = (a,b) : zip as bs

예를 들면, 이렇게 사용한다.

ghci> zip [10, 20] [30, 40]
[(10,30),(20,40)]
ghci> 
ghci> 
ghci> zip [10, 20] [30, 40, 50]
[(10,30),(20,40)]

연습 삼아 Python의 itertools.product를 구현해보자. 이런 식으로 동작한다.

ghci> product [10, 20] [30, 40]
[(10,30),(10,40),(20,30),(20,40)]

product.hs를 다음과 같이 작성했다. product는 prelude에 이미 존재하므로 함수 이름을 바꾸었다.

product' :: [a] -> [b] -> [(a,b)]
product' []  _bs     = []
product' _as []      = []
product' (a:as) _bs  = (aux a _bs) ++ (product' as _bs)
    where   aux :: a -> [b] -> [(a,b)]
            aux a = zip $ repeat a

ghci에서 확인해보자.

ghci> :l product.hs 
[1 of 1] Compiling Main             ( product.hs, interpreted )
Ok, one module loaded.
ghci> product' [10,20] [30,40]
[(10,30),(10,40),(20,30),(20,40)]

만든 product'를 해설해보자.

첫 줄의 :: 는 함수의 타입 선언이다. product' 함수는 타입 [a]와 타입 [b]의 인자를 받아 타입 [(a,b)]를 리턴한다는 뜻이다. [a]에서 a는 타입 변수이다. 타입스크립트를 써봤다면 <T, K>같은거라고 생각하면 된다. [a]a의 리스트를 말한다. 리스트의 요소는 모두 같은 타입이어야 한다. 그리고 ()는 튜플이다. 파이썬의 튜플을 생각해도 된다. 튜플은 길이가 고정되어 있으며, 요소들의 타입이 서로 다를 수 있다. 서로 다른 길이의 튜플은 서로 다른 타입으로 간주된다.

밑의 세줄은 패턴 매칭을 활용해서 함수를 선언했다. 패턴 매칭을 활용하면 조건에 맞는 경우의 구현에 따라서 결과가 나오게 된다. 예를 들어서 두번째줄에서는 첫번째 인자가 빈 리스트인 경우를 나타낸다. 그렇다면 첫번째 인자가 빈 리스트라면 두번째 줄의 함수는 구현을 따라간다. 패턴 매칭을 활용할 때는 구체적인 경우에서부터 일반적인 경우의 순서로 작성하는게 좋다. 위에서부터 읽기 때문이다. 일반적인 경우를 맨 위에 적으면 그 아래에 적은 구체적인 경우는 실제로 사용되지 못할것이다.

두번째 줄은 첫번째 인자가 빈 리스트이고, 두번째 인자가 오면 빈 리스트를 리턴한다는 뜻이다. product' 함수의 반환값은 인자 두 리스트의 길이의 곱의 길이를 가지므로, 첫번째 인자 리스트의 길이가 0이라면 결과 리스트도 길이가 0이어야 한다.

세번째 줄은 두번째 인자가 빈 리스트일때도 빈 리스트를 리턴한다는 의미이다. 둘째 줄과 셋째 줄은 재귀함수의 base case를 정의했다고 봐도 된다. 마지막 줄을 보면 product'가 재귀적으로 선언됐다는걸 알 수 있다.

마지막 줄은 가장 일반적인 경우이다. 첫번째 인자는 (a:as)로 표현되었다. 이는 첫번째 인자에 대한 패턴 매칭이다. (:) 함수는, 혹은 연산자는 list constructor이다. 타입은 a → [a] → [a]이다. 예를 들면, 10:[20] = [10,20]이다. 따라서 (a:as)는 첫번째 인자가 비어있지 않은 리스트라는 것을 의미한다. 두번째 인자 _bs[b]를 뜻한다. ++는 리스트 두개를 합치는 연산자로 타입은 [a] → [a] → [a]이다. 따라서 (aux a _bs) ++ (product' as _bs)를 한국어로 풀어 쓰면, 첫번째 인자의 head와 두번째 인자로 aux를 호출한 결과에, 첫번째 인자의 tail과 두번째 인자로 자기자신을 호출한 결과를 합쳐달라는 의미이고, 결과적으로 첫번째 인자가 빈 리스트가 될 때까지 리스트에 리스트를 이어붙이게 된다.

여기서 aux는 where 절로 설명되었다. where 절은 일종의 지역변수를 선언하며, 스코프는 전역이 아니라 지역적이다. auxproduct' 선언의 마지막 줄 이외에 다른 곳에서 갖다 쓸 수 없는 것이다. aux 함수를 예를 들어 설명하면, 인자로 10[30,40]을 받아 [(10,30),(10,40)]을 리턴하는 함수다. 즉 product'의 subproblem을 해결해주는 도우미 함수로서 선언되었다. aux를 본 함수의 선언에 이용한 것이다.

시작하기

이제 책을 읽어보자.

위에서 봤다시피 하스켈의 함수 호출은 흔히 쓰는 파이썬스타일과 다르게 생겼다. 중위(infix) 함수를 호출하면 다음과 같이 생겼다.

ghci> 8 + 1
9

보다시피 인자에 괄호를 쓰지 않는다. 두개의 인자를 받는 중위함수를 흔히 연산자라고 부른다. 하스켈에서 연산자는 함수다.

전위(prefix) 함수의 예제를 보자.

ghci> div 92 10
9

나누기라는 뜻이다. 나누기 연산의 결과는 첫번째 인자를 두번째 인자로 나눈 몫과 같다. 따라서 92를 10으로 나눈 몫인 9가 결과로 나온다. (여기서는 Int 타입이다.) 이를 중위 함수로 호출하면 더 보기가 좋을 수도 있다.

ghci> 92 `div` 10
9

이런 느낌이다.

예를 하나 더 들어보겠다.

bar (bar 3)

이것은 C언어로 이해하면 bar(bar(3))와 같다.

첫 번째 함수

함수의 예를 들어보겠다. 아래 파일을 baby.hs로 저장해보자.

doubleMe x = x + x

이것이 함수 정의다. 실행하면 다음과 같이 된다.

ghci> :l baby.hs
[1 of 1] Compiling Main     ...
...
ghci> doubleMe 9
18
ghci> doubleMe 8.3
16.6
  • 연산자는 정수뿐만 아니라 숫자인 모든 것에서 잘 동작한다.

이제 두개의 숫자를 받아서 각각 2를 곱한 결과를 더하는 함수를 만들어보자. baby.hs에 다음 코드를 추가한다.

doubleUs x y = doubleMe x + doubleMe y

이것은 좀 더 복잡한 함수를 형성하기 위해 함수들이 결합될 수 있다는 예시이다.

if문의 예시를 들어보겠다. 아래 함수는 숫자가 100 이하라면 2를 곱하고 그렇지 않으면 그대로인 함수이다.

doubleSmallNumber x = if x > 100
												then x
												else x * 2

if문은 다른 언어와 달리 식(expressoin)이다. 또한, 하스켈에서 else 부분은 필수이다.

리스트 소개

리스트를 다루는 예시를 들어보겠다.

ghci> let lostNumbers = [4,8,15,16,23,42]
ghci> lostNumbers
[4,8,15,16,23,42]
ghci> [1,2,3,4]++[9,10,11,12]
[1,2,3,4,9,10,11,12]
ghci> "hello w" ++ "orld!"
"hello world!"
ghci> ['h'] ++ ['i']
"hi"
ghci> [10,11,12] == 10:11:12:[]
True
ghci> "hello" == ['h','e','l','l','o']
True
ghci> "hello world" !! 7
'o'
ghci> "hello world" !! 99
*** Exception: Prelude.!!: index too large
ghci> lostNumbers ++ lostNumbers
[4,8,15,16,23,42,4,8,15,16,23,42]
ghci> lostNumbers 
[4,8,15,16,23,42]
ghci> [10,11] >= [10,11]
True
ghci> [10,11] > [10,11]
False
ghci> [10,11,12] > [10,11]
True
ghci> [10,11,12] < [10,11]
False
ghci> [10,11,12] < [10,11,13]
True
ghci> [10,11,20] < [10,11,13]
False
ghci> [10,11,20,21] < [10,11,20,22]
True
ghci> head lostNumbers 
4
ghci> tail lostNumbers 
[8,15,16,23,42]
ghci> lostNumbers
[4,8,15,16,23,42]
ghci> last lostNumbers
42
ghci> init lostNumbers
[4,8,15,16,23]
ghci> head lostNumbers : tail lostNumbers
[4,8,15,16,23,42]
ghci> head lostNumbers : tail lostNumbers == init lostNumbers ++ [last lostNumbers]
True
ghci> 3 == 3 == 3

<interactive>:58:1: error:
    Precedence parsing error
        cannot mix ‘==’ [infix 4] and ‘==’ [infix 4] in the same infix expression
ghci> head []
*** Exception: Prelude.head: empty list
ghci> tail []
*** Exception: Prelude.tail: empty list
ghci> length [10,11,12,13,14]
5
ghci> null [1]
False
ghci> null []
True
ghci> reverse [10,11,12,13,14]
[14,13,12,11,10]
ghci> take 3 [10,11,12,13,14]
[10,11,12]
ghci> drop 3 [10,11,12,13,14]
[13,14]
ghci> (maximum [10,11,12,13,14], minimum [10,11,12,13,14])
(14,10)
ghci> sum [1,2,3]
6
ghci> produce [1,2,3]

<interactive>:70:1: error:
    • Variable not in scope: produce :: [a0] -> t
    • Perhaps you meant one of these:
        ‘product’ (imported from Prelude), ‘product'’ (line 2)
ghci> product [2,3,4]
24
ghci> product [0,1,2,3,4,5,6]
0
ghci> 11 `elem` [10,11,12,13,14]
True
ghci> 9 `elem` [10,11,12,13,14]
False

범위

파이썬의 range()를 생각해보자. 이와 유사하다.

ghci> [10..20]
[10,11,12,13,14,15,16,17,18,19,20]
ghci> ['a'..'z']
"abcdefghijklmnopqrstuvwxyz"
ghci> ['K'..'R']
"KLMNOPQR"
ghci> [2,4..20]
[2,4,6,8,10,12,14,16,18,20]
ghci> [3,6..20]
[3,6,9,12,15,18]
ghci> [20..1]
[]
ghci> [20,19..1]
[20,19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3,2,1]
ghci> [9,18..81]
[9,18,27,36,45,54,63,72,81]
ghci> take 9 [9,18..]
[9,18,27,36,45,54,63,72,81]
ghci> take 10 (cycle [1,2,4,8])
[1,2,4,8,1,2,4,8,1,2]
ghci> take 20 (cycle "coffee ")
"coffee coffee coffee"
ghci> take 10 (repeat [5])
[[5],[5],[5],[5],[5],[5],[5],[5],[5],[5]]
ghci> take 10 (repeat 5)
[5,5,5,5,5,5,5,5,5,5]
ghci> replicate 5 10
[10,10,10,10,10]
ghci> [0.1,0.3..1]
[0.1,0.3,0.5,0.7,0.8999999999999999,1.0999999999999999]

중간에 있는 끝이 없는 범위는 무한리스트다. 무한리스트를 ghci에서 출력하면 이렇게 된다.

ghci> [10,12..]
[10,12,14,16,18,20,22,24,26,28,30,........,36,38,40,42,44,46,4Interrupted.

중간을 생략했다. Control+C를 눌러서 끊어주어서 interrupted가 출력되었다. 끊지 않으면 계속해서 숫자가 올라간다. 10초 정도 기다렸다가 끊어보았다.

...,424292,4^C0,424332,424334,424336,424338,4243Interrupted.

리스트 통합

리스트 컴프리헨션(list comprehension)이다. 수학에서 보던 조건제시법이라고 생각하자.

ghci> [ x^2 | x <- [3..8] ]
[9,16,25,36,49,64]

해석하는 방법은 다음과 같다. x에 3부터 8까지 순서대로 바인딩된다. 그리고 x의 제곱들의 리스트라는 뜻이다. 따라서 3부터 8까지의 제곱들이 리스트에 담긴다. 이 때 서술부를 추가할수도 있다.

ghci> [ x^2 | x <- [3..8], x > 5 ]
[36,49,64]

추가된 조건은 콤마로 구분됐고, x가 5보다 큼을 나타내므로, 리스트에는 6의 제곱부터 담기게 된다.

0부터 100까지의 수 중 1의자리가 3인 수들에 2를 곱한 수들의 리스트는 다음과 같다.

ghci> [ x*2 | x <- [0..100], x `mod` 10 == 3 ]
[6,26,46,66,86,106,126,146,166,186]

서술부를 여러 개 쓸 수 있다.

ghci> [ x | x <- [0..100], x /= 10, x > 5, x < 15 ] 
[6,7,8,9,11,12,13,14]

연산자 /=는 같지 않다는 뜻이다. (흔히 !=로 다른데에서 쓰이는 그거다.)

여러개의 리스트에서 가져온 값들로 리스트 컴프리헨션을 작성해보겠다.

ghci> [ x + y | x <- [2,4..6], y <- [3,6..12] ]
[5,8,11,14,7,10,13,16,9,12,15,18]

이해할 수 있을 것이다. 이것을 활용하면 처음에 작성한 product'를 리스트 컴프리헨션으로 간편하게 재작성할 수 있다.

product'' :: [a] -> [b] -> [(a,b)]
product'' as bs = [ (a,b) | a <- as, b <- bs ]
ghci> :r
[1 of 1] Compiling Main             ( product.hs, interpreted )
Ok, one module loaded.
ghci> product'' [10, 20] [30, 40]
[(10,30),(10,40),(20,30),(20,40)]

끝내며

하스켈은 다른 언어들과 조금 다르게 이상하게 생겼지만, 그렇기에 다른 식으로 생각해볼수있는 기회를 제공해주고, 또 함수형 언어를 체험해보기에도 아주 좋다. 그리고 재밌다. 재귀 함수나 기타 고차 함수를 써본다면 더욱 그렇다. 이 책의 제목은 “느긋하지만, 우아하고 세련된 함수형 언어”다. 나는 하스켈을 한번 공부해보고 다른 언어를 쓸 때에도 활용 가능한 영감을 몇개 얻었다. JS에 등장하는 map이나 filter, reduce 등의 함수에 익숙해진것도 덤이다. 요새 “함수형 프로그래밍”이 유행하고 있다. Rust, Kotlin, ReScript 등 새로 나온 언어들은 함수형 언어의 장점들을 특징으로 차용한다. 이 책을 읽어보고 함수형 유행에 편승해보자.

https://product.kyobobook.co.kr/detail/S000001556109
http://learnyouahaskell.com

profile
프론트엔드 엔지니어

0개의 댓글