컨텍스트, Applicative Functor, Traversable

Posted on July 2, 2020

컨텍스트

맥락같이 추상적인 말 말고, 실제 코드에서 드러나는 모양을 보면, 어떤 동작을 할 때 반드시 실행되는 코드를 말합니다. 같은 타입의 동작을 여러번 연결 하면 여러 번 실행됩니다. 보통 효과가 극적으로 나타나는 건 이렇게 연결, 연결할 때입니다.

Applicative functor

class (Functor f) => Applicative f where  
    pure :: a -> f a   -- f는 펑크터이므로 * -> * 카인드를 받는다.
    (<*>) :: f (a -> b) -> f a -> f b 

instance Applicative Maybe where  
    pure = Just  
    Nothing <*> _ = Nothing  -- 여기 패턴 매칭에서 갈래 길이 만들어진다.
    (Just f) <*> something = fmap f something 
        -- 첫 번째 인자를 패턴 매칭해서 갈래길을 만들고,
        -- something에 fmap이 실행되어 또 한 번 갈래길이 만들어진다.

보통 펑크터들의 추가적인 정보는 갈래 길(분기)을 통해 드러납니다. <*> 정의를 보면, NothingJust f 패턴 매칭으로 갈래 길이 드러납니다. 컨텍스트로 반복해서 동작할 코드가 바로 이 패턴 매칭입니다.

pure (+) <*> Just 3 <*> Just 5

보통 펑크터 안에 있는 값에, 여러 개의 매개 변수를 가진 함수를 적용하는데, 커링 진행 중간 단계를 보면 커링된(부분 적용된)함수가 펑크터 안에 놓여 있는 상태가 되어, 커링된 함수를 다시 펑크터에서 꺼낼 때 <*> 가 필요합니다. 좀 더 쉽게 보기 위해 위 코드의 중간 커링 단계를 보면

pure (+) <*> Just 3 -- 결과는 함수가 펑크터 안에 놓인 Just (3+) 상태가 됩니다.

그럼 여기서 다시 <*> 를 적용하게 되면 컨텍스트에 있는 코드가 또 동작합니다.

Just (3+) <*> Just 5 

를 만나면, Just인지 Nothing인지 보는 패턴 매칭(Applicative의 컨텍스트)이 동작해서 (3+)를 얻고, 뒤에 Just 5(3+)를 적용하기 위해 Myabe 펑크터의 fmap(Functor의 컨텍스트)이 동작합니다. 다시 정리해서 보면 <*>를 적용할 때마다 패턴 매칭(컨텍스트)이 계속 실행됩니다.

보통 펑크터에 들어 있는 값들을 데이터 생성자에 넣어 줄 때 <*>를 사용합니다.

> data Some = Some Int Int Int deriving Show
> Some <$> Just 1 <*> Just 2 <*> Just 3
Just (Some 1 2 3)

Traversable

한 발자국 더 들어가 봅시다.
maptraverse의 차이가 뭘까요?
※ 하스켈의 작명 센스는 프로그래머와 그리 친해보이지 않는다는 의견이 종종 보이는데, traverse도 단어 뜻(가로지르다. 횡단하다.)만 보고 언뜻 알기는 어려워 보입니다.

-- Traversal Maybe 인스턴스의 traverse 정의
traverse f (Just a) = Just <$> f a -- 또는 pure (Just) <*> f a
                                   -- 들어 온 값이 Just a인지 살펴봤는데, 
                                   -- 뒤에 또 한번 f a가 Just인지 <$>로 살펴 봅니다.
                                   -- <$>는 fmap의 중위infix 표현입니다.
traverse f Nothing = pure Nothing

-- map 정의
map f (Just a) = Just (f a)
map f Nothing = Nothing

둘 다 펑크터 안의 값에 함수를 적용하는 역할을 합니다. 단, 적용할 때 map은 기본 함수 적용이고, traverse<*>로 함수를 적용합니다. 무슨 차이가 있을까요? 이제 컨텍스트 개념을 알게 됐으니 간단히 이렇게 얘기하면 됩니다.

