`try_trait` and board games
It’s covered in one of the first lessons in the Rust book, which includes many examples for how it can be used.
In Rust, ?
is a special operator that is commonly used to signal an early return if an error is encountered.
It’s a killer feature of Rust because of how much it improves readability of code, allowing us to write concise code while handling errors.
The original implementation of the ?
operator was syntactic sugar for writing
match <expr> {
Ok(val) => val,
Err(err) => return Err(From::from(err)),
}
as <expr>?
.
You might recognize this as shorthand for writing the Go equivalent for error handling
val, err := <expr>
if err != nil {
return nil, err
}
There’s a proposal to address error handling boilerplate in Go 2.
Using ?
in Rust code is great for removing a lot of error handling cruft that might be present in Go code.
But the question mark operator isn’t limited to Result
s:
try_trait
is a feature that allows Rust programmers to use the ?
operator on any types implementing the Try
trait,
so that they can use ?
for any short-circuiting logic.
Short-circuiting
There’s a whole suite of higher-order functions under Iterator that demonstrate short-circuiting logic:
try_find
, try_fold
, and try_for_each
.
Each of these try-flavored methods takes in a function that returns something of the type Try
and applies that function to the elements in the iterator,
stopping once the function fails.
For example, here’s how try_for_each
behaves:
let mut it = [1, 2, -3, 4, -5].iter();
let result = it.try_for_each(|&i| {
if i < 0 {
Err(format!("Value {} is negative.", i))
} else {
Ok(())
}
});
assert_eq!(result, Err("Value -3 is negative.".to_string()));
assert_eq!(it.next(), Some(&4));
try_for_each
attempts to apply the closure on each element.
As long as the closure returns Ok
, iteration continues as normal, but upon reaching an Err
, the iteration short-circuits and returns that result.
As we can see in the example, the remaining elements are still available after the call short-ciruits.
Result
is probably the most common return type used when applying these iterator functions.
But the type signature of the closure passed into try_for_each
is FnMut(Self::Item) -> impl Try<Ok = ()>
,
and it turns out Option
implements Try
as well.
We can modify the closure in the above example to return an Option
instead, and it behaves in a similar way,
with Some
acting as an analogue for Ok
and None
for Err
:
let mut it = [1, 2, -3, 4, -5].iter();
let option = it.try_for_each(|&i| {
if i < 0 {
None
} else {
Some(())
}
});
assert_eq!(option, None);
assert_eq!(it.next(), Some(&4));
Since Option
also implements Try
, we can use ?
to write code
that propagates Option
s instead of Result
s:
fn bar() -> Option<i32> {
Some(3)
}
fn baz() -> Option<i32> {
None
}
fn foo() -> Option<i32> {
let i = bar()?;
let j = baz()?; // Early return here.
i + j
}
Tic-tac-toe
Rust’s algebraic data types make the language a great choice for describing board game state. If we’re implementing tic-tac-toe, we might come up with the following data structures to encode the state of the board:
enum Player {
X,
O,
}
struct State {
board: [Option<Player>; 9],
}
And then to manage the progression of the game, we can create a wrapper around the board to manage game state as it transitions between different phases.
enum Phase {
InProgress(State),
Win(State, Player),
Tie(State),
}
fn transition(mut state: State, input: &Input) -> Phase {
state.apply_input(input);
if let Some(winner) = state.winner() {
Phase::Win(state, winner)
} else if state.is_tie() {
Phase::Tie(state)
} else {
Phase::InProgress(state)
}
}
With these data structures in place, we can write a really pretty game loop that looks something along the lines of
let mut phase = Phase::InProgress(State::initial());
while let Phase::InProgress(state) = phase {
let input = read_input();
phase = transition(state, &input);
}
println!("Game over!\n{}", phase);
One thing we notice about our implementation is that there’s a lot of short-circuiting logic:
we short-circuit in transition
if we notice the game is over (someone has won or there’s a tie),
and the main loop breaks once the game is no longer in progress.
This sounds like the perfect use case for try_trait
.
If we draw analogues between Phase
and Result
, we see that Phase::InProgress
and Result::Ok
are both values that indicate whether execution should continue, while Phase::Win
, Phase::Tie
,
and Result::Err
indicate that execution should halt.
The target behavior we’d want looks like this:
fn test_result() -> Result<u32, &'static str> {
Ok(4)?;
Err("This value is returned.")?;
Ok(3)
}
fn test_phase() -> Phase {
Phase::InProgress(State { ... })?;
Phase::Win(State { ... }, Player::X)?; // This value is returned.
Phase::Tie(State { ... })
}
With this in mind, we can implement Try
for Phase
like so:
Implementing Try
essentially amounts to writing converters between your type and Result
.
impl Try for Phase {
type Ok = State;
type Error = (State, Option<Player>);
fn into_result(self) -> Result<Self::Ok, Self::Error> {
match self {
Phase::InProgress(state) => Ok(state),
Phase::Win(state, winner) => Err((state, Some(winner))),
Phase::Tie(state) => Err((state, None)),
}
}
// Indicate short-circuiting behavior.
fn from_error((state, player): Self::Error) -> Self {
match player {
None => Phase::Tie(state),
Some(winner) => Phase::Win(state, winner),
}
}
// Indicate resuming behavior.
fn from_ok(v: Self::Ok) -> Self {
Phase::InProgress(v)
}
}
This is what ?
now desugars into.
Now that we’ve enabled the ?
operator on Phase
, we can go back and rewrite our transition logic:
fn check_win(state: State) -> Phase {
if let Some(winner) = state.winner() {
Phase::Win(state, winner)
} else {
Phase::InProgress(state)
}
}
fn check_tie(state: State) -> Phase {
if state.is_tie() {
Phase::Tie(state)
} else {
Phase::InProgress(state)
}
}
fn transition(mut state: State, input: &Input) -> Phase {
state.apply_input(input);
state = check_win(state)?;
state = check_tie(state)?;
Phase::InProgress(state)
}
For tic-tac-toe we only need to check two game-ending conditions,
but for complicated games with more game-ending conditions,
?
can remove a lot of boilerplate for handling those checks.
Applying try_fold
If we try to generalize playing tic-tac-toe, we’re really just taking an initial state (an empty board)
and applying a sequence of modifications to it (placing marks in cells) until we obtain a final state.
This procedure is equivalent to taking a fold
over the sequence of Input
s, with a seed value of the initial
state State::initial()
and a combining function transition
.
Conveniently, we also need to stop execution as soon as we detect that the game is over,
which is behavior that try_fold
offers.
This observation makes writing unit tests for this game a joy:
let inputs = [
Input { position: 0, player: Player::X, },
Input { position: 8, player: Player::O, },
Input { position: 1, player: Player::X, },
Input { position: 7, player: Player::O, },
Input { position: 2, player: Player::X, },
// X has won at this point, so no more inputs are processed.
Input { position: 6, player: Player::O, },
];
let end = inputs.iter().try_fold(State::initial(), State::transition);
assert_eq!(
end,
Phase::Win(
State {
board: [
Some(Player::X), Some(Player::X), Some(Player::X),
None, None, None,
None, Some(Player::O), Some(Player::O),
]
},
Player::X
)
);
A complete implementation of tic-tac-toe using this pattern is available at https://github.com/mingyli/tictactoe-rs.