Lens - 펑크터의 독특한 활용

Posted on May 13, 2021

목차

  1. Lens의 목적
  2. 템플릿이 만들어내는 대략적인 코드 모양
  3. Traversals
  4. 쥬스 만들기에 비유해 봤습니다
  5. 왜 펑크터가 들어가 있을까요?
    1. 펑크터 없이 구현
    2. 펑크터가 필요한 이유
천재만 하스켈을 쓰는 건 아닙니다. 몸 좀 쓰면 안되나요?

Lens의 목적

예시 소스는 공식 튜토리얼에서 발췌했습니다.
https://hackage.haskell.org/package/lens-tutorial-1.0.4/docs/Control-Lens-Tutorial.html

우선 무엇을 위한 라이브러리인지부터 보겠습니다.

data Atom = Atom { _element :: String, _point :: Point } deriving (Show)
data Point = Point { _x :: Double, _y :: Double } deriving (Show)

atom :: Atom
atom = Atom "I am Atom" (Point 1.0 2.0)

atom에서 _x값이 필요하다면

spitX :: Atom -> Double
spitX (Atom str (Point x y)) = x

_x에 값을 지정하려면

updateX :: Double -> Atom -> Atom
updateX newX (Atom str (Point x y)) = Atom str (Point newX y)

_x에 함수를 적용해서 수정하려면

fmapX :: (Double -> Double) -> Atom -> Atom
fmapX f (Atom str (Point x y)) = Atom Str (Point (f x) y)

그럼 나머지 요소들에도 같은 함수들이 필요할 거라 예상할 수 있습니다.

spitElement
updateElement
fmapElement

spitY
updateY
fmapY

데이터 타입이 다양한 깊이로 여러 값을 가지고 있을 때 매 번 패턴 매칭으로 접근하는 코드를 만들어야 합니다. 딱 봐도 프로그래머가 뭔 짓을 할 것만 같은 지루함이 보입니다. Lens는 바로 이 뭔 짓에 해당하는 라이브러리입니다.

import Control.Lens hiding (element)
import Control.Lens.TH

data Atom = Atom { _element :: String, _point :: Point } deriving (Show)
data Point = Point { _x :: Double, _y :: Double } deriving (Show)

$(makeLenses ''Atom)
$(makeLenses ''Point)

각 필드들에 바로 접근할 수있는 함수들을 여기선 렌즈라고 부릅니다. 하스켈 템플릿을 활용해 AtomPoint를 위한 렌즈를 만듭니다. 그럼 다음처럼 필드에 접근 할 수 있습니다.

_x값이 필요하다면

view (point . x) atom

_x에 함수를 적용해서 수정하려면

over (point . x) (+1)

단 두 줄의 템플릿 코드로 생기는 혜택이 꽤 훌륭합니다.

템플릿이 만들어내는 대략적인 코드 모양

type Lens' a b = forall f . Functor f => (b -> f b) -> (a -> f a)

element :: Lens' Atom String
point   :: Lens' Atom Point
x       :: Lens' Point Double
y       :: Lens' Point Double

언더바underscore가 붙은 필드당 하나의 렌즈가 만들어집니다. 렌즈는 여기서 언더바를 떼어낸 걸 이름으로 씁니다. 혹 템플릿을 쓸 수 없는 상황이면 아래와 같이 lens 함수를 써서 수작업으로 렌즈를 만듭니다.

lens :: (a -> b) -> (a -> b -> a) -> Lens' a b

point :: Lens' Atom Point
point = lens _point (\atom newPoint -> atom { _point = newPoint })

lens 라이브러리를 쓰지 않고, 만들면 제일 처음 봤던 코드와 비슷하게 나옵니다.

point :: Functor f => (Point -> f Point) -> Atom -> f Atom
point k atom = fmap (\newPoint -> atom { _point = newPoint }) (k (_point atom))

x, point는 모두 함수입니다. point . x 같은 스타일로 쓸 수 있게 됩니다.

-- GHC가 (.) 쓰는데, Lens'을 만나면 다음처럼 추론합니다.
(.) :: Lens' a b -> Lens' b c -> Lens' a c
--        point    .    x     

