[Pre-RFC] Final Trait Methods

Pre-RFC: Final Trait Methods

Summary

Allow marking trait methods as final, making them:

  • illegal to override in impl blocks
  • exempt from object safety calculations
  • always statically dispatched, even when called on a trait object (because there is only one possible implementation)

Motivation

Some trait methods are "extension" methods, adding additional functionality for users, without placing an additional burden on implementers. Usually, these methods only have one correct implementation, and by enforcing this, we should be allowed to do more things with the trait (such as having a final method with generics on an object safe trait).

Example:

use std::io::Result;

trait Decoder {
    fn read_buf(&mut self, buf: &mut [u8]) -> Result<usize>;

    final fn read_array<const N: usize>(&mut self) -> Result<[u8; N]> {
        let mut arr = [0; N];
        self.read_buf(&mut arr)?;
        Ok(arr)
    }
}

Here, read_array is an extension method, building on other, lower level methods. Implementers don't need to override it. However, if we want Decoder to be object safe, we have to compromise by adding where Self: Sized to read_array, meaning that we cannot call read_array on an &mut dyn Decoder. Marking read_array final allows the trait to remain object safe.

Reference-Level Explanation

Trait methods can be marked final:

trait Foo {
    final fn bar(&self) {
        // ..
    }
}

final trait methods must have a body, and cannot be overridden in impl blocks.

final trait methods can have any signature, without affecting the trait's object safety.

trait DynSafe {
    final fn qux<T>(&self) {
        // ..
    }
}

Final trait methods:

  • are always statically dispatched
  • doesn't need to be part of trait vtables
  • can have generics and/or be associated

Additionally, associated functions could be dispatched directly from the trait, without specifying the implementing type.

trait Foo {
    final fn bar() -> usize {
        42
    }
}

fn main() {
    // this could be legal, although I haven't found a use case for it yet
    println!("{}", Foo::bar());
}

Backwards Compatibility

Adding final to an existing trait method is a breaking change. However, we could add some sort of #[deprecated_non_final] attribute (akin to deprecated_safe), and display a deprecation warning in any impl blocks that override the method.

