Pre-RFC: trait class for single inheritance

EDIT (20250405): remove the statements that the trait class can contain an associated enum item, and move it to the "future possibilities" part as it is largely related to something like enum-dispatch.


Summary

Introducetrait class containing to support single inheritance.

Motivation

It is hard to design a GUI framework in Rust without inheritance. E.g., rendering objects are usually organized as a tree, and they share lots of properties and behaviours but vary with one another. Rust now has excellent trait for abstraction over different types, but may lack reusability or extendability (e.g. it is not easy to extend from a trait interface with default implementations and keep most of its behaviours but override only a few of them).

Moreover, It is not easy to construct custom virtual tables and use them in Box, Rc, etc. as they don't support pointers with custom metadata.

It has been greatly debated whether to introduce inheritance in Rust. I would like to vote yes but it definitely should be done in a native rusty way.

Guided-level explanation

This (Pre-)RFC is to introduce trait class that is a combination of a trait and an ADT.

Example: trait class as a type

trait class can be used like a struct type:

pub trait class Person {
    // `struct` specify the data structure contained by this trait class,
    // besides `struct`, `enum` is also allowed (`union` is not allowed).
    struct {
        pub name: String,
        pub age: u32,
    }
}

mod adt {
    // It can be used samely as the following type
    pub struct Person {
        pub name: String,
        pub age: u32,
    }
}

The above trait class is a simple case with only a struct, and there is no difference with the struct inside when it is used as a type:

let person = Person { name: "Bob".to_string(), age: 38 };
let person = adt::Person { name: "Bob".to_string(), age: 38 };

Person has the same memory layout with adt::Person as it doesn't have a superclass.

Example: trait class as a trait (bound)

trait class are also very similar to the trait:

pub trait class Animal {
    fn make_sound(&self);
}

mod _trait {
    pub trait Animal {
        fn make_sound(&self);
    }
}

The above trait class can be used similarly like a trait:

fn foo<T: Animal>(animal: T) {
    animal.make_sound();
}

fn bar<T: _trait::Animal>(animal: T) {
    animal.make_sound();
}

or by dynamic dispatch:

fn foo(animal: &dyn Animal) {
    animal.make_sound();
}

fn bar(animal: &dyn _trait::Animal) {
    animal.make_sound();
}

This is on the public interface side of the trait class. However, trait class is different from trait on the implementation side.

To "implement" a trait class, you have to inherit from it instead of impl it.

// pub trait class Animal { ... }

pub trait Dog : Animal {
    // it contains an implicit `struct {}` definition
    override fn make_sound(&self) {
        println!("Wolf!");
    }
}

mod _trait {
   // pub trait Animal { ... }
    pub struct Dog {}
    impl Animal for Dog {
        override fn make_sound(&self) {
            println!("Wolf!");
        }
    }
}

Then, the inherited trait class can be used similarly like the implemented type:

// `use` is not needed to call a trait class method, as it belongs to the type `Dog` itself.
let dog = Dog {};
dog.make_sound();

//  `use _trait::Animal` is needed to call a trait method. 
let dog = _trait::Dog {};
dog.make_sound();

Example: using trait class to express inheritance

Data and behaviours can be easily reused via trait class:

pub trait class Person {
    struct {
        name: String,
        age: u32,
    }
    fn greet(&self) {
        println!("Hello, my name is {}. I'm {} years old.", self.name, self.age);
    }
}

// `Employ` inherits data and behaviors from its superclass `Person`
pub trait class Employee: Person {
    struct {
        company: String,
    }
    // Using `override fn`, we can override its superclass's behaviour.
    override fn greet(&self) {
        // We can reuse its superclass's behavior via `super.method(...)` syntax
        super.greet();
        println!("I work for {}.", self.company);
    }
}

pub trait class Developer: Employee {
    override fn greet(&self) {
        super.greet();
        println!("I'm a developer.");
    }
}

Constructing the trait class objects:

let person = Person { name: "Bob".to_string(), age: 38 };
person.greet();

let employee = Employee {
    name: "Alice".to_string(),
    age: 45,
    company: "Facebook".to_string(),
};

