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
expectstatements 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
usestatements - 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.