view와 같은 일을 하는 중위 연산자 ^.를 정의해서 최대한 직관적으로 모양을 만듭니다.

(point . x) atom = atom^.point.x 

여기까지만 알아도, 대부분의 렌즈 사용 코드는 이해할 수 있다고 합니다. 조금 더 들어가면

Traversals

참고 - Traversable

더 깊이가 있는 데이터 구조를 예를 들어 봅시다.

data Molecule = Molecule { _atoms :: [Atom] } deriving (Show)

$(makeLenses ''Molecule)

molecule :: Molecule
molecule = Molecule
    { _atoms =
        [ Atom { _element = "C", _point = Point { _x = 2.0, _y = 2.0 } }
        , Atom { _element = "O", _point = Point { _x = 4.0, _y = 4.0 } }
        ]
    }

Molecule^.atoms 까지는 추측 되는데 그 다음은 어떻게 될까요? Atom이 하나가 아니라서 traverse를 써주고, 그 다음 pointx에 접근합니다.

shiftMoleculeX :: Molecule -> Molecule
shiftMoleculeX = over (atoms . traverse . point . x) (+ 1)

traverse를 썼을까요? fmap을 쓰면 안됐을까요?
molecule^.atoms[Atom1, Atom2] 까지는 도달했습니다. 그 다음 point . x 함수를 먹여야 하는데, fmap으로 먹이면 될 것 처럼 보입니다. 하지만 fmap을 쓰면 에러가 나고, traverse를 써야합니다.

> over ( traverse . point . x ) (+ 1) $ [ Atom "1" (Point 1.0 2.0), Atom "2" (Point 3.0 4.0) ]
[Atom {_element = "1", _point = Point {_x = 2.0, _y = 2.0}}
,Atom {_element = "2", _point = Point {_x = 4.0, _y = 4.0}}]
> :t (traverse . point . x)
(traverse . point . x) :: (Traversable t, Applicative f) => (Double -> f Double) -> t Atom -> f (t Atom)
> :t (map . point . x)
(map . point . x) :: Functor f => (Double -> f Double) -> [Atom] -> [f Atom]

> :t over
over :: ASetter s t a b -> (a -> b) -> s -> t
> :i ASetter
type ASetter s t a b = (a -> Identity b) -> s -> Identity t
        -- Defined in ‘Control.Lens.Setter’

필요한 정보를 찾아 ASetter까지 도달해서 보니 map을 썼을 때, traverse와는 달리 f가 리스트 컨텍스트 안에 들어가 있습니다. 일단 타입 서명들끼리 맞춰봐도 map은 안되는 걸 알 수 있습니다.

> :t x
x :: Functor f => (Double -> f Double) -> Point -> f Point
> :t point
point :: Functor f => (Point -> f Point) -> Atom -> f Atom
> :t (point . x)
(point . x) :: Functor f => (Double -> f Double) -> Atom -> f Atom

모두 펑크터가 붙어 있긴 한데, 일단 traverse부터 보고 나중에 보겠습니다.
위 서명을 보면 (point . x)Atom 하나에는 적용할 수 있는데, Atom이 리스트에 들어있으면 얼핏 생각하기엔 traverse . point . x 처럼 composition이 아니라 traverse의 인자로 point . x가 넘어가야 하지 않을까 생각이 듭니다. 일단 리스트를 해체해야 point . x 를 적용할 수 있을테니 말입니다.

그런데, point 나 x는 인자가 하나인 함수가 아니라, 두 개인 함수입니다. 어떻게 컴포지션 하는 걸까요?

함수는 오른쪽 결합입니다. a -> b -> ca -> (b -> c)와 같고, 인자 하나 a를 받아서 (b -> c)를 돌려주는 함수로 봐도 됩니다. xpoint를 괄호를 넣어 다시 써보면

x :: Functor f => (Double -> f Double) -> (Point -> f Point)
point :: Functor f => (Point -> f Point) -> (Atom -> f Atom)

x, point 둘 다 어딘가에 바로 적용할 함수들이 아니라, 함수를 받아서 값을 바꿀 준비만 하는 함수입니다.
그리고, 타입 서명을 보면 x의 결과와 point의 입력은 같은 타입이라 point . x가 가능합니다.

