[Pre-RFC] object_oriented

Summary

Support for optional object-oriented classes under a crate object_oriented.

Motivation

Class inheritance is commonly used in the following platforms:

  • W3C — Uses class inheritance for custom elements
  • AIR SDK — Uses class inheritance for display objects

Using procedural macros does not solve the issue of class inheritance:

  • it limits the programmer to specifying all super classes for every class declaration,
    • which allows the programmer to miss or mispell a super class in the sequence of super classes for every class declaration;
  • it does not support virtual methods, abstract methods and override methods.

The object_oriented crate is been reserved for this RFC.

Virtual methods

Virtual methods with support for dynamic dispatch are often required in an object-oriented model, and abstract methods allow for exhaustive implementations of subclasses.

Protected methods

Protected methods are important.

Flexibility

This proposal suggests a form of object-oriented programming for Rust that may be used to model classes based either on Box, Rc, or Arc.

Guide-level explanation

Use the object_oriented crate to define object-oriented classes. To use this crate, use the following attribute in your crate:

#![object_oriented]

A class must have either BoxedObject, UnsyncObject, or SyncObject as the topmost super class.

The following program illustrates defining a class based on Arc:

use object_oriented::{class, sync::SyncObject};

class! {
    abstract struct C0: SyncObject {
        x: f64,
        y: f64 = 0.0,
    }
    impl {
        fn constructor(&mut self) {
            self.x = 0.0;
        }
        abstract fn f(&self);

        fn f1(&self) {}
    }
}

class! {
    struct C1: C0 {
    }
    impl {
        fn constructor(&mut self) {
            super();
        }

        override fn f(&self) {}

        override fn f1(&self) {
            super.f1();
        }
    }
}

let mut object = C1::new();
object.x = 10.0;

Object-oriented classes are recommended for particular cases, such as for nodes in interactive systems.

  • Use object_oriented::boxed::BoxedObject as the topmost super class if the class is based on Box.
  • Use object_oriented::unsync::UnsyncObject as the topmost super class if the class is based on Rc.
  • Use object_oriented::sync::SyncObject as the topmost super class if the class is based on Arc.

Protected methods

Protected methods are available through the priv reserved word:

class! {
    struct C1: SyncObject {}
    impl {
        priv abstract fn f(&self) {}
    }
}

Such methods are only accessible from within a class and its subclasses.

Traits

A class C1 implements several traits:

  • If not a BoxedObject, impl Clone for C1 — Clones the reference
  • impl Hash for C1 — Hashes the reference
  • impl PartialEq for C1 — Reference comparison
  • impl Eq for C1
  • impl AsRef<C1> for C1
  • For every super class C0, impl AsRef<C0> for C1
  • If not a BoxedObject, for every super class C0, impl From<C1> for C0
  • If not a BoxedObject, for every super class C0, impl TryFrom<C0> for C1

It does not implement Deref; instead, it introduces a new object-oriented name resolution to Rust, for use with object_oriented.

Representation

Classes are reference types, internally boxed with either Box, Rc, or Arc.

Weak references

You can work with weak references in the context of reference counting using Weak and object.downgrade():

use object_oriented::sync::Weak;

let weak_reference: Weak<C1> = object.downgrade();

Constructor parameters

The constructor accepts a common formal parameter list, including the where clause. It must always contain a &self or &mut self parameter.

The constructor subroutine and the special new method have the same parameter list, except that the new method excludes the first parameter (self).

Reference-level explanation

Syntax

  • class!
    • Consists of a struct followed by an empty impl
    • struct
      • Consists of a name, super class (non optional), and fields
      • Fields may specify a default initializer; more details in the Object-oriented fields section
    • impl
      • Consists of optional constructor and methods in any order.
  • A super(); statement
    • Invokes the super constructor subroutine
    • super(); is only allowed at the block of a constructor subroutine and the subroutine's execution must not terminate before super() executes (control flow check). It must appear if and only if the class has a super class not equals the topmost super class.
  • A super.method() expression
    • This expression invokes a method defined by the super class.
    • This expression requires self in scope whose static type is a a class (C, &C or &mut C).
  • Qualifiers inside impl block from class!: abstract and override
    • Visibility, abstract and override are allowed to be in any order

Availability

The object_oriented crate is available to the programmer by using the following crate attribute:

#![object_oriented]

When the object_oriented attribute is used, consequently the programmer's crate depends on std, as object_oriented relies on std::sync for its object_oriented::sync module, and the crate name object_oriented is made available to the programmer's crate.

The object_oriented class is natively implemented in rustc.

The crate contains the following items:

  • the memory-specific models:
    • object_oriented::boxed::BoxedObject,
    • object_oriented::unsync::UnsyncObject,
    • object_oriented::unsync::Weak,
    • object_oriented::sync::SyncObject,
    • object_oriented::sync::Weak;
  • the class! macro;
  • the ClassError struct (exception returned from object.to::<T>()).

