Pre-RFC: global_generic — crate-level type parameters

Pre-RFC: global_generic — crate-level type parameters

Summary

Add a global_generic declaration that lets a crate expose a type parameter which is bound exactly once, in main.rs, and then used anywhere in the dependency graph as if it were a concrete type.

Syntax

// lib.rs (or any library crate)
global_generic TypeName: TraitName;

// main.rs (the binary crate — the only place binding is allowed)
global_generic crate::TypeName = ConcreteType;

Example

// infrastructure/src/lib.rs
pub global_generic Db: DatabaseRead;

pub fn load_accounts(client: &mut Db::Client) -> Vec<Account> {
    // Db is treated as a concrete type here
}
// main.rs
global_generic infrastructure::Db = PostgresDb;

fn main() {
    let mut client = PostgresDb::connect();
    let accounts = infrastructure::load_accounts(&mut client);
}

Motivation

Layered applications must pick a handful of types (Db, Runtime, Clock, Logger) once at startup, but Rust forces one of:

  1. Generic soup — every function declares <Db: DatabaseRead, Rt: Runtime, …>.
  2. dyn Trait — loses static dispatch, breaks on GATs / impl Future.
  3. Service locators — runtime .unwrap(), no compile-time guarantee.
  4. Forked crates — one copy per backend.

global_generic expresses "this parameter is fixed for the whole build" without any of these.

Semantics (sketch)

  • global_generic T: Trait; declares T in the declaring crate. T behaves like a type alias with bound Trait.
  • The binding global_generic path::T = Concrete; is legal only in a binary crate (the root of the compilation). Exactly one binding per declared global_generic per build.
  • Resolution happens after macro expansion, before type-checking. T::Assoc, T::GAT<'a>, T::method() all work as if the concrete type were written literally.
  • If main.rs never binds a reachable global_generic, compilation fails with "unbound global generic T (declared in <crate>)".
  • A library crate that declares global_generic is monomorphised once per binding. Two binaries with different bindings produce two instantiations.
  • #[global_allocator] becomes a special case of this feature.

Drawbacks

  • "What type is T?" is no longer locally answerable — you must know the binary's bindings.
  • Changing a binding in main.rs is a full recompile of the affected library crates.
  • Adds a new resolution pass and new monomorphisation keys to crate metadata.
  • Coherence: two binaries linking the same generic library with different bindings is fine; a single binary with two conflicting bindings is an error.

Prior art

  • #[global_allocator] — same shape: declared once, bound once, seen everywhere.

Unresolved questions

  1. Should binding be allowed in a library crate that is itself global_generic (i.e. re-exported binding)?
  2. Do two binaries sharing the same library binding share the monomorphised rlib?
  3. Should pub global_generic participate in semver?
  4. Is #[rebind] needed for tests, or should tests use a wrapper binary?
  5. What is the visibility rule for the bound type inside the declaring crate?

How does the compilation process proceed if (as is likely to be wanted in many use cases) the type PostgresDb is defined in a crate that depends on the infrastructure crate, e.g. to implement the relevant trait?

It seems to me that as described, this can only work if the chosen concrete type is a type from a crate that could be a dependent of the crate that defines the global_generic; otherwise you introduce a cycle in the dependency graph.

1 Like

i don't know too much about that but let say there is three crate :

  • one define global_generic with the trait
  • the second implement the trait only that mean it depend on the first one
  • the third one is binding the global_generic with the type

but here we have the cycle in the compiler and i don't know how to solve the cycle in compiler

Well, there is the possibility of this usage pattern:

  • Library crate definer_a defines the Trait.
  • Library crate definer_b depends on definer_a, and defines the global_generic Global: definer_a::Trait.
  • Library crate user_c depends on definer_a, and defines impl definer_a::Trait for MyType.
  • Binary crate user_d depends on definer_b and user_c, defines global_generic definer_b::Global = user_c::MyType.

However, I expect that this will not work in many cases where this feature would be desirable:

  • There may be be more complex relationships between the items the library project wants to offer, such that the split of the defining library into definer_a and definer_b is either not possible, or means that definer_a contains almost entirely generic code, which defeats some of the point of having global generics.
  • There may be more complex relationships between the items the binary project wants to define, such that splitting it into user_c and user_d isn’t possible. For example, user_c can’t write code that assumes the global generic exists or has any specific concrete type!