type Lens' a b = forall f . Functor f => (b -> f b) -> (a -> f a)
point :: Lens' Atom Point
x     :: Lens' Point Double

(.) :: Lens' a b -> Lens' b c -> Lens' a c -- 타입 정의가 이렇다는게 아니라 이렇게 추론된다입니다.

point . x :: Lens' Atom Double

Lens' 타입과 traverse를 어떻게 composition할까요?

traverse :: (Traversable t, Applicative f) => (a -> f b) -> ( t a -> f (t b))
(point . x) :: Functor f => (Double -> f Double) -> (Atom -> f Atom)

a, bAtom으로 맞추면 (point . x)의 결과를 traverse가 받을 수 있습니다. 타입은 이렇게 맞춰 컴포지션이 가능하다는 건 알겠는데, 동작이 눈에 잘 들어오지 않습니다.

traverse는 펑크터로 싸인 함수를 받아서, 펑크터 안에 들어 있는 값에 적용합니다. 인자로 받아야 할 함수를 컴포지션으로 연결하는 모양이라 눈에 금방 안들어 옵니다. (point . x)는 적용할 함수가 아니라, 적용할 함수를 만드는 함수입니다.

(point . x)(+1)을 주면 Atom에 적용할 수 있는 함수를 만들어주는 브로커같은 함수입니다.

※ 생각하는 팁
각 연결된 함수들의 동작을 끝 맺으면서 다음 함수로 넘어가지 마세요. 첫 번째 함수의 동작을 끝내고, 결과를 가지고 다음 함수로 넘어가지 말고, 첫 번째 함수도 끝나지 않은 채 다음 함수로 언젠가 값을 넘길 준비를 한 상태로 읽는 게 이해하기 더 좋습니다.

(traverse . point . x) 는 나중에 언젠가
Double -> f Double 함수를 받아 x가 작업해서
Point -> f Point 함수를 만들어 point에 넘기면
Atom -> f Atom 함수를 만들어 traverse 첫 번째 인자로 넣어 줄 준비를 한 상태입니다.
> :t traverse
traverse :: (Traversable t, Applicative f) => (a -> f b) -> t a -> f (t b)

> :t (traverse . point . x)
(traverse . point . x) :: (Traversable t, Applicative f) => (Double -> f Double) -> t Atom -> f (t Atom)

쥬스 만들기에 비유해 봤습니다

비유, 은유적인 예를 그다지 좋아하는 편은 아닌데, 함수가 많이 등장해 한 번 생각해 봤습니다.

(+1)은 칼날
(point . x)는 칼날을 결합할 블렌더
[Atom1, Atom2]는 과일 바구니
traverse는 작업자
f Atom 은 쥬스

목표는 아래 모양을 만드는 겁니다.

traverse ( blenderfunc ) [Atom1, Atom2]
-- 작업자는 보통 (칼날이 끼워진 블렌더)를 받아서, 바구니에서 과일을 하나씩 꺼내 쥬스를 만듭니다.

그런데 지금은 칼날이 빠진 블렌더가 먼저 손에 들어왔습니다.

traverse . (point . x)
-- 컴포지션이란 (point . x)에 칼날을 끼워 블렌더가 완성되면 그걸 작업자traverse에게 줄 준비를 한다는 말입니다.

여기에 칼날을 넣어줘야 작업할 준비 상태가 됩니다.

traverse . point . x $ (+1)
-- 컴포지션은 오른쪽 우선 결합이므로 괄호를 생략할 수 있습니다. 

이렇게 칼날을 주면 생각하기가 편해지는데, 지금 당장 칼날은 주어지지 않습니다.

traverse . point . x
-- 언젠가 칼날을 주면 블렌더를 완성해서 작업을 할 "준비"를 뜻합니다.

작업자에 칼날을 넘기며 오더를 내리는 함수는 over입니다.

over는 (작업자. 블렌더) 칼날 과일바구니 

이제 traverse와 컴포지션 모양이 나오는 걸 이해했으니, 다음은 펑크터로 넘어가겠습니다.

