Random thoughts on software architecture design approaches in Rust

Hi,

I am trying to design a software architecture of a quite simple turn based game and trying to define a scalable architecture in order to make easier to extend rules and features in the future.

I was trying to follow SOLID approach (it is one of my favorite one), but I find myself overthinking on everything. The result is an overcomplicated architecture whose maybe I will never get benefits of its flexibility.

One thing that I struggle is minimizing usage of match (and pattern matching), but I am starting to think/find that is quite idiomatic in Rust. I think it is a little more elegant that checking some type field and then downcast (as it could happen with C++ code).
Anyway over-usage of match and pattern matching means that in case of some game extensions/rule changes, I'll have to change my code somewhere (at least extend some match clauses) and that is a little against with open-close principle (see O from SOLID).

What is your opinion about? Which king of approach/methodologies you follow with Rust?

Thank you

I think trying to avoid matches is a bad idea. That the compiler explicitly points out where you need to change your code as a result of the extra variant is exactly what makes them so great.

12 Likes

Seconded; I think match (and pattern matching in general) is one of the language's best features and you should use it liberally. Certainly that's what I do when I'm writing Rust code.

What's the alternative to explicitly handling the rule/extension changes in your matches? Forcing errors to occur at run time instead?

2 Likes

If your concern is remaining backwards compatible while also retaining the ability to evolve non-opaque types, you may be interested in the non_exhaustive attribute.

Making use of traits so that the specific data structures involved are abstracted is another general approach.

1 Like

Yes, agreed: using pattern matching means that your code does the right thing because of its structure. The Option enum is a good example of this. The enum has two variants None and Some(T). Because of this, you cannot accidentally use an Option::None value as if it was an Option::Some. That is, the syntax forces you to unpack things:

let elem = match some_list.last() {
    None => ...  // handle this case, perhaps with an early return 
    Some(e) => e // handle this as well, perhaps just pass `e` through
}

Contract this with Abseil's StatusOr class for C++. There you get a absl::StatusOr<T> back which is a pointer-like type. You must then check if it is ok() and only then can you dereference the value:

StatusOr<Foo> result = Calculation();
if (result.ok()) {  // We could leave this check out by accident!
  result->DoSomethingCool();
} else {
  LOG(ERROR) << result.status();
}

Here, it is the programmer who must remember to check if the StatusOr<T> value is of the right variant before accessing the wrapped T value. In Rust, it's syntactically impossible to get access to the T value in an Option<T> without checking if the Option is of the right variant.


Now, if the pattern matching is spread around the code, it might very well be annoying to use:

let hit_points = match unit {
    Unit::Soldier => 20,
    Unit::Catapult => 35,
    Unit::Tank => 1500,
};
let vulnerable_to_fire = match unit {
    Unit::Soldier | Unit::Catapult => true,
    Unit::Submarine => false,
};

You can then of course move this logic to methods on the Unit enum itself:

impl Unit {
    fn hit_points(&self) -> i32 {
        match self {
            Unit::Soldier => 20,
            Unit::Catapult => 350,
            Unit::Tank => 2500,
        }
    }
    fn vulnerable_to_fire(&self) -> bool {
        match self {
            Unit::Soldier | Unit::Catapult => true,
            Unit::Submarine => false,
        }
    }
}

This will at least move some of the sprawling complexity back near the definition of the Unit enum.

Alternatively, you could define a trait for your units:

trait Unit {
    fn hit_points(&self) -> i32;
    fn vulnerable_to_fire(&self) -> bool;
}

You can then implement the trait for different structs/enums and run your game logic in terms of the behavior exposed by the trait. I think that might fit the SOLID principles you reference a little better.

4 Likes

I was thinking to represent all the possible actions (made by players or even the system itself) using a trait Action, then create a generic trait called GameRule<T: Action>, so each game rule is associated to one action. Something like the following:

trait GameRule<T: Action> {
    fn can_handle_action(&self, action: &T) -> bool;
    fn is_valid(&self, action: &T, game_state: &GameState) -> bool;
    fn execute(&self, action: &T, game_sate: &GameState) -> Result<GameState>;
}

So a game rule has the responsibility to know which kind of action is about and to know how to update the game state (as a real game rule).
Adding/changing game rules is a matter of adding/changing struct that inherits GameRule<T>.
They could be even load dynamically as plug-ins.
Anyway as you can guess this approach make code more hard to follow and overcomplicated. I should also follow a similar approach for GameState in order to make it possible to add new information in the game state... And it is not so easy to make a game state quite flexible to adapt at any new feature without making everything overcomplicated and heavy (time/space heavy).

Thanks for the quite exhaustive reply! :+1:

That could be an approach, my concern is what happen if maybe in the future I need to manage also laser beam (not only fire)? I have to implement a new method vulnerable_to_laser to Unit trait and update all structs that inherit it (and maybe they are thousand).
While I would like to use an orthogonal (in some way) approach, that is the unit itself has the responsibility to know to which kind of attack is sensitive. So if I add a new weapon, I have only to update code of only unit that are sensitive to.

trait Weapon {}

trait Unit {
   fn vulnerable_to_weapon(weapon: &Weapon) -> bool;
   ...
}

Anyway, don't take me wrong, I don't mean that my approach is better.
I share this topic with you just because I am feeling that I am trying to define an architecture that is so flexible to manage a world sand box, while I was supposed to implement a simple turn based game with 5 rules! :rofl: So I would like to hear what other people thinks about.

That sounds like a nice approach indeed! :+1:

I would let the system evolve a bit and then you'll probably see where there are similar parts which can benefit from such an abstraction — maybe you never get more than three kinds of weapons and then the upfront effort would have been unnecessary.

You may be interested in the following article by Matklad:

He talks about an issue in rust-analyzer where they decided to pull something into its own trait, but found most of the code wouldn't actually use generics.

In a similar way, I'd be tempted to say that GameRule<T: Action> could be a premature abstraction and you actually want to code against the game rules directly.

Depending on the size of the game, you could start implementing without trying to force any particular design patterns on it. Then as more is implemented, you'll start to see patterns and commonalities and be able to re-design things to be more ergonomic and complement the language better. The borrow checker is really powerful here, often you'll run into borrowing issues or needing to write lifetime annotations everywhere when the architecture is getting too complicated. It's like your code is using compile errors to tell you that your design needs revisiting.

Also keep in mind that a lot of the SOLID principles won't be implemented in Rust the same way you'd implement them in a traditional OO language like C#. Don't try to force a round peg into a square hole, different languages will favour different ways of doing things.

2 Likes

In the software world there is the expression "premature optimization". Attributed to Donald Knuth I believe, "premature optimization is the root of all evil" : Program optimization - Wikipedia

As I was reading your post I wondered if there is such a term as "premature abstraction"?

As beginner programmers we generally have task "A" to do and we write code that does task "A". And nothing else. Job done. Life is good.

With experience we soon find some new task "B" which has a lot in common with task "A". We start to think "It would be great if I could reuse some of that code from the task "A" solution for the new task "B"?

But often the code we wrote for task "A" is so specialized for "A" that it's easier to just write all new code for task "B". Anyway, the boss is paying and it's work for us, right?

With more experience comes task "C". This time, we think, we are going to get ahead of the game. We know that whatever we write for task "C" some of it will likely be useful for task "D", "E", "F"....etc. Even if we have no idea what those tasks may be.

At this point we are doomed. No longer solving that problem at hand but trying hard to generalize, abstract, the thing to solve imaginary problems we may or may not have in the future.

Sounds like you are about at that point!

My take on all this is that finding and creating such useful abstractions is not so easy. I can kind of do it for simple functions and objects, when it comes to bigger architectural abstractions, not so much.

As it happens, Rust and Cargo have helped me with this. I don't dive in and write a program off the bat anymore. I think of some part my program will need and write that as a lib. Run and test it with the built it test mechanism of cargo. Then the next part, then the next... Eventually I have enough parts to glue together as a program starting from "main()".

Seems to me this is likely to produce parts that are more easily reusable if the need arises.

Are you looking for "Premature Generalization"?

https://wiki.c2.com/?PrematureGeneralization

It sounds like I'm not the only one who's seen/used/written code that tries to be so generic that it abstracts the usefulness away :sweat_smile:

2 Likes

I was not sure what I was looking for. Just a "feeling in the air" I wanted to talk about.

Oddly enough you, yourself, used the term "premature abstraction" in your post before mine which I had not seen :slight_smile:

As for that "feeling in the air", it also comes about from many posts I have seen here in the last year. Posts with questions like:

I have this code that does "X", which works fine, but I'd like to know how to write it in a more "Functional style"?

I have this code "Y" but are there any suggestions to write it in a more "idiomatic" Rust style?

I'm always wondering, why? Is your code not performing well and it needs a boost? Did you find your code does not scale well? Is it using too much memory? Is it difficult for others to understand when they read it? What problem are you actually trying to solve by spending time on making it more "idiomatic/Functional"?

I'm not saying one should not read or learn from all the countless books by the "gurus" on software design, be it SOLID, TDD, Agile, Functional, whatever. It's just that I get the feeling that people take it all too literally sometimes and forget what it is they are actually trying to do.

4 Likes

Why? What's wrong with pattern matching? Why should it be an architectural goal to minimize it?

1 Like

One way I often look at things is that match and interface are just different groupings of the same thing. Imagine you have a big table of kinds and actions, where each of the cells is the behaviour you need to run on that action for that kind.

