haskell study 9

33
Haskell Study 9. Monad & IO

Upload: nam-hyeonuk

Post on 17-Jan-2017

371 views

Category:

Software


0 download

TRANSCRIPT

Haskell Study

9. Monad & IO

Monad

Monad는 Applicative Functor에서 다시 한 발 더 나아간 개념입니다. Monad에서 핵심 역할을

하는 함수 >>=의 타입 서명은 다음과 같이 정의되어 있습니다.

(>>=) :: (Monad m) => m a -> (a -> m b) -> m b

즉, 모나드는 어떤 Context 속에 있는 값(m a)과, 일반적인 값을 받아서 특정 Context 속의 값을

반환하는 함수(a -> m b)가 주어졌을 때 Context 속에서 값을 꺼내 함수를 적용한 결과(m b)를

구할 수 있는 타입들의 집합입니다. >>= 함수는 bind라고 읽습니다.

Monad

모나드 타입 클래스는 다음과 같이 정의되어 있습니다.

class Monad m where

return :: a -> m a

(>>=) :: m a -> ( a -> m b ) -> m b

(>>) :: m a -> m b -> m b

x >> y = x >>= \_ -> y

fail :: String -> m a

fail msg = error msg

Monad

하나씩 각각의 함수에 대해 살펴봅시다.

•return

Applicative Functor에서 pure와 같은 역할을 하는 함수입니다. 어떤 값이 주어져있을 때 이 값을

해당 Context 속으로 집어넣는 역할을 합니다. 명령형 언어에서의 return과 헷갈릴 수 있는데 의미가

전혀 다르기 때문에 코드를 볼 때 주의하셔야 합니다.

•>>=

앞에서 설명한 bind 함수입니다. Monad의 동작에서 핵심적인 역할을 합니다.

•>>

지금은 크게 신경쓸 필요 없습니다. 이 후 설명하겠습니다.

•fail

직접 사용하는 함수는 아니고, Monad 연산에서 뭔가 문제가 생겼을 때 컴파일러 측에서 사용하는

함수입니다. 크게 신경 쓸 필요 없습니다.

Monad

가장 간단한 예인 Maybe Monad를 예제로 삼아봅시다. Monad의 최소 정의는 return과 >>=

입니다. 이 두 함수만 잘 정의하면 Monad 타입 클래스에 속할 수 있습니다.

instance Monad Maybe where

return x = Just x

Nothing >>= f = Nothing

Just x >>= f = f x

fail _ = Nothing

return은 Applicative Functor에서의 pure와 완전히 동일하고, >>= 함수는 Context 속에 값이

없으면 함수와 상관없이 Nothing을, 그렇지 않으면 Context 속에서 값을 꺼내(Just x에서 x)

거기에 함수를 적용시킨 결과(f x)를 리턴하는 방식으로 동작합니다.

Monad

Maybe Monad의 사용 예제를 살펴봅시다.

Prelude> return "WHAT" :: Maybe String

Just "WHAT"

Prelude> Just 9 >>= \x -> return (x*10)

Just 90

Prelude> Nothing >>= \x -> return (x*10)

Nothing

Maybe Context 내부에 있는 값에 대해 연산을 하고 있음에도 불구하고 따로 패턴매칭을 하고

있지 않습니다. bind 함수(>>=)에 의해 Nothing이 인자로 들어온 경우 함수에 상관없이 자동으로

Nothing을 리턴하게 되고, 그렇지 않을 경우 Context 내부에서 값을 꺼내 인자로 넘기기

때문입니다.

Monad

Maybe Monad는 실패할 가능성이 있는 연산에 대해 굉장히 유용하게 사용할 수 있습니다. 다음

예제를 봅시다.

square :: Integer -> Maybe Integer

square n

| 0 <= n = Just ( n * n )

| otherwise = Nothing

square 함수는 양수인 숫자에 대해서만 그 값을 제곱해서 돌려주는 함수입니다. 그렇지 않을 경우 이

함수는 실패하게 되고, 결과로 Nothing을 반환합니다.

Monad

squareRoot :: Integer -> Maybe Integer

squareRoot n

| 0 <= n = squareRoot' 1

| otherwise = Nothing

where squareRoot' x

| n > x * x = squareRoot' (x + 1)

| n < x * x = Nothing

| otherwise = Just x

squareRoot 함수는 어떤 숫자의 제곱근 값을 구합니다. 단, 그 숫자의 제곱근 값이 정수가 아닌 경우

