Skip to content

Instantly share code, notes, and snippets.

@jyn514
Last active December 23, 2024 01:49
Show Gist options
  • Save jyn514/de2621d780ac75eb2b34969bfe5542aa to your computer and use it in GitHub Desktop.
Save jyn514/de2621d780ac75eb2b34969bfe5542aa to your computer and use it in GitHub Desktop.
`const if`: type-checked conditional compliation for rust

Summary

const if allows type-checked conditional compilation.

Motivation

Currently, Rust's mechanism for conditional compilation works at the macro level. Conditional code is macro-expanded eagerly very early in compilation, and discarded altogether if not present. This has several consequences:

  • Name resolution, type checking, and borrow checking do not occur for inline cfg'd out code.
  • Parsing does not occur for cfg'd out outlined modules (#[cfg(foo)] mod bar;).
  • The compiler has very little information about cfg'd out code. Since recently, the compiler records items that are cfg'd out, but not outlined modules, and the ways it uses this information are adhoc and not easy to generalize. As a result, maintaining codebases that heavily use conditional code is very difficult. Checking all possible combinations of conditions involves combinatorial complexity, and refactoring such code is very hard.

const if performs parsing, name resolution, type checking, and borrow checking for conditionally disabled code, and makes maintaining conditional code much easier, at the cost of slightly less flexibility.

Guide-level explanation

const if allows you to use if and else blocks at module scope. Here is a simple example:

const if cfg!(feature = "serde") {
	use serde::{Serialize, Deserialize};
	#[derive(Serialize, Deserialize)]
	struct Foo;
} else {
	struct Foo;
}

impl Foo {
	fn bar(self) {}
}

The main advantage of const if blocks is that they allow the compiler to check your conditionally-compiled code. For example, the following code which uses #[cfg(windows)] delays parsing the windows.rs file until you try to compile for a windows target:

#[cfg(windows)]
mod windows;  // unchecked; compiles successfully on linux
#[cfg(unix)]
mod unix;     // checked

With const if, the error is caught immediately, even if you are currently on a linux platform.

const if cfg!(windows) {
	mod windows;  //~ ERROR: file not found for module `windows`
} else if cfg!(unix) {
	mod unix;
}

const if blocks let you use local variables, conditions, and simple loops, as long as they would be allowed in a normal const block. This can avoid lengthy repetitions of the same conditions.

const BARE_WASM: bool = cfg!(target_arch = "wasm32") && !cfg!(target_os = "wasi");
const SGX: bool = cfg!(target_vendor = "fortanix") && cfg!(target_env = "sgx");
const if cfg!(doc) && (BARE_WASM || SGX) {
	pub mod unix {}
	pub mod darwin {}
	pub mod linux {}
	pub mod windows {}
} else {
	const if cfg!(doc) || cfg!(target_vendor = "apple") {
		mod darwin;
	}
	// ...
}

The equivalent expressed with #[cfg(...)] blocks is very verbose, hard to understand and maintain, and prone to error.

Items defined in a const if block can be used outside that block as long as the compiler can guarantee they are present.

const if cfg!(windows) {
	impl WindowsExt for Command {
		fn async_pipes(yes: bool) { /* ... */ }
	}
}

fn foo(cmd: &mut Command) {
	const if cfg!(windows) {
		cmd.async_pipes(true); // OK
	} else {
		cmd.async_pipes(false); //~ ERROR: `async_pipes` is only defined when `cfg(windows)` is true
	}
}

Normal if blocks are not used to infer whether items are present, only const if blocks.

fn foo(cmd: &mut Command) {
	if cfg!(windows) {  //~ NOTE: try changing this to `const if cfg!(windows)`
		cmd.async_pipes(true); //~ ERROR: `async pipes` is only defined when `cfg(windows)` is true
	}
}

Note that this means that const if is "infectious". If a dependency uses const if, the use site must also use const if, or the items will not be visible. this necessitates that all dependencies for all platforms will be compiled at once, hurting compile times. to avoid this, const if lets you opt-out of compile-time checking:

#[disable_cfg_checking]
use windows::Win32::System::Com::*;

In this case, const if statements in the dependencies behave exactly like the equivalent #[cfg(...)] statements.

If you have a build.rs, you have two options. You can continue using unchecked #[cfg] attributes. Or, you can have your build script generate code that uses const if, and check in that generated code to your version control system.

Reference-level explanation

