Extending behaviour with composite types and Deref

Hello,

I was hoping to get some advice on API design, which I'm struggling with coming from an OOP background in C++.

I am writing a library that helps to build a type of agent-based model called CPM.
The structure of a fully implemented CPM model using this library would be something like:

struct Model {
    pond: Pond,
    ...
}

struct Pond {
    environment: Environment,
    potts: Potts,
    ...
}

I hope to provide many commonly needed operations for Pond, Environment, and Potts, while still supporting easy extension of behaviour so that users of the lib can implement their desired model features (if you are not familiar with scientific modeling, it's something like using a game engine).

Doing that for Potts is easy. Because a Potts object is just the implementation of an algorithm, Ive made a PottsAlgorithm trait that has a bunch of default methods and can be easily implemented for any concrete Potts object.

I am struggling with Pond and Environment, however. These types need to hold a significant number of data fields for their methods to work, such that implementing the logic as traits would also require the implementation of many accessor methods like Environment::cells(), Environment::cell_lattice() etc.

My current solution is providing BasePond and BaseEnvironment types, which implement essential behaviour for a CPM simulation to run and can be extended through composition.

This makes sense in my head because BaseEnvironment and BasePond are concrete types with concrete objectives: they manage an environment where cells live in isolation. Thus, when extending the behaviour of these types, I find myself often doing some prior os posterior operation and then delegating to BaseEnvironment or BasePond. For example:

struct MyEnvironment {
    base_env: BaseEnvironment,
    other_object: OtherObj
} 

impl MyEnvironment {
    fn spawn_cell(&self) {
        self.other_object.do_something();
        self.base_env.spawn_cell();
    }
}

The problem with this pattern is that it leads to some very long object paths. For example, I commonly find myself writing my_pond.base_pond.my_environment.base_environment. One way to solve this would be implementing Deref<BaseEnvironment> for MyEnvironment and Deref<BasePond> for MyPond, but it seems to me that these traits are implemented for pointer-like structs (see Deref in std::ops - Rust and Understanding the perils of `Deref`).

What do you guys think, is it ok to use composite types to extend behaviour (and is it ok to implement Deref in that case), or should this be done only with traits?

As something to preface, traits shouldn't be treated as parent classes, but rather as operations many different types can implement. For example, Rust doesn't have an Integer trait, but has traits like Add and Sub. So I think you were correct to avoid making an Environment or Pond trait, that would then require a bunch of accessors to be implemented.

What you could do, is make traits for certain types of things you do with environments and ponds. I'm not familiar with the context of CPM, but you could provide a trait like CleanCells and implement it for BaseEnvironment and BasePond.

pub trait CleanCells {
    fn clean_cells(&mut self, clean_level: CleanLevel);

    // This is an example of how you can add functionality without any
    // extra effort for the user of your library.
    fn fully_clean_cells(&mut self) {
        self.clean_cells(CleanLevel::Full);
}

impl CleanCells for BaseEnvironment {
    fn clean_cells(&mut self, clean_level: CleanLevel) {
        // Actual implementation here
    }
}

impl CleanCells for BasePond {
    fn clean_cells(&mut self, clean_level: CleanLevel) {
        self.base_environment.clean_cells(clean_level);
    }
}

Any user of BasePond could then easily implement CleanCells by calling BasePond::clean_cells in their implementation. So rather than my_pond.base_pond.my_environment.base_environment.clean_cells(), they could just call my_pond.clean_cells(), or if they don't implement CleanCells, just my_pond.base_environment.clean_cells().

You could also take advantage of the standard library by implementing its traits on your types like Add for example. I'm not sure about if you should use Deref, but using traits this way may prevent the need for it.

Thanks for your insights on the matter!

This is indeed another option that I considered, but my understanding is that it comes with a few caviats.

First, it makes the trait bounds on function signatures complex. For example, if all the functionality of BaseEnvironment is captured in a single type or trait, then it's easy to write a function fn foo(base_env: BaseEnvironment) or fn foo(env: impl Environment). If I have the same functionality spread across many different traits, then foo easily becomes fn foo(env: impl CleanCells + SpawnCells + CountCells...). This is not always a problem because foo might be a simple function that doesn't ask much of Environment. But it can be quite a challenge for higher-level functions.

The second problem is that I'm confused about whether traits are the tool for the job here at all, regardless of how big or small their scope is. A BaseEnvironment has specific, concrete responsibilities, and therefore my instinct would be to make it a concrete type. For example, BaseEnvironment::spawn_cells() spawns a valid cell the same way, no matter how deep you nest it in composite types. So even if you make a:

struct VeryComplexEnvironment {
    complex_environment: ComplexEnvironment
}

struct ComplexEnvironment {
    base_environment: BaseEnvironment
}

calling very_complex_environment.complex_environment.base_environment.spawn_cells() still works. But then again, we are back to where we started with the long call chains.

Of course, regardless of whether a method is part of a trait or not, one can always surface it in the API by reimplementing it:

impl ComplexEnvironment {
    fn clean_cells(&mut self) {
        self.base_environment.clean_cells()
    }
}

Assuming you probably would want to do this for every method in BaseEnvironment is indeed the reason for which I wonder if Deref<Target = BaseEnvironment> should be implemented for ComplexEnvironment, despite what seems to be considered "best practice" in the language... I think that the main problem with doing this would be that you might want to make some modification to ComplexEnvironment::clean_cells() that is more complex than simply passing down its arguments to the base implementation. In that case, the method would have to be manually reimplemented:

impl ComplexEnvironment {
    fn clean_cells(&mut self) {
        do_something_complex();
        self.base_environment.clean_cells()
    }
}

and this implementation would collide with the implicit Deref implementation.

It does seem like the manual implementation is correctly chosen by Rust though (Rust Playground), so I still don't really see how this can go wrong.

Mainly, I'm just scared by all the talk about Deref not being suitable for this use case, and was hoping on some clarification on why that would be :sweat_smile:.

in general, rust discourages OO-style inheritance hierarchies, and it's hard to mimic traditional OOP design patterns in an ergonomic way that also feels natural and idiomatic in rust.

you are right Deref is intended for smart pointers, and is often frown upon when used for OO-style inheritances. however, this is a "soft" guideline, not a "hard" rule, you can make your own judgement whether it makes sense to use it that way.

personally, I rarely make OOP designs these days, but I think it does make sense in certain specific scenarios, such as DOM elements in web browsers, or a widget library in a GUI toolkit: it's the most natural way to map the code to the problem domain and share common functionalities across the hierarchy. one example of this kind of usage is makepad, though it uses its own custom DSL and not a exemplar use case.

another approach to design an OO-style API is indeed to use traits, but it's more verbose and tedious, compared to Deref. if you want the user code more ergonomic, then the implementor needs a lot of boilerplate code. for example, the data structure can be implemented using composition, then you can combine inheritance marker traits (a.k.a. "is-a") and blanket implementation of protocol/interface helper extension traits to build a OO API on top, which looks like this:

/// the projection from derived object to base object
trait IsA<Base> {
    fn as_ref(&self) -> &Base;
    fn as_mut(&mut self) -> &mut Base;
}

// base "class"
struct Base {
    // fields
}

impl Base {
    fn foo(&self) {
        println!("Base::foo called");
    }
}

// the interface
trait BaseInterface {
    fn foo(&self);
}

// blanket implementation delegates to `Base`
impl<T: IsA<Base>> BaseInterface for T {
    fn foo(&self) {
        self.as_ref().foo()
    }
}

// derived "class", uses composition
struct Derived {
    base: Base,
    // more fields
}

// multi-level inheritance
struct DerivedDerived {
    base: Derived,
    //...
}

// "super" delegation
impl IsA<Base> for Derived { ... }

impl IsA<Derived> for DerivedDerived { ... }
impl IsA<Base> for DerivedDerived { ... }

// usually also reflexivity
impl IsA<Base> for Base { ... }
impl IsA<Derived> for Derived { ... }
impl IsA<DerivedDerived> for DerivedDerived { ... }

as you can see, this is a lot of (mostly valueless) boilerplates. but this form of OO API does have its use cases, one example is the gtk-rs project, for example, ToggleButton implements IsA<Button>, IsA<Widget>, IsA<Actionable>, etc; in order to call Button methods, you need to import ButtonExt.

but gtk-rs is a binding crate for the OO based ffi library gtk, I wouldn't recommend writing such horrible code yourself, even if many of the boilerplate code could be automated.

all in all, I think you should stick with composition, maybe with some light weight helper traits (or, std::AsRef and std::AsMut may be good enough for simple cases) for API ergonomics. however, using Deref to mimic inheritance can be justified if you think it better fits the scenario. there's no single best strategy for every use case, be reasonable.

1 Like