I've been writing large Rust projects for a while now and found that, while useful and sometimes unavoidable, conditional compilation tends to have a pretty big impact on your crate's readability and long term maintainability.
How do you tend to handle this complexity?
To make things a bit more concrete, let's look at some example use cases.
Optional code within a function
One example I found was where you might want to add some extra code to your function.
Imagine you're implementing a file parser which may include a cryptographic signature. Crypto tends to add a fair amount of dependencies that often have their own quirks[1], so you might want only validate signatures when the crypto
feature is enabled (e.g. because most of the time you know things come from a trusted source.
const SIGNATURE_LENGTH: usize = 32;
struct Document {
signature: [u8; SIGNATURE_LENGTH],
// other stuff
}
fn deserialize(data: &[u8]) -> Result<Document, Error> {
anyhow::ensure!(data.len() >= SIGNATURE_LENGTH);
let (signature, content) = data.split_at(SIGNATURE_LENGTH);
#[cfg(feature = "crypto")]
{
verify_signature(signature, content)?;
}
Ok(Document {
signature: signature.try_into().expect("Length already checked"),
...
})
}
How do you feel about that #[cfg(feature = "crypto")]
? Whenever I see it, I'm reminded of C projects where you'll see large swathes of a function wrapped in (potentially nested) #ifdef
s and the overall control flow is hard to follow.
Optional Fields
Another example of conditional compilation is when you might want a struct to have a particular field only under certain circumstances.
struct Foo {
#[cfg(linux)]
fd: std::os::unix::io::OwnedFd,
}
Something very similar came up in a recent thread:
Swapping out entire implementations
Sometimes you will want to switch between implemetations of something depending on a condition. For example, you might want to mock out a type during testing or you are implementing a cross-platform wrapper like std::fs::File
.
I can't remember who said it (maybe Matklad?), but the idea is that you want to avoid conditional compilation as much as possible because conditional compilation has a tendency to be viral and you end up having a combinatorial explosion of CI jobs if you want to make sure all your code is exercised and features don't accidentally conflict (which people almost never bother doing).
This is pretty close to how I feel, so the approach I've started gravitating to is
- Avoid conditional compilation as much as possible
- Push back when someone writes a PR that introduces conditional compilation
- If it really is unavoidable, use dependency injection to push it further up the dependency tree and out of the main code
So in the "optional code" example I would remove it and instead accept an argument implementing a Validator
trait which has a verify_signature()
method. Then users can choose to pass in a no-op validator or, if the crypto
feature is enabled, my crate might provide a CryptographicValidator
which they can use. That means we would always call verify_signature()
, but the no-op implementation would just do nothing.
When I need to switch between implementations, I would create some sort of trait which gets implemented for each situation and then provide a constructor that returns impl Trait
. Or, if that's not practical, I might use type aliases.
trait FileLike: Read + Write + Seek {
fn open(path: &Path) -> Result<Self, Error>;
}
cfg_if! {
if #[cfg(windows)] {
pub type File = windows::File;
} else if #[cfg(unix)] {
pub type File = unix::File;
}
}
fn open_file(path: &Path) -> Result<impl FileLike, Error> {
cfg_if! {
if #[cfg(windows)] {
windows::File::open(path)
} else if #[cfg(unix)] {
unix::File::open(path)
} else {
Err(Error::Unsupported)
}
}
}
mod windows {
struct File { ... }
impl super::FileLike for File { ... }
}
mod unix {
struct File { ... }
impl super::FileLike for File { ... }
}
-
For example, you might need to have OpenSSL installed or compile it from source, or you might be using
ring
which pressures users to follow the latest release. ↩︎