Pre-RFC: Function Variants

I've been thinking about "function variants" since writing an RFC to add a bunch of try_ methods to Vec: https://github.com/rust-lang/rfcs/pull/3271.

In the "Future Possibilities" section I proposed the idea of "Function Variants" to potentially deal with the proliferation of associated functions that arise for handling different combinations of function variations. This Pre-RFC is an exploration of that idea further.

Problem Space

To understand the problem, one only has to look at the number of Box::new variations:

impl<T> Box<T, Global> {
    pub fn new(x: T) -> Box<T, Global>;
    pub fn new_uninit() -> Box<MaybeUninit<T>, Global>;
    pub fn new_zeroed() -> Box<MaybeUninit<T>, Global>;
    pub fn pin(x: T) -> Pin<Box<T, Global>>;
    pub fn try_new(x: T) -> Result<Box<T, Global>, AllocError>;
    pub fn try_new_uninit() -> Result<Box<MaybeUninit<T>, Global>, AllocError>;
    pub fn try_new_zeroed() -> Result<Box<MaybeUninit<T>, Global>, AllocError>;
}
impl<T, A> Box<T, A> {
    pub fn new_in(x: T, alloc: A) -> Box<T, A>;
    pub fn new_uninit_in(alloc: A) -> Box<MaybeUninit<T>, A>;
    pub fn new_zeroed_in(alloc: A) -> Box<MaybeUninit<T>, A>;
    pub fn pin_in(x: T, alloc: A) -> Pin<Box<T, A>>;
    pub fn try_new_in(x: T, alloc: A) -> Result<Box<T, A>, AllocError>;
    pub fn try_new_uninit_in(alloc: A) -> Result<Box<MaybeUninit<T>, A>, AllocError>;
    pub fn try_new_zeroed_in(alloc: A) -> Result<Box<MaybeUninit<T>, A>, AllocError>;
}

