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 bothBox<T>
andBox<MaybeUninit<T>>
. - The return type contains
TError
to allow for fallible allocation returningAllocError
and infallible allocation returning!
(which then needs to be unwrapped to get rid of theResult
type). -
alloc
is a required parameter, soGlobal
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).