employee.greet();

let developer = Developer { ..employee };
developer.greet();

// Output:
/*
Hello, my name is Bob. I'm 38 years old.
Hello, my name is Alice. I'm 45 years old.
I work for Facebook.
Hello, my name is Alice. I'm 45 years old.
I work for Facebook.
I'm a developer.
*/

Trait classes support both static and dynamic dispatch like traits:

// Static dispatch over trait class `Person`
fn hello<T: Person>(person: &T) {
    person.greet();
}
fn hello_dyn(person: &dyn Person) {
    person.greet();
}

let developer = Developer {
    name: "Bob".to_string(),
    age: 38,
    company: "Intel".to_string()
};
hello(&developer);
hello_dyn(&developer);
// Both above prints:
/*
Hello, my name is Bob. I'm 38 years old.
I work for Intel.
I'm a developer.
*/

Notice that when the trait class Person is used as a type, it is not polymorphic:

fn hello_non_poly(person: &Person) {
    person.greet();
}
hello_non_poly(&developer);
// It only prints the first line from `Person::greet`.
/*
Hello, my name is Bob. I'm 38 years old.
*/

Reference-level explanation

The trait class consists of the header part (approximately the same as a trait) and a block { } with zero-to-many associated items.

The syntax of the header is like this:

// trait class header
$meta
$vis $(abstract)? trait class $name $(<$generics>)? 
    : $($superclass)? $(+ $trait_bound)*
    $(where $($where_clause,)* )?

The associated items include:

  • Zero or one associated struct. Syntax $meta struct { $($field_def)* }
  • Zero-to-many associated types, the same as in a trait.
  • Zero-to-many associated constants, the same as in a trait.
  • Zero-to-many associated functions:
    • required functions that end with ;, without bodies, the same as in a trait.
    • provided functions that end with a body block { $block_expr }. Syntax