The Object class defines the following methods:

  • object.is::<T>() — Indicates whether an object is a class T or any subclass of T
  • object.to::<T>() — Converts an object to a class T, returning Result<T, ClassError> (alternative to .try_into())
  • object.downgrade() — Downgrades a strong reference to a weak reference, returning object_oriented::Weak::<T>

Representation

The structure of a class is put into either a Box (for BoxedObjects), a Rc (for UnsyncObjects), or an Arc (for SyncObjects).

Class

A class is a struct attached with additional object-oriented class characteristics:

  • An super class (struct Subclass: SuperClass)
    • BoxedObject, UnsyncObject and SyncObject are the only classes without a super class.
  • abstract modifier
    • Indicates that the class privatizes its constructor; therefore, the user cannot call C::new(), but can call super() to invoke the class constructor.
  • A constructor subroutine
    • The constructor subroutine is defined inside the impl block of the class! invokation, in the form fn constructor(&mut self) {}.
      • The first parameter can be either self: &Self or self: &mut Self
    • The constructor subroutine must contain a super(); statement if it extends a class other than Object.
  • A new static method is defined based on the constructor subroutine and the abstract modifier. This method creates the class object and invokes the constructor subroutines.

A class C1 implements all the following traits as already covered in the above explanation:

  • If not a BoxedObject, impl Clone for C1 — Clones the reference
  • impl Hash for C1 — Hashes the reference
  • impl PartialEq for C1 — Reference comparison
  • impl Eq for C1
  • impl AsRef<C1> for C1
  • For every super class C0, impl AsRef<C0> for C1
  • If not a BoxedObject, for every super class C0, impl From<C1> for C0
  • If not a BoxedObject, for every super class C0, impl TryFrom<C0> for C1

Object-oriented fields

  • The name of class fields must not conflict with the fields of the super classes.
  • Every class field may have a default initializer. The default initializer is not a constant; it is allowed to be a runtime evaluated expression.
  • If a class field has no default initializer, it must be first assigned in the constructor subroutine.
  • For SyncObjects, fields are represented by RwLock<T>.
    • Referencing a field returns is equivalent to lock.read().unwrap() or lock.write().unwrap()
  • For UnsyncObjects, fields are represented by either Cell or RefCell.
    • RefCell is used for String, Vec, Box, and Rc fields.

Object-oriented instance method

An object-oriented instance method is a struct's instance method with additional characteristics:

  • If it does not override a method, it must not conflict with a method in any of the super classes.
  • An override modifier
    • If present, must override a method in a super class, with the same method signature.
  • An abstract modifier
    • If present, all subclasses of the enclosing class must override this method.
    • If present, the method definition must contain no body.
  • A protected visibility (priv)
    • This visibility means that the method is only available for use within the impl block of a class and its subclasses

Strictness

  • In a constructor subroutine, self is only allowed after super();. It cannot be used inside the arguments to super();.
  • Object-oriented fields must implement Send + Sync.

Object-oriented name resolution

An object-oriented name resolution is used for handling accesses to fields and methods (o.x, o.method()) in a base whose type is an object-oriented class:

  • If the name resolves to a method in any of the classes in the hierarchy and the method is object-oriented, and the base is not super:
    • On execution, the method conditionally delegates control to overriding methods in subclasses (dynamic dispatch)

Weak<T>

The Weak<T> struct, either sync or unsync, only allows T to be a class.

Summary of compiler changes

  • Detect optional usage of the object_oriented crate
    • Parsing and verification of the class! declaration
    • Object-oriented field or method resolution
    • Object-oriented classes
    • Object-oriented methods
    • Object-oriented fields

Drawbacks

This proposal cannot be used as a way to interface with existing object-oriented systems as it relies on the Rust standard library to represent the classes (Box, Rc, and Arc).

Rationale and alternatives

The crate object_oriented suggested by this proposal is not possible to be implemented by the user without changes to the rustc compiler: it involves attaching semantics to structs, fields, and methods, as well as introducing a dynamic dispatch resolution for the member expression (object.x); it also introduces to the language a super(); statement and a super.method_name() expression.

Inheritance is often seen as an anti-pattern in Rust; however, this proposal adds object-oriented classes as part of a crate in the package registry, to which a developer can depend on optionally. Furthermore, it leverages the existing future reserved words abstract and override.

This feature can facilitate the development of frameworks. It serves an alternative to the Entity-Ccmponent-System pattern, which demands fragmenting nodes into smaller pieces. Object-oriented inheritance and Entity-Component-System both have their positives and negatives. It is up to the programmer which pattern they work with. Entity-Component-System leads more to code fragmentation, while object-oriented inheritance leads more to unification.

