예시 소스는 공식 튜토리얼에서 발췌했습니다.
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 "I am Atom" (Point 1.0 2.0) atom
atom
에서 _x
값이 필요하다면
spitX :: Atom -> Double
Atom str (Point x y)) = x spitX (
_x
에 값을 지정하려면
updateX :: Double -> Atom -> Atom
Atom str (Point x y)) = Atom str (Point newX y) updateX newX (
_x
에 함수를 적용해서 수정하려면
fmapX :: (Double -> Double) -> Atom -> Atom
Atom str (Point x y)) = Atom Str (Point (f x) y) fmapX f (
그럼 나머지 요소들에도 같은 함수들이 필요할 거라 예상할 수 있습니다.
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
값이 필요하다면
. x) atom view (point
_x
에 함수를 적용해서 수정하려면
. x) (+1) over (point
단 두 줄의 템플릿 코드로 생기는 혜택이 꽤 훌륭합니다.
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
= lens _point (\atom newPoint -> atom { _point = newPoint }) point
lens 라이브러리를 쓰지 않고, 만들면 제일 처음 봤던 코드와 비슷하게 나옵니다.
point :: Functor f => (Point -> f Point) -> Atom -> f Atom
= fmap (\newPoint -> atom { _point = newPoint }) (k (_point atom)) point k atom
x
, point
는 모두 함수입니다. point . x
같은 스타일로 쓸 수 있게 됩니다.
-- GHC가 (.) 쓰는데, Lens'을 만나면 다음처럼 추론합니다.
(.) :: Lens' a b -> Lens' b c -> Lens' a c
-- point . x
view와 같은 일을 하는 중위 연산자 ^.
를 정의해서 최대한 직관적으로 모양을 만듭니다.
. x) atom = atom^.point.x (point
여기까지만 알아도, 대부분의 렌즈 사용 코드는 이해할 수 있다고 합니다. 조금 더 들어가면
참고 - 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
= over (atoms . traverse . point . x) (+ 1) shiftMoleculeX
왜 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)
. x) :: Functor f => (Double -> f Double) -> Atom -> f Atom (point
모두 펑크터가 붙어 있긴 한데, 일단 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 -- 타입 정의가 이렇다는게 아니라 이렇게 추론된다입니다.
. x :: Lens' Atom Double point
Lens'
타입과 traverse
를 어떻게 composition할까요?
traverse :: (Traversable t, Applicative f) => (a -> f b) -> ( t a -> f (t b))
. x) :: Functor f => (Double -> f Double) -> (Atom -> f Atom) (point
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)은 칼날
(. x)는 칼날을 결합할 블렌더
(point Atom1, Atom2]는 과일 바구니
[
traverse는 작업자Atom 은 쥬스 f
목표는 아래 모양을 만드는 겁니다.
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
= at { _point = k (_point at) }
pointN k at
xN :: (Double -> Double) -> Point -> Point
= po { _x = k (_x po) } xN k 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
# f = f x
x infixl 0 #
보조 함수를 정의하고 아래와 같이 쓸 수 있습니다.
# (pointN . xN) (+1) atom
_x
값을 보고 싶다면
# _point # _x atom
아직까진 왜 펑크터가 필요한지 모르겠습니다.
map ( # (pointN . xN) (+1) ) [atom1, atom2]
역시 map으로 잘 작동합니다. 왜 펑크터가 필요한 걸까요?
위 사이트에서 답을 찾았습니다. 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
= runIdentity (lens (Identity . f) s)
over lens f s
personAddressL :: LensModify Person Address
= Identity $ person { personAddress = runIdentity $ f $ personAddress person }
personAddressL f person -- f를 먹인 후 runIdentity를 실행하는 걸로 봐서, f의 결과는 Identity 타입을 예상할 수 있습니다.
-- Identity 없이 구현했던 버전
personAddressL :: Lens Person Address
= Lens
personAddressL = personAddress
{ lensGetter = \f person -> person { personAddress = f (personAddress person)}
, lensModify }
이 경우는 크게 달라지는 건 없지만, Identity를 씌웠다 벗겼다 하기 싫으면 Functor
로 해결해도 됩니다.
그럼 코드에서 Identity를 지울 수 있습니다.
personAddressL :: LensModify Person Address
= (\address -> person { personAddress = address}) <$> f (personAddress person) personAddressL f 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
= getConst (lens s)
view lens s
personAddressL :: LensGetter Person Address
= Const (personAddress person)
personAddressL 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
= getConst (lens Const s)
view lens s -- ^^^^^
-- 값 생성자
personAddressL :: LensGetter Person Address
= Const $ getConst $ f (personAddress person)
personAddressL f 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
= (\address -> person { personAddress = address}) <$> f (personAddress person) personAddressL f person
명시적인 Const는 사라지고, LensModify
때 정의한 함수와 같아 보이지 않나요?
타입만 LensGetter
냐, LensModify
냐지 함수 본체body는 완전히 동일한 모양이 됐습니다.
personAddressL :: LensGetter Person Address
= (\address -> person { personAddress = address}) <$> f (personAddress person)
personAddressL f person
personAddressL :: LensModify Person Address
= (\address -> person { personAddress = address}) <$> f (personAddress person) personAddressL f 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
= runIdentity (lens (Identity . f) s)
over lens f s -- 위의 기나긴 여정 없이 Identity가 왜 들어갔는지 알 수 있을까요?
view :: Lens s a -> s -> a
= getConst (lens Const s)
view lens s -- 위의 기나긴 여정 없이 Const가 왜 들어갔는지 알 수 있을까요?
personAddressL :: Lens Person Address
=
personAddressL f person -> person { personAddress = address })
(\address <$> f (personAddress person)
getPersonAddress :: Person -> Address
= view personAddressL
getPersonAddress
modifyPersonAddress :: (Address -> Address) -> Person -> Person
= over personAddressL
modifyPersonAddress
setPersonAddress :: Address -> Person -> Person
= modifyPersonAddress (const address) setPersonAddress 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
렌즈의 <$>
는 over
1에서 쓰이는 실제 코드와 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
값 생성자를 넘기는 트릭을 먼저 떠올렸을까요?
실제 over
정의
over :: ASetter s t a b -> (a -> b) -> s -> t
= runIdentity #. l (Identity #. f)
over l 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
로 변환하는 함수정도로만 알고 지나 가겠습니다.
( #. )
는 함수 두개를 받는 컴포지션과 서명이 비슷한데, 받는 인자 순서가 반대입니다.↩︎