Nothing을 반환합니다.

Monad

이제 이 두 함수를 이용해서, a^2 + b^2 의 제곱근을 구하는 함수를 작성한다고 해봅시다.

squareSumRoot :: Integer -> Integer -> Maybe Integer

squareSumRoot a b = case square a of

Nothing -> Nothing

Just as -> case square b of

Nothing -> Nothing

Just bs -> squareRoot (as + bs)

square 연산의 결과가 존재하는지 아닌지(함수가 실패하지 않았는지)를 먼저 검사를 해 봐야하기

때문에 코드가 굉장히 지저분해집니다. 함수를 여러 번 쓸 수록 기하급수적으로 복잡해지겠죠.

Monad

이런 형태의 코드는 명령형 언어에서도 심심찮게 찾아볼 수 있습니다. 앞의 함수를 C++로 짠다면 아마

다음과 같은 형태가 되겠죠.

//실패시 -1 리턴

int sqaureSumRoot(int a, int b)

{

int as = square(a);

if (as == -1) return -1;

int bs = square(b);

if (bs == -1) return -1;

return squareRoot (as + bs);

}

Monad

명령형 언어든 Haskell이든 저런식으로 코드를 작성하게 되면 굉장히 피곤하게 되고, 타입 시스템에

의해 예외 처리를 어느정도 강제적으로 하게되는 Haskell에 비해 대다수 명령형 언어의 경우 예외

처리를 깜빡하면 찾기 힘든 버그를 유발할 수도 있습니다. 즉, 실제 로직과는 크게 상관없는 예외

상황의 처리에 프로그래머가 상당히 많은 노력을 기울여야하는 짜증나는 상황에 처하게 된다는 거죠.

이럴 때 Maybe Monad를 사용하면 코드를 상당히 깔끔하게 바꿀 수 있습니다. Maybe Monad를

이용해 squreSumRoot 함수를 구현해봅시다.

squareSumRoot :: Integer -> Integer -> Maybe Integer

