commercialhaskell / rio

A standard library for Haskell
Other
838 stars 54 forks source link

Reduce boilerplate for Has classes #113

Open akhra opened 6 years ago

akhra commented 6 years ago

Considering the first bit of the Has-pattern example in the current docs:

class HasLogFunc env where
  logFuncL :: Lens' env LogFunc

class HasConfig env where
  configL :: Lens' env Config
instance HasConfig Config where
  configL = id

data Env = Env { envLogFunc :: !LogFunc, envConfig :: !Config }
class (HasLogFunc env, HasConfig env) => HasEnv Env where
  envL :: Lens' env Env
instance HasLogFunc Env where
  logFuncL = lens envLogFunc (\x y -> x { envLogFunc = y })
instance HasConfig Env where
  configL = lens envConfig (\x y -> x { envConfig = y })
instance HasEnv Env where
  envL = id

This seems awfully expository. Using Data.Has shaves it down to this (instance Has a a is provided):

data Env = Env { envLogFunc :: !LogFunc, envConfig :: !Config }
instance Has LogFunc Env where
  hasLens = lens envLogFunc (\x y -> x { envLogFunc = y })
instance Has Config Env where
  hasLens = lens envConfig (\x y -> x { envConfig = y })

Is there a particular reason Rio doesn't already include Has? (I can think of a few possibilities, but haven't seen any specific comments.) If so, how do we feel about adding some Template Haskell to generate the class and identity instance from a type? Then we could reduce it to something like:

makeHasClass ''LogFunc
makeHasClass ''Config

data Env = Env { envLogFunc :: !LogFunc, envConfig :: !Config }
makeHasInstance ''Env ''LogFunc
  $ lens envLogFunc (\x y -> x { envLogFunc = y })
makeHasInstance ''Env ''Config
  $ lens envConfig (\x y -> x { envConfig = y })
makeHasClass ''Env

(Edit: makeHasInstance might not be worthwhile, but makeHasClass certainly seems so in absence of generic Has.)

snoyberg commented 6 years ago

My reservation about the plain multi-param Has class is that IMO error messages and type inference are a little bit worse. Overall, I've been trying to reduce my own usage of TH in favor of explicit, boilerplate-y code instead, at least when there's no chance of the boilerplate being buggy (as seems to be the case here). I'm not opposed to adding in some convenience functions though to do this. Want to take a crack at a PR? I'd be OK with both makeHasClass and makeHasInstance, or just one of them.

akhra commented 6 years ago

I've spent a solid day on this and gotten almost nowhere. My TH-foo is not strong. Not giving up, but it may be a while before I can try again. In case someone else wants to take a crack at it, I'll share my thoughts on design. I've been aiming for:

makeHasClass :: BaseName -> DecQ
makeHasInstance :: BaseName -> InstanceName -> FieldName -> DecQ

makeHasInstance would be more versatile if it took a Lens' s a instead of a field name, as in my initial example; but I'm not sure how to constrain the s and a, since it's only names in the signature otherwise. (Proxy and extract the names later?)

Tangent: makeFieldLens would be nice on its own, and would make a lensy makeHasInstance even nicer.

A minor concern I have is that there is no configurability here; e.g. if someone doesn't want their instance members to end in L, that's just too bad. I've thought about calling these derive*, reserving make* for a snazzier future implementation that takes a HasClassRules of some sort. Not sure if it's really necessary though. Or, those could be make*With.

akhra commented 6 years ago

Not a terrible stopgap:

module Control.Lens.TH.RIO where

import           RIO
import qualified RIO.Char as C

import Control.Lens ((.~))
import Control.Lens.TH
import Language.Haskell.TH

makeRioLenses, makeRioClassy :: Name -> DecsQ
makeRioLenses = makeLensesWith rioLensRules
makeRioClassy = makeLensesWith rioClassyRules

rioLensRules, rioClassyRules :: LensRules
rioLensRules = lensRules
  & lensField .~ rioFieldNamer
rioClassyRules = classyRules
  & lensField .~ rioFieldNamer
  & lensClass .~ rioClassyNamer

rioFieldNamer :: FieldNamer
rioFieldNamer _ _ name = case nameBase name of
  x:xs -> [TopName (mkName $ C.toLower x:xs<>"L")]
  _    -> []

rioClassyNamer :: ClassyNamer
rioClassyNamer name = case nameBase name of
  n@(x:xs) -> Just (mkName $ "Has"<>n, mkName $ C.toLower x:xs<>"L")
  []       -> Nothing
akhra commented 6 years ago

Hmm, this doesn't explode:

makeRioClassOnly = makeLensesWith rioClassOnlyRules

rioClassOnlyRules = classyRules
  & lensField .~ (\_ _ _ -> [])
  & lensClass .~ rioClassyNamer

Gonna play with this a bit more. I think rioFieldNamer may be a mistake overall, since it can generate names which overlap with HasFoo typeclass members per rioClassyNamer. But makeRioClassOnly seems, at first blush, to be equivalent to makeHasClass as proposed.

JakobBruenker commented 3 years ago

It's been a while, but I stumbled across this - the rules look pretty good, but if I understand correctly one problem with them is that they don't produce the superclass constraints. Not sure if that's something that can be done with lens rules.

JakobBruenker commented 3 years ago

I made a prototype that correctly generates instances for superclasses (though it doesn't support types with type parameters at the moment).

First, a usage example

data WindowSize = MkWindowSize { windowWidth  :: !Natural
                               , windowHeight :: !Natural
                               }
makeRioClassy ''WindowSize
-- This will generate
-- class (HasWindowWidth env, HasWindowHeight env)
--    => HasWindowSize env where windowSizeL :: Lens' env WindowSize
-- class HasWindowWidth env where ... (method signatures omitted for brevity from here on out)
-- class HasWindowHeight env
-- (as well as associated `id` instances, omitted for brevity for this and the other types)

newtype Email = MkEmail { emailString :: String }
makeRioClassy ''Email
-- This will generate
-- class HasEmailString env => HasEmail env
-- class HasEmailString env

data Config = MkConfig { windowSize   :: !WindowSize
                       , verbose      :: !Bool
                       , studentEmail :: !Email
                       , teacherEmail :: !Email
                       }
makeRioClassy ''Config
-- This will *not* generate class HasWindowSize, since that class already exists
-- However, it *will* generate
-- instance HasWindowSize Config -- as well as instances for HasWindowWidth and HasWindowHeight
-- class HasVerbose env
-- class HasStudentEmail env -- since generation is name-driven it will *not* generate a HasEmail instance
-- class HasTeacherEmail env -- ditto
-- class (HasWindowSize env, HasVerbose env, HasStudentEmail env, HasTeacherEmail env)
--    => HasConfig env

data App = MkApp { config  :: !Config
                 , logFunc :: !LogFunc
                 }
makeRioClassy ''App
-- This will generate
-- class (HasConfig env, HasLogFunc env) => HasApp env
-- as well as
-- instance HasConfig App, instance HasWindowSize App, instance HasWindowWidth App, ...
-- instance HasLogFunc App

The full output and the implementation of makeRioClassy can be found in this gist.