본문으로 건너뛰기

Functor 와 Applicative_Haskell 노트 7

무료2018-06-03#Functional_Programming#Functor#Applicative#Haskell Applicative Functor#Functor vs Applicative

Functor 에서 Applicative 로

一.Functor 는 상자 같을까?

상자의 비유

일반적인 Functor 클래스의 인스턴스는아마도상자 (또는 컨테이너라고 부름) 로 비유할 수 있습니다. 예를 들어 Maybe/Either, List ([]):

> fmap (+1) (Just 3)
Just 4
> fmap (+1) (Right 3)
Right 4
> fmap (+1) [1, 2, 3]
[2,3,4]

fmap 을 통해 함수를 컨테이너 내의 값에 작용시켜, 새로운 값을 담은 동종의 컨테이너를 얻습니다. I/O Action 도 이렇게 이해할 수 있습니다:

> fmap (++"!") getLine
abc
"abc!"

I/O Action 클래스 컨테이너의 특수한 점은, 컨테이너 내의 값이 불확정적이며, 외부 입력에 의존하고, 사용자의 입력, 파일 읽기, 심지어 시스템 환경에서 직접 가져올 (예를 들어 난수 시드) 수 있다는 것입니다. 하지만 확실한 것은, I/O Action 이라는 컨테이너에는 값이 들어 있으며 (그 값이 어디서 오든), fmap 은 그 값에 함수를 작용시켜, 마찬가지로 새로운 값을 담은 I/O Action 을 얻을 수 있다는 것입니다

至此, 상자의 비유는 여전히 적절합니다: 순수 환경 하의 컨테이너는 나무로 만든 보물상자로, 안에는 확정 불변의 것이 들어 있으며, 불순 환경 하의 컨테이너는 식인 보물상자로, 안에 무엇이 들어 있는지 모릅니다. 아래 그림과 같습니다 (六一刚過, 조금 장난쳐봅니다):

[caption id="attachment_1722" align="alignnone" width="423"]functor and box functor and box[/caption]

함수도 Functor 클래스의 인스턴스?!

그렇다면, 모든 Functor 클래스의 인스턴스는 이렇게 이해할 수 있을까요?

instance Functor (Const m) -- Defined in 'Data.Functor.Const'
instance Functor (Either a) -- Defined in 'Data.Either'
instance Functor [] -- Defined in 'GHC.Base'
instance Functor Maybe -- Defined in 'GHC.Base'
instance Functor IO -- Defined in 'GHC.Base'
instance Functor ((->) r) -- Defined in 'GHC.Base'
instance Functor ((,) a) -- Defined in 'GHC.Base'

(주의: 간단히 하기 위해, 위에 열거한 것은 일반적인 Functor 인스턴스만으로, Applicative 에 속하는 3 개의 특수한 Functor 인스턴스를 제외했습니다)

其它는 특별히 아무것도 없지만, ((->) r) 는 조금 이상하게 보이며, 함수 정의 (r map to something) 처럼 보입니다. 정의를 봅시다:

instance Functor ((->) r) where
  fmap = (.)

((->) r) 는 확실히 Functor 클래스의 인스턴스로, 구현된 fmap 은 함수 합성 (.) 입니다:

(.) :: (b -> c) -> (a -> b) -> a -> c

b to c 를 맵하는 함수와 a to b 를 맵하는 함수를 받아, 후자의 출력을 전자의 입력에 연결하고, a to c 를 맵하는 함수를 반환합니다. 이는 우리가 아는 함수 합성이지만, Functor 와 어떤 관계가 있을까요?

먼저 Functorfmap 타입은:

fmap :: Functor f => (a -> b) -> f a -> f b

((->) r)Functor 인스턴스이므로, f((->) r) 로 바꿉니다:

fmap :: (a -> b) -> (->) r a -> (->) r b

마지막으로 -> 를 관습적인 중치 형식으로 변환합니다:

fmap :: (a -> b) -> (r -> a) -> (r -> b)

이것은, 함수 합성 (.) 이 아닙니까?

(.) :: (b -> c) -> (a -> b) -> a -> c