map은 컨텍스트 없이 함수를 적용하고, traverseapplicative의 컨텍스트를 발현 시키고 함수를 적용한다.”

applicative 컨텍스트로 돌아갈 코드는, 타입에 따라 정의한 인스턴스에 있는 <*>의 구현체입니다

instance Applicative [] where
    pure x    = [x]
    fs <*> xs = [f x | f <- fs, x <- xs]

리스트의 <*> 는 리스트의 comprehension 표기로 되어 있는데, f <- fs 가 동작할 때 컨텍스트가 동작하고, x <- xs 에서도 컨텍스트가 동작합니다.

class (Functor t, Foldable t) => Traversable t where

instance Traversable [] where
    traverse _ [] = pure []
    -- traverse :: Applicative f => (a -> f b) -> t a -> f (t b)
    traverse f (x:xs) = (:) <$> f x <*> traverse f xs 
    -- f는 펑크터 결과를 주는 함수이므로, <$>, <*>는 f펑크터의 것을 쓴다.

    -- Base에는 liftA2로 정의되어 있다. 
    -- traverse f = List.foldr cons_f (pure [])
    --  where cons_f x ys = liftA2 (:) (f x) ys
infixl 4 <*>
infixl 4 <$>

liftA2 :: (a -> b -> c) -> f a -> f b -> f c
liftA2 f x = (<*>) (fmap f x)

리스트에 있는 모든 엘리먼트에 f를 적용하도록 되어 있는데, 엘리먼트 하나에 적용 후 (:)로 나머지와 붙일 때 <*>가 동작하고 있습니다. f의 결과 타입이 가진 컨텍스트가 발현됩니다.

liftA2를 이용한 Travserable 리스트 인스턴스는 좀 복잡해 보이는데, liftA2를 따라가면 fmap이 나옵니다. 리스트용 traversefmap으로 갈래 길을 만들고 있는 겁니다.

> let evenFilter = (\a -> if even a then Just (a*a) else Nothing)
> map evenFilter [2,4,6] -- [Just 4,Just 16,Just 36]
> map evenFilter [1,2,3] -- [Nothing,Just 4,Nothing]
> traverse evenFilter [2,4,6] -- Just [4,16,36]
> traverse evenFilter [1,2,3] -- Nothing

traverse는 컨텍스트가 계속 동작하면서 함수를 적용합니다. mapNothing이 나오든 말든 리스트의 끝까지 함수 적용을 합니다. 하지만 traverse<*>의 패턴 매칭으로 Nothing을 만나면 더 이상 다른 <*>의 두 번째 인자에 도달하지 않고 그냥 Nothing을 반환합니다.

evenFilter를 리스트의 1에 적용 후 나머지 리스트와 붙일때 (:)<*>로 적용합니다. 인스턴스 정의의 f자리에 Maybe가 들어왔으므로 <*>는 리스트가 아니라 Maybe용이 작동합니다. 결과가 Nothing이라면 함수 적용을 하지 않고 그냥 Nothing을 반환합니다.

Nothing <*> traverse f xs 

Maybe용 인스턴스의 <*> 정의대로 위 결과값은 오른 쪽 traverse... 를 실행할 필요 없이 그냥 Nothing입니다.

그래서 traverse로 적용할 때는 하나라도 결과가 Nothing이면 전체 결과값이 Nothing입니다.

조금 복잡하긴 한데, <*>로 적용한다”란 말은 “컨텍스트 코드를 동작시킨 후 적용하겠군” 이라고 읽으면 됩니다. evenFilter를 먹인 후 값들을 묶어 “리스트로 만들려고 할 때 Maybe의 컨텍스트가 동작한다”가 map과는 다른 traverse의 동작입니다.

단순 연결만 하려면 함수 컴포지션 (.)을 쓰면 되고, 컴포지션할 때 어떤 동작을 항상 하게 하려면 (.)를 새로 만들면 됩니다.
단순 인자를 넘길 땐 (공백)을 쓰면 되지만, 인자를 넘길 때 어떤 동작(펑크터에서 꺼낸다거나…)을 항상 하게 하려면 새로 만들면 됩니다.

