본문으로 건너뛰기

Monad_Haskell 노트 10

무료2018-06-23#Functional_Programming#Haskell Monad#Haskell do notation#JavaScript Monad#Haskell单子#Applicative and Monad

3 년 전 처음 monadic 을 듣고, 호기심이 나무로 자랄 때까지

一.Functor 에서 Monad 로

타입으로 보면, Functor 에서 Applicative 를 거쳐 Monad 로는 일반에서 특수로의 단계적 프로세스입니다 (Monad 는 특수한 Applicative, Applicative 는 특수한 Functor)

Functor

일반 함수를 context 를 가진 값에 map over 할 수 있습니다

fmap :: (Functor f) => (a -> b) -> f a -> f b

context 관련 계산 중에서 가장 간단한 장면을 해결하기 위해 사용:context 를 갖지 않는 함수를 context 를 가진 값에 어떻게 적용하는가?

(+1) ->? Just 1

fmap 의 등장:

> fmap (+1) (Just 1)
Just 2

Applicative

Functor 위의 강화로, context 내의 함수를 context 를 가진 값에 map over 할 수 있습니다

(<*>) :: (Applicative f) => f (a -> b) -> f a -> f b
pure :: (Applicative f) => a -> f a

Applicative 는 계산 문맥 (computation context) 으로 이해할 수 있습니다. Applicative 값은 계산입니다. 예를 들어:

Maybe a 는 실패할 가능성이 있는 computation 을 나타내고, [a] 는 동시에 많은 결과가 있는 computation(non-deterministic computation) 을 나타내며, IO a 는 side-effects 가 있는 computation 을 나타냅니다.

