Tech Note

Haskell 책 한 권 읽었다

Samgim 2021. 5. 16. 23:52

함수형 프로그래밍을 좀 더 깊게 이해하고 싶어서 Haskell 책을 한 권 끝내보자 하는 결심을 세웠다. 

Haskell 은 순수 함수형 언어로 유명하니 함수형 프로그래밍을 제대로 이해하려면 한 번쯤은 해보고 싶다는 생각도 들던 참이었다.

Haskell 책으로 유명한 책은 LYHGG 라고도 불리는 <가장 쉬운 하스켈 책(원제: Learn You a Haskell for Great Good!)>이라 이 책을 골랐다.

번역서 퀄리티가 좋지 않다는 이야기는 들었는데, 확실히 만족스러운 퀄리티는 아니었다.

원서는 온라인으로도 읽을 수 있으니 관심이 있으면 http://learnyouahaskell.com/chapters 에서 읽어보자.

책 한 권을 읽으면서 Haskell(이하, 하스켈)에 아주 능숙해지지는 않았지만, 코드를 읽을 수 있는 수준까지는 올라온 것 같다. 

더불어 함수형 프로그래밍에 대해서도 좀 더 깊게 이해할 수 있었다. 

 

아래는 내가 책을 읽기 전에 갖고 있던 배경 지식과 책을 읽으면서 어떤 것이 좋았는지 나빴는지, 그리고 기억해 둘 만한 개념들을 적어둔 것이다.

 

배경 지식

나는 함수형 언어를 사용해 본 경험이 없지는 않아서, 생각보다 책을 이해하기가 어렵지는 않았다. 

물론 늘 쉬운 건 아니었지만, 하스켈의 악명에 비하면 그만큼 함수형 프로그래밍이 보편화되었을지도 모르겠다.

내가 사용했던 꽤 많은 언어들이 함수형 프로그래밍을 지원하고 있어서 더욱 도움이 되었던 것 같다.

나는 javascript, scala, java8, elixir 를 다뤄보았는데 이제는 꽤 많은 언어에서 함수형 프로그래밍을 지원한다고 봐도 될 것 같다.

react 문서를 보면 가끔 하스켈에 대한 언급도 있을만큼 이제는 라이브러리나 프레임워크 단위로도 종종 함수형 개념이 튀어나올 때가 있어서 다행히 어느 정도 함수형으로 생각하는 방법을 알고 있었다.

하지만 만약 함수형 프로그래밍을 전혀 하지 못한 상태에서 도전했다면 상당히 어려웠을 것 같다.

 

좋았던 점

책을 읽으면서 나온 예시들이 함수적으로 생각하기에 좋았다. 

함수형 언어를 사용하면서도 알고리즘 문제를 푸는 경우에는 함수적으로 생각하기가 쉽지 않은데 예제를 통해서 보니 조금 더 감이 잡히는 기분이었다.

물론 몇 번 더 풀어보면서 수련을 쌓아가야겠지만, 함수 합성이나 apply 를 사용해서 필요할 때 필요한 연산(함수)만 꺼내어 사용한다는 개념 등을 익히기에 좋았다.

monad, monoid 등 어려운 개념도 순수 함수형 언어로 보여주니 좀 더 이해하기가 편했다. 

그 외에도 함수형 언어들을 이해하기 좋은 핵심적인 내용들이 있어서 좋았다.

 

어려웠던 점

그렇다고 아예 어렵지 않은 건 아니었다.

타입 시스템이 생각보다 빡빡한데, 책에서는 '강력하다'고 표현하지만 나로써는 학술적이다는 느낌을 지우기가 어려웠다.

인자를 2개 받는 함수도 사실은 인자를 하나, 반환값을 하나 받는 함수를 합성한 것이고 그것을 타입 시스템에 녹여야 했다.

사실 지금도 그다지 이해가 가지는 않는데, 실무적으로 함수를 하나 하나 따져가며 사용하기에는 불편하지 않을까 하는 생각이 들었다.

듣던 대로 순수 함수를 지향한다는 점에서는 긍정적이지만, 어느 정도 편의성을 제공해주어도 괜찮지 않았을까 하는 부분이었다.

 

합성 함수에 대한 부분도 그렇다.

. 이나 $ 로 표현해서 함수를 합성해서 이어가는 부분이 있는데, 읽는 방향에 있어서는 합성 함수를 인지하기 좋아서 편안했다.

f(g(h(x))) 보다는 f . g . h (x) 가 훨씬 편안한 것은 분명하다.

하지만 프로그래밍 언어로서 읽기에는 요상해 보인다고 느꼈다.

elixir 의 파이프 연산자가 그립다고 해야하나.

h |> g |> f (x) 가 훨씬 개발자에게는 와닿는 표기법이라고 생각한다.

 

 

기록

아래는 책을 읽으면서 기억해야 할 내용들을 정리했다. 

책에 있는 내용을 대략적으로 정의한 거라 아마 나만 알아볼지도 모르겠다...