Each variant has a different combination of prefixes and suffixes to try to describe how it is different to the original new function. Finding all these variants is difficult: the use of prefixes means that the functions are not listed together in an alphabetical list, the use of different impl blocks means that the functions aren't grouped together by rustdoc and some functions don't follow the naming convention (why is pin not new_pin?). Additionally, the documentation for each function is copy-and-pasted with some subtle differences, which leaves developers having to manually compare the docs to understand what the different prefixes/suffixes mean (even worse, some documentation isn't copied: new, try_new, new_in and try_new_int mention that "This doesn’t actually allocate if T is zero-sized" but the other variations don't - does this mean that they don't hold this invariant? Or was this line not copied into their docs?).

The use of different names also negates the benefits of Rust's amazing type inference: a developer must manually select the correct name for the type they require, and manually update the name used at each call site if they change a downstream function signature (e.g., changing a function parameter from Box<T> to Pin<Box<T>>).

Ideally the docs would state the common behavior of all the variations, then show what individual options can be selected and combined and the behavior that those options would produce. The compiler could also infer the options that the developer requested but should also give the ability for a developer to explicitly define what options they'd like.

Proposal

Caller syntax

I'd really like this Pre-RFC (and even the subsequent RFC) to focus on the syntax used to call a specific function variant: most Rust developers are only ever going to call a function variant, not implement one. My goal here is to make Rust an easier language to use. If we can find caller syntax that is familiar, comfortable and works well then that will benefit the most Rust developers while we can iterate on making the implementation syntax easier and more powerful.

Proposed caller syntax:

// Inferred variants:
let a = Box::new(42);    // Infers Box::new::<Emplace>();
let b = a.as_ref() + 1;

let a = Box::new(42)?;    // Infers Box::new::<Emplace + Fallible>();
let b = a.as_ref() + 1;

let a = Box::new(42, SomeAllocator)?;    // Infers Box::new::<Emplace + Fallible + Alloc>();
let b = a.as_ref() + 1;

// Explicit variants:
let a = Box::new::<Emplace>(42);
let a = Box::new::<Zeroed>();
let a = Box::new::<Emplace + Fallible + Alloc>(42, SomeAllocator)?;

// Errors:
let a = Box::new();        // Error: Ambiguous between Box::new::<Zeroed>() and Box::new::<Uninit>()
let b = a.assume_init();

let a = Box::new(42, 42);    // Error: No variant of Box::new can use `i32` as its second parameter.

let a = Box::new::<Zeroed + Uninit>();    // Error: `Zeroed + Uninit` does not match any know variants.

This syntax is similar to calling a generic function, but with some minor changes:

  • The generic parameters aren't real types but are instead "markers" declared by the called function.
  • The developer can specify more than of these markers.

Based on this syntax, one can also imagine what the docs entry for Box::new would look like:

Allocates memory on the heap. This doesn’t actually allocate if `T` is zero-sized.

Variant options:
- `Emplace`: Takes a `T` and places it into the `Box`. Incompatible with `Zeroed` and `Uninit`.
- `Fallible`: Uses a fallible allocator, returning an error if the allocation fails.
- `Alloc`: Allocates memory in the given allocator.
- `Zeroed`: Constructs a new `Box` with uninitialized contents, with the memory being filled with 0 bytes. Incompatible with `Emplace` and `Uninit`.
- `Uninit`: Constructs a new `Box` with uninitialized contents. Incompatible with `Zeroed` and `Emplace`.
- `Pin`: Constructs a new `Pin<Box<T>>`. If `T` does not implement `Unpin`, then the box's contents will be pinned in memory and unable to be moved.

By leveraging type inference, developers should be able to write a simple Box::new(value) and get the default behavior - this is especially important for new developers.

Using existing generic syntax should make this feature feel familiar to existing developers, and easy for new developers to learn alongside generic functions.

Being able to explicitly choose a variant gives developers control and allows the compiler to fail if there is any ambiguity (and point the developer at the explicit syntax, rather than trying to make a "best guess").

Implementation syntax

This is where things get a bit more... vague.

The goal with this syntax is to minimize the number of changes needed to the compiler, while also leaving enough room in the syntax to allow forward compatibility with improved syntax.

The proposed syntax is to add a way to declare a function that supports variants, then to explicitly define each variant that is permitted:

impl<T, A> Box<T, A> {
    pub fn new<variants>(...);
}

impl<T> Box<T, Global> {
    pub fn new<Emplace>(x: T) -> Box<T, Global> {
        // Implementation here...
    }

    pub fn new<Emplace + Fallible>(x: T) -> Result<Box<T, Global>, AllocError> {
        // Implementation here...
    }

    pub fn new<Zeroed>() -> Box<MaybeUninit<T>, Global> {
        // Implementation here...
    }
}

impl<T, A> Box<T, A> {
    pub fn new<Emplace + Alloc>(x: T, alloc: A) -> Box<T, A> {
        // Implementation here...
    }
}

Some notes on the implementation syntax:

  • The variants keyword in the declaration is used to indicate that this function has variants. The function may also have normal generic parameters as well.
  • The ... keyword in the parameter list indicates that variants may take additional parameters, parameters common to all functions must go before this.
  • The declaration of the function variant has no return type, instead each variant defines its own return type.
  • Both the declaration and the definitions have visibility specifiers (although I'm open to changing this if it makes it easier to implement in the compiler).

I've tried to expand the definition syntax to avoid having to explicitly define each variant, but it ends up becoming very messy very quickly since variants can have different parameters and return types. Additionally, such expanded syntax is likely to make the implementation in the compiler more difficult and restrict potential future syntax. Instead, one can centralize the actual implementation using a generic function, then call that generic function from each variant:

impl<T, A> Box<T, A> {
    pub fn new<variants>(...);
    fn new_impl<TError, F, TBox>(fill_box: F, alloc: A) -> Result<TBox, TError>
        where TError: MaybeAllocError,
        F: FnOnce(*mut T) -> TBox,
    {
        let ptr = alloc.allocate::<TError>(Layout::new::<T>())?;
        Ok(fill_box(ptr))
    }
}

impl MaybeAllocError for AllocError { } // Infallible allocation
impl MaybeAllocError for ! { } // Fallible allocation

impl<T> Box<T, Global> {
    pub fn new<Emplace>(x: T) -> Box<T, Global> {
        Box::new_impl(move |ptr| { ptr.write(x); Box::from_raw(ptr) }, Global).unwrap_infallible()
    }

    pub fn new<Emplace + Fallible>(x: T) -> Result<Box<T, Global>, AllocError> {
        Box::new_impl(move |ptr| { ptr.write(x); Box::from_raw(ptr) }, Global)
    }

    pub fn new<Zeroed>() -> Box<MaybeUninit<T>, Global> {
        Box::new_impl(|ptr| { ptr.write_bytes(0, 1); Box::from_raw(ptr as *mut MaybeUninit<T>) }, Global).unwrap_infallible()
    }
}

impl<T, A> Box<T, A> {
    pub fn new<Emplace + Alloc>(x: T, alloc: A) -> Box<T, A> {
        Box::new_impl(move |ptr| { ptr.write(x); Box::from_raw_in(ptr, alloc) }, alloc).unwrap_infallible()
    }
}

Isn't this just...

Function overloading

Yes, and no.

They are similar in that the goal is to choose the correct variation of a function depending on the context of the call site.

However, Function Variants have some benefits over the typical function overloading that you'd see in C++, Java, and C#:

  • Functions can only be overloaded by their parameters, not by their return type.
  • There is no way to select a specific overload, other than adding casts to each parameter and explicitly stating each default/optional parameter.
  • Overloading usually has complex rules about "building an overload set" (made worse by type hierarchies) and then "selecting the 'best' overload" (made worse by implicit conversions, generics/templates, default parameters and variadic arguments). Function variants has a simple set of rules: it builds the set by name and parameter count, then selects the variant by what EXACTLY matches the parameter and return types. If there is more than one variant that matches, then raise an error to show the developer what matched and how to explicitly select one of those matches.
  • Overloading doesn't help the documentation issue: each overload is a separate function with its own docs, it just happens to share a name with other functions.

Template function specialization

Superficially the two look similar, and certainly the implementation of Function Variants borrows heavily from template specialization, but there is one key difference: template specialization cannot fundamentally change the function signature.

Consider the new_impl function declared above:

fn new_impl<TError, F, TBox>(fill_box: F, alloc: A) -> Result<TBox, TError>
    where TError: MaybeAllocError,
    F: FnOnce(*mut T) -> TBox,

In order to support all of the different capabilities of the Function Variants that utilize this implementation function, its signature is quite horrible:

  • The return type contains TBox to accommodate both Box<T> and Box<MaybeUninit<T>>.
  • The return type contains TError to allow for fallible allocation returning AllocError and infallible allocation returning ! (which then needs to be unwrapped to get rid of the Result type).
  • alloc is a required parameter, so Global must be explicitly where the global allocator is used.

Open issues

Again, I would like to focus this Pre-RFC on the caller syntax - the implementation syntax needs a lot of work, prototyping and naming discussions.

  • In general, does this seem like a reasonable approach to dealing with function variations/options?
  • Should the marker types be real types? Or "local names" declared by the function definitions?
    • Real types would allow type checking (i.e., to avoid one definition accidentally introducing a typo as a new marker).
    • Real types could be "forwarded" to other functions (as one can do with generic parameters today). This could be useful but may also be a complete nightmare (since Function Variants can have different signatures).
    • Markers are only useful for Function Variants that support them - it seems like a lot of boilerplate to have to declare a new type just to be used as a marker.
  • How to ensure that all markers are documented (and link markers to their docs)? Do we need to introduce new doc syntax for this?
  • How to introduce this without it being a breaking change?
    • Could it be gated by an Edition? (It should be possible to detect where a break would occur by detecting if a function would have a different signature with the feature enabled).
6 Likes

I feel like grouping methods should be a reasonable new feature idea for rustdoc, if it doesn’t exist already (I’m referring to the feature idea existing somewhere, i.e. a different thread or issue). You could just create a grouping like “constructors” once there’s a lot of methods; and/or you could perhaps allow re-defining alphabetical order by specifying “this try_ is just a prefix that shouldn’t be considered for alphabetical order”. Or something like that.

Regarding naming, if Box::new_uninit is a method we need at all (I haven’t looked into the tracking issue too much; it seems to be only because optimizing Box::new(MaybeUninit::new()) is considered hard?), then the question whether it should be named Box::uninit or Box::new_uninit isn’t necessarily settled yet anyway, right?


A counterpoint may be that the use of different names for different functions, and the lack of overloading in Rust helps a lot with type inference.


Overall, after reading through the problem statement, I feel like the correct / most straightforward way to address most of this – apart from that short detour paragraph essentially just stating what sounds to me like “function overloading would be nice” – is just by improving documentation, and perhaps expanding the capabilities of what’s possible through rustdoc, in order to achieve sufficient improvements.

9 Likes

One question that comes to mind is that, with this, what is the type of Box::new?

Another about the caller side is that these are not generic types, so how is this supposed to be handled? Where and how are these selector names declared? I think using generics on either side is not going to age well. Some more fun error cases:

Box::<Emplace>::new();
Box::new::<u32>();

Let's say the method is also generic independently. How is that going to work on either side?

Box::new::<SignatureSelect, u32>();

fn foo<Zeroed, T>() { // How to tell that `Zeroed` is a call selector and `T` is a generic parameter?
}
2 Likes

If you're relying on inference of the return type to distinguish between new and try_new,

  1. This can be done already via return type generics (see e.g. Iterator::collect), and

  2. You're going to have a bad time, because Box::new(x)? is

    match Box::new(x) { _0 => match Try::branch(_0) {
        Continue(_1) => _1,
        Break(_2) => return FromResidual::from_residual(_2),
    }}
    

    which is not going to infer try_new like you want it to with the current inference. If this is what we do, it'll require an all-new inference algorithm to handle. The current one actually gives up pretty quick if you do anything other than pass inference variables to a function that is concrete or exactly as equally generic.


The current set of constructors is not ideal, yes. But if we have defaults that impact inference, we can do a lot better:

impl<T, A> Box<T, A> {
    pub fn new(value: T, alloc: A = Global) -> Box<T>
    where
        A: Allocator,
    { ... }

    pub fn new_uninit<Fill = Zeroed>(alloc: A = Global) -> Box<MaybeUninit<T>>
    where
        A: Allocator,
        Fill: AllocationKind,
    { ... }

    pub fn try_new<Fill = Zeroed>(alloc: A = Global) -> Result<Box<MaybeUninit<T>>
    where
        A: Allocator,
        Fill: AllocationKind,
    { ... }

    pub fn pin(value: T, alloc: A = Global) -> Box<T>
    where
        A: PinningAllocator,
    { ... }
}

Notable improvements w.r.t. API sprawl:

  • new/new_in is the same function.
  • new_uninit takes a generic parameter for the kind of allocation (malloc-like/calloc-like) rather than two different functions[1].
  • try_new only needs an uninit variant; you can pin/initialize/etc after the allocation, plus returning the T in case of allocation failure makes a non-two-step API really awkward.

In any case, I think stabilizing new Box constructors or trying to design a feature to reign in constructor sprawl is a bit too soon until we properly figure out emplacement (which may itself be another axis of constructors). As @steffahn notes, Box::new_zeroed() is just Box::new(MaybeUninit::zeroed()) except that it's guaranteed strongly implied to just be a calloc rather than malloc + memcpy.


  1. The Allocator trait used to take a function parameter for this, but the cost was deemed too high as it couldn't optimize our over the global allocator interface. Box doesn't have this same limitation, and it can use a type level argument to do the selection anyway. ↩︎

4 Likes

Adding some mechanism to group in the docs would certainly help, and it seems feasible in the short-term.

However it doesn't solve all of the issues that I've raised:

  • What about in IDEs? They may not have a mechanism to group suggestions.
  • pin is missing a bunch of permutations, do we really want to add another 10 functions to cover everything? And if there is another permutation, that's an additional 12 methods...
  • Grouping suggests that the functions are related, but it doesn't who how they are related or what the various prefixes/suffixes mean.

As lack of overloading helping type inference, you're probably correct (I'm not a compiler expert) - I have no idea how adding a feature like this would mess with it...

Box::pin is really just a convenience function for the common case. You can always just convert your Box<T> into Pin<Box<T>> afterwards.

1 Like

Box::pin(x) is a helper for Pin::from(Box::new(x)) and doesn't have any performance considerations over the longer spelling, just a safety advantage. Those using an uninit new necessarily are doing unsafe to initialize the place, so it seems fine to require to unsafely pin the pointee. The one place it might reduce unsafe usage is with try_new, since "try_pin" wouldn't require using unsafe, but fallible allocation (in the global allocator) is a niche operation (due to how OOM works on the major OSes) anyway.

EDIT: ninjad by @steffahn pointing out the safe From conversion

No need for the unsafety, see the conversion implementation I linked above.

Since you missed that, that does arguably say something about bad visibility of From-based constructors. I suppose if we would add the ability to group/list constructors (or arbitrary named groups of methods), methods from traits would need to be allowed for this, too, so that such From-constructors can be highlighted better… :thinking:


Reviewing the large amount of From implementations, I’m not 100% sure anymore if all of them, of which of them, should be listed as “constructors” in such an overview. In any case, Box::pin should definitely be made to appear closer to that From implementation IMO, so that readers can immediately learn that Box<T> -> Pin<Box<T>> conversion is possible, too.

On a third thought… as a first step, maybe the documentation of Box::pin should just outright mention that it’s equivalent to <Pin<Box<_>>::from(Box::new(x)). (Edit: Or rather equivalent to Box::new(x).into_pin()   Box::into_pin(Box::new(x)); see below.)


Edit: Oh, there’s also (unstable) Box::into_pin

Edit2: Ah, and the thing is literally just about to be stabilized, too ^^.

Edit3: Now it’s merged. And I opened #97655 for adding the abovementioned Box::into_pin(Box::new(x))-explanation to Box::pin-docs.

5 Likes

Definitely not capable of adding anything useful to the discussion WRT the compiler, but purely from a language syntax standpoint, the concept of grouping could be more formally represented in the syntax. In many languages, to the reader the only grouping is noticing that they have the same name. E.g. something like:

pub variant fn new {
    <Emplace>(x: T) -> Box<T, Global> {
        // Implementation here...
    }
    <Emplace + Fallible>(x: T) -> Result<Box<T, Global>, AllocError> {
        // Implementation here...
    }
    <Zeroed>() -> Box<MaybeUninit<T>, Global> {
        // Implementation here...
    }
}
  • Removes the repetition of the function declaration
  • Removes the repetition of the name
  • Makes it visibly obvious that they're variants
  • I tend to think that variants should all share a visibility

They definitely support it for other languages. Generally they suggest the function name as is, then when opening the parenthesis, the suggestion box will have up/down arrows, as well as inferring while you type.

Example Typescript Repl is a basic editor that has the behavior.

If you type:

example(

at the bottom, it'll pop up suggestions, and if you start typing a number like:

example(1

it will switch to the number variant.

One question that comes to mind is that, with this, what is the type of Box::new ?

As with any other open generic function, the type of Box::new will depend on its context and what the compiler can infer.

Another about the caller side is that these are not generic types, so how is this supposed to be handled? Where and how are these selector names declared? I think using generics on either side is not going to age well. Some more fun error cases:

Box::<Emplace>::new();
Box::new::<u32>();

I disagree: using the existing generic syntax makes this familiar to developers and should allow the compiler to re-use its existing inference system. The set of markers that are allowed to be used depends on the actual function definitions - the compiler can inspect there, see what markers are used and then build a set of valid markers that can be used at the call site (along with the function signatures for each).

For the cases that you present, attempting to use a marker for a normal type parameter will result in the compiler complaining that it can't find the type (as it would today). Trying to use a normal type where a marker is required will result in the compiler complaining that it can't find that marker, or that it is not a valid function variant.

Let's say the method is also generic independently. How is that going to work on either side?

This is dependent on the declaration of the function:

pub fn new<variants>(...);

In this signature variants is a new keyword that indicates to the compiler that generic parameter can only be replaced with markers. This is no different to how Rust currently handles type parameters vs const generics.

Definitely not capable of adding anything useful to the discussion WRT the compiler, but purely from a language syntax standpoint, the concept of grouping could be more formally represented in the syntax. In many languages, to the reader the only grouping is noticing that they have the same name. E.g. something like:

pub variant fn new {
    <Emplace>(x: T) -> Box<T, Global> {
        // Implementation here...
    }
    <Emplace + Fallible>(x: T) -> Result<Box<T, Global>, AllocError> {
        // Implementation here...
    }
    <Zeroed>() -> Box<MaybeUninit<T>, Global> {
        // Implementation here...
    }
}

Wow, I love this! Definitely addresses a bunch of issues with my suggested syntax (although it'll be more difficult to implement).

What about in IDEs? They may not have a mechanism to group suggestions.

They definitely support it for other languages. Generally they suggest the function name as is, then when opening the parenthesis, the suggestion box will have up/down arrows, as well as inferring while you type.

I was talking about grouping methods together when displaying the list of all methods, rather than showing a list of overloads.

If we add support in rustdoc to be able to group together functions, that helps the HTML docs, but not IDEs. Most IDEs show a flat, alphabetical list of members (sometimes with the option of filtering by member type):

bar
bar_in
baz
foo
foo_in
try_bar
try_bar_in

There's no IDE that I know of that will allow this list of members to then have arbitrary grouping:

Bar methods
  bar
  bar_in
  try_bar
  try_bar_in
baz
Foo methods
  foo
  foo_in

Ah, I misunderstood. Yeah, I don't think there is. The filtering behavior of suggestions is trying to always put the most relevant at the top, so the "header" would be in the spot for the tab-completion.

vscode-api#CompletionContext

I think the question was, what do I put here?

let make_box: ??? = Box::new;

At the moment, that's (iirc) for<T> fn(T) -> Box<T>. Generic types are still types.

I think the question was, what do I put here?

let make_box: ??? = Box::new;

At the moment, that's (iirc) for<T> fn(T) -> Box<T>. Generic types are still types.

If you're referring to capturing an instance of the generic, you can still use the same syntax and the compiler will attempt to infer which Variant you wanted:

let a: fn(i32) -> Box<i32> = Box::new;    // Infers Box::new<Emplace>
let a: fn() -> Box<i32> = Box::new;    // Error: Ambiguous between Zeroed and Uninit

However, if you are referring to the open generic, then I don't believe that Rust allows you to do that (I could be wrong: I tried to figure out how to do this, but maybe I missed something).

This smells like the {integer} pseudo-type to me. One representation with multiple types that must be determined by some other inference (though, IIRC, {integer} will default to i32 for C-shaped reasons). This means that this change will make some code ambiguous where the type may have been inferred by Box::new today.

let x = |vec: Vec<_>| vec.into_iter().map(Box::new);
let f = x(vec![3]);
f.for_each(|i| println!("{:?}", i))

So is this using Box::new(T) -> Box<T, Global> or Box::new(T) -> Result<Box<T, Global>, AllocError>? How can you tell?

Without some "default" mechanism, this is a breaking change.

100% agree: Changing a function to be a Function Variant is a breaking change. If we were to change existing functions in the standard library, we'd have to figure out a mechanism for users to opt-in and then change the default at some point (probably with an Edition). The nice thing is that tooling can be built around this: you could detect if a call site with Function Variants enabled does not resolve to a particular variant, and then present an automatic fix where it changes that call site to use explicit syntax.

Just a random idea I got after reading:

What if we make Box impl to contain a generic function alloc (in is keyword), same for uninit and zeroed; make these return a builder and have:

Box::alloc(alloc).uninit().try_new();
Box::zeroed().new()
3 Likes

Using a builder pattern is interesting, but it has a few issues:

  • It doesn't help with different options having different return types: you'd either need to have multiple methods on the builder type, or different builder types.
  • It's difficult to encode that options are mutually exclusive without using runtime errors. You could use different types (e.g., once you use uninit() you get a BoxBuilderUninit which doesn't have a zeroed method), but then you're multiplying the different return types issue per each one of these types. You could use an enum to represent the mutually exclusive options, but that doesn't encode that each option may have different inputs (e.g., Emplace requires one value and Zeroed/Uninit require no values).
  • I'm not sure how builders would work for methods: what does Vec::push vs Vec::try_push look like if we're using builders?

Builders are useful for non-method functions that produce a value, where the function signature to produce that value is uniform (e.g., always fallible or infallible), but that's not what I'm trying to solve here.

2 Likes

Sticking just to the doc-grouping question, something like a #[doc(group = "constructors")] seems like a minimal solution. It's repetitive versus having some grouping construct, but it avoids new syntax.

1 Like