Applicative functor
이번 챕터에서는 펑터에 업그레이드 버전인 Applicative 펑터에 대해서 알아보겠습니다. 하스켈에서는 Control.Applicative
모듈에 Applicative
타입클래스로 정의되어 있습니다.
하스켈에서는 기본적으로 모든 함수는 커링입니다. 이 말은 여러개의 매개변수를 받는 함수들은 사실은 하나의 매개변수를 받아서 리턴하는 함수들의 체인으로 이루어져있다는 것입니다. 만약 a -> b -> c
타입의 함수가 있다면, 두개의 매개변수를 받아서 c
를 반환하는 함수라고 합니다. 하지만 실제로는 a
를 받아서 b -> c
를 반환하는 함수입니다. 따라서 f x y
또는 (f x) y
로 함수를 호출할 수 있습니다. 이러한 매커니즘은 적은 매개변수의 함수 호출를 부분적으로 적용하여, 그 결과를 다른 함수로 전달할 수 있게 합니다.
펑터로 맵핑을 할때 보통은 하나의 매개변수만 받는 함수로 맵핑하였습니다. 하지만 만약 두개의 매개변수를 받는 *
같은 함수를 펑터로 맵핑할때는 어떻게 될까요? 예를들어 fmap (*) (Just 3)
의 결과는 Just ((*) 3)
이고, 이것은 Just (* 3)
와 같습니다. 따라서 결과적으로 Just
로 랩핑된 함수를 얻었습니다!!
ghci> :t fmap (++) (Just "hey")
fmap (++) (Just "hey") :: Maybe ([Char] -> [Char])
ghci> :t fmap compare (Just 'a')
fmap compare (Just 'a') :: Maybe (Char -> Ordering)
ghci> :t fmap compare "A LIST OF CHARS"
fmap compare "A LIST OF CHARS" :: [Char -> Ordering]
ghci> :t fmap (\x y z -> x + y / z) [3,4,5,6]
fmap (\x y z -> x + y / z) [3,4,5,6] :: (Fractional a) => [a -> a -> a]
compare
의 타입은 (Ord a) => a -> a -> Ordering
입니다. 문자의 리스트를 compare
로 맵핑하면 Char -> Ordering
타입의 함수의 리스트를 반환합니다. (Ord a) => a -> Ordering
함수의 리스트가 아닌 이유는 첫번째 매개변수 a
가 Char
이므로 두번째 매개변수 a
는 이미 Char
로 결정되었기 때문입니다.
여기서 매개변수가 여러개인 경우에는 펑터를 적용하면 함수를 가지고 있는 펑터가 된다는 것을 알았습니다. 그렇다면 이것들로 무엇을 할 수 있을까요? 우선 매개변수로 함수를 받는 함수를 맵핑할 수 있습니다. 왜냐하면 펑터안에 있는 것이 뭐든간에 매개변수로 맵핑하는 함수에 입력으로 주어지기 때문입니다.
ghci> let a = fmap (*) [1,2,3,4]
ghci> :t a
a :: [Integer -> Integer]
ghci> fmap (\f -> f 9) a
[9,18,27,36]
만약 값이 Just (3 *)
인 펑터와 값이 Just 5
인 펑터를 가지고 있을때, Just (3 *)
에서 함수만 꺼내서 Just 5
에 맵핑하고 싶다면 어떻게 해야할까요? 일반적인 펑터는 기존 펑터에 함수를 맵핑하는 기능만 제공합니다. 내부에 함수가 포함된 펑터에서 \f -> f 9
를 맵핑할때도 일반 함수로 맵핑했습니다. 따라서 fmap
을 사용해서는 펑터 내부의 함수를 다른 펑터 내부의 함수로 맵핑할 수 없습니다. 펑터내의 함수를 얻기 위해서 Just
생성자에 대한 패턴 매칭을 사용할 수 있습니다. 그리고 나서 Just 5
를 맵핑합니다. 하지만 이 작업을 수행하는 더 일반적이고 추상화된 방법으로 펑터를 활용할 수 있습니다.
Control.Applicative
모듈에 정의된 Applicative
타입클래스는 pure
와 <*>
함수가 있습니다. 그리고 default 구현체는 제공하지 않습니다. 따라서 어플리케이티브 펑터로 만들기 위해서는 두 함수 모두 정의해야합니다. 이 클래스는 아래와 같이 생겼습니다.
class (Functor f) => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
이 간단한 세라인짜리 선언은 많은 것을 말해줍니다. 먼저 Applicative
클래스의 정의와 한정자를 선언하였습니다. 만약 타입생성자를 Applicative
타입클래스의 부분으로 만들려면 먼저 Functor
를 가지고 있어야 합니다. 따라서 타입생성자가 Applicative
타입클래스의 부분일때는 Functor
에도 포함되므로 fmap
을 사용할 수 있습니다.
pure
함수의 타입은 pure :: a -> f a
입니다. f
는 여기서 어플리케이티브 펑터 인스턴스입니다. pure
는 임의의 타입의 값을 받아서 안에 그 값을 포함한 어플리케이티브 펑터를 반환합니다. a -> f a
는 상자에 비유하면, 어떤 값을 받아서 그 결과값이 안에 들어있는 어플리케이티브 펑터 상자에 포장합니다.
pure
는 어떤 값을 받아서 어떤 기본 컨텍스트안에 넣는 것 입니다.
<*>
함수의 타입은 f (a -> b) -> f a -> f b
입니다. 이것은 fmap :: (a -> b) -> f a -> f b
인 fmap
이 업그레이드된 버전입니다. fmap
은 함수를 받아서 적용한 후, 펑터에 다시 넣는 반면, <*>
함수는 함수를 가진 펑터와 또다른 펑터를 받아서, 첫번째 펑터에서 함수를 빼서 두번째 펑터에 맵핑합니다. 여기서 추출한다는 것은 실제로 실행(run)하고나서 추출(extract)하는 것을 의미합니다.
Maybe
어플리케이티브 펑터
이제 Maybe
를 Applicative
의 인스턴스로 만들어 보겠습니다.
instance Applicative Maybe where
pure = Just
Nothing <*> _ = Nothing
(Just f) <*> something = fmap f something
여기서 어플리케이티브 평터인 f
는 하나의 구체적인 타입을 매개변수로 받습니다. 그래서 instance Applicative (Maybe a) where
대신, instance Applicative Maybe where
로 작성되었습니다.
pure
가 pure = Just
로 작성된 것은 Just
와 같은 값 생성자가 일반적인 함수이기 때문입니다. 이것은 pure x = Just x
와 같이 작성될 수도 있습니다.
<*>
는 함수를 가지고있지않는Nothing
에서 어떤 함수도 뽑아낼 수 없기때문에, 그대로 Nothing
입니다. Applicative
내의 클래스 선언을 보면, Functor
클래스 한정자가 있습니다. 따라서 <*>
의 두개의 매개변수가 펑터라는 것을 알 수 있습니다. 따라서 첫번째 매개변수가 Just
일때는 어떤 함수를 포함하게 됩니다. <*>
함수는 Just
안에 포함된 함수로 두번째 매개변수인 포함된 값을 맵핑 합니다. Nothing
인 경우는 어떤 함수를 맵핑시켜도 Nothing
을 반환합니다.
ghci> Just (+3) <*> Just 9
Just 12
ghci> pure (+3) <*> Just 10
Just 13
ghci> pure (+3) <*> Just 9
Just 12
ghci> Just (++"hahah") <*> Nothing
Nothing
ghci> Nothing <*> Just "woot"
Nothing
이 경우, pure (+3)
과 Just (+3)
가 동일한 동작을 하는 것을 확인할 수 있습니다. 어플리케이티브 컨텍스트(예를들어 <*>
)에서 Maybe
값들을 다룰때는 pure
를 사용하고, 아니면 Just
를 사용합니다. 위에서부터 4번째 라인까지는 단순하게 첫번째 펑터에서 함수를 추출해서 값을 맵핑하고, 다시 펑터로 랩핑하였습니다. 그리고 마지막 예제에서는 Nothing
에서 함수를 추출하고 어떤값에 맵핑해서, 그 결과는 역시 Nothing
이 되었습니다.
일반적인 펑터를 사용하면 함수를 펑터로 맵핑할 수 있고, 그 결과가 부분적용 함수일지라도 동일한 방법으로 그 결과를 얻을 수 있습니다. 반면에 어플리케이티브 펑터는 단일 함수로 여러개의 펑터들을 조작할 수 있습니다. 아래 코드에서 확인해보겠습니다.
ghci> pure (+) <*> Just 3 <*> Just 5
Just 8
ghci> pure (+) <*> Just 3 <*> Nothing
Nothing
ghci> pure (+) <*> Nothing <*> Just 5
Nothing
단계별로 분석해보면 아래와 같습니다.
<*>
는 left-associative이므로pure (+) <*> Just 3 <*> Just 5
->(pure (+) <*> Just 3) <*> Just 5
+
함수는Maybe
펑터안에 포함되므로(pure (+) <*> Just 3) <*> Just 5
->(Just (+) <*> Just 3) <*> Just 5
(Just (+) <*> Just 3)
는Just (3+)
이므로(Just (+) <*> Just 3) <*> Just 5
->Just (3+) <*> Just 5
Just (3+)
도 부분적용로 커링되므로Just (3+) <*> Just 5
->Just 8
어플리케이티브 펑터와 pure f <*> x <*> y <*> ...
와 같은 어플리케이티브 스타일은 펑터로 쌓여있지않은 매개변수를 가진 함수를 허용하고, 이 함수가 펑터 콘텍스트내의 여러개의 값들을 조작할 수 있습니다. 그리고 이 함수는 항상 <*>
에 의해서 단계별로 부분적용되기 때문에 원하는 만큼의 매개변수를 받을 수 있습니다.
pure f <*> x
는 fmap f x
와 동일한 것을 고려하면, 어플리케이티브 펑터가 훨씬 더 편리하고 분명해집니다. 이것이 어플리케이티브의 첫번째 법칙입니다. 이 부분은 뒤에서 자세히 다루겠습니다. 전에 이야기한 것처럼 pure
는 기본적인 콘텍스트안에 어떤 값을 넣습니다. 함수를 넣으면 함수를 추출하여 다른 어플리케이티브 펑터에 있는 값에 적용합니다. 따라서 pure f <*> x <*> y <*> ...
은 fmap f x <*> y <*> ...
와 같습니다. 이것이 Control.Applicative
가 fmap
의 중위 연산자 버전인 <$>
함수를 제공하는 이유입니다.
(<$>) :: (Functor f) => (a -> b) -> f a -> f b
f <$> x = fmap f x
Quick Reminder: 타입 변수를 매개변수 이름 또는 다른 값 이름과 독립적입니다. 함수 선언의
f
는f
를 대체하는 모든 타입 생성자가Functor
타입클래스에 있어야 한다는 클래스 제약이 있는 타입 변수 입니다. 함수 본문의f
는 맵핑하는 함수 f를 나타냅니다. 사실은 둘 모두를 표현하기 위해서f
를 사용해도 같은 것을 나타낼 수는 없습니다.
<$>
를 사용하면 더 어플리케이티브 스타일로 작성할 수 있습니다. 예를들어 세개의 어플리케이티브 펑터의 f
를 적용하고 싶다면 f <$> x <*> y <*> z
와 같이 작성할 수 있습니다. 매개변수가 어플리케이티브 펑터가 아니라 일반적인 값인 경우, f x y z
로 작성합니다.
값 Just "johntra"
와 Just "volta"
가 있을때는 하나의 Maybe
펑터안에 하나의 문자열로 합치고 있다면 아래와 같이 할 수 있다.
ghci> (++) <$> Just "johntra" <*> Just "volta"
Just "johntravolta"
내부적인 변환 과정은 아래 예제와 비교해보면 알 수 있습니다.
ghci> (++) "johntra" "volta"
"johntravolta"
아래와 같은 과정으로 변환되었다는 것을 알 수 있습니다.
(++) <$> Just "johntra" <*> Just "volta"
실행.- 타입이
(++) :: [a] -> [a] -> [a]
인(++)
함수로Just "johntra"
가 맵핑 - 타입이
Maybe ([Char] -> [Char])
인Just ("johntra"++)
로 변환 Just ("johntra"++) <*> Just "volta"
에서Just
가 가진 함수를 빼서Just "valta"
를 맵핑Just "johntravolta"
로 변환
리스트 어플리케이티브 펑터
리스트 역시 어플리케이티브 펑터입니다. 리스트를 어플리케이티브 펑터로 인스턴스화하면 아래와 같습니다.
instance Applicative [] where
pure x = [x]
fs <*> xs = [f x | f <- fs, x <- xs]
이전에도 말했듯이 pure
는 어떤 값을 받아서 기본 콘텍스트안에 그 값을 넣습니다. 리스트는 최소한의 컨텍스트로 빈리스트, []
가 될 수 있습니다. 따라서 빈리스트에는 pure
를 사용할 수 없습니다. 이러한 이유로 pure
는 한개의 값을 받아서 싱글톤 리스트에 그 값을 넣습니다. 유사하게 Maybe
의 최소한의 컨텍스트는 Nothing
이고, 값을 담을 수 없기때문에 pure
는 Just
로 구현되었습니다.
ghci> pure "Hey" :: [String]
["Hey"]
ghci> pure "Hey" :: Maybe String
Just "Hey"
<*>
의 경우, 타입을 리스트에 한정지으면 (<*>) :: [a -> b] -> [a] -> [b]
가 됩니다. 그리고 <*>
는 list comprehension으로 작성되어 있습니다. <*>
은 왼쪽의 매개변수에서 함수를 추출하여 오른쪽 매개변수에 맵핑합니다. 하지만 리스트의 경우는 왼쪽의 리스트가 빈리스트이거나, 하나의 함수를 포함하거나, 여러개의 함수를 가질수도 있습니다. 오른쪽의 리스트도 여러개의 값들을 가질 수 있습니다. 그래서 list comprehension으로 두개의 리스트를 모두 표현하였습니다. 왼쪽 리스트의 가능한 함수를 오른쪽 리스트의 모든 가능한 값들에 적용합니다. 즉, 왼쪽의 함수들과 오른쪽 값들의 모든 가능한 조합의 리스트를 만듭니다.
ghci> [(*0),(+100),(^2)] <*> [1,2,3]
[0,0,0,101,102,103,1,4,9]
왼쪽에 세개의 함수와 오른쪽에 3개의 값이 있으므로 총 9개의 결과값을 가진 리스트가 반환됩니다. 만약 두개의 매개변수를 받는 함수들의 리스트를 가지고 있다면, 이 함수들에 두개의 리스트들을 적용할 수 있습니다.
ghci> [(+),(*)] <*> [1,2] <*> [3,4]
[4,5,5,6,3,4,6,8]
<*>
는 left-associative이기 때문에 [(+),(*)] <*> [1,2]
가 먼저 수행됩니다. 순서대로 살펴보면 아래와 같습니다.
1 [(+),(*)] <*> [1,2]
-> [(1+),(2+),(1*),(2*)]
2 [(1+),(2+),(1*),(2*)] <*> [3,4]
-> [4,5,5,6,3,4,6,8]
어플리케이티브 스타일로 아래와 같이 작성할 수 있습니다.
ghci> (++) <$> ["ha","heh","hmm"] <*> ["?","!","."]
["ha?","ha!","ha.","heh?","heh!","heh.","hmm?","hmm!","hmm."]
리스트는 비결정적(non-deterministic) 계산으로 볼 수 있습니다. 100
이나 "what"
와 같은 값은 하나의 결과만 가지기 때문에 결정적(deterministic) 계산이라고 볼 수 있지만, [1,2,3]
과 같은 리스트는 어떤 결과를 가질지를 결정할 수 없기때문에 가능한 모든 결과를 나타냅니다. 그래서 (+) <$> [1,2,3] <*> [4,5,6]
를 수행할때, 두개의 비결정적 계산을 +
로 합쳐서, 다른 불확실한 비결정적 계산을 만들어냅니다.
list comprehensions을 어플리케이티브 스타일을 사용하여 대체할 수 있습니다. [2,5,10]
과 [8,10,11]
의 가능한 모든 곱의 리스트를 만들고 싶다면 아래와같이 작성할 수 있습니다.
ghci> [ x*y | x <- [2,5,10], y <- [8,10,11]]
[16,20,22,40,50,55,80,100,110]
이것을 어플리케이티브 스타일로 바꾸면 아래와 같습니다.
ghci> (*) <$> [2,5,10] <*> [8,10,11]
[16,20,22,40,50,55,80,100,110]
여기에서 50보다 큰값만 리스트 남기고 싶다면 아래와 같이 작성합니다.
ghci> filter (>50) $ (*) <$> [2,5,10] <*> [8,10,11]
[55,80,100,110]
리스트에 대해서 pure f <*> xs
가 fmap f xs
가 어떻게 같은지 쉽게 이해할 수 있습니다. pure f
는 [f]
이고 [f] <*> xs
는 왼쪽 리스트의 모든 함수로 오른쪽의 모든 값들을 맵핑할 것 입니다. 여기에 왼쪽 리스트에 한개의 함수만 있다면 맵핑과 같습니다.
IO
어플리케이티브 펑터
다른 어플리케이티브 펑터의 예로 IO
가 있습니다.
instance Applicative IO where
pure = return
a <*> b = do
f <- a
x <- b
return (f x)
pure
는 어떤 결과를 가지고 있는 최소한의 컨텍스트에 하나의 값을 넣는것이 전부입니다. return
이 정확하게 그런일을 하기때문에 여기서는 pure
가 그냥 return
을 하였습니다. 여기서 return
은 아무것도 하지않는 I/O 작업을 만듭니다. 이것의 결과값으로 어떤 값이 나오지만 파일을 읽거나 터미널에 찍는 어떤 I/O 작업같은 것은 실제로 하지 않습니다.
<*>
의 타입을 IO
에 제한하면, (<*>) :: IO (a -> b) -> IO a -> IO b
가 됩니다. 여기서는 IO의 결과가 함수가되는 I/O 작업과 다른 I/O 작업을 입력으로 받아서 새로운 I/O 작업을 생성합니다. a <*> b
의 구현부에서는 do 구분내에서 첫번째 IO에서 함수를 얻고, 두번째 IO에서 값을 얻은 후, 함수에 적용하여 결과를 만듭니다. do 구분은 여러개의 I/O 작업들을 받아서, 하나의 작업으로 붙입니다.
Maybe
와 []
에서 <*>
는 단순히 왼쪽 매개변수에서 함수를 빼서, 오른쪽 매개변수의 값을 적용하는 함수였습니다. IO
에서도 함수를 추출하고 적용하지만, 두가지 I/O 작업을 받아서 순서대로 수행하여 붙이기 때문에 순서적인 개념이 생깁니다. 따라서 반드시 첫번째 I/O 작업에서 함수를 먼저 추출해야 하는데, 함수를 추출하려면 해당 I/O 작업을 실행해야 합니다.
아래 예제를 보겠습니다.
myAction :: IO String
myAction = do
a <- getLine
b <- getLine
return $ a ++ b
사용자로부터 두개의 라인을 입력받아서, 두 라인을 합치는 함수입니다. 여기서는 두개의 getLine
과 return
을 하나의 I/O 작업으로 붙였습니다. 이것을 어플리케이티브 스타일로 작성하면 아래와 같습니다.
myAction :: IO String
myAction = (++) <$> getLine <*> getLine
여기서 getLine
은 I/O 작업이고, 함수의 타입은 getLine :: IO String
입니다. 두개의 어플리케이브 펑터 사이에 <*>
를 사용했을때, 그 결과도 역시 어플리케이티브 펑터입니다.
(++) <$> getLine <*> getLine
의 타입은 IO String
입니다. 따라서 이것은 어떤 값을 포함한 일반적인 I/O와 같다는 것을 알 수 있습니다. 이점을 이용하면 아래와 같이 할 수 있습니다.
main = do
a <- (++) <$> getLine <*> getLine
putStrLn $ "The two lines concatenated turn out to be: " ++ a
이와같이 I/O 작업의 일부를 이름에 바인딩한 다음에 이것을 호출하고 return
을 사용하여 결과를 표시할때, 어플리케이티브 스타일을 사용하면 보다 간결하게 표현할 수 있습니다.
(->) r
어플리케이티브 펑터
또다른 Applicative
의 인스턴스로 (->) r
가 있습니다. 어플리케이티브 스타일에서는 거의 사용되지 않지만, 재미있는 어플리케이티브이기 때문에 한번 살펴보겠습니다.
이전 섹션에서
(->) r
이 어떻게 펑터가 되는지 설명한 부분을 살펴보자.
instance Applicative ((->) r) where
pure x = (\_ -> x)
f <*> g = \x -> f x (g x)
pure
에 의해서 어떤 값이 어플리케이티브 펑터에 들어갈때, 그 값의 결과값도 항상 동일한 값이어야 합니다. 따라서 pure
함수의 구현부는 어떤 값을 받아서 입력 매개변수를 무시하고 항상 받은 값을 그대로 반환하는 함수입니다. (->) r
의 인스턴스에서 pure
의 타입을 보면, pure :: a -> (r -> a)
입니다.
ghci> (pure 3) "blah"
3
커링에 의해서 괄호는 아래와 같이 생략될 수 있습니다.
ghci> pure 3 "blah"
3
<*>
의 구현부는 좀 어려워서 어플리케이티브 스타일로 직접 사용해보는 것이 이해하기 좋습니다.
ghci> :t (+) <$> (+3) <*> (*100)
(+) <$> (+3) <*> (*100) :: (Num a) => a -> a
ghci> (+) <$> (+3) <*> (*100) $ 5
508
두개의 어플리케이티브 펑터를 사용해서 <*>
를 호출하면 결과는 어플리케이티브 펑터가 됩니다. 따라서 두개의 함수를 사용하면, 하나의 함수를 반환합니다. 여기서는 (+) <$> (+3) <*> (*100)
를 실행하면 (+3)
과 (*100)
의 결과에 +
를 사용한 결과를 반환하는 함수를 만듭니다. 따라서 (+) <$> (+3) <*> (*100) $ 5
를 실행하면 5
는 먼저 (+3)
과 (*100)
에 적용되서, 8
과 500
이 됩니다. 그리고나서 8
과 100
을 매개변수로 +
가 호출되어 500
이 출력됩니다.
ghci> (\x y z -> [x,y,z]) <$> (+3) <*> (*2) <*> (/2) $ 5
[8.0,10.0,2.5]
이 예제도 동일하게 (+3)
, (*2)
과 (/2)
의 결과들이 \x y z -> [x,y,z]
함수의 매개변수로 호출됩니다. 즉, 5
는 세개의 함수에 입력으로 들어가고, \x y z -> [x, y, z]
는 각 함수의 실행결과들을 받습니다.
함수들은 결국 그 자신의 실행 결과를 담은 박스로 생각할 수 있습니다. 따라서 k <$> f <*> g
는 f
와 g
의 결과값으로 k
를 호출하는 함수를 생성하는 것입니다.
예를들어, (+) <$> Just 3 <*> Just 5
는 안에 값이 있거나 없을 수 있는 값을 매개변수로 +
를 호출합니다. (+) <$> (+10) <*> (+5)
는 (+10)
과 (+5)
의 미래 결과값들을 매개변수로 +
를 호출합니다.
함수를 사용할때는 어플리케이티브를 사용할 일이 많진 않지만, 상당히 재미있는 부분입니다. (->) r
가 어떻게 Applicative
의 인스턴스로 동작하는지는 크게 중요하진 않습니다. 단지 학습을 위해서 사용해보시기 바랍니다.
ZipList
어플리케이티브 펑터
이번에는 Control.Applicative
에 정의된 ZipList
에 대해서 알아보겠습니다.
리스트를 어플리케이티브 펑터로 만드는 방법은 다양합니다. 그중 한가지 방법으로 함수의 리스트와 값의 리스트로 <*>
를 호출하면 리스트의 모든 함수에 모든 값을 적용한, 모든 가능한 조합의 리스트가됩니다.
예를들어, [(+3),(*2)] <*> [1,2]
에서 (+3)
은 1
과 2
에 모두 적용되고, (*2)
도 마찬가지로 모두 적용되어 4개의 결과값을 포함한 [4,5,2,4]
가 됩니다.
다른 방법으로 [(+3),(*2)] <*> [1,2]
에서 첫번째 함수는 첫번째값만, 두번째 함수는 두번째값만 적용해서 [1 + 3, 2 * 2]
, 결과적으로 [4, 4]
가되는 어플리케이티브 펑터가 바로 ZipList
입니다.
하나의 타입은 동일한 타입클래스 두개의 인스턴스를 가질 수 없기때문에 ZipList
의 타입은 하나의 리스트 매개변수를 받는 ZipList a
입니다. 아래 인스턴스입니다.
instance Applicative ZipList where
pure x = ZipList (repeat x)
ZipList fs <*> ZipList xs = ZipList (zipWith (\f x -> f x) fs xs)
<*>
는 첫번째 함수는 첫번째 값에 적용하고, 두번째 함수는 두번째 값에 적용합니다. 이와같은 작업을 zipWith (\f x -> f x) fs xs
로 끝냈습니다. zipWith
가 동작하는 방식에 의해서 결과 리스트의 길이는 두 리스트중 짧을 리스트와 같습니다.
pure
는 어떤 값을 받아서 그 값을 무한히 반복적으로 가지고있는 리스트안에 넣습니다. 예를들어 pure "haha"
는 ZipList (["haha", "haha", "haha"]...
가 됩니다. pure
함수는 최소한의 컨텍스트에 그 값을 넣어야 하는데, 무한 리스트는 최소한이 될 수 없다고 생각할수도 있습니다. 하지만 zip 리스트는 모든 위치에 값을 만들어야 하기 때문에 의미가 있습니다. 예를들어 pure 3
은 ZipList [3]
를 반환하는데, pure (*2) <*> ZipList [1,5,10]
는 ZipList [2]
가 됩니다. 왜냐하면, 두개의 리스트중 짧은 길이의 리스트로 zip되기 때문입니다. 따라서 무한 리스트를 사용하면, 결과 리스트의 길이는 항상 무한 리스트의 길이와 같습니다. 또한 pure
는 pure f <*> xs
= fmap f xs
법칙 역시 만족합니다.
이제 어플리케이티브 스타일로 zip 리스트를 사용해보겠습니다. 여기서 ZipList a
타입은 Show
의 인스턴스가 아니라서, zip 리스트에서 raw 리스트를 빼는 getZipList
함수를 사용하였습니다.
ghci> getZipList $ (+) <$> ZipList [1,2,3] <*> ZipList [100,100,100]
[101,102,103]
ghci> getZipList $ (+) <$> ZipList [1,2,3] <*> ZipList [100,100..]
[101,102,103]
ghci> getZipList $ max <$> ZipList [1,2,3,4,5,3] <*> ZipList [5,3,1,2]
[5,3,3,4]
ghci> getZipList $ (,,) <$> ZipList "dog" <*> ZipList "cat" <*> ZipList "rat"
[('d','c','r'),('o','a','a'),('g','t','t')]
(,,)
함수는\x y z -> (x,y,z)
와 같습니다. 또한(,)
는\x y -> (x,y)
와 같습니다.
zipWith
외에도 표준 라이브러리에는 zipWith3
, zipWith4
와같은 함수가 7까지 있습니다. zipWith
는 두개의 리스트를 받아서 한개로 압축합니다. zipWith3
는 세개의 리스트를 받아서 하나로 압축합니다. 어플리케이티브 스타일을 사용하면 리스트의 개수마다 다른 함수를 사용할 필요가 없습니다. 임의의 리스트를 계속 추가할 수 있습니다.
liftA2
함수
Control.Applicative
에는 liftA2
라는 함수가 정의되어 있습니다.
liftA2 :: (Applicative f) => (a -> b -> c) -> f a -> f b -> f c
liftA2 f a b = f <$> a <*> b
이 함수는 특별할 것은 없지만, 두개의 어플리케이티브들 사이에 함수를 적용하여 지금까지 보아온 어플리케이티브 스타일을 숨깁니다. 이것은 어플리케이티브 펑터가 일반 펑터보다 강력한 이유를 보여줍니다. 일반 펑터를 사용하면 함수를 하나의 펑터로 맵핑할 수 있습니다. 하지만 어플리케이티브 펑터는 여러 펑터간에 함수를 적용할 수 있습니다. 결과적으로 liftA2
는 일반 바이너리 함수를 받아서, 두개의 펑터에서 작용하는 함수로 승격시켰다고 볼 수 있습니다.
우리는 두개의 어플리케이티브 펑터를 받아서 결과들을 가지고 있는 하나의 어플리케이티브 펑터안에 조합할 수 있습니다. 예를들어 Just 3
과 Just 4
를 가지고 있을때, Just [4]
를 얻고 싶다면 아래와 같이 간단하게 처리할 수 있습니다.
ghci> fmap (\x -> [x]) (Just 4)
Just [4]
Just 3
과 Just [4]
를 가지고 있을때, Just [3,4]
를 얻고 싶다면? 아래와 같은 방법을 사용할 수 있습니다.
ghci> liftA2 (:) (Just 3) (Just [4])
Just [3,4]
ghci> (:) <$> Just 3 <*> Just [4]
Just [3,4]
:
는 어떤 구성요소와 리스트를 받아서 구성요소를 리스트에 붙인 새로운 리스트 반환하는 함수 입니다.
이제 Just [3,4]
를 얻었습니다. 여기에 Just 2
를 조합하면 Just [2,3,4]
를 얻을 수 있습니다. 어떤 양의 어플리케이티브도 결과들의 리스트를 가진 하나의 어플리케이티브에 조합할 수 있습니다.
sequenceA
함수
sequenceA
는 어플리케이티브의 리스트를 받아서 각 어플리케이티브들의 결과값들을 리스트로 가지고 있는 어플리케이티브 한개를 반환하는 함수 입니다. 이 함수는 아래와 같이 정의되어 있습니다.
sequenceA :: (Applicative f) => [f a] -> f [a]
sequenceA [] = pure []
sequenceA (x:xs) = (:) <$> x <*> sequenceA xs
내부적으로 재귀가 사용되었습니다. 먼저 타입을 보면 어떤 어플리케이티브의 리스트를 리스트를 가진 어플리케이티브에 넣을 것 입니다. 빈리스트를 결과값의 리스트를 가진 어플리케이티브로 바꾸려면, 빈리스트를 기본 컨텍스트에 넣으면 됩니다. 그리고 head(어플리케이티브)와 tail(어플리케이티브 리스트)이 있는 어플리케이티브의 리스트일때는 tail 부분을 sequenceA
를 재귀 호출해서 리스트를 가진 어플리케이티브로 만듭니다. 그리고 여기에 head인 x
만 붙이면 됩니다.
따라서 sequenceA [Just 1, Just 2]
를 실행하면 (:) <$> Just 1 <*> sequenceA [Just 2]
가 됩니다. 수행과정을 보면 아래와 같습니다.
(:) <$> Just 1 <*> sequenceA [Just 2]
(:) <$> Just 1 <*> ((:) <$> Just 2 <*> sequenceA [])
(:) <$> Just 1 <*> ((:) <$> Just 2 <*> Just [])
(:) <$> Just 1 <*> Just [2]
Just [1,2]
sequenceA
를 만드는 다른 방법으로 fold를 사용할 수 있습니다. fold를 사용하면 리스트의 구성별로 결과를 누적하는 대부분의 기능을 구현할 수 있습니다.
sequenceA :: (Applicative f) => [f a] -> f [a]
sequenceA = foldr (liftA2 (:)) (pure [])
초기값은 pure []
이고 리스트의 오른쪽에서부터 연산한 누적값을 초기값에 업데이트합니다. 누적값과 어플리케이티브 내부에 결과 리스트의 마지막 요소로 liftA2 (:)
를 수행합니다. 이 과정을 누적값만 남을때까지 반복합니다.
이 함수를 실행해보면 아래와 같습니다.
ghci> sequenceA [Just 3, Just 2, Just 1]
Just [3,2,1]
ghci> sequenceA [Just 3, Nothing, Just 1]
Nothing
ghci> sequenceA [(+3),(+2),(+1)] 3
[6,5,4]
ghci> sequenceA [[1,2,3],[4,5,6]]
[[1,4],[1,5],[1,6],[2,4],[2,5],[2,6],[3,4],[3,5],[3,6]]
ghci> sequenceA [[1,2,3],[4,5,6],[3,4,4],[]]
[]
sequenceA
는 Maybe
를 사용하면 리스트의 모든 결과들이 Maybe
입니다. 만약 값들의 하나가 Nothing
이면, 결과도 역시 Nothing
입니다. 이것은 Maybe
값들의 리스트를 가지고 있고, Nothing
이 하나도 없는 값들에만 관심이 있을때 유용합니다.
sequenceA
가 함수를 사용할때는 함수의 리스트를 받아서 리스트를 반환하는 함수를 반환합니다. 예제에서는 부분함수에 매개변수로 숫자를 넘겨서 리스트의 각 함수가 적용된 결과의 리스트를 얻었습니다. 즉, sequenceA [(+3),(+2),(+1)] 3
에서 (+3) 3
, (+2) 3
, (+1) 3
으로 적용된 값들의 리스트를 반환하였습니다.
(+) <$> (+3) <*> (*2)
는 매개변수를 받는 함수를 생성합니다. 입력받은 값을 (+3)
과 (*2)
모두에 넣고 구한 결과값을 +
에 적용해서 결과값을 얻습니다. 같은 맥락에서 sequenceA [(+3),(*2)]
는 어떤 매개변수를 받는 함수를 만들고, 입력값을 리스트내의 모든 함수에 넣어서 수행합니다. 함수의 결과들로 +
를 호출하는 대신에 :
와 pure []
의 조합으로 함수의 결과 리스트에서 결과값들을 수집합니다.
sequenceA
는 함수의 리스트를 가지고, 어떤 동일한 입력값을 모든 함수에 넣고 결과의 리스트를 보고싶을때 유용합니다. 예를들어 어떤 숫자를 가지고 리스트의 모든 조건들을 만족하는지 확인할때 사용될 수 있습니다. 유사한 동작을 아래와 같이할 수도 있습니다.
ghci> map (\f -> f 7) [(>4),(<10),odd]
[True,True,True]
ghci> and $ map (\f -> f 7) [(>4),(<10),odd]
True
여기서 and
는 boolean의 리스트를 받아서 모두 True
면 True
를 반환하는 함수입니다. 그리고 동일한 동작을 위해 sequenceA
를 사용하면 아래와 같습니다.
ghci> sequenceA [(>4),(<10),odd] 7
[True,True,True]
ghci> and $ sequenceA [(>4),(<10),odd] 7
True
sequenceA [(>4),(<10),odd]
는 어떤 숫자를 받는 함수를 생성하고, [(>4),(<10),odd]
의 모든 조건에 입력값을 넣습니다. 그 결과로 boolean의 리스트를 반환합니다. 이 과정을 타입으로 설명하면 (Num a) => [a -> Bool]
를 (Num a) => a -> [Bool]
로 바꾸는 것 입니다.
리스트의 모든 구성요소들은 동일한 타입이기때문에 함수 역시 모두 동일한 타입이어야 합니다. [ord, (+3)]
와 같은 리스트는 ord
와 (+3)
의 타입이 다르기 때문에 만들수 없습니다.
[]
를 사용했을때, sequenceA
는 리스트의 리스트를 받아서 리스트의 리스트를 반환합니다. 실제로는 리스트의 구성요소의 모든 가능한 조합들의 목록을 만듭니다. sequenceA
로 한 작업을 그대로 리스트 정의(list comprehension)를 사용해서 실행해보면 아래와 같습니다.
ghci> sequenceA [[1,2,3],[4,5,6]]
[[1,4],[1,5],[1,6],[2,4],[2,5],[2,6],[3,4],[3,5],[3,6]]
ghci> [[x,y] | x <- [1,2,3], y <- [4,5,6]]
[[1,4],[1,5],[1,6],[2,4],[2,5],[2,6],[3,4],[3,5],[3,6]]
ghci> sequenceA [[1,2],[3,4]]
[[1,3],[1,4],[2,3],[2,4]]
ghci> [[x,y] | x <- [1,2], y <- [3,4]]
[[1,3],[1,4],[2,3],[2,4]]
ghci> [[1,2],[3,4],[5,6]]
[[1,3,5],[1,3,6],[1,4,5],[1,4,6],[2,3,5],[2,3,6],[2,4,5],[2,4,6]]
ghci> [[x,y,z] | x <- [1,2], y <- [3,4], z <- [5,6]]
[[1,3,5],[1,3,6],[1,4,5],[1,4,6],[2,3,5],[2,3,6],[2,4,5],[2,4,6]]
sequenceA [[1,2],[3,4]]
의 실행과정을 설명하면 아래와 같습니다.
sequenceA (x:xs) = (:) <$> x <*> sequenceA xs
가 평가됨sequenceA xs
가 평가되면,(:) <$> [1,2] <*> ((:) <$> [3,4] <*> sequenceA [])
- 종료조건에 의해서,
(:) <$> [1,2] <*> ((:) <$> [3,4] <*> [[]])
((:) <$> [3,4] <*> [[]])
가 평가되는 과정을 보면..4-1. 왼쪽에는
3
,4
가 있고, 오른쪽에는[]
만 있으므로[3:[], 4:[]]
4-2.
[[3],[4]]
(:) <$> [1,2] <*> [[3],[4]]
5-1. 왼쪽은
1
,2
, 오른쪽은[3]
,[4]
가 있으므로[1:[3], 1:[4], 2:[3], 2:[4]]
5-2.
[[1,3],[1,4],[2,3],[2,4]
(+) <$> [1,2] <*> [4,5,6]
는 [1,2]
에서 모든 값을 취한 x
와 4,5,6
에서 모든 값을 취한 y
에 대해서 비결정적 연산 x + y
의 모든 가능한 결과들을 리스트에 담습니다. 유사하게 sequence [[1,2],[3,4],[5,6],[7,8]]
의 결과는 [x,y,z,w]
의 비결정적 연산입니다. 비결정적 연산의 결과는 리스트로 표현합니다. 따라서 가능한 모든 결과의 리스트의 리스트로 표현합니다.
I/O 작업을 할때, sequenceA
는 sequence
와 동일합니다. I/O 작업의 리스트를 받아서 각 작업들을 수행할 I/O 작업을 반환합니다. 그리고 I/O 작업들의 결과의 리스트를 반환합니다. 따라서 [IO a]
는 IO [a]
됩니다. 모든 I/O 작업들은 순서대로 하나씩 수행되어야 합니다. 그리고 실제로 실행하지 않으면 값을 가져올 수 없습니다.
ghci> sequenceA [getLine, getLine, getLine]
heyh
ho
woo
["heyh","ho","woo"]
일반적인 펑터와 마찬가지로 어플리케이티브 펑터에는 몇가지 법칙이 있습니다. 가장 중요한 것은 이미 언급한, pure f <*> x = fmap f x
입니다. 실제로 본 챕터의 예제 펑터들로 이 법칙을 증명할 수 있습니다. 다른 펑터의 법칙들로는 아래와 같은 것들이 있습니다.
pure id <*> v = v
pure (.) <*> u <*> v <*> w = u <*> (v <*> w)
pure f <*> pure x = pure (f x)
u <*> pure y = pure ($ y) <*> u
여기서는 위 법칙들에 대해서 자세히 다루지는 않을 것 입니다. 하지만 챕터를 끝내고 자세히 살펴보면 각 법칙의 몇가지 예들을 찾을 수 있을 것 입니다.
결론적으로 어플리케이티브 펑터들은 어플리케이티브 스타일로 I/O 연산, 비결정적 연산, 실패를 가질 수 있는 연산 등, 서로 다른 연산들을 합칠 수 있다는 점에서 상당히 재미있고 유용합니다. <$>
와 <*>
를 사용해서 항상 동일하게 동작하는 일반 함수들을 임의의 개수의 어플리케이티브 펑터위에서 사용할 수 있습니다.