Removing final from a trait method is a breaking change.
(Except possibly in some cases where the trait isn't object safe and the method isn't associated, although I haven't fully considered how this could work)

Prior Art

The only other mention I could find of final trait methods (not that I searched very thoroughly): Don't derive `PartialEq::ne`. by nnethercote · Pull Request #98655 · rust-lang/rust · GitHub.

13 Likes

Some other discussions:

https://rust-lang.zulipchat.com/#narrow/stream/213817-t-lang/topic/RFC.3A.20Restrictions/near/303159975

(As you might guess, I'm a fan of the idea.)


"Prior Art" isn't just Rust art, so you probably want to link to final methods in Java too. (And sealed in C#, but Rust uses that word to mean something else.)

Remember as you finalize this to follow the https://github.com/rust-lang/rfcs/blob/master/0000-template.md -- you're missing rationale and alternatives right now, which is one of the most important sections. I'd suggest adding a subsection to it for each thing that someone could ask "why did you pick ______?" about, like I'm going to below.


Extra motivation: this allows unsafe code to trust them as much as a normal function.

For example, range in std::slice - Rust was originally on RangeBounds, but that means that unsafe code couldn't trust it, so it needed to become an ordinary generic function instead of a trait method to be able to trust the implementation.


Extra motivation: Having #[final] methods would be reasonable on #[marker] traits, which currently cannot have methods because with multiple implementations there'd be no way to know which to call.

For example, you could imagine something like

#[marker]
unsafe trait SafeToInitFromZeros: Sized {
    #[final]
    fn zeroed() -> Self { unsafe { std::mem::zeroed() } }
}

This one isn't necessarily obvious to me.

Suppose we made RangeBounds object-safe and put slice::range on it again, as a #[final] method.

range is somewhat complicated, because it needs to handle Bound::{Included, Excluded, Unbounded} on both ends:

If it's not vtable dispatched, then those start_bound and end_bound calls will be vtable dispatched, and the matches will never optimize down, the out-of-bounds checks cannot optimize away, etc. And thus &.. as &dyn RangeBounds<usize> would be horrible.

Whereas if the final method monomorphized for the specific impl, and ended up in the vtable, then there's just the one indirect call to the optimized function, and the inner calls can be static. And thus range on &.. as &dyn RangeBounds<usize> would just have the one dyn call, to something that would directly return 0..bounds.end without needing to do all the other checks.

Thus my instinct here is to say that, as much as possible, a #[final] method behaves just like any other method, with the restriction on overrides being the only difference.

After all, if I wish a not-in-the-vtable not-overridable helper for a trait object today, I can define it via impl dyn MyTrait + '_, without needing final fn.


The obvious bikeshed: final fn or #[final] fn.

(I don't know whether lang has good guidelines on this :slightly_frowning_face:)

7 Likes

I forgot to mention that final is already a reserved keyword (markdown even bolds it in code blocks like other keywords :grin:).

+1 on the extra extra motivation

I think that whether or not the method is statically dispatched could be an implementation detail, as I was hoping you could be able to do something like this (while keeping object safety):

trait Decoder {
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;

    final fn read_array<const N: usize>(&mut self) -> io::Result<[u8; N]> {
        // ..
    }
}

I think the solution here is to have it vtable dispatched if possible (ie. if the method could exist non-final)

Oh, I didn't know that :upside_down_face:

Sadly, I don't know how much it resolves the discussion. non_exhaustive struct Foo { ... } would also have been non-breaking (same as union) but it ended up being #[non_exhaustive], for example.

And the attribute has the advantage of avoiding the "is it final pub fn or pub final fn?" questions. (You'll eventually need grammar details like that in the Reference Explanation.)

I just don't even know which I prefer, let alone which is "better".

How does final interact with const and async ? unsafe ?const ?async final fn bar(&self) { } ? :sweat_smile:

sounds like a good reason to spell it #[final] -- readability of functions with lots of modifiers:

#[final]
fn f<T, R>(v: T) -> R, // name easy to spot since it's not obscured by 9000 modifiers on the same line
where
    T: FnOnce() -> R,
    async if T: ~async FnOnce() -> R,
    const if T: ~const FnOnce() -> R,
    try if T: ~try FnOnce() -> R,
{
    f().do
}
1 Like

Can't you already solve this problem with an extension trait containing the "final" functions and with a blanket implementation for all types implementing the supertrait?

(Not saying that this proposal might not be more ergonomic.)

3 Likes

One place I'm not sure that would work would be for things like how Visitor in rustc_middle::mir::visit - Rust would like to use it -- the default implementation of visit_region calls super_region, and that would at least be really weird if the default implementation called a sub-trait. (That trait has a "please don't override the super_* methods", so it would very much like to mark all those as #[final], though I don't think it depends on it for soundness.)

And I think this is hugely important. Yes, there could be a sealed CopyExt trait with a copy method on it, but then you can't .map(Copy::copy), and would need .map(CopyExt::copy) instead, for example.

And at a more meta-level, I think it's generally worth adding first-class support for common patterns even if they were already possible indirectly. (Assuming, of course, that doing so is restrained in the impact it has on other parts of the language.)

For example, "sealed traits" have been a thing that people have been doing and talking about for a while. But there were multiple possible ways to encode it through visibility, and no obvious "yes this is sealed" marker. So having a language feature for "look, this is a sealed trait" that rustdoc can notice, that error messages can mention, etc is well worth it.

I think that "final trait method" fits well in the same category -- particularly if the rule is really just "it can't be overridden" and everything else is the same for them -- rather than writing in a "rust patterns book" that if you want to do this kind of thing the was is to ____________.

Notably, if the only restriction is on when it can be overridden, then you can always read existing working code without knowing what it does, since the code would do exactly the same thing if you removed the final. The runtime semantics of the code aren't affected at all by it, just some extra compile-time errors against future additions.

2 Likes

Another use case:

Currently, std::error::Error::type_id uses a visibility hack similar to the workaround for sealed traits for soundness (source). Unsafe code would benefit from the guaruntee that the function is implemented correctly, by marking it final.

4 Likes

As prior art, Swift allows extensions of protocols (traits) to define methods which are "final" in the sense used here, i.e:

protocol P {
  func foo()
}
extension P {
  func foo_twice() {
    foo(); foo()
  }
}

In rust syntax

trait P {
  fn foo(&self);
}
impl<T: P> T {
  final fn foo_twice(&self) {
    self.foo(); self.foo()
  }
}

I would appreciate some investigation of the interaction with specialization. It's a bit out of cache, but I recall for example this IMNSHO misdesign of the current implementation:

You can provide a partial implementation by writing default impl. All items provided within are implicitly default. There is no way to make them non-default ("final").

Personally I think partial impl with explicit and optional default is the right design,[1] but an alternative would be to support the final keyword in default impl... and for final on a function with a body in the trait declaration to also mean it's not default like it (effectively) is on stable today. However, this approach isn't necessarily compatible with the desired functionality of final in this topic.

Independent of that, we have "final" functions today (from the specialization POV): any non-default function in an implementation. But clearly those don't meet the desired functionality discussed here.


  1. and that more generally conflating distinct concepts behind a single keyword makes things more confusing and complex, not the opposite ↩︎

1 Like

I think this proposal is basically syntactic sugar for defining free function which accepts an argument bound to a given trait, in the namespace of that trait, accessible via method call syntax.

If this is the case, I think that it doesn't have any interaction with specialization.

3 Likes

simulating final methods (annoying to define the traits, works well enough from the downstream users/implementers afaict):

pub trait FinalMethodsForAny {
    fn type_id(&self) -> TypeId
    where
        Self: Any,
    {
        ...
    }
}

impl<T: ?Sized> FinalMethodsForAny for T {}

pub trait Any: FinalMethodsForAny + 'static {
    // ...
}
1 Like

How would Self in function bodies work when called on trait objects? An &self argument is &dyn Trait, however TypeId::of::<Self>() should return the TypeId of the implementing type (if we want to fix Error::type_id)

The type_id function would need to be monomorphisized and put in the VTable, however that's not possible if the function is generic.

For example, this function can't exist in a VTable:

trait Trait {
    #[final]
    final fn generic<T>(&self, x: T) {
        // if called on a trait object, Self cannot refer to the concrete type here
    }
}

But this one should be in the VTable:

trait Error {
    #[final]
    final fn type_id(&self) -> TypeId where Self: 'static {
        // however, Self must refer to the concrete type here
        TypeId::of::<Self>()
    }

    // ..
}

Here, a type_id free fn wouldn't be able to replace the VTable one.

we need a way to specify/infer if a function should be in the VTable

2 Likes

Alternative syntax idea, to avoid the lots-of-fn-modifiers problem:

use std::io::Result;

trait Decoder {
    fn read_buf(&mut self, buf: &mut [u8]) -> Result<usize>;
}

impl Decoder {
    fn read_array<const N: usize>(&mut self) -> Result<[u8; N]> {
        let mut arr = [0; N];
        self.read_buf(&mut arr)?;
        Ok(arr)
    }
}

// Or perhaps:
// impl<T: Decoder> T { ... }

(though this is only possible in Rust 2021 and above).

3 Likes

We could support older editions with syntax like

impl trait Decoder {
    // ..
}
4 Likes

+1

This would make traits like Iterator more readable since most adapters cannot be overloaded anyways since they don't have a public constructor. (and overloading them is probably bad anyways)

a similar concept is also suggested by the 1210-impl-specialization - The Rust RFC Book RFC, here with the keyword default (I personally prefer final)

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.