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:
- logic driven
- interaction driven
Logic-driven
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:
- the architecture is very clean and natural;
- it abstracts the nature of a
Player
: aHumanPlayer
and aComputerPlayer
are interchangeable, even if the former has to deal with IO, whereas the latter represents a pure computation (you can verify this by puttingGame::new(ComputerPlayer::new("CPU-1"), ComputerPlayer::new("CPU-2"))
in themain
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();
}
Interaction-driven
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();
}
Conclusion
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.