Associate a function with a type in Haskell
Asked Answered
E

1

14

Suppose you have a serializer/deserializer type class

class SerDes a where
    ser :: a -> ByteString
    des :: ByteString -> a

and it turns out that it's crucial to have a special helper function for each type a, e.g.

compress :: ByteString -> ByteString     -- actually varies with the original type

I see compress as a function that I would like to associate with each a that is a SerDes. (The word "associate" is probably a bad choice, and the reason why internet searches yield nothing.)

The example is not as contrived as it looks, for example when decompress is an optional feature of the serializer/deserializer. (Yes, the helper could be avoided by augmenting ser with a switch that controls the compression, ser:: a -> Bool -> ByteString, or better use a Config record. But let's stick with the example.)

One way to do this is a 'dummy' class, a singleton:

data For a = For

Then this will work:

class SerDes a where
    ser      :: a -> ByteString
    des      :: ByteString -> a
    compress :: For a -> ByteString -> ByteString

and a compress for a would be instantiated as

compress (For :: For MyType) input = ...

Another way, somewhat unusual, would be to stick all the functions in a record.

data SerDes a = SerDes { ser      :: a -> ByteString
                       , des      :: ByteString -> a
                       , compress :: ByteString -> ByteString 
                       }

Are there any other ways to "associate" the compress function with the type a?

Egger answered 31/8, 2020 at 8:22 Comment(0)
L
19

Your For a type is known as Proxy a in the libraries.

import Data.Proxy

class SerDes a where
    ser      :: a -> ByteString
    des      :: ByteString -> a
    compress :: Proxy a -> ByteString -> ByteString

Sometimes this is generalized to a generic proxy type variable.

class SerDes a where
    ser      :: a -> ByteString
    des      :: ByteString -> a
    compress :: proxy a -> ByteString -> ByteString

There is another option, similar to proxies. Instead of forcibly adding a to the arguments, one can add a to the result type using Tagged:

import Data.Tagged

class SerDes a where
    ser      :: a -> ByteString
    des      :: ByteString -> a
    compress :: ByteString -> Tagged a ByteString

This needs to be used as unTagged (compress someByteString :: Tagged T ByteString) to tell the compiler we want the compress function for T.


Personally, I'm not a fan of proxies and tags. They were needed in the past when GHC did not allow another simpler solution, but right now they should no longer be used.

The modern approach is to turn on the harmless extensions AllowAmbiguousTypes and TypeApplications and simply write your wanted class

class SerDes a where
    ser      :: a -> ByteString
    des      :: ByteString -> a
    compress :: ByteString -> ByteString

In this approach, instead of calling compress (Proxy :: Proxy T) someByteString we will need to use the shorter compress @T someByteString where we explicitly "pass the type a we want" (T in this case), so to select the wanted compress.

Full example:

{-# LANGUAGE AllowAmbiguousTypes, TypeApplications, OverloadedStrings #-}

import Data.ByteString as BS

class SerDes a where
    ser      :: a -> ByteString
    des      :: ByteString -> a
    compress :: ByteString -> ByteString

-- bogus implementation to show everything type checks
instance SerDes Int where
   ser _ = "int"
   des _ = 42
   compress bs = BS.tail bs

-- bogus implementation to show everything type checks
instance SerDes Bool where
   ser _ = "bool"
   des _ = True
   compress bs = bs <> bs

main :: IO ()
main = BS.putStrLn (compress @Int "hello" <> compress @Bool "world")
-- output: elloworldworld
Lezlie answered 31/8, 2020 at 8:31 Comment(10)
“Personally, I'm not a fan of proxies and tags” – me neither, and I don't really see why you start the answer with them in the first place. I think most would now agree that TypeApplications are the way to do this sort of thing in modern Haskell, whereas proxies / tagged values are merely of historical interest. (In fact Tagged is probably just obselete, it never was very popular; I used to prefer it over proxies while TypeApplications weren't available, but now I'd never use it anymore. Proxies can be combined with TypeApplications better, simply passing (Proxy @a).)Marlite
@Marlite Interesting. In the past, I thought my preference was a minority one, and that many people still preferred to use proxies. If that changed, I'm glad to hear it :). Anyway, since the OP reinvented proxies with For a, I think it's appropriate to start with that. I will edit the last section so to stress that it's the modern solution.Lezlie
Well, proxies still are used a lot because of legacy libraries, but I hope this will wane in the future.Marlite
I believe there are still cases where you must use a Proxy argument instead of a type application, no? The latter is definitely preferable unless you actually run into such cases, or unless you need to support older compiler versions.Bonni
@JonPurdy I seem to remember those cases existed, but can't recall any concrete one. Is that still the case nowadays?Lezlie
@Lezlie Consider data IsCons (xs :: [a]) where IsCons :: IsCons (x : xs). For f :: forall xs. IsCons xs -> Blah, you need to do type family UnCons1 (xs :: [a]) :: a where { UnCons1 (x : _) = x }; type family UnCons2 (xs :: [a]) :: [a] where { UnCons2 (_ : xs) = xs }; f IsCons = _blah @(UnCons1 xs) @(UnCons2 xs). Compare data IsCons (xs :: [a]) where IsCons :: Proxy# x -> Proxy# xs -> IsCons (x : xs). Also, higher rank ambiguous contexts like type family F (x :: Type) :: Type; f :: (forall a. F a -> F a) -> () are pure pain, but f :: (forall a. Proxy# a -> F a -> F a) -> () works.Glennisglennon
Yeah, I was vaguely recalling some issue I had with an ambiguous higher-rank type as well, where I had to add a proxy to satisfy the typechecker. The reflection package also depends on the presence of a Proxy argument for class Reifies and reify, first in order to help get away with not actually requiring any instances for Reifies, but also to allow for multiple values—the behaviour is undefined for class Given, which is the equivalent without the Proxy, if multiple instances are in scope.Bonni
@JonPurdy I think you do need Proxy when a function is not member of a type class, e.g. when des is used and the result is consumed and not returned.Egger
@Glennisglennon I see that f IsCons is more convenient to use with proxies since we do not have a nice way to "pattern match" on types, so we can not easily access the head & tail of the type list there. Technically, proxies are not needed, but they are more convenient there. OTOH, I can't understand the pain in f :: (forall a. F a -> F a) -> () since one could use something like f g = undefined (g @Int (undefined :: F Int)) to call g, and proxies don't make this easier, AFAICS.Lezlie
@Lezlie I meant actually calling f. If you try to write f (\x -> _) you have nothing that can name a in _. Honestly, I don't remember finding any way to refer to a inside the lambda in this context. This becomes highly annoying in when working with arbitrary type functions, e.g. through singleton's defunctionalization approach: data HList (f :: a ~> Type) (xs :: [a]) :: Type where { .. }; mapH :: (forall a. f @@ a -> g @@ a) -> HList f xs -> HList g xs. Though in this case, we can set either f, g = TyCon1 Blah instead of Proxy#, but that kind of defeats the point...Glennisglennon

© 2022 - 2024 — McMap. All rights reserved.