user273132
user273132

Reputation:

Abstract over lens type to provide better read-write control of properties

I'm writing small game-like program using lens library and have the following code:

class HasHealth a where
  health :: Lens' a Int

class HasPower a where
  power :: Lens' a Int


hitEachOther :: (HasHealth a, HasPower a, HasHealth b, HasPower b) => a -> b -> (a, b)
hitEachOther first second = (firstAfterHit, secondAfterHit)
  where
    secondAfterHit = first `hit` second
    firstAfterHit = second `hit` first
    hit damageDealer unitUnderHit = health `over` subtract (view power damageDealer) $ unitUnderHit

powerUp :: HasPower a => a -> a
powerUp = power `over` (+10)

Such code allows me to use functions hitEachOther and powerUp with any game entity which is instance of HasHealth and HasPower.

The problem here is the signature of hitEachOther function, in current form it allows to write logic which can update health and also power properties of two entities coming from function arguments, while I want to make sure this function can update health only, and have power as read-only property.

Means I can write such code (note addition of power `over` (+1)):

hitEachOtherBad :: (HasHealth a, HasPower a, HasHealth b, HasPower b) => a -> b -> (a, b)
hitEachOtherBad first second = (firstAfterHit, power `over` (+1) $ secondAfterHit)
  where
    secondAfterHit = first `hit` second
    firstAfterHit = second `hit` first
    hit damageDealer unitUnderHit = health `over` subtract (view power damageDealer) $ unitUnderHit

though I want to prohibit it at compile-time.

One way to fix it is to change HasPower typeclass to

class HasPower a where
  power :: Getter a Int

and it will indeed solve the issue for hitEachOther function, but will make it impossible to write powerUp function.

I had small experience of using monad transformers and classes like MonadState s, so was thinking to try to generalize my code in the same way using multiparam typeclasses:

{-# LANGUAGE MultiParamTypeClasses #-}

class HasHealth l a where
  health :: l a Int

class HasPower l a where
  power :: l a Int

hitEachOther :: (HasHealth Lens' a, HasPower Getter a, HasHealth Lens' b, HasPower Getter b) => a -> b -> (a, b)
hitEachOther first second = (firstAfterHit, secondAfterHit)
  where
    secondAfterHit = first `hit` second
    firstAfterHit = second `hit` first
    hit damageDealer unitUnderHit = health `over` subtract (view power damageDealer) $ unitUnderHit

powerUp :: HasPower Lens' a => a -> a
powerUp = power `over` (+1)

So it will give required restrictions at compile-time and it also will be very clear from function signature that hitEachOther can modify health, but can only read from power, and same for powerUp - signature says it can update power.

But such code gives me error:

error:     
  * The type synonym Lens' should have 2 arguments, but has been given none
  * In the type signature:         
    hitEachOther :: (HasHealth Lens' a, HasPower Getter a, HasHealth Lens' b, HasPower Getter b) => a -> b -> (a, b)

Questions:

  1. Why it gives error? I may guess it is because Len's and Getter are type synonyms, not separate types.
  2. How I can update my code to achieve my goal - to have proper control of what my function can read/write at compile-time?

--

Full compilable minimal sample of original code:

{-# LANGUAGE TemplateHaskell #-}
module Main where

import Control.Lens

data Hero = Hero {_heroName :: String, _heroHealthPoints :: Int, _heroMoney :: Int, _heroPower :: Int} deriving Show
data Dragon = Dragon {_dragonHealthPoints :: Int, _dragonPower :: Int} deriving Show
makeLenses ''Hero
makeLenses ''Dragon

myHero :: Hero
myHero = Hero "Bob" 100 0 15

myDragon :: Dragon
myDragon = Dragon 300 40

main :: IO ()
main = do
  let (heroAfterFight, dragonAfterFight) = hitEachOther myHero myDragon
  let heroAfterPowerUp = powerUp heroAfterFight
  print heroAfterPowerUp
  print dragonAfterFight


class HasHealth a where
  health :: Lens' a Int

class HasPower a where
  power :: Lens' a Int

instance HasHealth Hero where
  health = heroHealthPoints

instance HasHealth Dragon where
  health = dragonHealthPoints

instance HasPower Dragon where
  power = dragonPower

instance HasPower Hero where
  power = heroPower


hitEachOther :: (HasHealth a, HasPower a, HasHealth b, HasPower b) => a -> b -> (a, b)
hitEachOther first second = (firstAfterHit, secondAfterHit)
  where
    secondAfterHit = first `hit` second
    firstAfterHit = second `hit` first
    hit damageDealer unitUnderHit = health `over` subtract (view power damageDealer) $ unitUnderHit

powerUp :: HasPower a => a -> a
powerUp = power `over` (+10)

Upvotes: 2

Views: 81

Answers (1)

Li-yao Xia
Li-yao Xia

Reputation: 33569

Lens' and Getter are type synonyms, so they must always be fully applied, whereas HasPower Lens' a requires a partial application.

Rather than parameterizing HasPower, note that you can more simply have two classes:

-- "Read-only" access to power
class HasPowerR a where
  powerR :: Getter a Int

-- Read-Write access
class HasPower a where
  power :: Lens' a Int

If you really want to avoid the duplication, one solution is to wrap the type synonym in a newtype, which can be applied (in other words, type synonyms are not as first-class as types defined with data and newtype). Remember that everytime you use this class you will have to unwrap it, making explicit whether you are using the "read-only" or the "read-write" version:

newtype R s a = Getter_ { unR :: Getter s a }  -- read-only
newtype RW s a = Lens_ { unRW :: Lens' s a }   -- read-write

class HasPower l a where
  power :: l a Int

instance HasPower R a where
  power = Getter_ (...)

instance HasPower RW a where
  power = Lens_ (...)

Note that some variant of those newtypes exist in Control.Lens.Reified, though only the 4-parameter variant for lenses.

Upvotes: 2

Related Questions