함수를 사상할 수 있는 데이터 구조를 functor라 한다. 흔히 fmap
함수를 적용할 수 있는 타입을 말하는데, 기본적인 functor로는 리스트, Maybe 등이 있다. 이 때의 fmap
의 타입은 a -> b
로 단인자 함수가 흔히 상정된다. 예를 들어 fmap (+1) [1,2,3]
이나 fmap (*2) (Just 3)
등의 식은 단인자 함수인 (+1)
, (*2)
를 functor에 맵핑하여 새로운 값을 만들어낸다.
만약 사상하는 함수가 단인자 함수가 아닌 경우라면 어떨까? fmap (*) (Just 3)
이라는 표현식은 어떤 결과를 내놓을까? (아니면 그냥 펑하고 터지게 될 것인가?)
하스켈의 함수는 기본적으로 항상 커링된다. 즉 어떤 함수의 타입이 (a, b) -> c
가 아닌 a -> b -> c
라고 표현되는 것은 단순히 이 함수가 인자를 2개 받는 함수라는 의미 외에, 함수가 인자 1개를 받아서 함수 1개를 리턴하는 함수로도 볼 수 있다는 것이다. 인자를 2개 받는 (+)
함수에 대해서 ( + 1)
은 여기에 한 개의 인자가 적용된 상태이고, 그 결과는 숫자값 1개를 받아서 여기에 1을 더하는 함수로 해석된다.
따라서 fmap
의 타입 시그니처(a -> b -> f a -> f b
)에 의해서 fmap (*) (Just 3)
은 Just (*3)
이라는 값이 됨을 알 수 있다. 즉, 어떠한 functor에 다인자 함수를 사상하게 되면 이는 해당 functor의 맥락 안에 (n-1)개의 인자를 받는 함수를 집어넣은 모양, 즉 함수를 내장하고 있는 functor를 얻게 된다.
이렇게 함수가 내재되는 functor를 Applicative Functor라 한다. 왜 따로 이름이 붙었으냐 하면 이 타입은 기존의 fmap
과 같은 방법으로는 타입 속에 내재된 함수에 인자를 넣어 적용할 방법이 없기 때문에, 별도의 방법이 필요하기 때문이다. 다음의 예를 살펴보자.
ghci> let a = fmap (*) [1,2,3,4]
ghci> fmap (\f->f 9) a
[9, 18, 27, 36]
위 예에서는 (*)
연산을 정수 리스트에 맵핑하여 함수의 리스트를 생성했다. 여기에 어떤 값을 집어넣어 그 결과의 리스트를 만들려고 한다. 이 상황은 정확하게 리스트에 함수를 맵핑하는 상황의 반대가 되어 있다. 방법이 없는 것은 아니라서 원하는 값을 함수에 주입하는 람다함수를 정의하고 이걸 다시 리스트에 맵핑하여 결과를 산출했다.
아무래도 이 방법이 조금 귀찮다보니, Applicative
라는 새로운 타입 클래스가 정의되었다. 이 타입 클래스는 두 개의 함수를 다음과 같이 선언한다.
class (Functor f) => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
여기서 <*>
연산자는 'apply'라고 읽는데, 타입 시그니처를 보면 f
속에 들어있는 함수를 다시 f
속에 들어있는 값에 '적용'하는 역할을 수행한다.
그리고 pure
함수는 전달받은 인자를 functor 내부로 삽입하는 기능을 수행한다. 인자 수를 한 개 줄이지 않고 함수를 있던 그대로 f
속으로 집어넣는다고 이해하면 된다. 리스트와 Maybe
는 이미 Applicative
이므로 다음과 같은 예를 볼 수 있다.
pure (+) <*> Just 3 <*> Just 4
--Just 7
-- 위 표현은 아래의 연산을 Applicative라는 맥락안에서 사용하는 것과 동일하다.
(+) 3 4
--7
하지만 앞서 보았듯이 일반적인 다인자 함수를 functor에 맵핑하는 것으로 pure f <*> firstArg
를 대신할 수 있으므로 다음과 같이 쓸 수도 있을 것이다. 참고로 fmap
은 Applicative에서 다시 <$>
연산자로도 쓸 수 있게끔 정의되어 있다.
(+) <$> Just 3 <*> Just 4
-- Just 7
이러한 관계는 IO를 다룰 때 매우 재미있는 지점을 만들어준다. 간단하게 2 줄의 텍스트를 입력 받아서, 이 둘을 연결해서 출력하는 프로그램을 작성한다고 해보자. do
블럭 표기를 사용하면 다음과 같은 식으로 작성할 수 있다.
--concat2lines.hs
main = do
l1 <- getLine
l2 <- getLine
putStrLn $ l1 ++ "-" ++ l2
위 코드는 >>=
연산자를 쓰는 폼으로 만들었을 때 다음과 같다.
main = getLine >>= (\l1-> getLine >>= (\l2-> putStrLn $ l1 ++ "-" ++ l2))
하지만 IO는 모나드이면서 동시에 Applicative이므로 다음과 같이 작성할 수 있다 .
main = (\a b -> a ++ "-" ++ b) <$> getLine <*> getLine >>= putStrLn
인자가 N 개인 다인자 함수는 인자 하나를 적용할 때마다 인자 N-1 개인 함수를 리턴하게 된다. 따라서 이 기법은 함수의 인자가 2개인 경우에 한정되지 않고 3, 4, 5... 개로 얼마든지 확장될 수 있다.
LIFT*
Control.Applicative에는 liftA2
라는 함수가 정의되어 있다. lift*
류 함수는 일반 함수를 Applicative Functor의 맥락으로 "들어올리는" 역할을 한다.
단인자 함수를 인자로 받는 liftA
의 경우에는 사실상 fmap
과 동일하게 작동한다고 보면 된다. liftA
함수의 타입은 다음과 같다.
liftA :: (Applicative f) => (a -> b) -> f a -> f b
여기서 f
가 Functor
가 되면 fmap
이랑 동일하며, 기본적으로 applicative는 모두 functor 이기 때문에 별다른 차이를 모르겠다.
인자를 2개 받는 liftA2
의 경우에는 다음과 같이 타입이 정의되는데,
liftA2 :: (Applicatvie f) => (a -> b -> c) -> f a -> f b -> f c
결국 일반 2인자 함수 1개와 applicative 값 2개를 받아서 적용하는 것이다. 따라서 liftA2
는 다음과 같이 구현된다고 보면 된다.
liftA2 f x y = f <$> x <*> y
비슷하게 liftA3
까지 정의되어 있는데, 이 역시 같은 맥락에서 이해하면 된다. f <$> x <*> y <*> z
의 식으로 쓰인다. 앞서 이야기했지만, applicative의 특성상 이러한 리프트함수가 없더라도 <$>
, <*>
를 사용해서 인자가 얼마든지 많은 함수를 이런 식으로 만들 수 있다. 혹은 pure
함수를 이용해서 값이나 함수를 applicative 속으로 집어넣어서 <*>
를 사용해도 된다.
sequenceA
Applicative 관련하여 눈여겨 봐둘 함수는 sequenceA
함수이다. 이 함수의 타입을 먼저 살펴보자.
sequenceA :: (Traversal t, Applicative f) => t (f a) -> f (t a)
Traversal
은 리스트나 트리등과 같이 순회가 가능한 타입을 말한다. 그리고 Applicative
는 Applicative이다. 이것은 컨테이너에 들어있는 값이나 액션이 될 수 있다. 예를 들어 [Maybe]
타입의 리스트가 있다고 하자. 여기에 sequenceA
함수를 적용하면 어떻게 될까? 함수 타입을 봐서는 [Maybe a]
값이 Maybe [a]
로 바뀌는 것으로 볼 수 있다. 뭐 실제로도 그렇다.
> sequenceA [Just 3, Just 4]
Just [3, 4]
간단히 타입만 살펴봐서는 박스 속에 박스가 있고 그 속에 값이 있을 때, 속의 박스와 겉의 박스를 서로 교환하는 것처럼 보인다. 하지만 이것이 실제로 동작하는 방식은 사실 타입마다 다 다르게 적용된다. 리스트 속에 리스트가 들어있는 경우를 보자. 겉박스도 리스트이고 속에 있는 박스도 리스트이므로 이 결과는 원래 인자와 동일할 것 같지만 그렇지 않다!
> sequenceA [[1,2],[3,4]]
[[1,3],[1,4],[2,3],[2,4]]
슬슬 뭔가 종잡을 수 없는 상황이 되는 것 같은 기분이다. 이번에는 Maybe의 리스트는 어떨까? 중간에 Nothing
이 들어있기라도 한다면?
> sequenceA [Just 3, Nothing, Just 4]
Nothing
sequenceA
의 공식 문서의 설명은 다음과 같이 기술되어 있다. "구조 내의 각각의 액션을 왼쪽에서 오른쪽으로 평가하여 그 결과를 수집한다." 만약 그 구조가 리스트라면 sequenceA
는 다음과 같은 식으로 기술된다. 각각의 요소가 평가되고, 그것이 (:) <$> x <*>
를 통해서 연결되는 구조이다. 이 때 <*>
에 대해서 Nothing
이 왼쪽이나 오른쪽에 있는 경우에 Noting
이 되므로 최종적으로 Nothing
이 되는 것이다.
sequenceA [] = pure []
sequenceA (x:xs) = (:) <$> x <*> sequenceA xs
그러면 리스트의 리스트를 이 함수에 적용하는 것 역시 이해된다. [(1:) <*> [3,4], (2:) <*> [3,4]]
가 되는 식이다.
자 그러면 다음의 식은 이해할 수 있을까?
sequenceA [(3<), (>20), odd]
리스트의 각 원소는 (Num a)=> a -> Bool
타입의 함수이고, 함수는 ((->)a)
라는 하나의 타입으로 표기할 수 있다. 이것이 ((->)[a])로 변경된다. 즉 인자 하나를 받아서 [Bool] 타입의 함수를 만드는 일이라고 생각할 수 있다. 실제로 여기에 9를 대입하면 [True, True, True]
라는 결과를 얻을 수 있다.
여기서 몇 가지 다른 이야기.
($)
연산은 함수와 값 사이에 놓여서 우측의 값을 먼저 평가한 후에 좌변의 함수에 대입하는 역할을 한다. 즉f $ x = f x
인 셈이다. 이 때sequence [(3<), (>20), odd]
는(Num a) => a -> [Bool]
타입의 함수가 되므로($)
를 사용해서 값을 넣어줄 수 있다. 이 함수 역시 applicative이므로pure ($ 9) <*> sequenceA [(>3), (<20), odd]
하여 결과를 얻을 수도 있다.어찌보면 이 접근이 좀 더 이해하기 쉬울 것 같기도 하다.
ZipList
앞서 살펴본 바와 같이 리스트는 functor 이면서 다시 applicative이기도 하다. 함수의 리스트와 값의 리스트를 <*>
로 연결하면 모든 함수 - 인자간의 조합이 생성되어 평가된다. 그런데 이런 방식외에도 순서대로 대응하는 함수와 값만을 조합하여 대입하는 연산이 종종 요구된다. 파이썬에서 zip()
함수를 이용해서 두 연속열을 결합하는 것과 유사한 방식이다.
하스켈에서는 zipWith
라는 함수를 사용해서 2인자 함수와 두 개의 리스트를 받아서 이 처리를 할 수 있다.
zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
<*>
와의 차이를 살펴보자.
> [(2*), (5*), (7*)] <*> [1,4,7]
[2,8,14,5,20,35,7,28,49]
> zipWith ($) [(2*), (5*), (7*)] [1,4,7]
[2, 20, 49]
ZipList
는 <*>
를 적용한 버전으로 동작하게 해주는 별정 타입이다. 이 타입은 리스트를 기반으로 newtype
키워드를 통해서 생성한다.
newtype ZipList a = ZipList { getZipList :: [a] }
instance Applicative ZipList where
(ZipList fs) <*> (ZipList xs) = ZipList (zipWith ($) fs xs)
pure [] = ZipList (repeat x)
ZipList
를 사용한 위 케이스의 계산은 다음과 같이 표현할 수 있다.
> getZipList $ ZipList [(2*),(5*),(7*)] <*> ZipList [1,4,7]
[2,20,49]
> (,,) <$> ZipList [1,4,9] <*> ZipList [2,8,1] <*> ZipList [0,0,9]
ZipList {getZipList = [(1,2,0),(4,8,0),(9,1,9)]
> liftA3 (,,) (ZipList [1,4,9]) (ZipList [2,8,1]) (ZipList [0,0,9])
ZipList {getZipList = [(1,2,0),(4,8,0),(9,1,9)]
그외 연산자
<*>
의 변형 연산자들이 있다.
*>
: 모나드 연산>>
와 비슷하다. 두 값을 평가하고 우측값을 리턴한다. (f a -> f b -> f b
)<*
:<<
와 비슷하다. 두 값을 평가하고 왼쪽값을 리턴한다. (f a -> f b -> f a
)<**>
:<*>
의 역방향 연산. 함수를 우변으로 쓸 수 있다. 모나드의=<<
와 같다. (f a -> f (a -> b) -> f b
)