我正在尝试学习如何用纯函数语言(Haskell)构建程序的最佳实践,但我在架构方面遇到了麻烦,我发现这种体系结构是自然的,但在“纯世界”中很难复制。
让我来描述一个简单的模型案例:一个两人猜谜游戏。
博弈规则
一个在0,100中的秘密随机整数是generated.
我的问题是关于这个游戏的实现,在这个游戏中,一个人类玩家在电脑上玩。
我提议两种实现:
driven
逻辑驱动
在逻辑驱动的实现中,执行由博弈逻辑驱动.Game有一个State和一些参与的Player。Game::run函数让玩家轮流玩,并更新游戏状态直到完成。玩家在Player::play中得到一个状态,并返回他们决定要玩的动作。
我可以看到这种方法的好处是:
Player的本质:HumanPlayer和ComputerPlayer是可互换的,即使前者必须处理IO,而后者则代表纯粹的计算(您可以通过在main函数中放置Game::new(ComputerPlayer::new("CPU-1"), ComputerPlayer::new("CPU-2"))来验证这一点,并观察计算机大战)。以下是在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();
}互动驱动
在交互驱动的实现中,所有与游戏相关的功能,包括由计算机玩家做出的决定,都必须是纯功能,没有副作用。然后,HumanPlayer变成了一个界面,坐在电脑前面的真人通过这个界面与游戏进行交互。在某种意义上,游戏变成了将用户输入映射到更新状态的函数。
在我看来,这种方法似乎是被一种纯粹的语言所迫,因为游戏的所有逻辑都变成了纯粹的计算,没有副作用,只是将旧的状态转化为新的状态。
我也有点喜欢这个观点(分离input -> (state transformation) -> output):它肯定有一些优点,但是我觉得,正如在这个例子中很容易看到的,它破坏了程序的其他一些优点,比如人类播放器和计算机播放器之间的对称性。从游戏逻辑的角度来看,下一步的决定是否来自计算机执行的纯计算,还是涉及IO的用户交互并不重要。
我在这里提供了一个参考实现,同样是在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();
}结论
一种有效的语言(如Rust)为我们提供了根据优先级选择方法的灵活性:人和计算机参与者之间的对称性或纯计算(状态转换)和IO (用户交互)之间的尖锐分离。
鉴于我目前对Haskell的了解,我不能对纯粹的世界说同样的话:我感到不得不采用第二种方法,因为第一种方法到处都是IO。特别是,我想听听一些函数式编程大师关于如何在Haskell中实现逻辑驱动的方法,以及他们对这一主题的看法/评论。
我准备从中学到很多洞察力。
发布于 2019-11-28 22:57:35
我想Haskell相当于您的逻辑驱动实现(省略了错误处理)如下所示:
{-# 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")我们可以撕下头发,撕裂衣服,可怜地抱怨说,我们不得不在几个地方使用liftIOs,单台变压器堆栈会伤害我们的肚子,但在我看来,它主要像是惯用的Haskell代码,但也许使用MaybeT结束循环有点难看。(和许多应该更了解的Haskell程序员一样,我使用了mapM_ runPlayer $ cycle [p1, p2]这一令人愉快的小表达式,并编写了一个愚蠢的monad堆栈来适应它。)
我想我觉得这样的说法有点不诚实,一方面,在一种有效的语言中,比如Rust,我们可以毫不费力地混合纯代码和不纯代码,因为纯代码实际上是用一种不纯的语言编写的,但出于某种原因,如果我们试图在Haskell中编写相同的代码,我们就必须受上帝和国家的约束,在IO monad之外编写我们的纯代码,然后哀叹我们不能在那里执行IO,因此我们的实现选择突然受到限制。用Haskell或任何其他语言,在不纯的上下文中编写“纯”代码并不是一种死罪。
特别是,您可以轻松地为该实现编写一个明显的纯播放器:
purePlayer :: String -> Player
purePlayer nam = Player nam (return . pureStrategy)
where pureStrategy :: Range -> Int
pureStrategy (lo, hi) = (lo + hi) `div` 2或者,如果您是一个狂热者,您可以在基本的monad中使上面的代码多态,并将IO用于人工,而Identity用于purePlayer。塔达,你的纯玩家现在是真正的纯粹了,你可以告诉你所有愚蠢的,有效的铁锈朋友,你比他们更好的同时,吹嘘你的天才,纯粹的哈斯克尔朋友,你刚刚知道为什么他们发明了UnliftIO。
https://stackoverflow.com/questions/59092892
复制相似问题