squaresumRoot a b = square a >>= (\as ->

square b >>= (\bs ->

squareRoot (as + bs))

Monad

squareSumRoot :: Integer -> Integer -> Maybe Integer

squareSumRoot a b = square a >>= (\as ->

square b >>= (\bs ->

squareRoot (as + bs))

당장은 이해하기 힘들게 생겨먹었지만 어쨌든 원래 코드보다는 훨씬 간단해졌습니다. Maybe

Monad를 쓰면 중간에 일일히 예외 처리 코드를 넣을 필요 없이 핵심적인 로직 코드만 작성할 수

있다는 장점이 생깁니다.

이제 위 코드가 어떤 의미인지, Maybe Monad가 어떤 식으로 동작하는지 차근차근 살펴봅시다.

Monad

squareSumRoot a b = square a >>= (\as -> square b)

코드를 여기까지만 잘라서 동작이 어떻게 되는지 살펴봅시다.

square a

우선 square a의 값을 계산 합니다.

Monad

squareSumRoot a b = square a >>= (\as -> square b)

코드를 여기까지만 잘라서 동작이 어떻게 되는지 살펴봅시다.

square a Justas

Nothing

square a의 계산 결과는 위의 두 가지 중

하나일 것입니다. 제대로 계산할 수 있는 경우

Just as 형태의 꼴, 그렇지 않을 경우 계산에

실패해서 Nothing을 리턴하겠죠.

Monad

squareSumRoot a b = square a >>= (\as -> square b)

코드를 여기까지만 잘라서 동작이 어떻게 되는지 살펴봅시다.

square a >>=Justas

Nothingbind

이제 square a의 연산 결과가

>>=(bind) 함수에 의해 다음 람다

(\as -> square b)의 인자로

넘어가게 됩니다.

Monad

squareSumRoot a b = square a >>= (\as -> square b)

코드를 여기까지만 잘라서 동작이 어떻게 되는지 살펴봅시다.

square a >>=Justas

Nothingbind

\as -> square bsquare a 연산이 성공한 경우, Just as

에서 as 값만 꺼내서 두 번째 함수의 인자로

넘깁니다. 여기서 다시 sqaure b 연산을

하고 있으니, 이 함수를 실행하면 square b

의 결과 값을 리턴하겠죠.

Monad

squareSumRoot a b = square a >>= (\as -> square b)

코드를 여기까지만 잘라서 동작이 어떻게 되는지 살펴봅시다.

square a >>=Justas

Nothingbind

\as -> square b

Nothing만약 square a의 연산 결과가 실패했다면,

이 함수는 이후의 람다를 실행하지 않고

단순히 Nothing을 리턴할 것입니다(>>=

함수의 구현에 의해)

Monad

squareSumRoot a b = square a >>= (\as ->

square b >>= (\bs ->

squareRoot (as + bs))

이제 이어서 전체 코드를 봅시다.

square a >>=Justas

Nothingbind

\as -> square b

Nothing

Monad

squareSumRoot a b = square a >>= (\as ->

square b >>= (\bs ->

squareRoot (as + bs))

이제 이어서 전체 코드를 봅시다.

square a >>=Justas

Nothingbind

\as -> square b

Nothing

square b이어서 두번째줄(빨간색)을 보면, square b

의 결과를 구한 후 다시 그 결과를 다음 람다와

bind(>>=)하고 있습니다.

Monad

squareSumRoot a b = square a >>= (\as ->

square b >>= (\bs ->

squareRoot (as + bs))

이제 이어서 전체 코드를 봅시다.

square a >>=Justas

Nothingbind

\as -> square b

Nothing

square b Justbs

Nothing

이 때도 역시 square b 함수의

결과는 다음 두 가지가 되겠죠.

Monad

squareSumRoot a b = square a >>= (\as ->

square b >>= (\bs ->

squareRoot (as + bs))

이제 이어서 전체 코드를 봅시다.

square a >>=Justas

Nothingbind

\as -> square b

Nothing

square b >>=Justbs

Nothingbind

\bs -> squareRoot(as+bs)square b에 성공했을 경우 그 결과 Just bs에서

bs만 꺼내 다음 람다에 넘깁니다. 여기서 앞서

구한 as와 bs를 더한 값의 제곱근을 구하죠.

Monad

squareSumRoot a b = square a >>= (\as ->

square b >>= (\bs ->

squareRoot (as + bs))

이제 이어서 전체 코드를 봅시다.

square a >>=Justas

Nothingbind

\as -> square b

Nothing

square b >>=Justbs

Nothingbind

\bs -> squareRoot(as+bs)

Nothing

물론 실패하면 그냥

Nothing을 리턴합니다.

Monadsquare a >>=

Justas

Nothingbind

\as -> square b

Nothing

square b >>=Justbs

Nothingbind

\bs -> squareRoot(as+bs)

Nothing

이 전체 흐름을 잘 보시면 앞에서 case expression을 이용해 작성한 것과 별반 다를 게 없다는 것을

알 수 있습니다. 다만, Monad의 경우 >>= 함수를 통해 프로그래머가 신경 쓸 필요 없이 예외 처리를

자동으로 해준다는 점만이 다를 뿐이죠.

do notation

Monad는 분명 좋은 개념이지만, 익숙하지 않은 사람이 봤을 때 코드를 읽기 힘들다는 단점이

있습니다.

Haskell에서는 모나드가 굉장히 많이 사용되기 때문에, 이런 표기 문제를 해결하기 위해 do

표기법이라는 문법을 지원합니다. 앞의 모나드를 사용한 함수 코드는 do 표기법을 이용해서 아래와

같이 쓸 수 있습니다.

squareSumRoot :: Integer -> Integer -> Maybe Integer

squareSumRoot a b = do

as <- square a

bs <- square b

squareRoot (as + bs)

do notation

squareSumRoot a b =

square a >>= (\as ->

square b >>= (\bs ->

squareRoot (as + bs))

squareSumRoot a b = do

as <- square a

bs <- square b

squareRoot (as + bs)

do 표기법을 쓰지 않은 경우와 do 표기법을 쓴 두 가지 경우의 비교입니다. do 표기법에서 <-는

>>=(bind)의 역할을 합니다.

(result) <- (function call)

이 때 바인딩되는 결과값은 Context내부의 값입니다. as <- square a에서 square a의 결과가

Just 9라면 as에는 9가 바인딩된다는 의미입니다. 람다에서 \as -> 에서 인자인 as를 합쳐서

as <- square a 형태가 되는 거라고 생각하시면 조금 이해하기 편할지도 모르겠네요.

>>

monad의 >> 함수는 단순히 이전의 연산 결과를 내버려두고 다른 연산을 수행하는 역할을 합니다.

이전 연산과 별 상관 없는 다른 연산을 할 때 사용할 수 있죠. do notataion은 다음의 1대1 변환을

기준으로 컴파일됩니다.

do e1

e2

는 e1 >> e2와 동일합니다.

do p <- e1

e2

e1 >>= (\v -> case v of p -> e2

_ -> fail "s") 와 동일합니다.

fail

e1 >>= (\v -> case v of p -> e2

_ -> fail "s"

이 코드를 보면 fail 함수가 쓰이고 있는 걸 알 수 있습니다. 저기서 "s"는 오류 상황에 맞는 어떤

텍스트를 의미합니다. 결국 fail 함수는 do notation 내부에서 어떤 잘못된 결과가 나왔을 때 그에

따른 처리를 하기 위한 함수라고 볼 수 있죠. 그리고 Maybe Monad에서 fail 함수는 다음과 같이

정의되어 있습니다.

fail _ = Nothing

따라서 do notation 내부에서 어떤 잘못된 결과가 발생했을 때 fail 함수가 호출이 되고, 이는

Nothing을 리턴하므로 연산 도중 뭔가 실패했을 때 Nothing을 반환하고 끝이 나게 되는 것입니다.

List Monad

List 역시 Monad 입니다. Applicative Functor에서 말했듯이, List Monad는 비결정성을

의미합니다. List Monad는 다음과 같이 정의되어 있습니다.

instance Monad [] where

return x = [x]

xs >>= f = concat (map f xs)

fail _ = []

리스트 모나드는 바인딩을 할 때(>>=) 주어진 함수를 map의 모든 원소에 적용한 후 그걸 합치는

형태로 동작함을 알 수 있습니다. 그리고 fail 함수가 []를 리턴하니 do notation 내부에서 뭔가

문제가 생겼을 때 []를 리턴하고 끝날 것이라는 것도 알 수 있죠.

List Monad

List Monad의 사용 예제를 살펴봅시다.

Prelude> [3, 4, 5] >>= \x -> [x, -x]

[3, -3, 4, -4, 5, -5]

Prelude> [1,2] >>= \n -> ['a','b'] >>= \ch -> return (n, ch)

[(1,'a'),(1,'b'),(2,'a'),(2,'b')]

List Monad

do notation 내부에서 let 키워드를 사용할 수 있습니다. 그리고 List Monad의 do notation과 List

comprehension은 사실 완전히 동일한 것입니다.

listOfTuples = do

n <- [1,2]

let chs = ['a','b']

ch <- chs

return (n, ch)

listOfTuples' = [ (n, ch) | n <- [1,2], ch <- ['a','b'] ]

Monad laws

Monad 역시 Applicative Functor와 마찬가지로 반드시 지켜야하는 규칙이 있습니다. 이 것도

자세한 내용은 생략하지만, 필요할 때 찾아서 깊이 있게 공부해보시는 것을 권장합니다.

• Left Identity

return a >>= f ≡ f a

• Right Identity

m >>= return ≡ m

• Associativity

(m >>= f) >>= g ≡ m >>= (\x -> f x >>= g)

IO Monad

Haskell에서는 IO 작업이 Monad로 취급됩니다. IO Monad는 Side Effect가 발생할 수 있는 연산을

뜻하는 Context입니다. Haskell은 IO Monad를 통해 모든 Side Effect가 발생할 수 있는 연산과

그렇지 않은 연산을 완전히 분리합니다.

Haskell의 main 함수는 IO () 를 리턴합니다.

main :: IO ()

main = do

putStrLn "Hello, World!"

모든 IO 연산은 결과 값으로 IO 컨텍스트 내부의 값을 리턴하기 때문에 주로 do notation을 써서

코딩하게 됩니다. 위 코드를 파일에 저장하고 cmd 창에서 ghc (file name).hs 를 실행하면 실행

파일이 생성됩니다. 실행해서 결과를 확인해봅시다.

IO Monad

간단하게 값을 입력 받아서 그대로 echo 해주고 종료하는 프로그램을 만들어 봅시다.

main = do

str <- getLine

putStrLn str

getLine 함수는 IO String 타입을 갖고 있습니다. 이 함수는 외부에서 값을 입력받아 그걸 IO

Context로 감싸서 결과를 돌려줍니다. side effect를 갖고 있는 IO 함수이기 때문이죠. putStrLn

역시 화면에 값을 출력하는 side effect를 갖고 있는 IO 함수기 때문에 IO () 타입을 가집니다.

IO와 관련해서도 굉장히 다양한 함수들이 있습니다. 역시 Hoogle 등에서 System.IO 모듈의 함수를

한 번 살펴보시는 걸 권장합니다.