There are related inheritance proposals, but they do not focus on frameworks, however.

Prior art

Inheritance is already seen in some form when using the web APIs, but the most important part where they are used are for custom elements. The oop_inheritance crate implements an unreliable form of inheritance lacking dynamic dispatch, abstract and conciseness as covered in the Motivation section.

AIR SDK uses inheritance for the DisplayObject class. AIR SDK is a technology originated from the legacy Flash Platform.

Unresolved questions

N/A

Future possibilities

N/A

I'm a bit confused: is this an RFC for a crate? What would be being added to Rust itself? Including it in rustup distributions?

It is a RFC for a crate and native object-oriented mechanisms in rustc that cannot be done through a procedural macro.

I would find this easier to follow if you could split it into two:

  1. The changes to the compiler, std etc needed to support the object_oriented crate. This is the bit that's important in the RFC, because these are the bits that affect everyone; you can, of course, reference the object_oriented crate as justification for some of these features.
  2. A separate text describing how the object_oriented crate uses the new features to implement your class inheritance system.

Right now, I'm not completely clear on which bits are procmacros from the object_oriented crate, and which bits need compiler or std changes. And therefore, I don't feel able to comment helpfully; in particular, I don't think it's worth your time arguing over details that live in the object_oriented crate, because that's the bit that's trivial to swap for a different implementation.

7 Likes

It is impossible for an user to implement this crate even if the language provides macros such as class_extends! because it uses intrinsic parts of the language. I could add a summary of the changes to the compiler, though...

These two statements conflict. If the crate is available at the package registry, using native compiler facilities, then I can implement a variation on the crate myself, also using native compiler facilities. If I can't implement it using native compiler facilities, but depend on a close tie between the compiler and the crate, then it needs to be akin to core or std, not a crate from the package registry (not least because I could be using incompatible versions from a private registry instead of crates.io).

4 Likes

Understood, maybe the crate can be made available through a feature?

#![feature(object_oriented)]

The question you need to answer in your RFC is what does #![feature(object_oriented)] do? In order to make the crate with the API you described possible.

2 Likes

Clarified that now:

I forgot to say it also makes object_oriented available, similiar to std...


I have changed the attribute to #![object_oriented] from #![feature(object_oriented)] for compliance with #![no_std].

I have several questions:

  1. Why do you require so many traits? C++ doesn't require Clone, Hash, PartialEq or Eq for a class, which implies that you're building something to fit a Python or JavaScript model. Remember that a user of this system can require more than the implementation.
  2. Why do classes have to be reference counted? Other objects in Rust are not reference counted - for example, String is not reference counted, and you can require something to be reference counted by type signature (take &Arc<Self>, Weak<Self> or similar in receiver position) if desired.
  3. Why are fields represented by RwLock<T>? If I have an outermost lock for a data structure, having to then lock and unlock individual fields is going to waste CPU time for an operation that will always succeed.
  4. How do I provide parameters to a constructor? An example here would be useful, including passing some parameters (but not all) up to the superclass constructor.
  5. Why can't I run other code before calling super()? I understand why I can't access self before then, but you've now blocked me from deriving parameters for the superclass constructor based on my own parameters, which is a useful trick in Python.

The general theme I'm seeing here is that you've got a decent core feature that has to be implemented in the compiler (abstract, override, super, virtual methods), and then a chunk of stuff that's only needed because you're trying to build something that's interoperable as-is with one specific existing OO implementation.

I would prefer to see a split into a minimal OO system that lives in the compiler, and then room for external crates to require more functionality if needed for interop (e.g. requiring that you have Arc<Self instead of a stack allocation of Self or Box<Self>, adding requirements for extra traits, locking of fields etc).

Additionally, I'd like to see some discussion of how this affects GTK Core Rust bindings and similar pre-existing bindings to OO libraries; for example, I'd expect a core Rust OO feature to enable wrapper to be built atop native inheritance instead of using macros.

5 Likes

I needed to implement these in oop_inheritance due to the following use-cases:

  • using DisplayObjects as keys in a HashMap;
  • finding DisplayObjects by equality in a Vec.

As to Clone though, it is because Copy implies Clone, so there is not much that can be done if not implementing Clone.

These implementations could be avoided though if Rust allows using classes inside Arc and Weak, maybe.

For my use-case reference counting was right because DisplayObjects can be children of other DisplayObjects and reference their parent back through a Weak reference.

I could effortlessly adapt using Arc<C> and Weak<C> if implicit super class covariance conversions occur:

// MyClassB: MyClassA
let object: Arc<MyClassB> = Arc::new(MyClassB);
let object: Arc<MyClassA> = object.clone();

