맥락같이 추상적인 말 말고, 실제 코드에서 드러나는 모양을 보면, 어떤 동작을 할 때 반드시 실행되는 코드를 말합니다. 같은 타입의 동작을 여러번 연결 하면 여러 번 실행됩니다. 보통 효과가 극적으로 나타나는 건 이렇게 연결, 연결할 때입니다.
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이 실행되어 또 한 번 갈래길이 만들어진다.
보통 펑크터들의 추가적인 정보는 갈래 길(분기)을 통해 드러납니다. <*>
정의를 보면, Nothing
과 Just 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)
한 발자국 더 들어가 봅시다.
map
과 traverse
의 차이가 뭘까요?
※ 하스켈의 작명 센스는 프로그래머와 그리 친해보이지 않는다는 의견이 종종 보이는데, 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
은 컨텍스트 없이 함수를 적용하고, traverse
는 applicative
의 컨텍스트를 발현 시키고 함수를 적용한다.”
applicative
컨텍스트로 돌아갈 코드는, 타입에 따라 정의한 인스턴스에 있는 <*>
의 구현체입니다
instance Applicative [] where
pure x = [x]
<*> xs = [f x | f <- fs, x <- xs] fs
리스트의 <*>
는 리스트의 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
= (<*>) (fmap f x) liftA2 f x
리스트에 있는 모든 엘리먼트에 f
를 적용하도록 되어 있는데, 엘리먼트 하나에 적용 후 (:)
로 나머지와 붙일 때 <*>
가 동작하고 있습니다. f
의 결과 타입이 가진 컨텍스트가 발현됩니다.
liftA2
를 이용한 Travserable
리스트 인스턴스는 좀 복잡해 보이는데, liftA2
를 따라가면 fmap
이 나옵니다. 리스트용 traverse
도 fmap
으로 갈래 길을 만들고 있는 겁니다.
> 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
는 컨텍스트가 계속 동작하면서 함수를 적용합니다. map
은 Nothing
이 나오든 말든 리스트의 끝까지 함수 적용을 합니다. 하지만 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 ()
= writeTVar t 1
tv1 t
tv2 :: TVar Int -> STM ()
= writeTVar t 5
tv2 t
= do
main <- atomically $ newTVar 1
a let xs = [tv1, tv2]
$ map (\f -> f a) xs -- (가)
atomically <- readTVarIO a
r print r
위 코드는 리스트가 바깥이라 atomically [STM.., STM..]
이런 모양이 됩니다. 리스트에 atomically
를 적용하는 모양이니 당연히 에러입니다.
Couldn't match expected type ‘STM 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 ()]
리스트 안에 들어 있는 IO
나 STM
을 바깥으로 꺼내놔야 현재 컨텍스트에서 쓸 수 있습니다. 바로 이럴 때 traverse
를 씁니다.
$ traverse (\f -> f a) xs atomically
다시 한번 위에 나왔던 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]
= do
bs <- newTVarIO "a"
b1 <- newTVarIO "bb"
b2 <- newTVarIO "ccc"
b3 return [b1,b2,b3]
getSize :: TVar String -> IO Int
= do
getSize b <- readTVarIO b
bytes return $ length bytes
= do
main <- bs --[TVar..., TVar...,... ]
bslist <- traverse getSize bslist
sizes -- TVar 안에 들어 있는 문자열의 길이를 모두 합하려면 traverse로 돌려야 합니다.
putStrLn $ show $ sum sizes
Q.
Applicative
펑크터와 모나드의 차이
Applicative
와 모나드 둘 다 effect를 가지고 있습니다.
Applicative
는<*>
를 통해 함수를 적용하는데, 모두 독립적으로 적용합니다.
이전 작업 결과(effect)가 다음 작업에 영향을 주지 않습니다.<*>
작업들은 parallel 하게 돌릴 수 있습니다.
모나드는 이전 effect와 다음 effect를 합성해서 하나의 effect로 표현합니다.
2022.6 새로 올린 Applicative Functor와 Monad 차이글을 참고하세요.
모나드는 모나드, 같음 글을 참고하세요.