P.S.computation context 에 관한 자세한 정보는, [Functor 와 Applicative_Haskell 노트 7](/articles/functor 와 applicative-haskell 노트 7/#articleHeader4) 참조

context 관련 계산 중의 다른 장면을 해결하기 위해 사용:context 를 가진 함수를 context 를 가진 값에 어떻게 적용하는가?

Just (+1) ->? Just 1

<*> 의 등장:

> Just (+1) <*> (Just 1)
Just 2

Monad

Applicative 위의 강화로, 일반 값을 입력하고 context 를 가진 값을 출력하는 함수를, context 를 가진 값에 적용할 수 있습니다

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

context 를 가진 값 m a 가 있는 경우, 그것을 일반 값 a 만을 받아들이는 함수에 던져 넣고, context 를 가진 값을 반환하려면 어떻게 해야 할까요? 즉, 타입이 a -> m b 인 함수를 m a 에 어떻게 적용하는가?

context 관련 계산 중의 마지막 장면을 해결하기 위해 사용:일반 값을 입력하고 context 를 가진 값을 출력하는 함수를, context 를 가진 값에 어떻게 적용하는가?

\x -> Just (x + 1) ->? Just 1

>>= 의 등장:

> Just 1 >>= \x -> Just (x + 1)
Just 2

三者의 관련

인터페이스의 동작으로 보면, 이 3 개는 모두 context 를 가진 값과 함수를 중심으로 일을搞합니다 (즉, context 관련의 계산). 그렇다면, 생각해 보세요. 공유하는 조합 상황은 몇 종류 있습니까?

  • 함수의 입력출력 타입이 일치하는 상황

    • context 내의 함수 + context 내의 값:Applicative

    • context 내의 함수 + 일반 값:pure 로 감싸서 호출

    • 일반 함수 + context 내의 값:Functor

    • 일반 함수 + 일반 값: 함수 호출

  • 함수의 입력출력 타입이 일치하지 않는 상황

    • 함수는 일반 값을 입력하고 context 내의 값을 출력 + context 내의 값:Monad

    • 함수는 일반 값을 입력하고 context 내의 값을 출력 + 일반 값:직접 호출

    • 함수는 context 내의 값을 입력하고 일반 값을 출력 + context 내의 값:직접 호출

    • 함수는 context 내의 값을 입력하고 일반 값을 출력 + 일반 값:pure 로 감싸서 호출

따라서, 이 장면 (context 내에 있는지 여부의 함수를 context 내에 있는지 여부의 값에 적용하는) 에 관해 말하면, Functor, ApplicativeMonad 를 가지고 있으면모든 상황에 대응するのに 충분합니다

二.Monad typeclass

class Applicative m => Monad m where
  (>>=)       :: forall a b. m a -> (a -> m b) -> m b
  (>>)        :: forall a b. m a -> m b -> m b
  m >> k = m >>= \_ -> k
  return      :: a -> m a
  return      = pure
  fail        :: String -> m a
  fail s      = errorWithoutStackTrace s

실제로, Monad 인스턴스는 >>= 함수 (bind 라고 불림) 를 구현하기만 하면 됩니다. 다시 말해, Monad>>= 조작을 서포트하는 Applicative functor 에 불과합니다

returnpure 의 별명이므로, 여전히 일반 값을 받아들이고, 그것을 최소의 context 에 넣습니다 (일반 값을 Monad 안에 감쌈)

(>>) :: m a -> m b -> m b 는 디폴트 구현을 정의하고, 함수 \_ -> m b>>= 를 통해 m a 에 적용하고, (체인 조작 중에) 앞의 계산 결과를 무시하기 위해 사용합니다

P.S. 체인 조작 중에, 만나는 >>>>= \_ -> 로 바꾸면 쉽게 이해할 수 있습니다

P.S. 위의 타입 선언 중의 forall 를 가리킵니다 (이산 수학 중의 양사, 전칭 양사 는 "임의" 를 나타내고, 존재 양사 는 "존재" 를 나타냅니다). 따라서 forall a b. m a -> (a -> m b) -> m b 는, 임의의 타입 변수 ab 에 대해, >>= 함수의 타입은 m a -> (a -> m b) -> m b 임을 말합니다. forall a b. 를 생략할 수 있습니다. 디폴트로 모든 소문자 타입 파라미터는 임의이기 때문입니다:

In Haskell, any introduction of a lowercase type parameter implicitly begins with a forall keyword

三.Maybe Monad

MaybeMonad 구현은 매우 직관적으로 부합합니다:

instance  Monad Maybe  where
  (Just x) >>= k      = k x
  Nothing  >>= _      = Nothing
  fail _              = Nothing

>>= 는 함수 kJust 내의 값에 적용하고, 결과를 반환합니다. Nothing 의 경우는, 직접 Nothing 을 반환합니다. 예를 들어:

> Just 3 >>= \x -> return (x + 1)
Just 4
> Nothing >>= \x -> return (x + 1)
Nothing

P.S. 우리가 제공하는 함수 \x -> return (x + 1) 에 주의.return 의 가치가 나타났습니다. 함수 타입은 a -> m b 임을 요구하므로, 결과를 return 로 감싸는 것은 매우 편리하고, 시맨틱스도 매우 적절합니다

이 특성은일련의 오류가 발생할 가능성이 있는 조작을 처리하는장면에 매우 적합합니다. 예를 들어 JS 의:

const err = error => NaN;
new Promise((resolve, reject) => {
  resolve(1)
})
.then(v => v + 1, err)
.then(v => {throw v}, err)
.then(v => v * 2, err)
.then(console.log.bind(this), err)

일련의 조작으로, 중간 스텝에서 오류가 발생할 가능성이 있고 (throw v), 오류가 발생한 후에는 오류를 나타내는 결과 (위의 예에서는 NaN) 를 취득하고, 오류가 발생하지 않으면 올바른 결과를 취득할 수 있습니다

MaybeMonad 특성을 사용하여 기술:

> return 1 >>= \x -> return (x + 1) >>= \_ -> (fail "NaN" :: Maybe a) >>= \x -> return (x * 2)
Nothing

1:1 로 완전히 복원하고, Maybe Monad 를 이용하여 일련의 오류가 발생할 가능성이 있는 조작에从容하게 대응

四.do 표시법

I/O 장면에서 do 문 블록을 사용한 적이 있습니다 (do-notation 라고 불림). 일련의 I/O Action 을 조합할 수 있습니다. 예를 들어:

> do line <- getLine; char <- getChar; return (line ++ [char])
hoho
!"hoho!"

3 개의 I/O Action 을 이어서, 마지막 I/O Action 을 반환했습니다. 실제로는, do 표시법은 I/O 장면뿐만 아니라, 임의의 Monad 에도 적용됩니다

구문에 관해 말하면, do 표시법은 각 행이 monadic value 여야 합니다. 왜일까요?

do 표시법은 >>= 의 문법 설탕에 불과하기때문입니다. 예를 들어:

foo = do
  x <- Just 3
  y <- Just "!"
  Just (show x ++ y)

context 를涉及하지 않는 일반 계산과 비교:

let x = 3; y = "!" in show x ++ y

do 표시법의 상쾌하고 간결한 우위성을 발견하는 것은 어렵지 않습니다. 실제로는:

foo' = Just 3   >>= (\x ->
  Just "!" >>= (\y ->
  Just (show x ++ y)))

do 표시법이 없으면, 수동으로一堆의 lambda 네스트를 써야 합니다:

