Applicative cubes: back to AoC 2023 day 2

A comment on my solution to day 2 suggested that the Applicative typeclass would let me better express my solution to the problem. I've not really defined my own Applicative much, so I thought I'd give it a try. (You'll need to read the Day 2 solution to make much sense of the detail of this problem.)

The typeclasses

I start by making the Revelation type polymorphic, and deriving Functor and Foldable instances for it. The Applicative instance is nothing unusual: applying a Revelation of functions to a Revelation of values applies the functions element-wise.

data Revelation a = Revelation a a a 
  deriving (Show, Functor, Foldable)
  
instance Applicative Revelation where
  pure n = Revelation n n n
  (Revelation rf gf bf) <*> (Revelation r g b) = 
    Revelation (rf r) (gf g) (bf b)

Next comes the definition of Monoid for the Revelation, which relies on Monoid being defined for the contained type. Again, nothing special: combining two Revelations combines them element-wise.

instance (Semigroup a) => Semigroup (Revelation a) where
  rev1 <> rev2 = liftA2 (<>) rev1 rev2

instance (Monoid a) => Monoid (Revelation a) where
  mempty = Revelation mempty mempty mempty

How they're used

The polymorphism of Revelation allows me to take advantage of all the pre-defined Monoids in Data.Monoid, such as Max and Sum. That makes the conversion of Cube to Revelation a little different: more noise from the conversion to and from the Sum monoid, but no need for a newtype. (I need the fmap (fmap Sum) to apply the Sum to each element of each Revelation in the list.)

revealify :: Showings -> Revelation Int
revealify = fmap getSum . mconcat . fmap (fmap Sum) . fmap reveal

reveal :: Cube -> Revelation Int
reveal (Cube Red   n) = Revelation n 0 0
reveal (Cube Green n) = Revelation 0 n 0
reveal (Cube Blue  n) = Revelation 0 0 n

I can define required in a similar way: the minimal requirement is the maximum of each element of the Revelation:

required :: Game -> Revelation Int
required = fmap getMax . mconcat . fmap (fmap Max) . getRevelations

I can use that to direcly define possible without needing to define compatibleWith. A Revelation is possible if the elements are no greater than the limit. required find the requirements for this game, and liftA2 (>=) means the comparison (between the limit and this revelation) is done element-wise. Because Revelation is Foldable, I can combine those three results into one. There are two definitions here, one using the all function and one using the All monoid; pick which one you like.

possible :: Revelation Int -> Game -> Bool
-- possible limit = all id . liftA2 (>=) limit . required
possible limit = getAll . fold . fmap All . liftA2 (>=) limit . required

Finally, I use Foldable in concert with the Product monoid to find the product of the elements of a Revelation.

power :: Revelation Int -> Int
power = getProduct . fold . fmap Product

Conclusion

Was this effort worth it? As a learning exercise, yes: I got a bit more practice with Applicative and Monoid. As a software development technique, I'm far from certain. This version is certainly more flexible than the original, but I don't think this problem really requires it. The noise of coercing values into and out of different monoids doesn't make for the tersest implementation.

Code

You can get the code from my locally-hosted Git repo, or from Gitlab, in advent02/MainApplicative.hs.