Rust from Basics to Mastery — Part 10
Cleaning up your Rust design with safe boundaries and tests
The articles written on this blog were created with the goal of teaching. Also, the information in the article may be incorrect, so please be careful.
Using Rust well doesn't involve complicated use of all lifespans and smart pointers. It's about keeping ownership boundaries, errors, concurrent operations, and unsafe areas small and making behavior testable.
Determining the owner of a state
struct GameState {
players: Vec<Player>,
monsters: Vec<Monster>,
}
impl GameState {
fn attack(&mut self, player_index: usize, monster_index: usize) -> bool {
let Some(player) = self.players.get(player_index) else {
return false;
};
let Some(monster) = self.monsters.get_mut(monster_index) else {
return false;
};
monster.take_damage(player.attack);
true
}
}
GameState owns the combat state and the method borrows only the fields it needs briefly. Reducing global mutable state also simplifies reference lifetime and concurrency.
Put external functions after trait
trait SaveStore {
fn save(&self, state: &GameState) -> Result<(), SaveError>;
fn load(&self) -> Result<Option<GameState>, SaveError>;
}
fn save_game(store: &impl SaveStore, state: &GameState) -> Result<(), SaveError> {
store.save(state)
}
File and network implementation details are not included in the rule code. In tests, you can implement memory stores to create failures and successes as desired.
Narrowing the boundaries of unsafe
fn value_at(values: &[i32], index: usize) -> Option<i32> {
values.get(index).copied()
}
There is no reason to use unsafe if it can be solved with a safe API. If unsafe is necessary due to FFI or low-level optimization, it should be hidden in a small function, and the conditions that the caller must meet should be guaranteed with safe types and checks.
unsafe is not a function that turns off all compiler checks. This is an indication that the developer will directly prove some responsibilities, such as pointer dereference.
Testing the contract
#[test]
fn invalid_target_does_not_change_state() {
let mut state = sample_state();
let before = state.monsters[0].health;
assert!(!state.attack(0, 99));
assert_eq!(state.monsters[0].health, before);
}
Tests check external contracts, such as whether state is maintained when they fail, rather than the internal implementation. The strength of Rust is not that it is absolutely error-free, but that it reduces many incorrect states before execution with type and compilation checks.