const if conditions must be const expressions. const if consequent blocks contain the same productions as the parent production in which they are defined (e.g. items can be used at module scope, variables can be defined at function scope). variables and expressions cannot be used outside of the scope of the const if block in which they are defined; const if is a statement and not an expression. Items may only be used outside of the const if block in which they are defined if one of the conditions are true:

  • they are present in all combinations of conditions. if the item is present in all branches of a const if expression including the else block, this is trivially true. if there is no else block, the compiler must do exhaustiveness checking to verify this is the case.
  • the const if conditions for a scope are a strict subset of the conditions for the item being used. this performs exhaustiveness checking to verify this is the case. expressions which are logically equivalent but differ lexically (e.g. cfg!(A) && B and B && cfg!(A)) are considered to be equivalent. An item that is not "active" (is defined in a block whose condition evaluates to false) behaves as if it were instead a declaration with private visibility. For example, on linux the following two pieces of code would be equivalent, except that the body of foo would also be name resolved, type-checked, and borrow-checked. [TODO: this doesn't work for trait impls lol]
const if cfg!(windows) {
	fn foo(cmd: Command) { /*...*/ }
}
mod __hidden {
	extern "Rust" {
		fn foo(cmd: Command);
	}
}

Conditional name resolution

  1. during expansion/name res, all items in a const if block are treated as declarations. each arm of a const if block is treated as its own scope, so defining multiple items with the same name is allowed.
  2. "constants are defined" (i don't know the exact time this happens, presumably sometime during analysis()).
  3. we have a separate pass that goes back through and resolves all of the declarations to a single definition based on the combination of cfgs. at this point, any item A that uses another item MAYBE_THERE inside a const if block is checked to make sure that MAYBE_THERE is actually defined in this Session.
  4. we do normal type checking as-if the items from 1 don't exist (except for the purpose of diagnostics)

The limitation that gives us is similar to the one for borrowck/typeck: you might end up with query cycles (in our case, between consts that use items in a const if block and const if blocks that use those consts in a condition), and those are surfaced as a user-facing error instead of an ICE.

Drawbacks

  • This does not allow conditionally generated code (e.g. a file from build.rs that only sometimes exists). That code will have to continue to use #[cfg].
  • It's complicated. It adds significant burden to the compiler, including a limited form of flow typing, to verify that the items being defined are allowed to be used at their call site. I heard from t-compiler that defining consts currently requires global analysis, which would have to be changed in order to let them participate in const if conditions. There are probably a dozen other things i'm missing.
  • The "intermediate" state between an item being declared in a const if block and being resolved to a definition is very sketchy. someone should probably experiment with this in their own language before we try to add it directly to rustc.

Rationale and alternatives

This reuses several concepts rust programmers are already familiar with:

  • Const expressions (the limitations of const expressions are already user-facing in various ways)
  • if and else blocks. unlike #[cfg] blocks, this does not introduce a new "mini-language".
  • exhaustiveness checking In various ways, this makes cfg code more consistent with the rest of the language, by making it always checked even if it is not always compiled.

I have seen proposals for where Platform: Windows floating around, which are a somewhat related idea for type-checking conditional code. The main problem with that is that I haven't seen any concrete proposals for it. The reason I didn't write up such a proposal is that it's strictly more limited than const if: it cannot define anything other than functions, it is limited to cfg combinations that can be expressed using traits (how would you express any() or all()?), and it is not clear how it can be extended to cfg attributes other than the target platform.

This cannot be done in a macro. The whole point of this is to move analysis of conditional code later than macro expansion.

Prior art

  • c++: if constexpr() allows discarded branches to fail to type check, as long as they are within a template. in a sense this works because c++ has delayed checking for monomorphization, unlike rust which checks eagerly (pre-mono).
  • kotlin: expect/actual, which works kinda similar to platform-specific traits.
  • zig: multibuilds are kinda like "run cargo check a bunch of times in parallel", but still has problems of combinatorial explosion.
  • (i am intentionally not including dynamic languages or #ifdef-like systems which don't do type checking)

Unresolved questions

  • "is this even possible"
  • do we need a separate syntax? can we have an opt-in for existing #[cfg] code to have this strict checking?
    • if so, it would have to be a scoped opt-in (like normal lints); otherwise you couldn't have conditionally generated code. that scoping would itself be quite complicated.
  • this would be way simpler if it were just a fancy syntax over #[cfg], although we should probably change the name away from const if in that case

Future possibilities

¯\_(ツ)_/¯

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment