Pre-RFC: Compile-time conditional branching on trait implementations

Pre-RFC: Compile-time conditional branching on trait implementations

I'd like to propose a way for generic Rust code to conditionally compile branches depending on whether a type implements a trait.

The idea is superficially similar to instanceof in some higher-level languages, but the condition would be resolved entirely at compile time rather than through runtime type inspection.

Motivation

Consider:

trait Describe {
    fn describe_static() -> &'static str;
    fn describe(&self) -> &'static str;
}

fn do_something<T>(this: &T) {
    let static_description = if T: Describe {
        T::describe_static()
    } else {
        "No static description available"
    };

    let description = if this: &Describe {
        this.describe()
    } else {
        "No description available"
    };
}

Here:

T: Describe

and:

this: &Describe

would be compile-time predicates.

A successful predicate would introduce the corresponding information into its branch. Thus T::describe_static() is valid inside the first branch because the compiler knows T: Describe there.

Likewise:

if this: &Describe {
    this.describe()
}

establishes the required capability for this inside that branch.

This requires no Any, dyn Trait, RTTI, or runtime trait lookup.

Exhaustiveness check

The same mechanism could allow generic code to support several alternatives and reject unsupported concrete instantiations.

For example:

fn stringify<T>(value: &T) -> String {
    if T: Display {
        format!("{}", value)
    } else if T: Debug {
        format!("{:?}", value)
    } else {
        compile_error!("T must implement Display or Debug");
    }
}

For a concrete T, at least one supported branch must apply or compilation of that instantiation fails.

Conceptually, this function accepts:

T: Display OR T: Debug

without introducing another common trait solely for dispatch.

If T implements both traits, ordinary if / else if ordering could establish precedence.

compile_error! is used here only to demonstrate the intended semantics. Whether this should literally use compile_error!, some form of language-level exhaustiveness checking, or another mechanism is open for discussion.

Boolean algebra over trait predicates

Once trait satisfaction is a compile-time Boolean predicate, logical operators become a natural extension.

AND

if T: Read && T: Write {
    // T implements both
}

OR

Boolean operators could also apply to predicates on values:

let value: &T = ...;

if value: &Display || value: &Debug {
    // value satisfies &Display, &Debug, or both
    stringify(value);
}

Inside this branch, the compiler knows:

value: &Display OR value: &Debug

The branch does not need to immediately determine which alternative is satisfied. The Boolean constraint itself becomes part of the branch-local type information.

The corresponding type-level form would be:

if T: Display || T: Debug {
    // T implements Display, Debug, or both
}

This differs from:

if T: Display {
    // T: Display
} else if T: Debug {
    // T: Debug
}

where each branch establishes one specific trait.

Rust already has a straightforward way to express simultaneous trait requirements:

T: Read + Write

but there is no corresponding direct form for an alternative requirement such as:

T: Display OR T: Debug

NOT

if !(T: Clone) {
    // T does not implement Clone
}

Predicates could also be composed:

if (T: Read && T: Write) || T: Serialize {
    // ...
}

Compile-time instanceof

A useful mental model for this proposal is a compile-time analogue of instanceof.

In a language with runtime type inspection, it is natural to ask a type question about essentially any object:

if value instanceof SomeInterface {
    ...
}

The object does not need to declare in advance that such a check will be performed. The check itself determines whether the corresponding branch is applicable at runtime.

The proposed Rust construct follows the same mental model:

if value: &Display {
    // ...
}

except that the check determines whether the corresponding branch is applicable during monomorphization, when the concrete type is known.

The programmer can therefore ask the question about any generic value:

fn process<T>(value: &T) {
    if value: &Display {
        // Display-specific code
    } else {
        // fallback
    }
}

process itself does not need:

T: Display

because implementing Display is not a precondition for calling process.

It is simply a compile-time question asked by its implementation.

The mental model is therefore:

runtime instanceof:
    object
        -> inspect runtime type
        -> choose branch

compile-time trait predicate:
    generic value
        -> concrete type becomes known during monomorphization
        -> resolve trait predicate
        -> compile the applicable branch

Why monomorphization?

The reason for resolving these predicates during monomorphization is that this is the point where the generic type becomes a concrete type.

Consider:

fn stringify<T>(value: &T) -> String {
    if T: Display {
        format!("{}", value)
    } else if T: Debug {
        format!("{:?}", value)
    } else {
        compile_error!("T must implement Display or Debug");
    }
}