그리고, map은 펑크터가 하나 등장하고 Traversable엔 펑크터가 두 개가 등장합니다.

당연한 얘긴데 <*>로 묶으면 <*>의 코드가 실행됩니다.

2021.7.31 추가

import Control.Concurrent.STM

tv1 :: TVar Int -> STM ()
tv1 t = writeTVar t 1

tv2 :: TVar Int -> STM ()
tv2 t = writeTVar t 5

main = do
  a <- atomically $ newTVar 1
  let xs = [tv1, tv2]
  atomically $ map (\f -> f a) xs -- (가)
  r <- readTVarIO a
  print r

위 코드는 리스트가 바깥이라 atomically [STM.., STM..] 이런 모양이 됩니다. 리스트에 atomically를 적용하는 모양이니 당연히 에러입니다.

Couldn't match expected typeSTM a0’ with actual type ‘[STM ()]’

아래와 같이 바꿔 쓰면 어떻게 될까요?

map (\f -> atomically $ f a) xs

이 것도 역시 가장 바깥이 리스트 상태 [IO.., IO..]여서 IO 컨텍스트인 main에서 바로 쓰지 못합니다.

Couldn't match type ‘[]’ with ‘IO
Expected type: IO (IO ())
Actual type: [IO ()]

리스트 안에 들어 있는 IOSTM을 바깥으로 꺼내놔야 현재 컨텍스트에서 쓸 수 있습니다. 바로 이럴 때 traverse를 씁니다.

atomically $ traverse (\f -> f a) xs

다시 한번 위에 나왔던 Maybe의 인스턴스를 예로 보면, 펑크터 두 개가 섞여 있는 코드에서 펑크터 위치를 바꾸는 걸 알 수 있습니다.

-- f와 Just의 감싼 순서를 바꾸고 있습니다.
traverse f (Just a) = Just <$> f a

생각을 좇아가는 팁은, 패턴 매칭으로 펑크터가 벗겨지는 걸 상상하며 따라가면 좋습니다. 언제나, 펑크터가 가진 고유의 동작이 드러나려면 패턴 매칭으로 벗겨야 한다는 걸 기억하세요.

> :t [putStrLn "1", putStrLn "2"]
[putStrLn "1", putStrLn "2"] :: [IO ()]

-- IO와 [] 순서를 바꾸기 위해 
> :t traverse id [putStrLn "1", putStrLn "2"]
traverse id [putStrLn "1", putStrLn "2"] :: IO [()]

> traverse id [putStrLn "1", putStrLn "2"]
1
2
[(),()]

실용 프로그램을 짜다 보면 [STM], [IO]를 갖고 작업할 일이 자주 생깁니다. 리스트 안에 STM이나 IO가 있으면 traverse를 떠올리세요.

import Control.Concurrent.STM

bs :: IO [TVar String]
bs = do
    b1 <- newTVarIO "a" 
    b2 <- newTVarIO "bb"
    b3 <- newTVarIO "ccc"
    return [b1,b2,b3]

getSize :: TVar String -> IO Int
getSize b = do
    bytes <- readTVarIO b
    return $ length bytes

main = do
    bslist <- bs --[TVar..., TVar...,... ]
    sizes <- traverse getSize bslist
-- TVar 안에 들어 있는 문자열의 길이를 모두 합하려면 traverse로 돌려야 합니다.
    putStrLn $ show $ sum sizes

Q. Applicative 펑크터와 모나드의 차이
Applicative와 모나드 둘 다 effect를 가지고 있습니다.
Applicative<*>를 통해 함수를 적용하는데, 모두 독립적으로 적용합니다.
이전 작업 결과(effect)가 다음 작업에 영향을 주지 않습니다. <*> 작업들은 parallel 하게 돌릴 수 있습니다.
모나드는 이전 effect와 다음 effect를 합성해서 하나의 effect로 표현합니다.
2022.6 새로 올린 Applicative Functor와 Monad 차이글을 참고하세요.

모나드는 모나드, 같음 글을 참고하세요.

Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com