Best Practices for Rust Projects
Info
We use Rust, and in general, strongly typed languages, to make undesirable states difficult or impossible to represent.
Speed up the Inner Development Loop
Use the lld
linker for faster linking, and leverage cargo-watch
to launch tests and builds when files change, thereby improving perceived compilation time. For more details see Zero to Production: Setup - Toolchain, IDEs, CI.
Use Test Driven Development
This is absolutely crucial. See Using Tests and Mocks for a primer on Rust testing.
Make it Work, then Make it Better
Often it's best to create solutions that emphasize expressiveness, testability, and maintainability, rather than hyper-optimize early in the development, particularly when working with a team of developers. If a performance bottleneck is located, these can be fixed later.
Keep Things Modular
Modularity improves the code's readability, testability, and simplifies error handling. Keep the following points in mind when developing a program.
- Each function should deal with a single task. If a function is handling multiple responsibilities then it should likely be refactored.
- Use configuration variables to reduce the number of variables that need to be in scope.
- Handle error messages correctly the first time. Using
expect
statements is fine for prototyping, but once an implementation is chosen, handle errors explicitly so that large code overhauls are avoided. - Place error-handling code in one place (as much as possible).
Separation of Concerns for Binary Projects
The Rust community has developed standards relating to what should be included in main.rs and what should be included in lib.rs. Primarily, only command line parsing, configuration setup, and a call to a run
function in lib.rs (with associated error handling) should ever be inside the main
function. Prototype code is the obvious exception to this rule, yet, it's always a good idea to keep these practices in mind as the code is developed.
It is also best practice to group related program configuration variables into a struct Config
which is parsed in main
by calling its constructor (e.g. new
).
Note: Since the main
function isn't testable directly, removing as much logic from main
as possible improves the overall testability of the program.
Handle Potential Errors with a Result
Returning a Result
ensures that any errors possible in a function are handled when the function is called. Use ?
to propagate errors upwards, and couple this with crates like thiserror
or anyhow
for truly effective error handling.
Split Code into a Library Crate
Move everything that isn't in the main
function into src/lib.rs. This includes:
- All functions called in
main
- Relevant
use
statements - Configuration structs and method definitions
To make objects available elsewhere in the same crate, first use the pub
keyword to make them public. Anything in the src/lib.rs file will be available under the crate's name (from Cargo.toml) in src/main.rs any by the crate::<object>
module in other modules included in src/lib.rs any of the objects can be used by prefixing the crate's name, or by including a use <crate_name>::<object>;
call to bring the <object>
into scope.
Trade-Offs of Using clone
Using clone
has runtime costs. For this reason the Rust community disfavours the its use to satisfy the borrow checker. However, it isn't always a bad idea to use clone
. When the data being cloned is very small and when prototyping code it is acceptable to use clone
to help address ownership problems. With more experience, better ways of satisfying the borrow checker will become clear, at which point you can hyper-optimize the code if you please.