따라서, 함수도 Functor 클래스의 인스턴스입니다

P.S.그렇다면, ((->) r) 는 왜 이렇게 이상하게 보일까요?Functor class 는 요구합니다:

class Functor (f :: * -> *) where
  fmap :: (a -> b) -> f a -> f b

f 는 구체적인 타입 파라미터를 1 개 받는 타입 (* -> *) 이어야 하며, -> 는:

(->) :: * -> * -> *

파라미터가 1 개 더 많으므로, 먼저 1 개 채워서, 추한 (->) r 을 얻습니다 (r 은 단지 형식 파라미터 이름으로, ab 라 해도 됩니다)

더 적절한 비유

함수는, 확실히 상자로 생각하기 어렵습니다. 상상력이 매우 풍부하다면, 생화 상자 (모스라), 또는 가마 (마녀의 숲의 새로운 카드) 등 내용을 변화시킬 수 있는 상자, 음, 시험관 으로 생각할 수 있습니다

함수 레벨의 fmap 은 함수 합성으로, map a to b 의 함수에 대해, map b to c 의 매핑을 수행하여, map a to c 의 새로운 함수를 얻습니다:

instance Functor ((->) r) where
  fmap = (.)

(.) :: (b -> c) -> (a -> b) -> a -> c

이전의 상자 비유와 비교:

fmap 을 통해 함수를 컨테이너 내의 값에 작용시켜, 새로운 값을 담은 동종의 컨테이너를 얻음

우리가 발명한 생화 상자에 대입하면: fmap 을 통해 (생화) 상자를 (생화) 상자에 작용시켜, 새로운 (생화) 상자를 얻음

이 3 개의「(생화) 상자」를 어떻게 이해해야 할까요?

map a to b 와 map b to c 를 2 개의 시험관으로 생각하고, 시험관이 연결될 수 있다면, 무리하게 통합니다:

-- 시험관 ab 는 물을 붉게 변함
a -> b
-- 시험관 bc 는 붉은 물을 푸르게 변함
b -> c
-- 시험관 bc 로 ab 에 매핑을 수행하는 것은, ab 의 바닥에 구멍을 뚫고, bc 시험관에 끼우는 것
(b -> c) . (a -> b)
-- 더 긴 새로운 시험관 ac 를 얻으며, 물을 푸르게 변하는 작용
a -> c

왜 시험관 (또는 생화 상자) 로 비유할까요?변환을 가리키며, 변화를 표현하고 싶기 때문입니다. 우리가 이해하는 상자에는, 이러한 변환 작용의 의미가 부족하므로, 이 비유는 적절하지 않습니다

따라서, 함수 문맥의 Functor 에 대해

상자의 비유는 그다지 적절하지 않으며, functors 는 실제로 computation 과 비슷합니다. function 을 computation 에 map over 하면, 그 function 으로 매핑된 computation 을 얻습니다

위의 설명은 매우 적절하며, computation 은 데이터 변환이며, 변환은 매핑을 할 수 있고, 매핑을 수행하는 방법은 합성 입니다

더 정확한 표현은 functors 는 계산 문맥 (computational context) 입니다. 이 문맥은 이 computation 이 값을 가질 수 있거나, 실패할 수 있거나 (MaybeEither a 처럼), 또는 여러 값을 가질 수 있습니다 (lists 처럼) 등.

따라서, 상자라고 부르지 말고, 계산 문맥 이라고 부르며, fmap 은 이 계산 문맥에 변환의 층을 추가하는 (매핑을 수행하는) 것에 해당합니다

Lifting

다시 한번 fmap 의 타입 정의를 봅시다:

fmap :: Functor f => (a -> b) -> f a -> f b

map a to b 의 함수와 Functor 인스턴스 a 를 입력하고, 다른 Functor 인스턴스 b 를 반환합니다. 특별히 아무것도 없습니다

다른 자세로 다시 봅시다:

fmap :: Functor f => (a -> b) -> (f a -> f b)