$meta $(override)? fn $($path::)? $name(
    $($receiver, )?
    $($pat: $ty,)*
) $(-> $ty)? 
$(where $($where_clause,)* )?
{
    $block_expr
}`

where the keyword override means it overrides other functions from its superclasses or supertraits.

Constructors are special associated functions that don't have a self receiver and return type Self

trait class as a type

In a trait class, we can define an associated struct item. Tuple structs and unit structs are not allowed to be associated struct items. If the user does not specify the associated struct, there is an implicit empty struct struct {} by default.

The trait class can inherit from another trait class. Writing trait class A : B means A extends from B, and we say A is the (direct) subclass of B and B the (direct) superclass of A. Superclass and subclass relationship is transitive: i.e. if A is the superclass of B, and B is the superclass of C, then A is also the superclass of C, vice versa.

Conceptually, every trait class can be treated as a struct with fields collected from its own and all of its superclass's associated structs. The names of fields must be unique in the trait class itself and its all superclasses (i.e., subclasses cannot override fields or variants of their superclasses).

When a trait class is used as a type, it means a monomorphic type. In contrast, a trait class can also be used as a trait bound or a trait object, which means a polymorphic type.

trait class Base {
    struct {
        field1: u32,
    }
}
trait class Extended : Base {
    struct {
        // field1: u32, //~ ERROR `field1` duplicated with `Base::field1`
        field2: u32,
    }
}

can be conceptually viewed as:

#[non_exhaustive]
struct Base {
    field1: u32,
}

// A flat struct with all fields from `Base` and `Extended`
#[non_exhaustive]
struct Extended {
    field1: u32,
    field2: u32,
}

Value construction, field access and pattern matching

Trait classes much resemble normal structs regarding value construction, field access, and pattern matching, except that the special field super can be used to represent the (direct) superclass during value construction and pattern matching.

When a trait class is used as a trait bounded or a trait object, the pattern matching of its values always requires the non-exhaustive notation .. in the struct pattern.

// Value construction
let base = Base { field1: 0 };
let extended = Extended { field1: 0, field2: 0 };
// use the special field `super` explicitly.
let extended = Extended { super: base, field2: 0 };

// Field access
let field1 = extended.field1;
let field2 = extended.field2;
let field3 = extended.field3;

// Pattern matching
let Extended { field1, field2 } = extended;
// use `super` to explicitly ignore fields from its superclass.
let Extended { super: _, field2 } = extended;

// Pattern matching for the polymorphic type
fn f<T: Base>(base: T) {
    let Base { field, .. } = base;
}

Abstractness of the trait class

We say a trait class abstract if any of the following holds:

  • it contains any associated functions without a default implementation.
  • it is explicitly restricted with abstract.
  • it doesn't implement (override) any unimplemented functions from its superclasses or supertraits.

Abstract trait class are not allowed to be used as a type, nor constructing its value (except in the constructors), but can be used as trait bounds or trait objects.

Memory layout

The associated struct of a trait class can be either repr(Rust) (by default) or repr(C).

In any representation (both repr(Rust) and repr(C)), the memory layout of any subclass must be compatible with its superclass's, that is:

  • the subclass must preserve the offsets of all fields and all variants (including variant fields) from all its superclasses.

For repr(Rust), there is no guarantee of the layout of fields and variants except the compatibility rule above.

For repr(C), the memory layout of a trait class is the same as the flatten struct with all fields of its own and its superclasses:

  • field order is preserved, and all superclass fields are placed before the subclass fields.
  • the first field begins at offset 0 if it doesn't have a superclass.
  • each field's offset is aligned to the ABI-mandated alignment for that field's type, possibly creating unused padding bits.
  • the total size of the struct is rounded up to its overall alignment.

trait class as a trait bound

The trait class can be used as a trait bound, and of course, it can be used together with other trait bounds (but at most one trait class is allowed in the trait bound).

Assuming TraitClass1 and TraitClass2 are trait classes, and Trait1 and Trait2 are normal traits:

fn f<T: TraitClass1 + Trait1 + Trait2>() {} //~ OK
fn g<T: TraitClass1 + TraitClass2>() {} //~ ERROR at most 1 trait class bound is allowed
fn h(x: impl TraitClass1 + Trait1) //~ OK
fn i(x: impl TraitClass1 + TraitClass2) //~ ERROR at most 1 trait class bound is allowed
fn j() -> impl TraitClass1 + Trait1 //~ OK
fn k() -> impl TraitClass1 + TraitClass2 //~ ERROR at most 1 trait class bound is allowed

Trait bounds can also appear in the definition of the trait class, which is a requirement instead of an providing of such trait bound. All trait bounds must be implemented then the trait class is allowed to used as a type.

trait class Example: fmt::Debug {
    struct {
        field: u32,
    }
    fn example(&self) {
        println!("{self:?}");
    }
}

imp fmt::Debug for Example {
    fn debug(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Example").field("field", &self.field).finish()
    }
}

Trait bound can also be implemented in-place:

trait class Example: fmt::Debug {
    struct {
        field1: u32,
    }
    fn example(&self) {
        println!("{self:?}");
    }
    // implement the `fmt::Debug` trait in-place inside the trait class
    // the keyword `override` is required as it is implementing its' supertrait's function
    override fn debug(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Example").field("field1", &self.field1).finish()
    }
}

Subclasses can override superclass's trait implementation:

trait class Inherited: Example {
    struct {
        field2: u32,
    }
    override fn debug(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Inherited")
            .field("super", &super)
            .field("field2", &self.field)
            .finish()
    }
}

trait class as a trait object

When a trait class is dyn-safe (obeying the same rule as normal traits), it can be used as a trait object (naming a trait class object) via the keyword dyn.

For any trait class TraitClass:

  • dyn TraitClass is unsized and must be used as a reference or pointer (Box, Rc, etc.).
  • dyn TraitClass implements Pointee where <dyn TraitClass as Pointee>::Metadata is DynMetadata<dyn TraitClass>, conceptually a &'static reference to the vtable of the trait class.
  • TraitClass implements Unsize<dyn TraitClass> so that any reference or pointers of TraitClass can be coerced into dyn TraitClass's.

Vtable of a trait class

Similar to the dyn-safe trait, each dyn-safe trait class also has a vtable. We can simply reuse the vtable layout of the trait object for trait class objects because it just add an associated data item that will not affect its vtable.

Thanks to the stabilized trait object upcasting feature, we can leverage it to support upcasting for trait class objects, too.

Subtyping/Auto-deref/Upcasting rules of trait class objects

In order not to increase the complexity of the Rust's type system, we do not introduce new subtyping for trait classes. We only add some auto-deref and upcasting mechanism.

Due to the guaranteed compatible trait class data layout regarding its superclasses, and reusing the compatible trait object vtable layout regarding its first supertrait, we can safely allow any reference or pointer to the trait class to be coerced to its superclass. We can even support upcasting of the trait class object to its other trait bounds besides the superclass.

For trait class types, we implement Deref and DerefMut to its direct superclass type.

trait Trait {}
trait class Base: Trait {}
trait class Extended : Base {}
fn f(x: &dyn Extended) -> &dyn Base { x } //~ OK trait-class-to-trait-class upcast
fn g(x: &mut dyn Base) -> &mut dyn Trait { x } //~ OK trait-class-to-trait-bound upcast
fn h(x: &Extended) -> &Base { x } //~ OK auto deref
fn i(x: &mut Extended) -> &mut dyn Base { x } //~ OK unsize coercion

Common std traits implementations of trait classes

  • Debug: #[derive(Debug)] can be applied to a trait object when all of its fields and variants are implemented Debug.
  • Clone: similar to Debug but doesn't apply to trait class objects as it requires Sized.
  • Copy: similar to Clone.
  • Default: similar to Clone.
  • PartialOrd, Ord, PartialEq and Eq: compares each field of two trait class types, but doesn't apply to trait class objects because it is not dyn-safe.
  • Hash: similar to Debug.
  • Send: a trait class is Send iff all of its fields and fields in variants are Send.
  • Sync: similar to Send.
  • Unpin: similar to Send and Sync.
  • Freeze: a trait class is Freeze when all of its fields and fields in variants do not contain UnsafeCell.

Drawback

  • It increases the complexity of the concept trait.
  • It can be quite confusing that TraitClass and dyn TraitClass are different types.
  • Inheritance may be misused.

Rationale and alternatives

TODO

Prior art

TODO

Unresolved questions

  • How to support custom #[derive] macros for trait classes?
  • Should trait classes be allowed to be used as traits that can be implemented by any types?
  • Should (checked) downcasting of trait class objects be supported?

Future possibilities

In this (Pre-)RFC, trait class can be dynamically dispatched by the fat-pointered trait object. Sometimes we may also want to use more cache-friendly enum-dispatch strategies. However, enum is not extendable, i.e. client users cannot add new variants to an enum defined in a library crate. We may extend the ability of trait classes to support enum-dispatch objects, i.e. using an discriminant value inlined in each trait class object header to indicate which type the instance belongs to, instead of using an extra vtable that requires fat pointers.

For example, assuming the notation #[repr(enum, Int)] enables a trait class to use enum-dispatch with an optional integer type to represent the discriminant value:

// it allows at most 256 classes to be inherited
#[repr(enum, u8)]
pub trait class Base {
    fn f(&self) {
        println!("Base");
    }
}
trait class Extended1 {
    struct { v: u32 }
    override fn f(&self) {
        super.f();
        println!("Extended1");
    }
}
trait class Extended2 {
    struct { v: u32 }
    override fn f(&self) {
        super.f();
        println!("Extended1");
    }
}

then the library crate generates code like

#[repr(u8)]
#[non_exhaustive]
pub enum Base {
    Extended1(Extended1),
    Extended2(Extended2),
}
impl Base {
    fn _f_impl(this: &Self) {
        println!("Base");
    }
    pub fn f(&self) {
        match self {
            Self::Extended1(extended1) => extended1.f(),
            Self::Extended2(extended2) => extended2.f(),
        }
    }
}
impl Extended1 {
    fn _f_impl(this: &Self) {
        println!("Extended1");
    }
    fn f(&self) {
        Base::_f_impl(self);
        Self::_f_impl(self);
    }
}

impl Extended2 {
    fn _f_impl(this: &Self) {
        println!("Extended2");
    }
    fn f(&self) {
        Base::_f_impl(self);
        Self::_f_impl(self);
    }
}

When the client code adds a new subclass of Base, it simply just adds a new variant to Base. If the discriminant values of the base trait class have been run out (e.g., at most 256 variants can be defined), the compiler reports an error. In other words, the library writers must decide how many discriminates they want to reserve. Usually, 256 of u8 or 65536 of u16 is a reasonable limit for the number of all subclasses.

What this reads to me, is that this is just OOP-flavored desire for traits to be able to specify required fields.

3 Likes

Where "fields" is arguably just a desire for compiler-understood disjoint borrows, aka view types https://smallcultfollowing.com/babysteps/blog/2024/06/02/the-borrow-checker-within/#step-3-view-types-and-interprocedural-borrows.

2 Likes

Not really. It's a desire for code reuse and partial overriding in a tree-shaped hierarchy.

We can define different behaviours for different types and dynamically dispatch via trait objects, but it's hard to reuse part of them. Such as, we can define Human, Cat, and Snack types, and a trait Animal for thousands of common behaviours like moving, but it's hard to define an abstract Mammal type that overrides a set of hundreds of common behaviours of Animal (such as mammals usually have 4 feets), so that Human and Cat can easily reuse the codes. And then, Mammal can also be overridden by Primates for tens of common behaviours that the Human type can reuse but not the Cat type (I'm not a biologist, just take this for a common example)

You can get most of the way there by composition (making your "subclass" contain an instance of the "superclass").

Then you can have methods that forward to methods of the "superclass" (here it would be useful to have delegation, to reduce boilerplate). You can also expose the "superclass" "instance" with a method, so users can call methods on it directly.

You also have the option to implement Deref<Target=Superclass>, although you're not supposed to do that because Deref has different semantics.

4 Likes

We've all seen Mammal and Animal and Cat before, but those kind of toy inheritance patterns are illustrations of how inheritance works, not motivation for why it should be.

Rust already has features that support code reuse, several of which can be organized into hierarchies, like generics, trait objects, and enums - not saying any of these is a drop-in replacement for inheritance (nothing is), but collectively Rust's feature set is a solid argument against the notion that Smalltalk-flavor inheritance is a necessary and desirable language feature.

12 Likes

That's a profound misunderstanding of OO, inheritance, and Smalltalk.

Smalltalk is a dynamically typed language that has sub-classing as a strategy for code reuse.

Inheritence as people commonly understand it today is based on the Java style approach (or C++, or C#..) where the example above becomes relevant: it provides a dual purpose: code-reuse as well as sub-typing (that Cat is-a Animal relationship) in a statically typed language.

The issue with inheritance is that duality of purpose. That's why OO best practice today is to avoid inheritance. In Java parlance, you should avoid extends but it's perfectly fine and in fact encouraged to use implements.

In other words, good design in dynamic languages uses sub-classing only whereas good design in static languages (i.e. Rust) ends up utilising sub-typing only.

Feel free to explain what was wrong about my post, rather than accusing me of having a profound misunderstanding of OO and then telling me stuff I already know.

Or don't, because this is about Rust and whether it needs something like classes with inheritance - whether you call it Smalltalk-like or Java-like, is there a compelling argument for it? Because Animal and Mammal isn't it.

2 Likes

I wonder if my thread Idea: partial impls might provide an alternative solution for this sort of code reuse. You could write a default partial impl<T: Mammal> Animal for T to specify that common behavior. (With the current orphan rules, this would require Mammal and Animal to be defined in the same crate, but it should be possible to relax that somewhat.)

2 Likes

Your analysis conflates subtyping and subclassing, a fundamental misunderstanding. The inclusion of Smalltalk is irrelevant to the central issue.

A prevalent bias against object-oriented programming (OOP) is evident in the Rust community. Your reference to Smalltalk seems like an example of this, as its unique mechanisms and design choices are inapplicable here.

To clarify, I do not endorse Java style inheritance. My previous forum contributions demonstrate my opposition to its use. Current best practices in object-oriented languages favor composition over inheritance. Composition aligns better with idiomatic Rust and is already robustly supported.

Made a separate topic for this: Slight relaxation of the orphan rules

1 Like

When you make categorical statements like this, in a tone of voice this abrasive, you are inviting someone to come along and say "while subclassing is not the exact same thing as subtyping, the type theory literature generally agrees that they are closely related and it is possible to use either to model the other with some caution" and back that up with a hyperlink to the most impenetrable 300-page type theory monograph they could find.

2 Likes

What my partial impls idea doesn’t address is data/fields.

I don’t think this is quite it. “Getters and setters with disjoint borrows” is a good approximation for public fields. But what if you just need the fields internally to support the default implementation of your trait, but you do not want them in public API?

If we already had disjoint borrows/view types, to use them in traits, traits would need to have some level of field awareness, I think.

You would want a way to define custom views for sets of fields, and/or a way for traits to talk about required relationships between views without mentioning any specific, concrete view. (Similar to how we can denote complex lifetime relationships, without mentioning any specific concrete lifetime other than 'static.)

I think this approach would make any &view self trait methods non dyn-compatible, which would be pretty unfortunate.

Though OOP is usually bad, it does provide a chance to migrate quickly from other OOP languages (especially for GUI frameworks). For productivity use, probably have to make it run first, then refactor it better.

I'm afraid language-level support of OOP features may not be a good idea. is it possible to provide some scaffolders (like pointers with custom vtables) that help write library-level OOP infrastructures?

It's possible to model C++ style memory layouts in current Rust with something like this:

#[repr(C)]
struct BaseClass<Ext:?Sized> {
    _vtable: BaseClassVTable,
    field1: u32,
    field2: SomeOtherType,
    ext: Ext
}

#[repr(C)]
#[derive(Copy,Clone)]
BaseClassVTable {
    method1: unsafe fn(&BaseClass<[MaybeUninit<u8>]>)
    ....
}

trait BaseClassInterface {
    fn method1(this: &BaseClass<Self>);
}

impl BaseClassVTable {
    const for_iface<T:BaseClassInterface>()->Self {
        BaseClassVTable {
            method1: |this| T::method1( some_ugly_transmute_code(this)),
            ....
        }
    }
}

/// Virtual method calls via `BaseClassInterface`
impl<T:?Sized> BaseClass<T> {
    pub fn method1(&self) {
        let f = self._vtable.method1;
        // Safety: We're calling the `method1` stored in our own vtable, so
        //         this transmute will be reversed by the one from
        //         `BaseClassVTable::for_iface`
        unsafe { f( transmute_to_uninit_slice_extension(self) ) }
    }
}

There's a lot of really poor ergonomics here, like ensuring that _vtable always corresponds to the actual concrete Ext type present and never accidentally calling a vtable method on a different instance of BaseClass. But those don't seem like insurmountable obstacles for a determined proc-macro author.

2 Likes

Yes, but there are some limits, like you cannot check whether an extended class has fields or methods with the same name as the base class unless they are defined in the same proc macro.

BTW, I'd prefer the fat-pointer style as it would be more FFI-friendly and simpler to implement regarding the single-class-multiple-interface inheritance strategy, but I cannot make a custom fat pointer like Rc<TypeWithCustomVtable> so I have to implement a custom struct RcDyn<D, V> { data: NonNull<D>, vtable: &'static V } and implement plenty of helper traits.

Whenever I'm faced with doing dynamic dispatch in ways that Rust doesn't know how to deal with, I've been able to use a pattern like this. Would something similar cover the cases you're concerned with, or am I missing something?

/// The behavior I need to model
trait MyNonDynCompatibleTrait { ... }

/// Trait for erasing the details that Rust doesn't know how to...
trait WithCustomVtable {
    fn vtable(&self)->&'static MyVTable
}

/// Blanket implementation for everything `Sized`...
impl<T:MyNonDynCompatibleTrait> WithCustomVtable for T {
   fn vtable(&self)->&'static MyVTable { ... }
}

/// Reconstitute behavior
impl MyNonDynCompatibleTrait for dyn WithCustomVtable + '_ {
    // Method implementations that dispatch via `Self::vtable()`
}
2 Likes