Basic Object Oriented Programming in Rust

I come from the Java world so please excuse me as a complete Rust noob.
In my OO-stuff, there are common methods as well as object-type-specific ditto. I have in this example showed how I wanted to do this in Rust but failed.

trait Speak {
    fn speak(&self) -> String;
}

enum Animal {
    Dog(Dog),
    Cat(Cat)
}

struct Dog {

}

impl Dog {
    fn only_for_dogs() {

    }
}

impl Speak for Dog {
    fn speak(&self) -> String {
        String::from("Bark")
    }
}

struct Cat {

}

impl Speak for Cat {
    fn speak(&self) -> String {
        String::from("Meow")
    }
}

impl Speak for Animal {
    fn speak(&self) -> String {
        // This is obviously entirely wrong, it is recursion!
        self.speak()
    }
}

fn main() {
    let dog = Animal::Dog(Dog{});
    println!("Animal says: {}", dog.speak());
    // How can you call the "only_for_dogs()" method
    // from an Animal variable including telling the
    // caller that the method in unavailable in case
    // the call was made from a Cat instance.
}
1 Like

If you choose to use an enum, then you need to match the enum to accomplish things with it. The proper implementation of Speak would be:

impl Speak for Animal {
    fn speak(&self) -> String {
        match self {
            Animal::Dog(a) => a.speak(),
            Animal::Cat(a) => a.speak(),
        }
    }
}

However, if the trait has more than one method, it may make more sense to match only once to produce a trait object:

impl Animal {
    fn inner_speak(&self) -> &dyn Speak {
        match self {
            Animal::Dog(a) => a, // &Dog is coerced to &dyn Speak
            Animal::Cat(a) => a,
        }
    }
}

impl Speak for Animal {
    fn speak(&self) -> String {
        self.inner_speak().speak()
    }
}

This is more verbose by itself but lets you avoid writing one match per method of the Speak trait. If there is only one method, then use the first version.

4 Likes

Thank you very much! I guess there is no match all construct, relieving you from having to reiterate the enum list?

unlike inheritance in OO, an enum is a closed set of types, and you use pattern matching to dispatch the call in such case:

impl Speak for Animal {
    fn speak(&self) -> String {
        match self {
            Animal::Dog(dog) => dog.speak(),
            Animal::Cat(cat) => cat.speak(),
        }
    }
}

