user1502040
user1502040

Reputation: 461

Proposed deriving mechanism for Haskell

I am sorry if this question seems ill thought-out, but I was wondering if it would be possible to define a consistent semantics for something like the following in Haskell:

derive Num String from Int where
    get = read
    set = show

derive Ord Bool from Integer where
    get = fromEnum
    set = toEnum

derive (Monad, Functor) Maybe from [] where
    get (Just x) = [x]
    get Nothing  = [ ]
    set [x]      = Just x
    set [ ]      = Nothing

I see no reason why not, and it seems like it would cut down on boilerplate in some situations but I don't know if (and if so, how easily) this could be implemented.

Edit:

My intention would be for e.g. the first example to be replaced with something like this:

instance Num String where
    get = read :: String -> Int
    set = show :: Int    -> String
    a + b = set $ get a + get b
    a * b = set $ get a * get b
    ...

Upvotes: 1

Views: 163

Answers (1)

J. Abrahamson
J. Abrahamson

Reputation: 74384

What you've described here is essentially defining class instances by isomorphism. This can obviously be achieved by defining the get and set functions, though let's call them to and fro for a little more clarity.

toSI   :: String -> Integer
fromSI :: Integer -> String

instance Num String where
  a + b = fromSI $ (toSI a) + (toSI b)
  abs   = fromSI . abs . toSI
  ...

These are indeed pretty easy definitions to write and there could be some value for reducing boilerplate by lifting class instantiation over (to, fro) automatically, but this system must carefully follow many rules in order to not pollute the global typeclass instance space with junk.

In particular, we might ask (to, fro) to form an isomorphism. This means that roundtrips in both directions are identities. Stated differently it means that given any integer n, I should not be able to distinguish (froSI (toSI n)) from n by any means (well, some things like computational speed are ignored). Furthermore, I must have the same property for any string s: toSI (froSI s) must be indistinguishable from n.

That second one clearly fails as "I am not a number!" throws an error on that roundtrip. There are many reasons why throwing errors in pure code is dangerous and with typeclasses that danger would carry on and pollute the code of anyone who ever imports your code.

You might point out that this only comes about because not all strings are valid numbers. It seems like fromSI . toSI is always trouble, but toSI . fromSI ought to work. Maybe it only affects things like instantiating instance Num String and if we instead used our (toSI, froSI) pair to derive some instance for Integer that String has would we be in a good position. Maybe.

Let's try it. String is an instance of Monoid which looks like this

instance Monoid [a] where       -- if a ~ Char then this is String
  mempty = []
  mappend as bs = as ++ bs

If we implemented mappend via mappend = toSI . mappend . fromSI it gives us "concatenating integers" like

(1 <> 2) <> 3   ==   123
1 <> (2 <> 3)   ==   123
(0 <> 1) <> 1   ==   11
0 <> (1 <> 1)   ==   11

and if we're careful to define "" -> 0 instead of making it fail then we can get a useful mempty too.

mempty = toSI mempty

which seems like it ought to work well. It's truly a Monoid of Integer inherited from its "one-way isomorphism" with String (think about why I have to use Integer here, not Int). More specifically, we can't distinguish toSI (fromSI n) from n by any test built from functions in the Monoid typeclass, so it's "good enough" to make this mapping.

But then we run smack into another problem. Integer already has a Monoid instance. It already has about 10 of them, the most popular being multiplication and addition

instance Monoid Integer            instance Monoid Integer
  mempty  = 0                        mempty  = 1
  mappend = (+)                      mappend = (*)

So, we're losing a lot of information by picking any one of these instances to be the canonical typeclass instance for Monoid. It's more accurate to state that Integer becomes a Monoid via its "one-way isomorphism" (a.k.a. "retract") with String but also via its stripping to just have addition but also via its stripping to just have multiplication.

Really we want to keep that information around which is why the Monoid package defines things like Sum and Product which indicate that this Integer has been specialized to just use its Addition properties.

And that's, at the end of the day, exactly the problem you have with lifting typeclass instances over isomorphisms at large. Usually, types have a lot of isomorphisms and retracts that can be abused in this way and it's hard to have truly canonical, law-abiding instances. When you find one, it's usually worth the code tax to write it out explicitly, even if you end up using an isomorphism to do so.

When there isn't a canonical choice you have instruments like newtype and a slew of libraries for quickly accessing what's just "underneath" your newtype layer all the way from the common GeneralizedNewtypeDeriving extension out to the Control.Newtype package or the directly inspired Iso, au and auf and entire Wrapped, ala, alaf mechanisms of lens.

Essentially this machinery is all in place to make it easier to talk richly about the instances inherited over various isomorphisms, especially those induced by newtype.

Upvotes: 4

Related Questions