map a to b 의 함수를 입력하고, 다른 함수를 반환합니다. 이 함수의 작용도 map a to b 이지만, Functor 의 문맥 내에 있습니다 (파라미터와 반환값이 모두 Functor 에 싸여 있습니다), 뭔가 의미가 있는 것 같습니다

어떤 함수를 다른 환경 하의 대응하는 함수로 변환하는 것을, lifting(提升?적절한 번역을 찾지 못했습니다) 이라고 합니다:

Lifting is a concept which allows you to transform a function into a corresponding function within another (usually more general) setting.

이 예를 봅시다:

> replicate 3 'a'
"aaa"
> :t replicate
replicate :: Int -> a -> [a]
> :t liftA2 replicate
liftA2 replicate :: (Applicative f) => f Int -> f a -> f [a]
> (liftA2 replicate) [1,2,3] ['a','b','c']
["a","b","c","aa","bb","cc","aaa","bbb","ccc"]
> :t liftA2
liftA2 :: (Applicative f) => (a -> b -> c) -> (f a -> f b -> f c)

그 중에서 liftA2 가 수행하는 것은 lifting 으로, 일반 함수 (replicate) 에 기반하여 기능이 유사한 새로운 함수를 생성하며, 새로운 함수는 다른 환경 (Applicative 문맥) 에 적용할 수 있습니다:

--  일반 함수
Int -> a -> [a]
--  lift 一下
f Int -> f a -> f [a]

따라서, lift 는 일반 함수가 f 의 문맥 내에서 정상적으로 작동할 수 있도록 편리하게 합니다

P.S.유사한 lift 함수는 총 3 개 있습니다:

liftA :: Applicative f => (a -> b) -> f a -> f b
liftA2 :: Applicative f => (a -> b -> c) -> f a -> f b -> f c
liftA3 :: Applicative f => (a -> b -> c -> d) -> f a -> f b -> f c -> f d

더 많은 파라미터는 <$><*> 로 초속으로 정의할 수 있습니다. 아래의 Applicative instances 소절의 (->) r 부분을 참조

二.Functor laws

이전에 언급했습니다:

Functor 를 구현할 때는 몇 가지 규칙을 따라야 합니다. 예를 들어 List 요소의 순서가 변하지 않기를 바라며, 이진 탐색 트리가 그 구조 성질을 유지하기를 바라는 등

([typeclass 를 깊이 이해하다_Haskell 노트 4](/articles/深入 typeclass-haskell 笔记 4/) 에서 인용)

따라서 functor laws 의 작용은 fmap 을 제약하여, 매핑 결과가 몇 가지 성질을 유지하도록 하는 것입니다:

functor laws 를 준수하면, 그것에 대해 fmap 을 수행해도 불필요한 일을 하지 않고, 단순히 함수로 매핑할 뿐임을 압니다

총 2 조의 규칙이 있습니다:

  • fmap id = id

  • fmap (f . g) = fmap f . fmap g

P.S.2 조는 fmap (f . g) F = fmap f (fmap g F) 로 쓸 수도 있으며, 합성을 제거하면 이해하기 쉽습니다

1 조, functor 에 대해 map id 를 수행하면, 얻어지는 새로운 functor 는 원래 것과 완전히 같아야 합니다

2 조, 2 개의 함수를 합성하여 결과를 functor 에 map over 한 ��과는, 먼저 1 번째 함수를 functor 에 map over 하고, 다음으로 2 번째 함수를 1 번째 단계에서 얻어진 functor 의 결과에 map over 한 결과와 완전히 같아야 합니다

(내장된)Functor 클래스의 인스턴스는 모두 이 2 조의 규칙을 만족합니다. 예를 들어:

> fmap id (Just 3)
Just 3
> fmap id Nothing
Nothing
> fmap ((+1) . (*2)) (Just 3)
Just 7
> fmap (+1) . fmap (*2) $ Just 3
Just 7

하지만 수동으로 구현한 Functor 인스턴스는 반드시 그렇지 않습니다. 이 2 조의 규칙은도덕적 제약만으로, 강제적인 체크는 없으므로, 커스텀 Functor 인스턴스를 구현할 때는 자발적으로 준수하도록 주의해야 합니다

