본문 바로가기

Haskell

Applicative에 대해

함수를 사상할 수 있는 데이터 구조를 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

여기서 fFunctor가 되면 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)