It was due to them being possibly mutated through a mut borrow of the class. In oop_inheritance I have instead generated get_ and set_ methods, either dealing with a reference (ref fields) or non reference type. The proposal also implies that the field access operations use either *value.write().unwrap() = v or *value.read().unwrap().

I did not show that in practice, but in my oop_inheritance you were able to do this. I have not fully clarified all the available syntax, so I thought most readers could deduce it was allowed to specify parameters in the formal punctuated parameter list (including the where clause).

In JavaScript, super() is only allowed at the top; in ActionScript 3, super() is allowed anywhere. Maybe it is viable to allow it anywhere and forbid self only later, but that might be complicated considering the control flow checking?


In case, Arc covariance can already be useful for borrows:

let o: &Arc<B> = o;
let o: &Arc<A> = o;

I was not able to find a more minimal design because objects require some form of memory management and it is really hard because having default field initializers is useful. I have decided to split the topmost super class though into BoxedObject (for Box), UnsyncObject (for Rc), and SyncObject (for Arc).

Look at how C++ does objects - there's no memory management required for C++ object, or for Rust trait objects (a different solution in the same design space).

I'm going to bow out for a bit, but my impression is that you've got a single exemplar of OO in mind (ActionScript), and you're trying to bring that specific model directly into Rust. I'd strongly recommend that you look at how C++ does objects, and how at least two different C OO systems work (say GObject and the Linux kernel), because they have very different assumptions underlying them to the ones that you're working from (they don't need dynamic memory management, although you can heap-allocate objects if you so choose, they don't need locking of fields, because they can delegate that to higher levels etc).

To make this RFC strong, you'll need to explain why your system has requirements that C++'s system, GObject and Linux kernel objects don't have, and what forces them into existence. You'd probably also gain from considering how this system behaves in contrast to trait objects (an existing Rust concept with inheritance); trait objects, for example, don't need runtime locking because you can use the existing system to ensure shared xor mutable.

10 Likes

I never heard of GObject, hmm... it looks a bit unusual to me, I might come up with a new RFC then...

It's at the core of GTK and Gnome as a whole, so it's not exactly a niche implementation.

It's also worth pointing out that Rust not having class/struct inheritance was never an oversight, but a purposeful design choice based on the 2 decades of industry experience using OOP that preceded the language as a whole.

So the proposal needs to explain why this integration would justify reversing course on that point.

22 Likes

What specific problem is this solving? And are there other ways to solve this problem? The RFC doesn't talk about this.

2 Likes

I decided that I will try sticking to ActionScript 3 and Adobe AIR instead of using Rust due to the lack of inheritance. I will be inactive here for a while maybe unless I touch my as3_parser project.

My problem though is that Rust is not flexible and expressive enough compared to Adobe AIR for the development of different things: in my case it is a certain game project that I am having to interrupt due to lack of a framework that matches my needs. The Bevy Engine uses a different pattern that I am not used to.

You can get similar behavior to inheritance via composition by implementing AsRef<InnterType> for all "inherited" objects.

I do agree though that bindings to OO feel overly complicated in a lot of cases. Something like web-sys is too hard to do just right and could be greatly simplified if Rust provided better ergonomics for such crates. The problem is that it's a very tricky thing to get it right and having a Rust version of OO doesn't really tackle the bindings thing in a way that would work (i.e. can't copy entire browser state into WASM memory to make web-sys more ergonomic).

What you're proposing though is a general shift in Rust's paradigm which as others have pointed out requires very good rationale. I started developing in OO, and don't miss it even a little with Rust because there's very few concepts from OO that Rust can't mimic in some way. I think it boils down to experience, once you're more comfortable with a language you stop looking for "that other language's" features in it because you start to model you code in a way that suits it better.

With regards to game dev, I suggest you examine Bevy a bit more because it's basically what most game engines are steering towards (e.g. Unity DOTS). It's almost a reference implementation of all best practices for a general purpose game engine (and ECS), so much so that there's very few reasons to write one from scratch in Rust.

9 Likes

I'm still unclear about which features Rust exactly lacks to build OO features as a library using macros. glib/gobject did this with plain C over twenty years ago, and the rust bindings show that this is totally possible to achieve in Rust as well. There are many more object based libraries in the ecosystem, like pyo3 (Python bindings, with the Python model), probably whatever the current state of Qt bindings is, some PDF manipulation libraries, etc.

I think that any proposal about object oriented Rust should:

  • Explain which features currently cannot (easily) be implemented in Rust with macros
  • Show that the language changes are minimal and agnostic of the OO model
    • Especially, it should cater to both systems with inner mutability and those who keep using the Rust borrow checker.
  • Show that the language changes will improve the code of existing OO projects like gtk-rs and pyo3
  • Be "zero-cost"
5 Likes

this is not a good idea in any way.