왜 펑크터가 들어가 있을까요?

펑크터 없이 구현

Atom안에 있는 Point, Point 안에 있는 x에 접근해서 (+1) 하는 작업을 하는데 왜 Double -> Double 함수가 아니라 Double -> f Double 함수를 받을까요? 어디서 나온 펑크터일까요? 그러지만 않았다면 map을 적용해도 됐을텐데요.

펑크터를 안쓰고 구현하면 어떤 모양이 될까 해보겠습니다.

pointN :: (Point -> Point) -> Atom -> Atom
pointN k at = at { _point = k (_point at) }

xN :: (Double -> Double) -> Point -> Point
xN k po = po { _x = k (_x po) }
>  pointN (xN (+1)) atom
Atom {_element = "I am Atom", _point = Point {_x = 2.0, _y = 2.0}}

모양은 보기 좋게 atom, point, x 순서는 아니지만 작동은 잘 합니다. 물론 모양도 바꿀 수 있습니다.

(#) :: a -> (a -> b) -> b
x # f = f x
infixl 0 #

보조 함수를 정의하고 아래와 같이 쓸 수 있습니다.

atom # (pointN . xN) (+1)

_x 값을 보고 싶다면

atom # _point # _x

아직까진 왜 펑크터가 필요한지 모르겠습니다.

map (    # (pointN . xN) (+1)    ) [atom1, atom2]

역시 map으로 잘 작동합니다. 왜 펑크터가 필요한 걸까요?

펑크터가 필요한 이유

Lens Tutorial - FPComplete

위 사이트에서 답을 찾았습니다. over, view 둘 다 같은 렌즈를 받도록 하기 위해서입니다. 좀 더 풀어서 얘기하면, 특정 필드의 값을 수정할 때와, 보기만 할 때 경로를 의미하는 렌즈가 다를 필요가 없으니, 이런 직관이 들어 맞도록 overview가 같은 타입의 렌즈를 받아야 합니다.

우선 두 타입을 다르게 놓고 구현한 걸 보여 줍니다. 여기 있는 소스는 모두 fpcomplete 사이트에서 발췌했습니다.

일단 목표를 코드로 설명하면,

type Lens s a = ?

view :: Lens s a -> s -> a
view = ?

over :: Lens s a -> (a -> a) -> s -> s
over = ?

이렇게 view, over에서 같은 Lens s a타입을 받도록 하는게 목표입니다.

일단 over에서 쓸 펑크터를 정의합니다.

newtype Identity a = Identity { runIdentity :: a } deriving Functor

나중에 값을 꺼낼 때 Identity를 벗겨야 하긴 하지만, over가 받는 타입을
(a -> a) -> (s -> s)가 아닌
(a -> Identity a) -> (s -> Identity s) 타입으로 바꿔 놓습니다.
의미가 달라진 건 없습니다.

type LensModify s a = (a -> Identity a) -> (s -> Identity s)

over :: LensModify s a -> (a -> a) -> s -> s
over lens f s = runIdentity (lens (Identity . f) s)

personAddressL :: LensModify Person Address
personAddressL f person = Identity $ person { personAddress = runIdentity $ f $ personAddress person }
-- f를 먹인 후 runIdentity를 실행하는 걸로 봐서, f의 결과는 Identity 타입을 예상할 수 있습니다. 

-- Identity 없이 구현했던 버전
personAddressL :: Lens Person Address
personAddressL = Lens
  { lensGetter = personAddress 
  , lensModify = \f person -> person { personAddress = f (personAddress person)}
  }

이 경우는 크게 달라지는 건 없지만, Identity를 씌웠다 벗겼다 하기 싫으면 Functor로 해결해도 됩니다.
그럼 코드에서 Identity를 지울 수 있습니다.

personAddressL :: LensModify Person Address
personAddressL f person = (\address -> person { personAddress = address}) <$> f (personAddress person)

이 번엔, getter를 구현하겠습니다.

이제 매직을 써서 (a -> a) -> (s -> s)s -> a로 바꾸는 작업을 합니다.
※ 어떻게 이런 매직 같은 아이디어를 떠올렸을지 궁금합니다.

view에서 쓸 펑크터를 정의합니다.

newtype Const a b = Const { getConst :: a } deriving Functor

const 함수와 하는 일은 완전히 같은 개념입니다. b를 받아 버리는 역할만 합니다.

type LensGetter s a = s -> Const a s

view :: LensGetter s a -> s -> a
view lens s = getConst (lens s)

personAddressL :: LensGetter Person Address
personAddressL person = Const (personAddress person) 
                     -- Const a b 에서 b는 뭐로 추론될까요?
                     -- LensGetter는 Person -> Const Address Person
                     -- b는 따로 지정 안해줘도 서명에 따라 Person으로 추론합니다.

아래 두 개의 타입을 같게 만드는 게 목표인데, 여전히 달라 보입니다.

type LensModify s a = (a -> Identity a) -> (s -> Identity s)
type LensGetter s a = s -> Const a s

비슷한 모양을 가지도록 LensGetter를 다시 정의하면,
(이런 아이디어가 어디서 왔을까요? 가설처럼 일단 서명을 세우고 고민한 건 아닐까요?)

2022.8.11 추가 - Yoneda lemma를 보면, 이런 아이디어가 나온 이유를 알 수 있다고 합니다.
2024.1.7 추가 - Yoneda lemma를 정리한 글을 추가했습니다.

type LensGetter s a = (a -> Const a s) -> (s -> Const a s)

view :: LensGetter s a -> s -> a
view lens s = getConst (lens Const s)
--                           ^^^^^
--                         값 생성자
personAddressL :: LensGetter Person Address
personAddressL f person = Const $ getConst $ f (personAddress person)
--             ^                             ^
--           Const                           |
--                        어차피 나머지가 하드 코딩인데, 
--                        그냥 Const라 써도 될 것 같지만 
--                        Setter와 모양을 맞추기 위해 f로 두어야 합니다.

LensGetter타입 lensConst p를 넣는게 아니라, Const 값 생성자, p를 넣으면 어찌되나 보겠습니다.
(Address -> Const Address Person) -> Person -> Const Address Person에서
처 번째 인자로 Const 값 생성자, 두 번째 인자로 p를 넣는다는 말입니다.

view personalAddressL p 
    = getConst (personalAddressL Const p) 
--              ^^^^^^^^^^^^^^^^^^^^^^
    = getConst (Const $ getConst $ Const (personAddress p))
--    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
--             Const와 관련된 걸 지울 수 있게 됐습니다.
    = personAddress p

Const 값 생성자를 넣는 방법으로 view 함수를 구현했습니다.
이 것도 Functor 스타일로 구현하면

personAddressL :: LensGetter Person Address
personAddressL f person = (\address -> person { personAddress = address}) <$> f (personAddress person)

명시적인 Const는 사라지고, LensModify 때 정의한 함수와 같아 보이지 않나요?
타입만 LensGetter냐, LensModify냐지 함수 본체body는 완전히 동일한 모양이 됐습니다.

personAddressL :: LensGetter Person Address
personAddressL f person = (\address -> person { personAddress = address}) <$> f (personAddress person)

personAddressL :: LensModify Person Address
personAddressL f person = (\address -> person { personAddress = address}) <$> f (personAddress person)

이 정도면 두 함수를 하나의 모양으로 표시하고, 조립할 때 쓸 실제 코드는 GHC가 골라 오도록 떠맡길 수 있습니다.

-- type LensModify s a = (a -> Identity a) -> (s -> Identity s)
-- type LensGetter s a = (a -> Const a s) -> (s -> Const a s)
type Lens s a = forall f. Functor f => (a -> f a) -> (s -> f s)

newtype Identity a = Identity { runIdentity :: a }
  deriving Functor

newtype Const a b = Const { getConst :: a }
  deriving Functor

over :: Lens s a -> (a -> a) -> s -> s
over lens f s = runIdentity (lens (Identity . f) s) 
-- 위의 기나긴 여정 없이 Identity가 왜 들어갔는지 알 수 있을까요?

view :: Lens s a -> s -> a
view lens s = getConst (lens Const s) 
-- 위의 기나긴 여정 없이 Const가 왜 들어갔는지 알 수 있을까요? 

personAddressL :: Lens Person Address
personAddressL f person =
      (\address -> person { personAddress = address })
  <$> f (personAddress person)

getPersonAddress :: Person -> Address
getPersonAddress = view personAddressL

modifyPersonAddress :: (Address -> Address) -> Person -> Person
modifyPersonAddress = over personAddressL

setPersonAddress :: Address -> Person -> Person
setPersonAddress address = modifyPersonAddress (const address)

Q. 나중에 GHC가 타입 추론한다 해도 <$> 인스턴스만 다른 걸 가져오는 걸 텐데, 그래 봤자 Constfmap이나 Identityfmap이나 별로 다른 동작을 하지 않을텐데, 이 걸로 어떻게 getter, setter 동작을 다 표현하지?
A. 추상화한 personAddressL 렌즈는 함수 하나를 받고, 이 함수를 fmap으로 안쪽에 적용하는 역할만 정해놨습니다. 어떤 일을 할지는 이 함수로 뭘 받냐에 따라 달라집니다. 동일 구조의 렌즈지만, getPersonAddressmodifyPersonAddress는 포인트 프리로 정의되어 있습니다. view가 동작하면 Person만 받을테고, over가 동작하면 함수와 Person을 받습니다.

Lens 타입 안에 들어 있는 Functor f
over 안에서 인자 lensrunIdentity를 쓰는 걸 보고 GHC가 알아서 Identity로 추론하고,
view 안에서 인자 lensgetConst를 쓰는 걸 보고 GHC가 알아서 Const로 추론합니다.

Lens 타입 서명을 보면 forall f. Functor f => 나중에 어떤 펑크터든 될 수 있다고 선언했습니다. personAddressL 렌즈의 <$>over1에서 쓰이는 실제 코드와 view에 쓰이는 실제 코드가 다른 겁니다. Lens하나의 코드로 해결한다는 말이 아닙니다. 실제 가져 올 코드는 GHC가 추론하도록 열어두고, 프로그래머는 같은 코드 모양을 쓰겠다가 펑크터를 쓴 목적입니다.

제작자가 어떻게 ConstIdentity 펑크터를 떠올렸을까 신기합니다.
(추가: 카테고리 이론 Monoidal Category 부분을 보면 Const 펑크터와 Identity 펑크터를 써서 Monoidal로 만드는 예가 나옵니다.)

(a -> a) -> s -> s 타입을, 어떤 조건이 맞으면 s -> a 타입이 되게 해야 됩니다.
타입 서명을 (a -> Const a p) -> (s -> Const a p) 이렇게 바꾸고, 이 타입의 렌즈를 가져다 쓰는 view쪽에서 Const를 적당히 적용해서 s -> a를 뽑아내야 합니다. 서명에서 치고 들어간게 아니라, Const 값 생성자를 넘기는 트릭을 먼저 떠올렸을까요?


  1. 실제 over 정의

    over :: ASetter s t a b -> (a -> b) -> s -> t
    over l f = runIdentity #. l (Identity #. f)
    -- 매개 변수가 3개여야 하는데 두 개면 하나만 포인트 프리로 보면 됩니다.
    ( #. ) :: Coercible c b => (b -> c) -> (a -> b) -> (a -> c)
    ( #. ) _ = coerce (\x -> x :: b) :: forall a b. Coercible b a => a -> b

    Coercible - 단어 뜻: 강제할 수 있는 coerce : 같은 표현representation을 가진 타입의 값들을 런타임 오버헤드 없이 서로 안전하게 변환할 때 씁니다. 가장 간단한 경우가, newtype의 구체 타입에서 추상 타입으로 갈 때 newtype 생성자를 대신해서 쓸 수 있을 뿐 아니라, newtype의 리스트를 구체 타입들의 리스트로 바꿀 때 쓸 수 있다고 합니다.

    깊이가 점점 깊어져, coercea를 받아 b로 변환하는 함수정도로만 알고 지나 가겠습니다.

    ( #. )는 함수 두개를 받는 컴포지션과 서명이 비슷한데, 받는 인자 순서가 반대입니다.↩︎

Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com