三.Applicative functors

이름에서강화판의 Functor 임을 알 수 있습니다. 그렇다면, 어디가 강한가요?

Functor 는 map over 할 수 있는 것을 1 개의 클래스로 한정하며, Functor 인스턴스에 대해 fmap 을 사용하고, 일반 함수를 Functor 의 계산 문맥에 작용시킬 수 있습니다

충분히 강력해 보이지만, 몇 가지 특수한 장면이 있습니다. 예를 들어:

> :t fmap (+) (Just 3)
fmap (+) (Just 3) :: Num a => Maybe (a -> a)

이것은 무엇일까요?Maybe 에 함수가 들어 있습니다 (즉 Just (+3)). 그렇다면, 이 함수를 어떻게 꺼내서 사용해야 할까요?

예를 들어 Just 2 에 작용시키고 싶다면, 이렇게 합니다:

> let (Just f) = (Just (+3)) in fmap f (Just 2)
Just 5

먼저 패턴 매칭으로 (+3) 을 꺼내고, 다음으로 Just 2 에 대해 (+3) 매핑을 수행합니다. fmap 만으로는 Functor 에 싸인 함수를 다른 Functor 에 싸인 값에 작용시킬 수 없기 때문입니다

그렇다면, 모든 Functor 에 유효한 범용 패턴 이 있을까요?이것 (Functor 내의 함수를 다른 Functor 내의 값에 작용시키는 것) 을 완료하는 것을 도와주는 것은?

있습니다. 그것이 Applicative 입니다:

class Functor f => Applicative f where
  pure :: a -> f a
  (<*>) :: f (a -> b) -> f a -> f b

Applicative 에서 인용)

먼저 Functor 인스턴스여야 하므로, Applicative 는 특수한 Functor 입니다. 따라서 Applicative functors 라고도 불립니다

2 개의 인터페이스 pure<*> 를 정의합니다

pure 는 일반 값을 디폴트 context 하에 놓고, 최소의 context 이지만 여전히 그 값을 포함합니다

어떻게 이해해야 할까요?예를 봅시다:

> pure 1 :: [Int]
[1]
> pure 1 :: Maybe Int
Just 1
> pure 1 :: IO Int
1

List 而言, 최소의 context 는 [] (빈 List) 이므로, 1 을 넣어 [1] 을 얻습니다. Maybe 의 경우, Nothing 에는 값을 저장할 능력이 없으며, contextJust 여야 하므로, Just 1 입니다. I/O Action 의 경우, 물론 return 1 입니다 (return 을 통해 값을 I/O Action 에 넣습니다)

<*> 의 작용은:

It applies the wrapped function to the wrapped value

这正是我们想要的、Functor 내의 함수를 다른 Functor 내의 값에 작용시키는

따라서, ApplicativeFunctor 에 대한 강화는 <*> 함수에 나타나며, 강화 방식은 이러한 Functor 인스턴스에 모두 <*> 를 구현하게 하여, Functor 내의 함수를 다른 Functor 내의 값에 작용시키는 것을 지원하는 것입니다

2 개의 이점을 가져옵니다. 첫 번째는 다중 파라미터 함수에 대해 더 우호적이라는 것입니다:

일반적인 functor 의 경우, (단일 파라미터) 함수를 이 functor 에 map over 할 수 있을 뿐입니다. 하지만 applicative functor 가 있으면, 여러 개의 functor 에 (다중 파라미터) 함수를 적용할 수 있습니다

두 번째는 Functor 의 결합을 허가한다는 것입니다 (fmap 은 1 회 계산하여 Functor 를 얻으면 종료이지만, <*> 를 통해 계속 연산을 계속할 수 있습니다):

applicative functor 는 재미있을 뿐만 아니라 실용적이며, 다른 종류의 계산을 결합하는 것을 허가합니다. I/O 계산, non-deterministic 한 계산, 실패할 가능성이 있는 계산 등. <$><*> 를 사용하여, 임의의 수의 applicative functors 에 대해 일반 함수를 동작시킬 수 있습니다.

예를 들어:

