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)

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

_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를 써주고, 그 다음 point와 x에 접근합니다.

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 -> c는 a -> (b -> c)와 같고, 인자 하나 a를 받아서 (b -> c)를 돌려주는 함수로 봐도 됩니다. x와 point를 괄호를 넣어 다시 써보면

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, b를 Atom으로 맞추면 (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 둘 다 같은 렌즈를 받도록 하기 위해서입니다. 좀 더 풀어서 얘기하면, 특정 필드의 값을 수정할 때와, 보기만 할 때 경로를 의미하는 렌즈가 다를 필요가 없으니, 이런 직관이 들어 맞도록 over와 view가 같은 타입의 렌즈를 받아야 합니다.

우선 두 타입을 다르게 놓고 구현한 걸 보여 줍니다. 여기 있는 소스는 모두 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타입 lens에 Const 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가 타입 추론한다 해도 <$> 인스턴스만 다른 걸 가져오는 걸 텐데, 그래 봤자 Const의 fmap이나 Identity의 fmap이나 별로 다른 동작을 하지 않을텐데, 이 걸로 어떻게 getter, setter 동작을 다 표현하지?
A. 추상화한 personAddressL 렌즈는 함수 하나를 받고, 이 함수를 fmap으로 안쪽에 적용하는 역할만 정해놨습니다. 어떤 일을 할지는 이 함수로 뭘 받냐에 따라 달라집니다. 동일 구조의 렌즈지만, getPersonAddress와 modifyPersonAddress는 포인트 프리로 정의되어 있습니다. view가 동작하면 Person만 받을테고, over가 동작하면 함수와 Person을 받습니다.

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

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

제작자가 어떻게 Const와 Identity 펑크터를 떠올렸을까 신기합니다.
(추가: 카테고리 이론 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의 리스트를 구체 타입들의 리스트로 바꿀 때 쓸 수 있다고 합니다.

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

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

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