I'm writing a simulation of a card game in Haskell wih multiple AI opponents.
I'd like to have a main function with someting like GameParameters -> [AIPlayer] -> GameOutcome
. But I'd like to think of the main function as a "library-function" so I can write a new AIPlayer with altering anything else.
So I thought of creating a typeclass AIPlayer, so the main function becomes AIPlayer a => GameParameters -> [a] -> GameOutcome
. But this only allows for one type of AI to be inserted. So to insert multiple AIPlayers in one game, I need to define a wrappertype
AIWrapper = P1 AIPlayer1 | P2 AIPlayer2 | ...
instance AIWrapper AIPlayer where
gameOperation (P1 x) = gameOperation x
gameOperation (P2 x) = gameOperation x
...
I don't feel happy with this wrapper type and I feel like there must be something better than this, or am I wrong?
It sounds like you might be on your way to what Luke Palmer termed the existential type class antipattern. First, let's suppose that you have a couple of AI-playing data types:
data AIPlayerGreedy = AIPlayerGreedy { gName :: String, gFactor :: Double }
data AIPlayerRandom = AIPlayerRandom { rName :: String, rSeed :: Int }
Now, you want to work with these by making them both instances of a type class:
class AIPlayer a where
name :: a -> String
makeMove :: a -> GameState -> GameState
learn :: a -> GameState -> a
instance AIPlayer AIPlayerGreedy where
name ai = gName ai
makeMove ai gs = makeMoveGreedy (gFactor ai) gs
learn ai _ = ai
instance AIPlayer AIPlayerRandom where
name ai = rName ai
makeMove ai gs = makeMoveRandom (rSeed ai) gs
learn ai gs = ai{rSeed = updateSeed (rSeed ai) gs}
This will work if you only have one such value, but can cause you to run into problems, as you noticed. What does the type class buy you, though? In your example, you want to treat a collection of different instances of AIPlayer
uniformly. Since you don't know which specific types will be in the collection, you'll never be able to call anything like gFactor
or rSeed
; you'll only ever be able to use the methods provided by AIPlayer
. So all you need is a collection of those functions, and we can package those up in a plain old data type:
data AIPlayer = AIPlayer { name :: String
, makeMove :: GameState -> GameState
, learn :: GameState -> AIPlayer }
greedy :: String -> Double -> AIPlayer
greedy name factor = player
where player = AIPlayer { name = name
, makeMove = makeMoveGreedy factor
, learn = const player }
random :: String -> Int -> AIPlayer
random name seed = player
where player = AIPlayer { name = name
, makeMove = makeMoveRandom seed
, learn = random name . updateSeed seed }
An AIPlayer
, then, is a collection of know-how: its name, how to make a move, and how to learn and produce a new AI player. Your data types and their instances simply become functions which produce AIPlayer
s; you can easily put everything in a list, as [greedy "Alice" 0.5, random "Bob" 42]
is well-typed: it's of type [AIPlayer]
.
You can, it's true, package up your first case with an existential type:
{-# LANGUAGE ExistentialQuantification #-}
data AIWrapper = forall a. AIPlayer a => AIWrapper a
instance AIWrapper a where
makeMove (AIWrapper ai) gs = makeMove ai gs
learn (AIWrapper ai) gs = AIWrapper $ learn ai gs
name (AIWrapper ai) = name ai
Now, [AIWrapper $ AIPlayerGreedy "Alice" 0.5, AIWrapper $ AIPlayerRandom "Bob" 42]
is well-typed: it's of type [AIWrapper]
. But as Luke Palmer's post above observes, this doesn't actually buy you anything, and in fact makes your life more complicated. Since it's equivalent to the simpler, no–type-class case, there's no advantage; existentials are only necessary if the structure you're wrapping up is more complicated.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With