The function itself does not need to restrict T. It can be called with any type:

fn foo<T>(value: &T) {
    stringify(value);
}

During monomorphization, however, T is concrete. At that point the compiler can answer:

Does T implement Display?
Does T implement Debug?

and select the corresponding branch.

This avoids requiring conditional trait requirements to become part of generic function signatures.

If stringify instead required something like:

T: Display OR T: Debug

then foo might need to repeat that requirement merely because it calls stringify.

A generic caller of foo might then need to propagate it again.

For more complicated conditions:

(A && B) || C

or:

(A || B) && !(C)

local implementation details could propagate throughout the generic call graph as increasingly complicated bounds.

Resolving the condition during monomorphization avoids that propagation. The generic function remains unconstrained, while each concrete instantiation is checked against the branches it actually uses.

For example:

stringify::<A>(...)    // A: Display  -> compile Display branch
stringify::<B>(...)    // B: Debug    -> compile Debug branch
stringify::<C>(...)    // neither     -> compilation error

This is also consistent with the instanceof mental model: the programmer is allowed to ask the question about any value. The difference is when the answer becomes available.

At runtime, instanceof answers the question from the runtime type.

Here, monomorphization answers the question from the concrete compile-time type.

The conditional capability therefore remains an implementation detail of stringify, rather than becoming a requirement that has to be expressed and propagated through every generic caller.

There is no runtime type inspection or runtime dispatch involved.

Extension: compile-time duck typing

(click to expand)

OR predicates could potentially go beyond conditional branching and enable a statically checked form of duck typing.

Consider two unrelated traits:

trait MyTrait1 {
    fn same_method_name();
}

trait MyTrait2 {
    fn same_method_name();
}

If every alternative admitted by an OR predicate provides the same compatible operation, it could potentially be possible to write:

fn do_something<T>() {
    if T: MyTrait1 || T: MyTrait2 {
        T::same_method_name();
    }
}

The code does not care which trait provides the operation.

It only cares that every possibility admitted by the predicate provides a compatible:

fn same_method_name();

In that sense, the usable interface inside an OR branch could include operations common to all alternatives.

This would provide a form of compile-time, statically checked duck typing.

An ambiguity occurs if T implements both traits.

In that case:

T::same_method_name();

could be ambiguous.

Rust could reject the ambiguous call and require explicit qualification:

<T as MyTrait1>::same_method_name();

or use some other rule.

I think this is something the design discussion should determine rather than something this Pre-RFC needs to prescribe.

Relationship to specialization

This overlaps conceptually with specialization, but the proposed construct would allow specialization locally inside an ordinary generic function:

fn process<T>(value: &T) {
    // common logic

    if T: SomeOptimization {
        // optimized implementation
    } else {
        // generic implementation
    }

    // common logic
}

Some such cases can be represented using helper traits and specialized implementations, but that moves a local implementation choice into separate traits and impl blocks.

The proposed mechanism keeps that decision where it is actually used.

Extension: concrete type predicates

(click to expand)

The same concept could potentially be applied to concrete types.

For example:

fn unwrap_str<T>(value: T) {
    let q = if value: &str {
        *value
    } else if value: &&str {
        **value
    } else {
        compile_error!("expected &str or &&str");
    };
}

Here &str and &&str are distinct types.

Inside the first branch, the predicate establishes that value has type &str, so:

*value

is valid.

Inside the second branch, it establishes that value has type &&str, so:

**value

is valid.

Again, the question can be asked without restricting T in advance.

For the simplest semantics, concrete type matching would be exact:

value: &str

means exactly &str, not &&str and not another type that can coerce or autoderef to &str.

Whether concrete type predicates belong in the same feature or should be a separate proposal is open for discussion.

Summary

The core primitive is:

if T: Trait {
    // T: Trait is known here
} else {
    // fallback
}

or, for a value:

if value: &Trait {
    // value satisfies &Trait here
} else {
    // fallback
}

Boolean algebra could extend the same model:

if T: Read && T: Write {
    // ...
}

if value: &Display || value: &Debug {
    stringify(value);
}

if !(T: Clone) {
    // ...
}

OR could further enable common operations across alternative traits:

if T: MyTrait1 || T: MyTrait2 {
    T::same_method_name();
}

and concrete type predicates could potentially extend the same idea beyond traits:

if value: &str {
    // ...
} else if value: &&str {
    // ...
}

The essential idea is simple:

Ask a type question about any generic value or type, resolve the answer during monomorphization when the concrete type is known, and compile only the applicable branch.

Everything else — exhaustiveness checking, Boolean algebra, duck typing, and concrete type predicates — can potentially build on that primitive.

I'm interested in whether something equivalent has been considered before, what problems this would introduce for Rust's type system and monomorphization model, and whether there is a more Rust-idiomatic way to express the same capability.

1 Like

This is already implementable via TypeId in std::any - Rust.

4 Likes

Not really, that allows going from “unknown/generic type” to “specific type”, not “some impl of Trait”. However you’re right that std has this, at least on nightly…

Edit: AH, clicked the link and made more sense. The info visible in IRLO made it look like a link to TypeId / I assumed dyn Any-related stuff.

Did you check for prior work? This idea already exists as the currently-under-development try_as_dyn.

4 Likes

This idea already exists as the currently-under-development try_as_dyn.

Note, however, that this has serious limitations and can return None even if the type does implement the trait. This is fundamentally required for soundness.

Also see this extensive Zulip discussion.

Those same issues would affect this pre-RFC, of course. The pre-RFC does not seem to even mention all these soundness concerns, so it does not seem like enough research was done to determine whether the proposal is even possible and what the problems are. But the tl;dr is that as-written the RFC is impossible to implement, for the same reasons that speculation specialization has been stuck for 10 years now.

5 Likes

It is whole point, you can do it in run-time, but rust build with philosophy of zero-cost abstraction, you should be able to do same but in compile time, with no any extra runtime cost.

I am generally not following nightly builds, but If went that far to make a Pre-RFC, it looks like something I should explore first.

This one tells to try_as_dyn, and dyn means do the dyn dispatch - how it work if compiler can determine type at compile time?

My point that, I want to avoid any possible runtime cost and do at compile time as much as I can.

Thank you for pointing to Zulip discussion, I will learn more from there.

How try_as_dyn works, my concern that when I see dyn it means dynamic dispatch, does it work well in compile time, when type can be determined during compilation, if it works, than perhaps that feature that I am looking for, it is already there.


This is exact example from official documentation:

let as_animal: Option<&dyn Animal> = try_as_dyn::<Dog, dyn Animal>(&dog);
assert_eq!(as_animal.unwrap().speak(), "woof");

What's conerns me here, dyn Trait still needs to be used, that pointing out to dynamic dispatch.


I see that there is ongoing conversation about that feature, and issue still open.

issues/144361

I've made a question about optimization guarantees.

144361#issuecomment-5849725719

Perhaps if we can enforce somehow this guarantees for performance critical parts of the code, that's would be what's needed.


I was following that issue about try_as_dyn looks like it tries to solve slightly different concept, they trying to remove limitation of 'static for Any trait by introducing another way to downcast. It doesn't answer question about runtime performance though.

Alternative syntax suggestion:

let static_description = if where T: Describe {

And have the where part support exactly everything that's supported in the where clause of generic definitions, with the exact same syntax. This means that AND would look like this:

if where T: Read, T: Write {

(although in this specific case T: Read + Write should also work)

Regarding OR - I see no reason to support it here when it's not supported in generic declarations. The problem is not the syntax - it's the semantics. OR is currently useless because you cannot rely on the type implementing either trait.

3 Likes

specialization ?

1 Like

we need a new in-joke after a certain type is not named anymore after the date of it's stabilization :laughing:

3 Likes

Oops, yes, specialization :slight_smile:

3 Likes

Look at the codegen. The optimizer can see it's a Dog, so it knows which vtable it's using, so it can devirtualize.

Performance is not observable -- if it was then we couldn't optimize anything, because it would be changing observable behaviour -- so there aren't and won't be any guarantees about it. Just like everything else performance-related, it'll be a Quality-of-Implementation question and might be different with different compiler flags. (Trivial example: at opt-level=0 it might still make a dynamic call because asking for opt-level=0 is saying "I don't care about performance at all so don't even try".)

1 Like

The "dyn" part is only how you consume the result. It's really there only to avoid introducing complexities into the language while experimenting, but under the hood it does pretty much what you're asking for.

Codegen wise it will create a trait object from a constant vtable, and pretty much any optimizer can walk that backward with constant propagation to devirtualize the calls on it.

This would be covered once Rust has proper compile time reflection that can inspect generic types along with their trait bounds at compile time and generate new Rust code from that, not macros, but proper comptime reflection. Macros doesn't know type info, and generate code too early, rather than at the point where the caller actually uses it

1 Like

As an example, here is how it looks in Zig's compile time reflection:

const std = @import("std");

fn makeStringify(comptime T: type) fn (T) []const u8 {
    if (@hasDecl(T, "display")) {
        return struct {
            fn call(value: T) []const u8 {
                return value.display();
            }
        }.call;
    } else if (@hasDecl(T, "debug")) {
        return struct {
            fn call(value: T) []const u8 {
                return value.debug();
            }
        }.call;
    } else {
        @compileError("T must implement display or debug");
    }
}

const A = struct {
    pub fn display(self: A) []const u8 {
        _ = self;
        return "I am A (display)";
    }
};

const B = struct {
    pub fn debug(self: B) []const u8 {
        _ = self;
        return "I am B (debug)";
    }
};

pub fn main() void {
    const stringifyA = makeStringify(A);
    const stringifyB = makeStringify(B);

    std.debug.print("{s}\n", .{stringifyA(A{})});
    std.debug.print("{s}\n", .{stringifyB(B{})});
}

But there is a difference. It requires assigning to a variable, or you could say a name alias, one name for one comptime reflection result. Proper fine hrained compile time monomorphization would likely abstract this at the compiler level, where you don't need to manually map multiple names, you simply call fn_name::<abc>() and it's automatically handled. Which is nice

1 Like

I just realized that I miss sent to the wrong thread :rofl:

I am totally agree with you at this point.

Many thing that I’ve tried, I can reduce lot’s of complexity in my code, by use of “dyn Trait” construct by relying on the fact, that optimizer will can de-virtualization, to keep my runtime performance steady and predictable as fast as well.

The main problem, here is that rust can do de-virtualization but it doesn’t have any guarantees for that, I’ll try to explain why guarantees makes difference.

Let’s say I’ve started building a software that relying on fact that certain constructions will be de-virtualized, and this assumption about de-virtualization will be putted in a core of software design.

Let’s say, next day I will keep increasing the complexity of software by bringing more functionality, and obviously I will try to reuse, that component that uses de-virtualization already.

The problem is that, as complexity will grow, I will got more usage examples, compiler may decide to stop doing de-virtualization at some due too fact that complexity of source code grown too much.

And the main problem not even that it stopped doing de-virtualization, sometimes it is acceptable to have some slower in runtime, but less burden while coding.

The main problem, compuler will stop doing de-virtualization silently, and I will be not be aware that it happened, and once I would realize it could be already too late, because I already add too much code from the moment de-virtualization stoped working, and I can’t simply revert it back easily, because of backwards compatibility. Why backward compatibility important - I guess it shouldn’t be explained separately.

Oppositely if I can give some hint to compiler, so once compiler failed to de-virtualization in certain part of the code, I still could try to keep my design straight, and before I will put more troubles for maintaining the code by adding more functionality, I can find proper design that will keep de-virtualization run first, and only then keep increasing software with new functionality.

Perhaps guarantees for de-virtualization may solve many more troubles, instead of introducing more features construct into the language. On the other side, I can realize how hard do built optimizer, and perhaps it is just easier introduce and language constructs similar to this Pre-RFC post that will solve the problem without increasing complexity for optimization processes, by giving another tool to software engineers who writing code in rust.

This is the bit of your text that seems least likely to me. Devirtualization is an optimization that improves performance in some situations (but importantly is not guaranteed to improve performance - if your CPU has good indirect branch target prediction, devirtualization can make no difference at all).

You thus wouldn't be assuming devirtualization, but rather you'd be seeing a performance concern that you traced down to this call being neither devirtualized nor correctly predicted by your CPU's indirect branch target prediction.

And as @scottmcm has already said, the compiler does not consider performance as an observable part of program behaviour - it aims to produce a binary that performs well, but the specifics of that can vary. On a platform with a great indirect branch target predictor, for example, the compiler might not bother devirtualizing because it can see already that it's outputting code where the CPU will perfectly predict the indirect target, and thus there's no performance gains from devirtualization in this case.

We building software and the application suddenly dropped in performance 10 times or even more.

Sarcastically speaking, that’s an application that makes users happy.