Just 3 >>= (\x -> Just "!" >>= (\y -> Just (show x ++ y)))

따라서 <- 의 작용은:

>>= 를 사용하여 monadic value 를 lambda 에 가져오는 것과 같음

>>= 가 있습니다. 그렇다면 >> 는? 어떻게 사용합니까?

maybeNothing :: Maybe Int
maybeNothing = do
  start <- return 0
  first <- return ((+1) start)
  Nothing
  second <- return ((+2) first)
  return ((+3) second)

do 표시법에서 연산의 행을 썼지만, <- 를 사용하여 값을 바인드하지 않는 경우, 실제로는 >> 를 사용한 것입니다. 그는 계산 결과를 무시합니다. 우리는 그들의 순서를 필요로 할 뿐, 그들의 결과를 필요로 하는 것이 아니며, _ <- Nothing 라고 쓰는 것보다 훨씬 깔끔합니다.

마지막으로, fail 이 있습니다. do 표시법에서 오류가 발생하면 자동으로 fail 함수를 호출합니다:

fail        :: String -> m a
fail s      = errorWithoutStackTrace s

디폴트에서는 오류를 보고하고, 프로그램을 정지시킵니다. 구체적인 Monad 인스턴스에는 고유의 구현이 있습니다. 예를 들어 Maybe:

fail _              = Nothing

오류 메시지를 무시하고, Nothing 을 반환합니다.试玩一下:

> do (x:xs) <- Just ""; y <- Just "abc"; return y;
Nothing

do 문 블록 중에서 패턴 매칭이 실패하고, 직접 fail 을 반환합니다. 의의는:

이렇게 패턴 매칭의 실패는 monad 의 context 중에 제한되고, 프로그램 전체의 실패에는なりません

五.List Monad

instance Monad []  where
  xs >>= f             = [y | x <- xs, y <- f x]
  (>>) = (*>)
  fail _              = []

List 의 context 는불확정한 환경 (non-determinism), 즉 여러 개의 결과가 존재하는것을 가리킵니다. 예를 들어 [1, 2] 에는 2 개의 결과 (1,2) 가 있고, [1, 2] >>= \x -> [x..x + 2] 에는 6 개의 결과 (1,2,3,2,3,4) 가 있습니다

P.S. "여러 개의 결과" 를 어떻게 이해하는가?

C 언어를 처음 배울 때困惑이 있었습니다. 함수는 여러 개의 return 을 가질 수 있는가? 그렇다면 어떻게 여러 개의 값을 반환하는가?

배열 (또는 구조체, 링크드리스트 등) 을 반환할 수 있습니다. 여러 개의 값을 함께 조직하고 (데이터 구조에 넣고), 패키지하여 반환합니다

만약 함수가 배열을 반환한다면, 그가 몇 개의 결과를 ��환했는지 불확정합니다. 이것이 소위불확정한 환경입니다

ListMonad 구현으로 보면, >>= 는 매핑 조작으로, 말할 것이 없습니다

>> 는 조금 재미있어 보입니다. Applicative 위에 정의된 *> 와 등가입니다:

class Functor f => Applicative f where
  (*>) :: f a -> f b -> f b
  a1 *> a2 = (id <$ a1) <*> a2

class  Functor f  where
  (<$)        :: a -> f b -> f a
  (<$)        =  fmap . const

const                   :: a -> b -> a
const x _               =  x

작용은첫 번째 파라미터 중의 값을 파기하고, 구조 의미 (List 길이 정보) 만을 보유하는 것입니다. 예를 들어:

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

等価于:

> ((fmap . const) id $ [1, 2]) <*> [3, 4, 5]
[3,4,5,3,4,5]
-- 또는
> [id, id] <*> [3, 4, 5]
[3,4,5,3,4,5]

List Comprehension 과 do 표시법

재미있는 예:

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

마지막의 n 은 그다지 과학적으로 보이지 않습니다 (infixl 1 >>= 를 보면 액세스할 수 없어 보입니다). 실제로는 n 에 액세스할 수 있습니다. 왜냐하면 lambda 표현의 탐욕 매칭 특성 때문으로, 等価于:

[1,2] >>= \n -> (['a','b'] >>= \ch -> return (n,ch))
-- 괄호를 추가한 완전판
([1, 2] >>= (\n -> (['a','b'] >>= (\ch -> return (n,ch)))))

함수 본체에 경계가 없으면 최우단까지 매칭합니다. 관련 토론은 Haskell Precedence: Lambda and operator 참조

