What's your stance towards conditional compilation?

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) #ifdefs 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

  1. Avoid conditional compilation as much as possible
  2. Push back when someone writes a PR that introduces conditional compilation
  3. 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 { ... }
}

  1. 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. ↩︎

3 Likes

That sums up my approach as well. Generally conditional compilation should be avoided in favour of language features, such as traits, or even macros. In case it is unavoidable, try to make the conditions orthogonal, to minimize the probability of unwanted interactions, and push conditional code as far as possible from the main flow of the program.

I think C heavily overuses conditional compilation for the same reason it heavily overuses macros: old compilers couldn't properly inline code, and the poor abstraction capabilities make it impossible to express some normal patterns via ordinary code.

4 Likes

The two legitimate instances of conditional compilation acceptable to me are:

  1. inherently platform-specific code, which must be different between – most often – Unix and Windows (like the File example you also mentioned)
  2. Crate features that you can use to exclude certain items and thus reduce the number of dependencies and/or compilation time.

Apart from case (1), I don't think there's a good reason for conditional compilation to change behavior; I strongly prefer additive behavior (as in case (2)). For example, I would probably frown upon your verify_signature() example in a real-life PR; I'd argue that there should instead be a config flag, an additional function argument, or a designated dummy type parameter (strategy-style) for deciding whether the signature should be verified. Alteration of behavior in my opinion belongs in configuration or generics, not in conditional compilation.

8 Likes

I myself prefer not to use conditional compilation for the reasons you mentioned. It is hard to read the code and also difficult to debug it. I also find that in many cases you can achieve the same result by utilizing proper software design and abstractions and parameterizing dependencies.
If you have no choice but to use them for different OS, I would move all os-specific code into own folders, then abstract them behind an interface (a Facade or a Bridge ?) and write os-agnostic code other than the few places that decides which files to import. Make it so you use conditional compilation in the minimal number of places and that most other code just imports the dependency.

1 Like

Yeah, that sounds like the approach used in the standard library's std::sys module and my FileLike example towards the bottom. It'd be much more maintainable than sprinkling your code with #[cfg(windows)] and #[cfg(unix)] annotations when calling platform-specific functions.

That's a good place where conditional compilation is useful, but I left it out of my original post because it's pretty well understood and there aren't that many maintainability issues... assuming the changes are purely additive and you aren't sprinkling #[cfg(...)] attributes through the middle of existing functions to invoke that extra functionality, anyway.

For something like this, I might try to leave the calls in, but swap out the implementation. Like a compile-time dependency injection kind of thing, so the cfg is in just two places:

  1. The module that defines the implementation with all the dependencies, and
  2. The type alias (or whatever) that you use to have the other code depend on the config.

You might even not use the second one by parameterizing something in the library by the compile-time strategy type, so Document<NoSignatureValidation> is usable by everyone regardless of the config switch, but Document<Validated> is only available when the crate feature is enabled.

(Or a variety of other possible strategies, like you always deserialize an UnvalidatedDocument type, and there's a not-always-available checked conversion to the normal Document type.)

2 Likes

I just struggled for a long time debugging a problem caused by optional fields, I'm always trying to avoid optional fields, because it could cause the “ABI doesn't match” problem. This may not be an issue in Rust because Rust builds projects from source code level in most cases, but will make people crazy for dynamic linkages. If a program crashes due to ABI not match, It's very hard to debug, because all the variables in a debugger make no sense, even the calling stack is weird. My experience is that even the field is not used in some cases, I just leave it there to make sure the ABI won't change in all kinds of build types.

1 Like