一.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[/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 와 어떤 관계가 있을까요?
먼저 Functor 의 fmap 타입은:
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 은 단지 형식 파라미터 이름으로, a 나 b 라 해도 됩니다)
더 적절한 비유
함수는, 확실히 상자로 생각하기 어렵습니다. 상상력이 매우 풍부하다면, 생화 상자 (모스라), 또는 가마 (마녀의 숲의 새로운 카드) 등 내용을 변화시킬 수 있는 상자, 음, 시험관 으로 생각할 수 있습니다
함수 레벨의 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 이 값을 가질 수 있거나, 실패할 수 있거나 (
Maybe와Either 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 에는 값을 저장할 능력이 없으며, context 는 Just 여야 하므로, Just 1 입니다. I/O Action 의 경우, 물론 return 1 입니다 (return 을 통해 값을 I/O Action 에 넣습니다)
<*> 의 작용은:
It applies the wrapped function to the wrapped value
这正是我们想要的、Functor 내의 함수를 다른 Functor 내의 값에 작용시키는
따라서, Applicative 의 Functor 에 대한 강화는 <*> 함수에 나타나며, 강화 방식은 이러한 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 의 실제 효과는: f2 와 f3 의 결과를 파라미터로 하여 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.ZipList 는 Control.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 의 차이를 제거합니다)
아직 댓글이 없습니다