좀 더 추가하거나 보충해야 할 내용들은 <스칼라로 배우는 함수형 프로그래밍> 과 여러 링크들을 참고했다.

 

  • 함수형 프로그래밍이란?
    • side effect가 없는 순수 함수로만 프로그램을 구축한다는 뜻.
    • side effect
      • 변수 수정, 객체의 필드 추가, exception 을 던지거나 오류를 내며 실행을 중단, 콘솔 출력 및 입력, 파일 또는 화면에 기록하거나 그림 등을 말한다.
      • 당연히 이것들 없이 프로그래밍을 할 수는 없지만, 최대한 순수 함수로 프로그래밍을 해보자는 뜻
      • 하지만 side effect 없이 오류 처리가 가능하고, 데이터를 수정하는 방법이 가능함
    • 순수 함수로 이루어진 경우, 함수의 부수 효과가 없어 테스트가 용이하고 오류 처리가 확실해진다.
  • map 과 reduce
    • map은 함수와 리스트를 받아서 리스트의 각 요소에 파라미터로 받은 함수를 적용한다.
    • reduce 대신 fold 함수를 사용한다. 자세한 내용은 foldl, foldr 참고.
    • (책에 있는 내용은 아니고 들은 내용) map 과 reduce 를 사용하면 왠만한 연산은 거의 다 만들 수 있다고도 한다.
  • foldl, foldr
    • foldl: accumulator 를 사용하여 함수를 왼쪽부터 적용시켜 나간다.
    •  reduce 사용법이랑 똑같다. foldr 은 반대로 오른쪽부터 계산한다는 것만 다름.
    • sum' :: (Num a) => [a] -> a sum' xs = foldl (\acc x -> acc + x) 0 xs ghci > sum' [3, 5, 2, 1] 11
    • foldr 의 경우 리스트를 합쳐서 새로운 리스트를 만들 때 ++ 연산이 느려서 foldr로 대신 사용하는 경우도 있음.
    • foldl1 과 foldr1 을 사용하면 시작 accumulator 값 없이 리스트의 첫 번째 값으로 시작할 수도 있다.
    • foldl' 은 lazy 연산을 모두 하는 것이 아니라 어느 정도 값을 미리 계산해서 스택에 넣는다는 점이 다름.
  • zip, zipWith
    • zip: 두 개의 리스트를 하나로 합쳐서 반환
    • ghci> zip [1, 2, 3] [7, 8]
      [(1, 7), (2, 8)]
    • zipWith: 두 개의 리스트를 파라미터로 받은 인자로 연산하여 하나로 합쳐 반환
    • ghci> zipWith (+) [4, 2, 5, 6] [2, 6, 2, 3]
      [6,8,7,9]
  • functor
    • fmap 은 flatmap 이 아니라 functor map
    • ghci> fmap (+3) (Just 2)
      Just 5
    • functor 는 타입 클래스 중 하나 인데, 여기서는 Just 2와 Just 5 를 말한다.
    • functor 는 fmap 이 어떻게 적용되는지에 따라서 (Just 이든, Nothing이든 기타 다른 형태든) 데이터 타입이 정의된다. 즉, 어떤 함수를 파라미터로 받은 데이터 타입에 적용하고, 반환하는 데이터 타입을 정의해놓은 것.
    • functor 는 데이터 타입과 관계없이 특정 연산을 적용할 수 있는지를 정의하기 위해 있는 개념 같...다. 
  • applicative functor
    • (Just 같은) context 를 가진 값을 받아 context 를 해체하여 다시 context 로 말아넣은 작업 (뭐라는 건지...그림 참고)
    • https://adit.io/posts/2013-04-17-functors,_applicatives,_and_monads_in_pictures.html 에서 가져옴
    • Just (+3) <*> Just 2
      Just 5
    • 이렇게 어플리커티브를 사용하면 함수를 context 안에 넣어서 context 안에서 연산을 할 수 있음
    • 특정 데이터 타입의 값을 context를 가진 값처럼 조회할 수 있게 해주고, context의 의미를 보존하며, 그 값들에 일반 함수들을 사용할 수 있게 해준다.
  • monoid
    • 모노이드 규칙 : 모노이드 인스턴스를 만들 때 지켜야 하는 규칙
      • mempty `mappend` x = x
      • x `mappend` mempty = x
      • (x `mapped` y) `mappend` z = x `mapped` (y `mappend` z)
    • 리스트, Any 랑 All, Ordering, Maybe 가 모노이드를 구현한 인스턴스
    • 위의 모노이드 규칙에 따라 foldable 할 때, 결합 법칙이 성립하여 어떤 순서로 연산을 해도 값이 같아진다. 이 성질을 이용하여 병렬 연산이 가능해진다.
    • https://kpug.github.io/fp-gitbook/Chapter3.html 를 참조
  • monad
    • 어플리커티브 펑터 중 >>=(bind) 연산을 지원하는 것
    • (>>=) :: (Monad m) => m a -> (a -> m b) -> m b
    • 이 연산은 함수 애플리케이션이랑 같다. 일반 값을 받아서 일반 함수에 넣는 대신 모나드 값(context를 가진 값)을 받아서, 일반 값을 받지만 모나드 값을 반환하는 함수에 넣는다.
    • 어떤 context 를 가진 값을 >>= 했을 때, >>= 은 context 안에 있는 값에 함수를 적용할 수 있다. 그리고 함수를 적용한 값을 다시 context 로 감싸서 반환한다.
    • 따라서 모나드는 >>= 가 어떻게 처리할지 인터페이스를 구현해놓은 것
    • Maybe, Writer, Reader 등이 모나드
    • 예시: half 함수 
    • half x = if even x
                 then Just (x `div` 2)
                 else Nothing
      라는 함수가 있을 때, Maybe 타입 클래스의 값을 파라미터로 적용해보자.
    • > Just 3 >>= half
      Nothing
      > Just 4 >>= half
      Just 2
      > Nothing >>= half
      Nothing
      Maybe 는 모나드이기 때문에 아래와 같이 정의되어 있다. 따라서 half 함수가 무엇을 반환하는지에 따라서 Nothing 이 반환될 수도, Just 가 반환될 수도 있다.
    • instance Monad Maybe where
          Nothing >>= func = Nothing 
          Just val >>= func = func val
  • lazy 
    • 함수의 결과를 내놓을 필요가 있을 때까지 계산을 미루는 것
    • 하스켈은 이 기능 때문에 무한한 데이터를 표시할 수 있어 보인다(!)
    • lazy 하지 않으면 doubleMe(doubleMe(doubleMe(list))) 와 같은 작업을 할 때, 가장 안쪽에 있는 함수부터 리스트를 전달하고 복사하는 과정을 반복한다.
    • lazy 하게 계산하면 계산이 필요할 때 '지금 계산을 원한다'며 함수를 호출한다. 즉, 가장 바깥쪽에 있는 함수가 2배 하는 연산을 전달하며 안쪽 함수에 전달하고, 안쪽에 있는 함수는 리스트를 한 번만 불러 와서 계산을 완료한다.
    • 좀 더 정확히 표현하자면 함수 호출 스택에 계산된 값이 아니라 연산이 쌓이는 구조. 자세한 내용은 아래 링크를 참조하자.
    • http://enshahar.com/haskell/cis194/lazy/evaluation/2018/01/22/cis194-LazyEvaluation/
  • currying
    • 파라미터가 여러 개인 함수를 파라미터가 하나인 여러 개의 함수의 합성으로 표현하는 것
    • add (1, 2) -> add (1) (2) 이런 식으로 만드는 걸 말하는데, 자세한 내용은 다른 자료를 찾아보면 많이 나오는 것 같으니 생략
    • 다만 굳이 이런 식으로 사용하는 이유는 단순히 currying 을 하는 것이 좋다거나 옳다는 것이 아니라, 함수 합성 시 currying 된 함수와 합성하는 것이 훨씬 유리하다.
    • 하스켈에서는 모든 함수가 파라미터를 하나만 받는데, 여러 파라미터를 가진 함수를 작성할 수 있는 것은 currying 을 하기 때문이다.

 