there's two parts to this question:

  • how to call the "only_for_dogs()from anAnimal`? a.k.a. downcasting.

    answer is, again, pattern matching.

    let animal = Animal::Dog(Dog{});
    // the `if-let` syntax is a "sugar" for `match` where you only cares a single case
    if let Animal::Dog(dog) = &animal {
      dog.only_for_dogs();
    }
    

    it is common to encapsulate the operation of "dowcasting an Animal to a Dog" in a function like this:

    impl Animal {
      fn as_dog(&self) -> Option<&Dog> {
        match self {
          Animal::Dog(dog) => Some(dog),
          _ => None,
        }
      }
    }
    
  • how to tell the caller the result

    answer: use return value, e.g. you can just return a boolean

    impl Animal {
      /// if it's a `Dog` variant, call `only_for_dogs()` and returns `true`;
      /// otherwise, do nothing and returns `false`;
      fn do_something_only_for_dog(&self) -> bool {
        match self {
          Animal::Dog(dog) => {
            dog.only_for_dogs();
            true
          },
          _ => false,
      }
    
      // same function, but a different style:
      fn do_something_only_for_dog2(&self) -> bool {
          self.as_dog().map(Dog::only_for_dogs).is_some()
      }
    }
    
1 Like

Fantastic! I'm thrilled. I was on the brink giving up on Rust :wink:
Regards

do you mean the dynamic dispatching using a base class or interface in OOP languages, a.k.a. virtual functions/methods? in rust it is achieved via trait objects, one such example is in the previous post by @kpreid

1 Like

If you haven't yet, read the books chapter on OOP and the advance traits section.

FYI: cbor - Rust

You may still reach that point of you would try to look for a way to write Java in Rust.

The sad, yet, simultaneously joyful, reality is that OOP just simply doesn't work.

It tries to bring together concepts that are fundamentally incompatible.

And while yes, it's not impossible to write OOP is Rust, most of the time it's very bad idea.

Because things that OOP lumps together are separated in Rust — and if your goal is to write Java then, believe me, Java is the best language for that. Java-in-Rust is not better than Java.

One example:

That's something that Rust hates with passion. It's not impossible to emulate such structure in Rust, of course, but it's just shows the flexibility of Rust and is not how you would, normally, program in Rust.

In particular Rust wants you to decide, upfront, whether you want to have common methods or different ones.

Something that OOP very much tries to postpone, as much as possible, Rust asks to do as step number zero. Not even step number one, but zero: you first need to decide which things are common and which are type-specific — and only then starting to design things.

Common methods are handled with generics, type-specific with traits, you couldn't go from one to another on the per-type basis!

1 Like

Hi @khimru, I won't go into the great OOP debate but my specific use-case, CBOR serialization/deserialization seems awfully hard to deal with at compile-time only unless you settle for a constrained model like the one @conqp pointed to. Although I have skimmed the docs, I still feel a bit lost. I really appreciate the help I got. I'm now considering not using enums. The reason I got into enums was because impl is not allowed for field objects which BTW feels a bit illogical.

As a Rust n00b I also looked at numerous videos: here is one which proposes the enum solution: https://www.youtube.com/watch?v=0XFq9K7N9o4

I have one related question left: a reason for going with enums is that they can act as a class with named types. CBOR decoding needs instanceof á la Java/JavaScript/Python and it is not obvious to me me how this would work in a trait-only scheme. Maybe I'm stupid (uneducated rather...), but I'm thinking about a Supertrait providing the "class" name. For that an enum would be quite useful.

Coming from OO and switching to Rust, I remember this exact desire to introduce some universal, unspecific methods like speak(&self), exactly as in your example. But in Rust, because it's not OO, this approach makes things more complicated instead of simpler.

The trick to unlearning OO habits is to focus on concrete functions and methods.

Sure, it's just an example, but I think it reveals the root cause: Neither cats nor dogs speak. Humans speak. Cats meow, and dogs bark. So in code, stay concrete instead of universal:

struct Cat;
struct Dog;

impl Cat {
    fn meow(&self) { ... }
    fn purr(&self) { ... }
}

impl Dog {
    fn bark(&self) { ... }
    fn jump(&self) { ... }
}

While each piece of code is more concrete on its own, the interplay is that the call site becomes the decision-maker. It decides what to do:

enum Pet {
    Dog(Dog),
    Cat(Cat),
}

impl Pet {
    fn owner_arrives(&self) {
        match self {
            Self::Cat(cat) => {
                cat.meow();
                cat.purr();
            }
            Self::Dog(dog) => {
                dog.bark();
                dog.jump();
            }
        }
    }
}

Of course this is a contrived example, but the observation is generally true in my experience: A lot of interfaces in OO exist purely for implementation purposes, because that's the only way to achieve polymorphism. So, due to language features and limitations, a general method or interface like speak() (or owner_arrives()) gets introduced.

Notice that the match arms can do completely different things. While a dog maybe interact with its owner, a fish in a aquarium does simply nothing.

I'd call this "one-shot" polymorphism. If only one place in the code dispatches on the type, you don't need a trait for that.

Traits start to matter when multiple independent call sites need to work with types they don't know about, like library boundaries or generic functions. But in application code where you own all the types and there's one dispatch point? An enum does the job with less ceremony.

10 Likes

CBOR “serialization/deserialization” is extremely vague thing: what exactly do you want to do with it? Why do you think you need or want OOP there?

CBOR/JSON is trivially parseable into tree-like data structure with enums and doesn't need OOP in any shape or form. You may want to use OOP later, when you process it — but this, again, would depend on what exactly do you plan to do with it.

For that phrase to even make some sense we need to know what do you mean by “field objects”, only then we may know what do you really want.

It probably wouldn't. There are Any, but it really looks as if you are trying to reinvent ADTs… poorly.

There are absolutely no need to invent clever ways to represent sum types in Rust, they are supported natively!

1 Like

Is "compile time" the key to the problem here?

Please bear with me, I have sort of "used" Rust for four days only.
It is possible that you can build trees of CBOR objects without using OOP but adding OOP as an afterthought is not my cup of tea. If you really want to know what I'm doing, maybe taking a peek at CBOR::Core - IETF Draft and CBOR.js - JavaScript API for CBOR would be of some use.

I'm leaning towards going for trait objects only and skip enum. It is possible that I will invent a wheel or two :laughing: Any and type_id didn't really got me, but I'm still very much at a learning point.

Is "compile time" the key to the problem here?

It is possible that there are no more fundamental problems. The lack of function overloading is a bummer but no showstopper. But there may be more challenges ahead :zipper_mouth_face:

Challenges? Call them "opportunities" :slight_smile:

4 Likes

“OOP” isn’t really a single thing you can (or can’t) “add” to Rust code. There isn’t even a generally agreed-upon definition of what that would mean. When designing Rust it is better to just consider what a given design lets you do, without reference to whether it is “OOP” or not.

8 Likes

Adding OOP, whether upfront or afterthought is bad idea in Rust, usually. 99% of time you don't need OOP at all.

What people normally understand as “OOP” (implementation inheritance) is just simply never works — and is explicitly not supported by Rust.

Now, “just forget about OOP” is good idea approximately 99% of time, not 100% of time. Sometimes you have to have OOP (e.g. if you use Rust to implement some kind of Java class then you would have to deal with OOP, obviously) — but I don't see where may you need OOP in you task.

Where in these documents one may find out about something that needs OOP? Remember: the problematic case that OOP have is combination of arbitrary expandable list of supported object types and, simultaneously, arbitrary expandable list of supported operations. That's what never works and couldn't ever work and is not supported in Rust (except via ugly tricks).

In all these documents the only thing that I can see are straightforward ADTs, sum types and product types — which are handled perfectly without any dyn Trair and other such mechanisms. If I'm wrong then can you, please, show me where they are not sufficient.

So you like to feel a lot of pain… your choice, obviously.

You would invent some OOP facsimile. And would then ask why something that is easy and fast in OOP languages is so painful and slow in Rust. Ultimately it's your choice, but AFAICS so far you are clearly looking for a way to apply spork in a language that only have spoon and fork. And, worse, in the vain hope to obtain that spork you are trying to eat soup with fork.

Not a good idea, at the first glance, but need to know more to understand why it's so important for you to have explandable list of types where specification clearly have finite list of them.

You absolutely can do that in Rust. The same way you may do that in C: fully manual implementation where something that is two lines of code in OOP languages becomes 20 lines of code with couple of tables and bazillion macros.

The big question is: why do you want that? It's wrong idea 99% of time!

Yes, but we know thing that Rust very forcibly rejects and makes extremely hard to use: open-ended, expandable, list of operations and, simultaneously, open-ended, expandable, list of types.

It never works, not in any OOP language (as explained in most OOP tutorials when they discuss “composition vs inheritance” choice, even if very few admit it's inborn and completely unfixable flaw in the whole concept) and in Rust, if you need it, you would have to do quite a crazy contortions to support that combo.

This person just started learning Rust a few days ago. Can you please ease up on the absolutist zealotry?

21 Likes

It's not “absolutist zealotry”, it's just constatation of the fact: OOP exist for half-century and in that time no one was able to invent solid, usable foundation that allows one to combine all three pillars of it (encapsulation, inheritance, and polymorphism) simultaneously. And while OOP courses teach people to combine inheritance and polymorphism without thinking twice and then spend your debugging time fighting the lack of encapsulation… Rust picked different approach. It's not that Rust rejected OOP because someone told developers it would make it worse… no, Rust developers simply wanted safety guarantees — and OOP, as it exist today, doesn't offer them. Perhaps we may find the way, one day, but after half-century of fruitless attempts… it's better to accept that it wouldn't happen any time soon.

Yes, but it hardly matters: what said person attempts to do in clearly marked as mistake 6a in the well-known list. And it's there because it's known to cause pain with no way to avoid said pain even if you spend years learning Rust.

Now, sometimes such pain is unavoidable and you have to accept it… but when everyone and their dog tell you that you should at least try to avoid it and not embrace it… it's good idea to think about whether you really need it.

1 Like