P.S. 더욱이, 식의 결합 방식이 불확정한 경우 (어떻게 괄호를 추가하는지 모르는) 는, 신기한 방법이 있습니다. How to automatically parenthesize arbitrary haskell expressions? 참조

do 표시법으로 다시 작성:

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

형식상은 List Comprehension 과 매우 비슷합니다:

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

실제로는, List Comprehension 과 do 표시법은 모두문법 설탕으로, 마지막은 모두 >>= 로 변환되어 계산됩니다

六.Monad laws

마찬가지로, Monad 도 몇 가지 규칙을 따라야 합니다:

  • 좌 단위 원 (Left identity):return a >>= f ≡ f a

  • 우 단위 원 (Right identity):m >>= return ≡ m

  • 결합 률 (Associativity):(m >>= f) >>= g ≡ m >>= (\x -> f x >>= g)

단위 원의 성질은 그다지 명확하게 보이지 않습니다. Kleisli composition 을借助하여 더 표준적인 형식으로 변환할 수 있습니다:

-- | Left-to-right Kleisli composition of monads.
(>=>)       :: Monad m => (a -> m b) -> (b -> m c) -> (a -> m c)
f >=> g     = \x -> f x >>= g

(Control.Monad 에서 인용)

타입 선언으로 보면, >=> 는*Monad 함수 간의 조합 연산*(monadic function) 에 상당합니다. 이러한 함수들은 일반 값을 입력하고, monadic 값을 출력합니다. 일반 함수 조합과 비교:

(.)    :: (b -> c) -> (a -> b) -> a -> c
(.) f g = \x -> f (g x)

>=> 는 좌에서 우로 Moand m => a -> m b 의 함수를 조합하고, . 는 우에서 좌로 a -> b 의 함수를 조합합니다

P.S. 그렇다면, 우에서 좌의 Monad 함수 조합은 있습니까?맞습니다, <=< 입니다

Kleisli composition(>=>) 을 사용하여 Monad laws 를 기술:

  • 좌 단위 원:return >=> f ≡ f

  • 우 단위 원:f >=> return ≡ f

  • 결합 률:(f >=> g) >=> h ≡ f >=> (g >=> h)

이 3 조를 만족하므로, 표준적인 Monoid 입니다. Moand m => a -> m b 함수 집합 및 그 위에 정의된 >=> 연산은 幺半群을 구성하고, 幺元은 return 입니다

P.S.>=> 로 기술된 Monad laws 의더 큰 의의는, 이 3 조가 수학 을 형성하기 위해 필요한 규율인 것으로,これから圏의 수학적 의미를 가집니다. 자세한 것은 Category theory 참조

MonadPlus

동시에 MonadMonoid 를 만족하는 것에는 전용의 이름이 있으며, MonadPlus 라고 불립니다:

class (Alternative m, Monad m) => MonadPlus m where
  mzero :: m a
  mzero = empty
  mplus :: m a -> m a -> m a
  mplus = (<|>)

List 의 장면에서는, mzero[] 이고, mplus++ 입니다:

instance Alternative [] where
  empty = []
  (<|>) = (++)

이것에는 무슨 용도가 있습니까?

예를 들어 리스트 요소를 필터링하는 경우, List Comprehension 이 가장 심플합니다:

> [ x | x <- [1..50], '7' `elem` show x ]
[7,17,27,37,47]

>>= 를 사용해도 처리할 수 있습니다:

> [1..50] >>= \x -> if ('7' `elem` show x) then [x] else []
[7,17,27,37,47]

조건 표현은 다소 臃腫하게 보입니다. MonadPlus 가 있으면더 간결하고 힘있는 표현 방식으로 바꿀 수 있습니다:

> [1..50] >>= \x -> guard ('7' `elem` show x) >> return x
[7,17,27,37,47]

그 중에서 guard 함수는 다음과 같습니다:

guard           :: (Alternative f) => Bool -> f ()
guard True      =  pure ()
guard False     =  empty

불리언 값을 입력하고, context 를 가진 값을 출력합니다 (True 는 디폴트 context 내의 () 에 대응하고, Falsemzero 에 대응)

guard 처리 후, >> 를 이용하여 비幺元 값을 원래 값으로 회복하고 (return x), 幺元은 >> 연산 후에도 幺元 ([]) 그대로이므로, 필터링됩니다

대응하는 do 표시법은 다음과 같습니다:

sevensOnly = do
  x <- [1..50]
  guard ('7' `elem` show x)
  return x

List Comprehension 형식과 비교:

[ x | x <- [1..50], '7' `elem` show x ]

매우 비슷하며, 모두 거의 여분의 구두점이 없는 간결한 표현입니다

do 표시법 중의 작용

Monad laws 를 do 표시법으로 바꾸어 기술하면, 또 한 조의 등가 변환 규칙이 얻어집니다:

-- Left identity
do { x′ <- return x;
    f x′
  }
≡
do { f x }

--  Right identity
do { x <- m;
    return x
  }
≡
do { m }

-- Associativity
do { y <- do { x <- m;
              f x
            }
    g y
  }
≡
do { x <- m;
    do { y <- f x;
          g y
        }
  }
≡
do { x <- m;
    y <- f x;
    g y
  }

이러한 규칙에는2 개의 작용이 있습니다:

  • 코드를 간소화하기 위해 사용 가능

     skip_and_get = do
       unused <- getLine
       line <- getLine
       return line
    
     -- Right identity 를 이용하여, 여분의 return 을 삭제
     skip_and_get = do
       unused <- getLine
       getLine
    
  • do block 네스트를 회피 가능

     main = do
       answer <- skip_and_get
       putStrLn answer
    
     -- 전개
     main = do
       answer <- do
         unused <- getLine
         getLine
       putStrLn answer
    
     -- 결합 률을 사용하여 do block 네스트를 풀다
     main = do
       unused <- getLine
       answer <- getLine
       putStrLn answer
    

七.Monad 과 Applicative

최초의 장면으로 돌아가, 우리는 Monad 가 구문상으로 context 관련 계산을 간소화할 수 있고, a -> m bm a 에 적용할 수 있는 것을 알고 있습니다

MonadApplicative 의 기초 위에 건설되어 있는既然, 그렇다면, Applicative 와 비교하여, Monad 의 핵심적인 우위성은 어디에 있으며, 무엇으로 존재할 자격이 있는가?

applicative functor 는 applicative value 간에 탄력적인交互를 허가하지 않기 때문

이것, 어떻게 이해하는가?

또 하나의 Maybe Applicative 의 예를 보다:

> Just (+1) <*> (Just (+2) <*> (Just (+3) <*> Just 0))
Just 6

중간 링크가 모두 오류를 내지 않는 Applicative 연산은, 정상적으로 결과를 취득할 수 있습니다. 만약 중간 링크에서 오류가 나면?

-- 중간에서 실패
> Just (+1) <*> (Nothing <*> (Just (+3) <*> Just 0))
Nothing

이것도 예상에 부합합니다. 순 Applicative 연산은 이미 수요를 만족시키는ように 보입니다. 잘見て보세요.刚才는 어떻게 중간 링크의 실패를 표현했는가:Nothing <*> some thing. 이 Nothing 은 하드코드로 부착된 폭탄과 같고, 순수한 정적 장면입니다

그렇다면 동적으로 폭발하고 싶다면, 어떻게 하는가?

-- 유연성 부족
> Just (+1) <*> (Just (\x -> if (x > 1) then Nothing else return (x + 2)) <*> (Just (+3) <*> Just 0))
<interactive>:85:1: error:
? Non type-variable argument in the constraint: Num (Maybe a)
  (Use FlexibleContexts to permit this)
? When checking the inferred type
    it :: forall a. (Ord a, Num (Maybe a), Num a) => Maybe (Maybe a)

오류의 원인은 동적으로 폭발을 제어하려고 했지만, Maybe (Maybe a) 를 만들어버린 것입니다:

> Just (\x -> if (x > 1) then Nothing else return (x + 2)) <*> (Just (+3) <*> Just 0)
Just Nothing

이러한 어색한 국면이 나타난 이유는, Applicative<*> 가 기계적으로 좌측의 context 에서 함수를 취해내고, 우측의 context 내의 값에 적용할 뿐이기 때문입니다. Maybe 에서 함수를 취하는결과는 2 종류뿐:Nothing 에서 아무것도 취해낼 수 없어, 즉시 폭발; 또는 Just f 에서 f 를 취해내고, 연산하여 Just (f x) 를 취득. 전 한 걸음 (x) 이 폭발하지 않으면 폭발할 수 없습니다

따라서, 응용 장면으로 보면, Monad 는 일종의 계산 문맥 제어로, 몇 가지 통용 장면 (예를 들어 오류 처리, I/O, 불확정 결과 수의 계산 등) 에 대응합니다. 그 존재 의의는:Applicative 보다도 유연하고, 각 스텝의 계산 중에 제어를 추가하는 것을 허가하며, Linux 파이프라인과 같습니다

참고 자료

댓글

아직 댓글이 없습니다

댓글 작성