사족

대단히 추상적이고 책 한 권에 걸쳐서 설명한 내용을 포스트 하나에 담다보니 잘 정리하지 못한 감이 없잖아 있다.

좀 더 개념이 명확해지면 추가하거나 수정해야겠다. (피드백은 언제나 환영)

그래도 기록을 하다보니 더 부족한 부분이나 이해가 가지 않는 부분이 좀 더 분명해진 느낌이다.

공부하고 나서도 이해가 가지 않는 부분이 많았지만, 나름대로 함수적으로 생각하는 부분이 익었는지 실무에서도 함수를 생각하면서 코딩할 수 있게 되었다.

시간이 더 많아져서 복습도 하고 자세히 살펴볼 기회가 있었으면 좋겠다.

 

 

참고 서적 및 링크

가장 쉬운 하스켈 책 - 미란 리포바카, 황반석 옮김 

스칼라로 배우는 함수형 프로그래밍 - 폴 키우사노, 루나르 비아르드나손, 류광 옮김

http://enshahar.com/haskell/cis194/lazy/evaluation/2018/01/22/cis194-LazyEvaluation/

 

[하스켈 기초][CIS194] 지연 계산

전문초보자의 공부방

enshahar.com

https://adit.io/posts/2013-04-17-functors,_applicatives,_and_monads_in_pictures.html

 

Functors, Applicatives, And Monads In Pictures - adit.io

Functors, Applicatives, And Monads In Pictures Written April 17, 2013 updated: May 20, 2013 Here's a simple value: And we know how to apply a function to this value: Simple enough. Lets extend this by saying that any value can be in a context. For now you

adit.io

https://kpug.github.io/fp-gitbook/Chapter3.html

 

Ch3. 모노이드 · Functional programming in Scala

3장 모노이드(Monoid) 3.1 Monoid란 무엇인가? 모노이드(Monoid)에 대해 이야기하기 전에, 집합(Set)과 마그마(Magma), 반군(Semigroup)에 대해 먼저 이야기하고 넘어가겠다. Set과 Magma, Semigroup, 그리고 Monoid는

kpug.github.io