I think that in order for this idea to be useful, you have to propose a mechanism to actually break the cycle somehow.


It’s also worth comparing this against externally implementable items (EII), which is the current candidate for replacing/generalizing the #[global_allocator] mechanism. EII supports the same sort of definition site and usage site, but only allows downstream to define the value of a static, not a type parameter, so there is no newly generic code. This is, of course, much more restricted in what it can do, but because of that, it doesn’t introduce new dependency and monomorphization problems.

1 Like

If I understand this correctly - there's some prior art:

is legal only in a binary crate (the root of the compilation)

Hmm... Feels arbitrary. I'd say make a module take optional type parameters, with currying or otherwise.

If foo takes parameters

mod foo<P: Constraint> {
    // here P is a concrete type
}
use foo;

shouldn't work since foo is not a module, but

use foo::<ConcreteType>; // this gets overrides foo so no more instantiations
// or
use foo::<ConcreteType> as concrete_foo; // can have several different ones

should as long as types are fully instantiated.

(but I don't mind generic soup)

5 Likes

Thanks both — that's very helpful.

I think pacak's idea is the right direction, but I'd extend it to work uniformly at every level of the module tree: inline modules, file modules, folder modules, and the crate root. Same syntax everywhere, same instantiation rules.

The unified idea

A module (or crate) can declare optional type parameters. Until it's instantiated, it can't be used. It's instantiated by writing ::<…> at the use site.

Inline module

rust

mod foo<P: Constraint> {
    // P is concrete inside here
}

use foo;                      // error: `foo` is not instantiated
use foo::<ConcreteType>;      // OK
use foo::<TypeA> as foo_a;
use foo::<TypeB> as foo_b;    // multiple instantiations allowed

File module and folder module

Same syntax, declared in the parent:

rust

// lib.rs
mod foo<P: Constraint>;              // foo.rs
mod bar<P: Constraint>;              // bar/mod.rs

use foo::<ConcreteType>;
use bar::<ConcreteType>;

The parameter is written where the module is declared, so foo.rs doesn't need a separate mod line. The compiler looks up P when type-checking foo.rs.

Crate root

The crate root (lib.rs) can declare parameters the same way. Any crate that depends on it must instantiate:

rust

// my_lib/src/lib.rs
crate<P: Constraint> {
    // the whole crate body; P is concrete here
}

// dependent crate
use my_lib::<ConcreteType>;
my_lib::some_function();

Or, if you prefer to keep lib.rs looking normal:

rust

// my_lib/src/lib.rs
#![generic(P: Constraint)]

and dependents instantiate with:

rust

use my_lib::<ConcreteType>;

Either shape gives the same semantics: the crate is compiled as a template and each dependent that names a concrete P produces its own instantiation.

Why this shape

  • Uniform. One concept (parameterised module) at every level. No special-case for crates, no separate global_generic item form.

  • Explicit. The instantiation is always visible at the use site — you can see which module/crate is bound to which concrete type, unlike a crate-level binding that's resolved from far away.

  • Composes with pub. A pub mod foo<P: Constraint>; exports a parameterised module; downstream crates can instantiate it themselves, or pass their own parameter through.

Open questions

  • Nesting. Can a generic module contain a generic module? If so, does the inner one see the outer's P? (I'd assume yes — like lexical scope.)

  • Default parameters. Should mod foo<P: Constraint = Postgres>; be allowed, so a default instantiation exists? That would give you pluggable-with-sane-defaults for free.

Compared to global_generic, this design makes the cycle question disappear: there's no separate "binding" step, just a normal use that happens to carry type arguments. The polymorphisation problem is still underneath, but at least the surface language has one consistent rule instead of two.


Not allowing this feels arbitrary. From the inner module point of view super::P should be a mostly regular type that is known to implement some traits (constraints). This P will make all sorts of proc macro upset though.

1 Like

Thanks both — I'm going to step back from this. The blocker is testing: with compile-time instantiation, testing foo::<Postgres> and foo::<InMemory> needs one test binary (or wrapper crate) per binding, which explodes once you have several parameters. Combined with the proc-macro problem, the trade-off isn't worth it. Thanks for the EII / functor / Backpack pointers — very useful.

Externally implementable items has relevance for the trait part: Tracking Issue for externally implementable items · Issue #125418 · rust-lang/rust · GitHub

1 Like