확장 DerivingVia

Posted on January 21, 2023

GHC 공식 문서 - 6.6.8. Deriving via
Deriving Via or, How to Turn Hand-Written Instances into an Anti-Pattern

완전히 같은 모양의 인스턴스들

아래는 GHC base 라이브러리에 있는 인스턴스입니다.

instance Monoid a => Monoid (IO a) where
  mempty = pure mempty
  mappend = liftA2 mappend

instance Monoid a => Monoid (ST s a) where
  mempty = pure mempty
  mappend = liftA2 mappend

완전히 똑같은 모양instance body을 가지고 있습니다. 뭔가 추상화할 수 있을 것처럼 보입니다. 이런 모양은 IOST s 뿐만 아니라, applicative f면 다 가능합니다. 다음과 같이 특정 제약을 만족하는 경우에 대한 인스턴스를 만들면 한 번에 해결될 것처럼 보입니다. 아래는 Applicative functor, Monoid를 만족하는 모든 f에 대한 인스턴스입니다.

instance (Applicative f, Monoid a) -- (가) 제약을 만족하는 모든 f a 를 위한
  => Monoid (f a) where
  mempty = pure mempty
  mappend = liftA2 mappend

Instance Resolution

이렇게 하면 추상화가 될 것 같은데, 문제가 있습니다. 이러면 Applicative f, Monoid a 제약이 있는 것이 아니라 모든 (f a) 인스턴스를 덮어 씌우게 됩니다. 이렇게 되는 이유는, 인스턴스 레졸루션resolution은 제약context을 보기 전에 head와 일치하는지 먼저 보기 때문에, fapplicative이든 아니든 간에 모두 매치돼버립니다. 일단, head가 매치되면 더 이상 살펴보지 않습니다. It will nerver backtrack.

예를 들면, 아래 Endo타입은 Applicative 인스턴스가 아니고, 별도의 Monoid 인스턴스가 있음에도, 위 General 인스턴스에 걸려듭니다.

newtype Endo a = MkEndo (a -> a)

instance Monoid (Endo a) where
  mempty = MkEndo id
  mappend (MkEndo f) (MkEndo g) = MkEndo (f . g)

또는, Applicative f이지만, 인스턴스가 위의 모양이 아닐 때도 어찌할 방법이 없습니다. 또는, 안에 있는 aMonoid가 아니더라도, 리스트 []의 경우도 f a이고 free Monoid로 별도의 인스턴스가 있지만, 위 인스턴스에 걸리게 됩니다.

사실, 리스트 Monoid 인스턴스는 Alternative 클래스 제약을 건 인스턴스에 걸려captured듭니다.

instance Alternative f -- (나)
  => Monoid (f a) where 
  mempty = empty
  mappend = (<|>)

인스턴스 레졸루션은 한 번 매칭되면 더 이상 찾질 않습니다. 아마도 이 걸, 매칭 됐던 부분으로 돌아가 다른 선택지가 있는 지 보지 않는다 해서 backtrack이 없다고 표현하는 것 같습니다. 그래서, (가)(나)를 구별해서 만들 방법도 없습니다.

※ resolution: a breaking or reducing into parts; process of breaking up, dissolution
process of reduction things into simpler form
Online Etymology Dictionary - resolution

영단어 뜻은 이렇긴 한데, 적절한 인스턴스를 찾는 작업을 resolution이라 합니다.

liftA, liftA2

어떤 펑크터가 안에 Num 클래스 인스턴스를 가지고 있다면, 이 펑크터 타입의 Num 인스턴스는 다음처럼 수작업으로 만들 수 있습니다.

instance (Applicative f, Num a) => Num (f a) where
  (+) = liftA2 (+)
  (-) = liftA2 (-)
  (∗) = liftA2 (∗)
  negate = liftA negate
  abs = liftA abs
  signum = liftA signum
  fromInteger = pure . fromInteger

지루하게, liftA, liftA2등을 이용해 리프팅 작업을 해야 합니다.

Deriving Via

제약을 이용해서 Generic하게 인스턴스를 정의하면, 필요 이상으로 덮어 씌우던 문제를, adapter로 newtype을 정의하고, 이 newtype으로 인스턴스 head를 감싸 해결할 수 있습니다.

인스턴스 헤드를 newtype으로 한 번 래핑해서, 해당 타입만을 위한 인스턴스를 General하게 만들면 다음처럼 됩니다.

newtype Ap f a = Ap (f a)

instance (Applicative f, Monoid a)
  => Monoid (Ap f a) where
  mempty = Ap (pure mempty)
  mappend (Ap f) (Ap g) = Ap (liftA2 mappend f g)

instance (Applicative f, Semigroup a)
  => Semigroup (Ap f a) where
  Ap f <> Ap g = Ap (liftA2 (<>) f g)

이제 새로운 규칙을 GHC에게 알려주기 위해 다음처럼 새로운 구문 via를 덧 붙입니다.

data Maybe a = Nothing | Just a
  deriving Monoid via (Ap Maybe a)

MaybeMonoid인스턴스를 자동으로 deriving하는데, Ap Maybe aMonoid인스턴스를 보고via 베껴 오라는 말입니다.

기능만 설명하면, “이미 존재하는 인스턴스를 참조, 복사해서 새 인스턴스를 만들 수 있다”고, 용도까지 같이 언급하면,
derivingVia는 General하게 인스턴스를 정의하면 제약에 관계없이 모두 다 덮어 씌워버리던 문제를,
1. Adapter로 쓸 newtype을 만들고, 이 타입의 인스턴스를 general한 모양으로 만들고,
2. via 문법으로 이 Adapter의 인스턴스를 복사해서 만들어라
라고 GHC에게 알려주는 확장입니다.

이렇게 작동하는 이유는 간단합니다.
Ap Maybe aMaybe a와 같은 internal representation을 갖고 있습니다. 그래서, 둘 중 하나에 정의된 인스턴스는 쉽게 다른 쪽에도 적용되게 만들 수 있습니다. 문서에서는 more precise language에선, 아예 둘이 representationally 동등이라 하는데, 하스켈이 여기에 속하는 것 같은데, 정확한 언급은 아직 못찾았습니다.

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