> (+) <$> (Just 1) <*> (Just 2)
Just 3
> (\a b c -> a + b + c) <$> (Just 1) <*> (Just 2) <*> (Just 3)
Just 6
> (+3) <$> ((+) <$> (Just 1) <*> (Just 2))
Just 6

四.Applicative instances

Applicative 클래스에는 많은 인스턴스가 있습니다:

instance Monoid m => Applicative (Const m)
  -- Defined in 'Data.Functor.Const'
instance Applicative (Either e) -- Defined in 'Data.Either'
instance Applicative ZipList -- Defined in 'Control.Applicative'
instance Monad m => Applicative (WrappedMonad m)
  -- Defined in 'Control.Applicative'
instance Control.Arrow.Arrow a => Applicative (WrappedArrow a b)
  -- Defined in 'Control.Applicative'
instance Applicative [] -- Defined in 'GHC.Base'
instance Applicative Maybe -- Defined in 'GHC.Base'
instance Applicative IO -- Defined in 'GHC.Base'
instance Applicative ((->) a) -- Defined in 'GHC.Base'
instance Monoid a => Applicative ((,) a) -- Defined in 'GHC.Base'

Maybe

instance Applicative Maybe where
  pure = Just
  Nothing <*> _ = Nothing
  (Just f) <*> something = fmap f something

Maybe 타입而言, 값을 연산에 참가시키는 최소의 context 는 Just something 으로, Nothing 에서는 함수를 꺼낼 수 없으므로, 결과는 반드시 Nothing 입니다. 좌측이 Nothing 이 아닌 경우, 패턴 매칭으로 함수 f 를 꺼내고, fmap 을 통해 우측의 Maybe 인스턴스 (something) 에 작용시킵니다

List

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

pure f[f] 이며, [f] <*> xs 는 좌측의 각 함수를 우측의 각 값에 적용합니다

P.S.pure f <*> xs 는 실제로 fmap f xs 와 등가임을 쉽게 발견할 수 있습니다. 이 또한 Applicative laws 중 하나입니다

IO

instance Applicative IO where
  pure = return
  a <*> b = do
    f <- a
    x <- b
    return (f x)

pure 의 대응하는 구현은 return 으로, 값을 I/O Action 에 싸서, IO 연산에 참가할 수 있게 합니다. <*> 가 수행하는 것은, 좌우 양측의 I/O Action 에서 각각 함수와 값을 꺼내고, 연산을 완료한 후 return 으로 결과를 싸는 것입니다

(->) r

instance Applicative ((->) r) where
  pure x = (\_ -> x)
  f <*> g = \x -> f x (g x)

이것은 조금 이상하게 보이며, pure 는 상수를 반환하는 함수를 생성하고, <*> 는 좌우 양측의 함수를 합성합니다

예를 들어:

(+) <$> (+3) <*> (*100)

그 중에서*<$> 는 중치판의 fmap* 입니다. 다음과 같습니다:

infixl 4 <$>
(<$>) :: Functor f => (a -> b) -> f a -> f b
(<$>) = fmap

<*><$> 는 모두 infixl 4 (중치 좌결합, 우선순위는 4) 이므로, 전개 프로세스는 이렇습니다:

(+) <$> (+3) <*> (*100)
=(fmap (+) (+3)) <*> (*100)
=((.) (+) (+3)) <*> (*100)
=((+) . (+3)) <*> (*100)
=\x -> ((+) . (+3)) x ((*100) x)
=\x -> (+) ((+3) x) ((*100) x)

즉:

f1 <$> f2 <*> f3
=\x -> f1 (f2 x) (f3 x)

2 개의 applicative functor 를 <*> 에 주면, 새로운 applicative functor 를 얻습니다. 따라서 2 개의 함수를 주면, 새로운 함수를 얻습니다

따라서 f1 <$> f2 <*> f3 의 실제 효과는: f2f3 의 결과를 파라미터로 하여 f1 을 호출하는 함수를 생성하는 것입니다. 따라서:

f1 <$> f2 <*> f3 <*> ... <*> fn
=\x -> f1 (f2 x) (f3 x) ... (fn x)

