Reputation: 644
I'm trying to learn best practices on how to structure a program in a pure functional language (Haskell), but I'm having trouble with an architecture which I find natural but cannot replicate easily in the "pure world".
Let me describe a simple model case: a two-player guessing game.
Game rules
- A secret random integer number in [0, 100] is generated.
- Players take turns trying to guess the secret number.
- If the guess is correct, the player wins.
- Otherwise the game tells whether the secret number is larger or smaller than the guess and the possible range for the unknown secret is updated.
My question concerns the implementation of this game where a human player plays against the computer.
I propose two implementations:
In the logic-driven implementation, the execution is driven by the game logic. A Game
has a State
and some participating Player
s. The Game::run
function makes the players play in turns and updates the game state until completion. The players receive a state in Player::play
and return the move they decide to play.
The benefits that I can see of this approach are:
Player
: a HumanPlayer
and a ComputerPlayer
are interchangeable, even if the former has to deal with IO, whereas the latter represents a pure computation (you can verify this by putting Game::new(ComputerPlayer::new("CPU-1"), ComputerPlayer::new("CPU-2"))
in the main
function and watching the computer battle).Here is a possible implementation in Rust:
use rand::Rng;
use std::io::{self, Write};
fn min<T: Ord>(x: T, y: T) -> T { if x <= y { x } else { y } }
fn max<T: Ord>(x: T, y: T) -> T { if x >= y { x } else { y } }
struct Game<P1, P2> {
secret: i32,
state: State,
p1: P1,
p2: P2,
}
#[derive(Clone, Copy, Debug)]
struct State {
lower: i32,
upper: i32,
}
struct Move(i32);
trait Player {
fn name(&self) -> &str;
fn play(&mut self, st: State) -> Move;
}
struct HumanPlayer {
name: String,
}
struct ComputerPlayer {
name: String,
}
impl HumanPlayer {
fn new(name: &str) -> Self {
Self {
name: String::from(name),
}
}
}
impl ComputerPlayer {
fn new(name: &str) -> Self {
Self {
name: String::from(name),
}
}
}
impl Player for HumanPlayer {
fn name(&self) -> &str {
&self.name
}
fn play(&mut self, _st: State) -> Move {
let mut s = String::new();
print!("Please enter your guess: ");
let _ = io::stdout().flush();
io::stdin().read_line(&mut s).expect("Error reading input");
let guess = s.trim().parse().expect("Error parsing number");
println!("{} guessing {}", self.name, guess);
Move(guess)
}
}
impl Player for ComputerPlayer {
fn name(&self) -> &str {
&self.name
}
fn play(&mut self, st: State) -> Move {
let mut rng = rand::thread_rng();
let guess = rng.gen_range(st.lower, st.upper + 1);
println!("{} guessing {}", self.name, guess);
Move(guess)
}
}
impl<P1, P2> Game<P1, P2>
where
P1: Player,
P2: Player,
{
fn new(p1: P1, p2: P2) -> Self {
let mut rng = rand::thread_rng();
Game {
secret: rng.gen_range(0, 101),
state: State {
lower: 0,
upper: 100,
},
p1,
p2,
}
}
fn run(&mut self) {
loop {
// Player 1's turn
self.report();
let m1 = self.p1.play(self.state);
if self.update(m1) {
println!("{} wins!", self.p1.name());
break;
}
// Player 2's turn
self.report();
let m2 = self.p2.play(self.state);
if self.update(m2) {
println!("{} wins!", self.p2.name());
break;
}
}
}
fn update(&mut self, mv: Move) -> bool {
let Move(m) = mv;
if m < self.secret {
self.state.lower = max(self.state.lower, m + 1);
false
} else if m > self.secret {
self.state.upper = min(self.state.upper, m - 1);
false
} else {
true
}
}
fn report(&self) {
println!("Current state = {:?}", self.state);
}
}
fn main() {
let mut game = Game::new(HumanPlayer::new("Human"), ComputerPlayer::new("CPU"));
game.run();
}
In the interaction-driven implementation, all the functionalities related to the game, including the decisions taken by the computer players, must be pure functions without side effects. HumanPlayer
becomes then the interface through which the real person sitting in front of the computer interacts with the game. In a certain sense, the game becomes a function mapping user input to an updated state.
This is the approach that, to my eyes, seems to be forced by a pure language, because all the logic of the game becomes a pure computation, free of side effects, simply transforming an old state to a new state.
I kind of like also this point of view (separating input -> (state transformation) -> output
): it definitely has some merits, but I feel that, as can be easily seen in this example, it breaks some other good properties of the program, such as the symmetry between human player and computer player. From the point of view of the game logic, its doesn't matter whether the decision for the next move comes from a pure computation performed by the computer or a user interaction involving IO.
I provide here a reference implementation, again in Rust:
use rand::Rng;
use std::io::{self, Write};
fn min<T: Ord>(x: T, y: T) -> T { if x <= y { x } else { y } }
fn max<T: Ord>(x: T, y: T) -> T { if x >= y { x } else { y } }
struct Game {
secret: i32,
state: State,
computer: ComputerPlayer,
}
#[derive(Clone, Copy, Debug)]
struct State {
lower: i32,
upper: i32,
}
struct Move(i32);
struct HumanPlayer {
name: String,
game: Game,
}
struct ComputerPlayer {
name: String,
}
impl HumanPlayer {
fn new(name: &str, game: Game) -> Self {
Self {
name: String::from(name),
game,
}
}
fn name(&self) -> &str {
&self.name
}
fn ask_user(&self) -> Move {
let mut s = String::new();
print!("Please enter your guess: ");
let _ = io::stdout().flush();
io::stdin().read_line(&mut s).expect("Error reading input");
let guess = s.trim().parse().expect("Error parsing number");
println!("{} guessing {}", self.name, guess);
Move(guess)
}
fn process_human_player_turn(&mut self) -> bool {
self.game.report();
let m = self.ask_user();
if self.game.update(m) {
println!("{} wins!", self.name());
return false;
}
self.game.report();
let m = self.game.computer.play(self.game.state);
if self.game.update(m) {
println!("{} wins!", self.game.computer.name());
return false;
}
true
}
fn run_game(&mut self) {
while self.process_human_player_turn() {}
}
}
impl ComputerPlayer {
fn new(name: &str) -> Self {
Self {
name: String::from(name),
}
}
fn name(&self) -> &str {
&self.name
}
fn play(&mut self, st: State) -> Move {
let mut rng = rand::thread_rng();
let guess = rng.gen_range(st.lower, st.upper + 1);
println!("{} guessing {}", self.name, guess);
Move(guess)
}
}
impl Game {
fn new(computer: ComputerPlayer) -> Self {
let mut rng = rand::thread_rng();
Game {
secret: rng.gen_range(0, 101),
state: State {
lower: 0,
upper: 100,
},
computer,
}
}
fn update(&mut self, mv: Move) -> bool {
let Move(m) = mv;
if m < self.secret {
self.state.lower = max(self.state.lower, m + 1);
false
} else if m > self.secret {
self.state.upper = min(self.state.upper, m - 1);
false
} else {
true
}
}
fn report(&self) {
println!("Current state = {:?}", self.state);
}
}
fn main() {
let mut p = HumanPlayer::new("Human", Game::new(ComputerPlayer::new("CPU")));
p.run_game();
}
An effectful language (such as Rust) gives us the flexibility to choose the approach based on our priorities: either symmetry between human and computer players or sharp separation between pure computation (state transformation) and IO (user interaction).
Given my current knowledge of Haskell, I cannot say the same about the pure world: I feel forced to adopt the second approach, because the first one would be littered with IO
everywhere. In particular, I would like the hear some words from some functional programming gurus on how to implement in Haskell the logic-driven approach and what are their opinions/comments on the subject.
I'm prepared to learn a lot of insight from this.
Upvotes: 1
Views: 226
Reputation: 51109
I suppose the Haskell equivalent of your Logic-Driven implementation (with error handling omitted) is something like this:
{-# OPTIONS_GHC -Wall #-}
{-# LANGUAGE FlexibleContexts #-}
import System.Random
import Control.Monad
import Control.Monad.State
import Control.Monad.Trans.Maybe
data Game = Game Int Range Player Player
type Range = (Int, Int)
data Player = Player String (Range -> IO Int)
humanPlayer, computerPlayer :: String -> Player
humanPlayer nam = Player nam strategy
where strategy _ = do
putStrLn "Please enter your guess:"
readLn
computerPlayer nam = Player nam strategy
where strategy s = do
x <- randomRIO s
putStrLn $ nam ++ " guessing " ++ show x
return x
newGame :: Player -> Player -> IO Game
newGame p1 p2 = do
let s = (0,100)
x <- randomRIO s
return $ Game x s p1 p2
run :: Game -> IO ()
run (Game x s0 p1 p2)
= do Nothing <- runMaybeT $ evalStateT game s0
return ()
where
game = mapM_ runPlayer $ cycle [p1, p2]
runPlayer (Player nam strat) = do
s@(lo,hi) <- get
liftIO . putStrLn $ "Current state = " ++ show s
guess <- liftIO $ strat s
put =<< case compare x guess of
LT -> return (lo, guess-1)
GT -> return (guess+1, hi)
EQ -> do
liftIO . putStrLn $ nam ++ " wins!"
mzero
main :: IO ()
main = run =<< newGame (humanPlayer "Human") (computerPlayer "Computer")
We could tear our hair, rend our clothes, and moan pitifully that we had to use liftIO
s in a few places and that the monad transformer stack hurts our tummies, but it mostly looks like idiomatic Haskell code to me, except perhaps that using MaybeT to end a loop is a little ugly. (Like many Haskell programmers who should know better, I got swept up in the pleasing form of the minor expression mapM_ runPlayer $ cycle [p1, p2]
and wrote a silly monad stack to accommodate it.)
I guess I find it a little disingenuous to argue that, on the one hand, in an effectful language like Rust we can effortlessly mix pure and impure code because the pure code is actually written in an impure language, but for some reason if we try to write the same code in Haskell, we are bound by God and Country to write our pure code outside the IO monad and then bemoan the fact that we can't do IO there and so our implementation choices are suddenly limited. Writing "pure" code in an impure context isn't a capital offense, in Haskell or any other language.
In particular, you can easily write an obviously pure player for this implementation:
purePlayer :: String -> Player
purePlayer nam = Player nam (return . pureStrategy)
where pureStrategy :: Range -> Int
pureStrategy (lo, hi) = (lo + hi) `div` 2
or, if you're a zealot, you could make the above code polymorphic in the base monad, and use IO
for the human but Identity
for the purePlayer
. Ta da, your pure player is now truly pure, and you can tell all your dimwitted, effectful Rust friends that you're so much better than they are while boasting to your genius, pure Haskell friends that you just figured out why they invented UnliftIO
.
Upvotes: 0