Then the two different coding styles are just whether you decide to group them by the rows or the columns. Either you make an interface with all the actions, and implement it per type, or you make an enum with all the kinds and match it per action. Neither of these is fundamentally better than the other.

So you just need to decide whether you want it make it easier to add more kinds or more actions. (And then the other one will always be less fun.)

3 Likes

I agree with this, but I also suspect, when it's difficult to translate a particular piece of software into Rust, it's commonly not only because Rust is being difficult -- the architecture itself may be at fault. It's certainly no secret that badly designed software is everywhere, and just because something works doesn't mean it's extensible, maintainable, and performant. If the design has inherent problems, it shouldn't surprise us that it's hard to translate into another language.

People often struggle with designs that make heavy use of inheritance. Design Patterns, the book so influential that it could be said to be the book on object-oriented design, dedicates a lot of words to combining other tools such as composition and generics, and using inheritance in more limited ways -- much as Rust requires you to do. I think if you have a good architecture that works in C#, based on SOLID principles and with no major structural flaws, chances are good that it will translate fairly straightforwardly to Rust. Conversely, when you're having trouble with translating something to Rust, it might be because there is a design flaw that should be corrected, or an implicit assumption that needs to be made explicit.

Of course I'm speaking in broad generalities. There are surely counterexamples.

3 Likes

When I look at the wikipedia page on SOLID I find:

Single-responsibility principle:
A class should only have a single responsibility, that is, only changes to one part of the software's specification should be able to affect the specification of the class.

Open–closed principle:
"Software entities ... should be open for extension, but closed for modification."
Liskov substitution principle:

"Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program." See also design by contract.

Interface segregation principle:
"Many client-specific interfaces are better than one general-purpose interface."

Dependency inversion principle:
One should "depend upon abstractions, [not] concretions."

Seems to me that pretty much all of that can be taken in to consideration when creating a large body of code no matter what language or paradigm. That does not mean one should slavishly try to recreate whatever is done in an OOP language to meet those goals in Rust. No, go back to the principles, as stated above for example, and see what you can do in Rust to satisfy them

In Rust we probably have to reword those principle descriptions a bit, clearly words like "class" are not appropriate. Even "Object" is problematic. But Rust has generics and traits that can be used instead.

Perhaps someone more skillful that I would like to take a stab at writing a Rust version of the principles above?

Regarding the Liskov substitution principle, I do not think it transfers directly as Rust doesn't have subtyping (or does it?), but the wikipedia article says that we should also see "design by contract", and I do think that one applies quite nicely to traits. In fact, that's all a trait is — some functions on the type that follow some contract.

As for the dependency inversion principle, to transfer that, the rule would be to use generics/trait objects everywhere instead of using the structs directly. I don't think this transfers very well to Rust, at least in some cases because how traits can complicate things quite a lot.

1 Like

No doubt the OOP guys would say that Rust does not have subtypes. What with not supporting classes an inheritance.

But going back to wikipedia I read:

Substitutability is a principle in object-oriented programming stating that, in a computer program, if S is a subtype of T, then objects of type T may be replaced with objects of type S (i.e., an object of type T may be substituted with any object of a subtype S) without altering any of the desirable properties of the program (correctness, task performed, etc.).

Well, if I boil that down, I get left with the essence of a key idea, which is about "Substitutability".

That is: If my program operates on a type T I should be able to replace that with any other type that provides the same interface and behaves semantically the same way as T. If those other types so that then my program won't notice the difference, will not need changing and will produce the same result.

To my mind the fact that those other types are not related to T by inheritance and hence are not subtypes is neither here nor there.

Surely we can do that in Rust with generics and traits? I'm sure I have been caught out by trying to use some crate and failing because some other crate defining a trait that was required was missing. Which is basically what I'm getting at.

I'd be interested to hear what the complications might be.

I would have thought, for example, that Vec could be a trait defining the interface to a vector, which could be implemented in many different ways.

Yeah, but this is what I tried to say with the design by contract.

It has a tendency to introduce the "line noise" you talk so much about. Of course, it's certainly possible to do it.

1 Like

I see your point (and also point from other users).

You're right I am stuck in trying to find a super-scalable-flexible architecture that can fit any possibile future requirements/scenarios and I'm loosing my focus.

Maybe it's because of my forma mentis, but I don't like to write tons of lines of code and then have to refactor half of that because a new ideas/requirements pop out and they don't fit quite well in my spaghetti architecture.
On the other hand it doesn't make too much sense to spend days to design an overcomplicated architecture that needs hours to be understood and tricky to be used... I have to find the right bias between the two sides.

Anyway, I would be very happy if somebody could write a document (maybe a book) about design patterns applicable in Rust, I think new kind of patterns could emerge and they could be very useful.