P.S.f1 <$> f2 <*> f3 라는 고정 패턴에는 툴 함수가 있으며, liftA2 라고 부릅니다:

liftA2 :: Applicative f => (a -> b -> c) -> f a -> f b -> f c
liftA2 f a b = f <$> a <*> b

liftA2 는 일반 2 항 함수를 받아, 그것을 2 개의 functor 위에서 동작할 수 있는 함수로 업그레이드합니다

이것이소위 lifting (업그레이드?) 입니다

ZipList

List 와 비교:

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

ZipList 의 구현은 다음과 같습니다:

instance Applicative ZipList where
  pure x = ZipList (repeat x)
  ZipList fs <*> ZipList xs = ZipList (zipWith (\f x -> f x) fs xs)

P.S.ZipListControl.Applicative 모듈에 위치하며, ZipList 가 존재하는 이유는, List 에 다른 다른 Applicative 구현을 다시 부여할 수 없기 때문입니다

pure 는 실제로 무한장의 ZipList 를 생성합니다. 이는 zipWith 의 결과가 2 개의 List 중 짧은 것을 기준으로 하기 때문입니다. 따라서, x 가 정상적으로 연산에 참가할 수 있도록 (다른 쪽의 임의의 길이의 List 를 만족시키기 위해), ZipList 而言, ZipList (repeat x)최소의 context 입니다

<*> 는 좌측에서 함수 List 를 꺼내고, 우측에서 데이터 List 를 꺼내고, 2 개의 List 의 요소를 하나씩 짝지어 매핑 (zipWith) 을 수행합니다

좌측의 함수 List 에 같은 함수만 있는 경우, 이 함수로 우측의 List 에 매핑을 수행하는 것과 동등합니다:

> getZipList $ pure (+1) <*> (ZipList [1, 2, 3])
[2,3,4]

P.S.그 중에서 getZipList :: ZipList a -> [a] 는 getter(상세는 [타입_Haskell 노트 3 | Record](/articles/类型-haskell 笔记 3/#articleHeader8) 참조) 로, 안의 List 를 꺼내는 데 사용합니다

다른花样:

> getZipList $ (+) <$> ZipList [1, 2, 3] <*> (ZipList [1, 2, 3])
[2,4,6]
> getZipList $ max <$> ZipList [1, 3, 4, 5] <*> ZipList [2, 0]
[2,3]

五.Applicative laws

마찬가지로, Applicative 도 몇 가지 규칙을 따라야 합니다:

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

pure 를 통해 일반 함수 f 가 Functor 연산에 참가할 수 있으므로, 다음과 같습니다:

pure f <*> x = fmap f x
pure id <*> v = v
pure f <*> pure x = pure (f x)

<*> 를 통해 좌측 Functor 중의 함수가 우측 Functor 중의 값에 작용할 수 있으므로, 다음과 같습니다:

-- $고정 파라미터 위치
u <*> pure y = pure ($ y) <*> u
-- (.) 결합성 변경
pure (.) <*> u <*> v <*> w = u <*> (v <*> w)

내장된 Applicative 인스턴스는 모두 이러한 규칙을 따르지만, 마찬가지로 도덕적 제약만으로, 수동으로 Applicative 인스턴스를 구현할 때는 자발적으로 준수해야 합니다

Applicative style

<$><*> 를 통해매우 우아한 호출 스타일 을 달성할 수 있습니다. 예를 들어:

> (++) <$> Just "johntra" <*> Just "volta"
Just "johntravolta"

함수 호출과 비교:

> (++) "johntra" "volta"
"johntravolta"

I/O 장면에서 더 명확합니다:

myAction = do
  a <- getLine
  b <- getLine
  return $ a ++ b

대응하는 applicative style:

myAction = (++) <$> getLine <*> getLine

매우 우아하며, Functor 레벨의 연산과 일반 연산이 형식상 거의 차이가 없어집니다 (형식상 연산이 소재한 context 의 차이를 제거합니다)

참고 자료

댓글

아직 댓글이 없습니다

댓글 작성