Design problem in program tetrii?

I'm trying to learn Rust, and am writing my standard Tetrii (multi-board tetris) program. I've run into some problems, and am hoping someone can either point out a memory technique I don't know, or (ugh) tell me that the overall design is not suited to Rust.

The code is on github, at https://github.com/russellyoung/tetrii . There are 2 branches. The branch "no-controller" works, it pops up 1 - 5 boards which can be played with either mouse or keyboard commands. This version uses a static Vec<Rc<RefCell< Board>>> value to store the Board structs, and I (think I) know this is frowned on.

The main branch has a new struct, Controller, which is to be another window with buttons to start and stop, select the number of boards, report scores, and so on. It is also intended to hold the memory items that are needed, again wrapped as necessary. Unfortunately, however I set it up, when I try starting up the game boards I get an "already borrowed" error. I think I see where it is coming from, though I had thought that using the RefCells would get me past that. The program does not use threads, but callbacks that need to write apparently are being called when other copies are still borrowed.

The code isn't that long but I'm not asking for a full review (though I'd welcome as much as is offered), but the big question is whether this design is suitable for Rust, or whether I'm bringing habits from other languages into play where they aren't appropriate.

Thanks for any comments, as a Rust newbie without access to anyone who knows about it getting this far has been a slog, and I'm hoping this is not the dead end it appears to be :frowning:

I fixed the first error and immediately hit another, but here's the fix for the first one

@@ -16,10 +16,14 @@ fn main() {
     gtk::init().expect("Error initializing gtk");
     let controller_rc = Controller::new(config);
     let controller_rc_activate = Rc::clone(&controller_rc);
-    let controller = controller_rc.borrow();
-    let app: &gtk::Application = &controller.app_rc();
+
+    let app: &gtk::Application = {
+        let controller = controller_rc.borrow();
+        &controller.app_rc()
+    };

You weren't actually using the borrow of controller anymore, it just wasn't getting dropped because the GTK event loop started first. I imagine there are several places you're doing something similar.

You may be interested in an event driven approach that avoids the interior mutability issue. Event-Driven GTK by Example shows one way of accomplishing this with async Rust, but you can also use a similar non-async design with the channel functionality detailed in the gtk4_rs book

5 Likes

This is pedantic, but it's static mut that is the problem (because it allows taking unique references that actually alias, and that's UB). An immutable static with interior mutability is fine because it disallows the mutable aliasing. One way to deal with this safely, if you really do need statically allocated state, is the once_cell crate. Which only allows initializing the static data once and then every other access is through a shared reference.

Just so that point is out of the way...

I can see how you might reach that conclusion. Just know that RefCell has the same borrowing rules as the borrow checker. It just defers the enforcement of those rules to runtime. This sometimes avoids issues that cannot be proven at compile time.

What semicoleon is showing above is that your runtime-deferred borrow checks are failing because some borrows are being held over reentrant function calls. (These are the kinds of bugs that the borrow checker can tell you about at compile time.)

Ok, I'll bite. One of the major architectural problems is that Controller is a self-referential struct: tetrii/controller.rs at main · russellyoung/tetrii (github.com) This is ultimately responsible for the second error mentioned in the comment above; your "Start" button acquires a unique Controller reference to call show_board() but that method in turn needs to borrow the same Controller out-of-band to create a new board reference. Similarly, Board and Controller both reference each other.

If I were to suggest an alternative design, it would be to adhere more closely to the ownership model. Only one of Board or Controller should own the other (and Controller obviously can't own itself). From the looks of it, I imagine Controller is the parent in the hierarchy, though that isn't actually clear with the shared ownership offered by Rc and the reference cycles created with RefCell.

And finally, remove the cross-cutting concerns. Board should not be able to directly update the state of Controller, but it can pass messages to Controller as described by semicoleon. Board::tick and Board::drop_tick should probably be moved to Controller since they need access to sibling Boards. Things like that. Or alternatively, break these monoliths into smaller structs which can be borrowed or cloned independently.

Another way to describe this suggestion is to make information flow only one direction, typically from top-to-bottom. Every time you need to swim upstream (i.e., referencing a parent) you need to remember how much more energy you need to put into it. Because, you know, water flows downstream and all that. Not a perfect analogy, and it isn't impossible to create these tangled hierarchies. But it most certainly is not the path of least resistance. Channels fit the ownership model because they are split into a Sender and Receiver pair; each half can be owned independently, but they give you a way to swim upstream with much less effort than self-references and reference cycles.

10 Likes

I love this "information stream" analogy. If you want to swim upstream, the best thing may be to make a separate channel...

3 Likes

Thanks, this makes so much sense, especially this:

It certainly is faster to find at compile time, I spent so much time trying to find a way around it which ended up being wasted, except I suppose as a learning experience.

I've taken out all the bad references (without putting in the channels), and Controller initializes. But I ran across 2 other problems, one I have solved but don't really like, and one tougher.

The first is the initialization: in main() I cannot make Controller or Config, because if I do I can't pass them in the connect_activate closure. It doesn't make a difference here, but Config feels like it should be at the top level. Logically I'd like to make a Config and a Controller at the top level initialize from there, but it appears that to access them in the handler they need to be made in the handler, they can't be passed in from outside without lifetime issues.

The bigger problem I guess is related, it is getting the Controller object into handlers so it can be called. I tried connecting the "Start" button with the handler method, but couldn't get either the Controller object or its Vec into the handler, essentially the same issue as the first problem but without the workaround of creating the objects on the inside.

The only possibility I can think of is to make Controller a Widget extension, and then (I think) it can be retrieved from inside glib. Does this make sense? Making everything message driven would cause the same problem, just moving it from the event handlers to the message handlers. I'm looking at the docs semicoleon suggested, and am trying to imagine how to get from where I am now to there. I'll try to incorporate that and update again.

I really appreciate your analysis, it gives me a better understanding of how I should be thinking of this.

I don't actually use GTK, so take this with a grain of salt. What follows is just my interpretation.

This appears to be an issue with inversion of control. As I'm skimming through the gtk4-rs book, I noticed what they tend to do is make the application's top-level state owned by the "window", which in your case is just an empty gtk::ApplicationWindow. But GTK is heavily inheritance-based, and what they seem to expect is that your window type is a subclass that owns the application state. This "todo app" example from the book is probably a good illustration: Building a Simple To-Do App - GUI development with Rust and GTK 4 (gtk-rs.org)

Looking specifically at their pub struct Window in the first few code snippets. Window owns the application state. I.e., your Window will own a Config and a Board, and maybe some other structs with relevant state and functionality.

That leads to the consideration of how you might arrange for windows to access the state of other windows, which is what your Controller appears to want. The answer should not be surprising: use channels to pass messages between windows. The channel pairs are additional state fields on your Window (ignoring for now the complexities involved with broadcasting to multiple receivers).

The glib::clone! macro appears to be the recommended way to pass references into the closure. IIUC, it's really just syntax sugar for cloning. But you can see from the docs that it also helps the signal handler access self by renaming it:

start_button.connect_clicked(clone!(@strong self as window => move |_| {
    window.show_boards();
}));

This is more or less what you already had before, except it doesn't use a self-refefrential Rc. It just uses &self directly because (presumably) the Self type inherits from GObject, which is already reference-counted. In other words, Self is the Window type like the one that the todo app tutorial has you create.

:+1: Yes, I think this is more along the lines of the recommended approach to GTK application design. GTK wants you to use inheritance. It's not the worst thing. Inheritance isn't well supported in Rust, but also inheritance isn't well-supported in C. And many (most?) GTK apps are written in C! I think it's just a matter of "doing things the GTK way".

I can't say that I agree, since we can establish, first, that the design does work for other applications (documentation and examples are supporting evidence). And secondly that the recommended approach (everything is a GObject) seems to strongly support the event driven/message passing model.

I found a link https://www.figuiere.net/technotes/notes/tn004/ that gives a description of subclassing that much improves on the gtk docs. I'm looking at it, though there may be a day or so of rest before I start coding, this has been more work than I expected.

All the other tetrii versions I've written have been pretty easy, the language differences were on the outside but the base concepts were pretty similar. On the surface Rust looks like a vanilla language, but because the differences are at the root my standard practice of "code until you hit a problem and then solve it" isn't so good a paradigm. But it is the different memory model that intrigued me and got me interested, so I can't really complain about it. As a C system programmer from way back I am really impressed by a totally new concept of memory management.

The other irritation I've had, which will need some thought, is integer types. I think much of the casting I have to do is because types were chosen pretty arbitrarily, without an overall plan. While that is little more than an inconvenience, it makes the code uglier (even to me), and probably was a clue that there is more going on than I was acknowledging. The takeaway is design in Rust needs to encompass more than design in some other languages.

I'm glad you're having fun with Rust. I'd suggest you also do some other example projects first, maybe with a command-line interface.
While Rust can call C++ bindings, the resulting code is often not very idiomatic, as Rust doesn't have inheritance. More info about the state of the GUI ecosystem here: https://www.areweguiyet.com/

Thanks, I know this is kind of ambitious for a first real shot (I have done some stuff in tutorials). I did start off with what I'd normally consider bells and whistles, merging input from a CLI and a config file, using clap and serde. Normally I would have hard coded values for initial development and then added those last, but I did want to get at least some practice first. Recently I've been doing systems work in C and expect working with the C API would be useful, which is why I decided not to use one of the other graphics front ends, which I know would have made things easier. The problems I'm running into, while painful at times, are just the sort of things I want to understand, so it is useful. I really appreciate the help I've been given here - without @parasyte and @semicoleon I'd have been stuck.

While this is a standard exercise I've done with a bunch of languages, you are right in that Rust is the most challenging, since (I've learned) the differences between it and other languages run deeper than just the surface grammar and concept of class. Working in C I have always tried to maintain a view of where everything is stored. Rust mechanizes that, so I thought it might turn out to be natural to me. Actually, in order to cover all cases, as the documentation says Rust is conservative in its view, so many things which I know are safe are not allowed. That's the cost of Rust's memory management - in some languages the cost is the developer's skill and experience, in others it is the resources required by the GC. See, I've learned something!

Rust certainly doesn't lend itself well to direct ports of code from other languages, especially when that code is heavily OOP.

It's interesting to me that many newer UI frameworks in more conventional languages are adopting design patterns that are much closer to what Rust pushes you towards. React, Jetpack Compose, and Swift UI all push you towards being much more explicit about how state and mutation move through your code. I think it's fair to say this is just a trend in the industry for UI code specifically.

2 Likes

I just checked in a new version, if anyone would care to review it. It is completely redesigned and rewritten, and while not all finished, the basic game does work - boards are drawn, pieces drop, scores are kept.

It is definitely a different experience than other languages, and @semicoleon is right. Most other languages fit well with standalone boards tied together with a lightweight controller. That was the first design, and it was not going to work. Now the controller drives everything top-down, and I still had issues reporting results upstream. One thing I learned was to move away from objects - Java encouraged me to fit everything into an object design, but in Rust often bare functions seem much easier to use than methods. One issue I still had was it felt like much of my effort was to get around compiler imposed limitations - using Rc<RefCell<>> or putting in unsafe{} blocks. I'm not sure if that is because I still don't really get it or or dancing around the memory restrictions is just a fact of life in Rust, for some types of programs at least.

It ended up with several static mut variables which seem to be needed (I came to dread the "needs static lifetime" error message) . Now I'm thinking that it might make more sense to move those into the main Controller struct and make a single static instance of that - it would not ever have to be mut because it contains a Rc<RefCell>> struct that holds all state information. A single static, even with a mutable inside, is probably preferable to 5 or 6 mut static variables.

I expect to take a break from it - I'm pretty tired out from the last few days, and it has started getting warm and nice outside, but I'll finish the remaining details in a few weeks if anyone cares.

I really appreciate that you take the time to understand rust. If you feel like you want to push further, consider running clippy, which has useful tips to make your code more idiomatic. Stay healthy!

1 Like

Great suggestion, thank you. I've been impressed with the helpful messages from the compiler. Especially at the start, getting suggestions for how to fix errors is so much better than just reporting them. And I'm pleased, clippy only found 68 warnings in its first check. I would have bet on more.

I've pretty much finished - there are still bells and whistles but the cost/benefit of any knowledge still gained makes moving on a good option.

Things I learned: this was not a great choice for Rust. For other languages it was fine, but because graphics uses glib this wasn't a typical Rust program.

Second: my design, in the end, was still not great for Rust. The OO deeply nested calls that are good form in languages like Python and Java, where breaking functionality into lots of small chunks and ending up with deep call stacks, might not be as good a design. If any subroutine at the bottom of a call stack needs mutability (for example, updating the score) it needs the entire call stack above it to be mutable, or it has to cheat the design by using RefCell. It seems to me that the design for a Rust program should be a fairly comprehensive top level which holds all the structs of interest, and shallow call stacks that are either all mutable or all immutable. In that way it seems more like the assembler programming I've done rather than other higher level languages.

Maybe my next step should be to look over some other code and see how it is actually used by people with more knowledge of it than I have,

If anyone is still following, I've finished work on this, and it is pretty complete. There are a bunch of nice features added, including an interactive search function and a parser for an alternative search syntax that is (IMHO) better than traditional regular syntax. Since this was intended to simulate a full project it includes both testing and documentation.

There still is a lot I'm missing on lifetimes, but at least there is more that I kind of understand too. The biggest breakthrough came when I finally got the full emacs support working, and it filled all the type info where it was elided.

For now, the weather is nice and getting out on the bike is a better choice than spending free time at the keyboard. Many thanks for all the help here - I seriously doubt I'd have been able to get this far from just the docs, certainly not as fast or retaining some sanity.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.