Rust vs. traditional OOP in GUI and related data structures

In the "extend a 3rd party struct" thread someone indicated that deref polymorphism was a Rust antipattern, noting a conscious decision not to use OOP.

I always thought the decision not to use OOP was because the traditional VTable approach implemented every method call using a pointer traversal, a structure lookup and an indirect jump. This combination invariably causes pipeline stalls and possibly cache thrashing thus eliminating the possibility of fast implementation.

Is there more to it than that? Data structures are disproportionately more reusable than most code as are GUI implementations. Because those types of code benefit more from OOP than usual, I think that the alternatives should be indicated here.

For starters, in the realm of GUI development, a GUI builder app generally stores its design structures as XML which leads me to think that a GUI specification should be treated as a structured language with the same hierarchal structure as a compiler would use.

What are your views? (Please be civil. I want no holy wars here.)

3 Likes

GUI's are actually my only complaint against the "don't try to emulate OOP in Rust" pattern. OOP made writing GUI's much more convenient, at least to me. I like C, but I just can't use GTK peacefully, on the other hand I can get behind gtkmm quite easily (even though I don't like C++).
That said, the builder pattern is one good alternative to the OOP pattern of extending Window or Frame classes (or whatever your library calls it). Flutter is one of my favourite GUI toolkits, and the language it uses, Dart, literally has the builder pattern as a language feature (Dart was made to fit Flutter, hence its presence). That said, Dart is pretty much a traditional OOP language - but you'd find yourself relying on inheritance very little for the GUI code. That said, the one place you'd always use inheritance is extending StatefulWidget, which actually gives you quite a bit of functionality.
In Rust, the only way is to do manual forwarding. The only saviour is that you really don't need all the functionalities, hence you can mostly get away with forwarding a few functions. So, yes I find Rust GUI toolkits somewhat annoying to use (like Druid or Iced) compared to Flutter, Java Swing or gtkmm. But maybe that's just me - people have made quite a few Rust GUI apps, so they seem to be doing just fine.
Go has a way of "extending" structs, which means that the "sub"-struct will automatically have the forwarding done for it. This can already be done in Rust with macros, of course.

I won't speculate regarding the perf issue with OOP virtual calls being the cause of not supporting inheritance in Rust, since you can also get it using dynamic dispatch in Rust (via dyn Traits).

Anyways, if you take C++ as an example since it supports OOP, data structures in the standard library don't use inheritance, they also lack virtual destructors (which can be considered a deterrent to inheritance).
Programming Principles and Practice Using C++ by Bjarne Stroustrup (the creator of C++) does use inheritance with std::string but it's mainly to simplify things for learners.

However, Gui libraries in C++ have embraced inheritance for the most part, and it has certainly lent itself to that use case. Even Gtk (a C library) models itself around inheritance.

In fltk-rs which wraps FLTK, to get a custom table widget, it's easier to just use the Deref pattern since implementing the needed traits directly or just wrapping a table and forwarding calls manually can be prohibitive. WidgetExt + WidgetBase have around 100 methods, GroupExt has around 20 methods and TableExt has around 60 methods. In C++ land, you can just do:

class MyTable: public Fl_Table {
    // define and impl only what you need
};

Yes. OOP with struct inheritance is fundamentally problematic. Both from theoretical standpoint and practical standpoint.

Consider the following simple example:

class Foo {
  virtual int x2(int x) {
      return x + x;
  }
  virtual int x3(int x) {
      return x2(x) + x;
  }
};

class Bar : public Foo {
  virtual int x2(int x) {
    counter++;
    return Foo::x2(x);
  }
};

Here class Foo have two functions: x2 and x3 (multiplication by 2 and 3) and it's descendant can count number of times x2 or x3 was called.

Suppose someone later found out that x3 is too slow and improved it:

class Foo {
 public:
  virtual int x2(int x) {
      return x + x;
  }
  virtual int x3(int x) {
      return x + x + x;
  }
};

Behavior of the x2 and x3 is the same… yet some totally unrelated code in another module is now broken.

When you are using OOP with implementation inheritance you have to specify not only what each and every function if used, but also when is used, too. And when it's not used, also.

Even most OOP advocates accept that this is the issue. And solution offered is simple: avoid use implementation inheritance, use interface inheritance instead. And offer default implementations for more complicated functions in these interfaces (where they become part of the interface, not implementation).

Coincidentally (yeah, right) that's the type of OOP which can be implemented easily in Rust with help of traits.

And it's not an anti-pattern, it's used in Rust everywhere.

OOP is also the reason why GUI is always buggy and laggy. These hidden dependencies introduced via common implementation is probably source of more bugs than anything else in programming.

Sure, many years ago, when 640KiB was typical memory size of a typical computer they also allowed to write quite compact code. But today it no longer works: because contemporary GUI applications are so large and complex people usually try to not rely on these hidden dependencies but try to encapsulate required work in every single method. The end result is code which does the same thing again and again. Hundreds or thousands of times. Any savings which were possible at some point in the past are dwarfed by cost of that “defensive GUI programming”.

GUI remains tough nut to crack, though: we already know that traditional approach to it leads to fragile and slow code, but it's not yet clear how to simplify things and yet keep them reliable.

But it's not surprising Rust is trying to achieve that instead of bringing “traditional OOP” back (fully or half-way like Go does): it's just not the Rust way to accept half-backed solution if there's hope of doing things better.

4 Likes

I'm still going to hold to my position that a GUI language can be more efficient by way of combining layouts into fewer objects using an irregular grid layout. Does anyone here remember the table layouts in HTML3 and how they could resize and word-wrap labels all from a single table made up of one or more cells? The colspan and rowspan tags allowed elements to quickly share resizing coordinates. I think that idea can be implemented and improved in Rust. The only part that suffered was that the row and column coordinates were difficult to control but using range checks in a match statement can greatly simplify layout techniques.

In my experience, Java GUI's are nether buggy or laggy (only ugly, but that can also be fixed up with some LAF libraries). That said, maybe you have specific experiences, so you could share that with us.
I'd say that GUI is a lot more fiddlier than other things - but that's why you shouldn't hire me as your frontend dev. :wink:

If you want a data point, the sole reason I ever started using Rust is because Java UI's were awful enough that I couldn't even get basic functionality implemented before I had to start tweaking the GC and dealing with massive lag. It was a major impediment to progress, and why I won't use any GC languages if I have an available alternative. (Straightforward reimplementations of the same projects in Rust are perfectly sharp and fast, and I've never had a perf problem in anything I've written, so it's not just 'bad code'.)

EDIT: I would also add that, even if Rust supported inheritance, I firmly believe that the GUI landscape would look almost exactly the same as it does today, because the impediment to building GUI frameworks is not a lack of inheritance, but a lack of need for "yet another never-to-be-completed GUI framework."

3 Likes

If it's simple enough it may not be buggy (but that's extremely rare, if it's a tiny bit non-trivial there are usually many ways of making it misbehave) but it's always laggy.

By “laggy” here I mean “reaction to a keypress or mouseclick takes more than 16ms which means I can see it”.

Usually when I use that definition people start explaining how these demands are not reasonable and how I should't expect the impossible… but then: text programs quarter-century ago on IBM PC with 4.77Mhz CPU and 640KiB RAM easily achieved that. Even with multitasking (called "TSR residents" back then). Why can't we satisfy the same criteria today? Sure, graphical screen is about 2000-4000 bigger than text buffer in MDA, but our CPUs are 10000 times faster and we have 10000 as much RAM in our computers.

2 Likes

Well that sucks. I am 20 and I still can't tell the lags in Java GUIs. So maybe my response speed is slow.

I'm not sure I see how this example shows an issue with OOP, or lack of it can prevent devs from relying on some implementation detail.

pub fn version_string() -> &'static str {
    "0.1.0"
}

Changed to

pub fn version_string() -> &'static str {
    "version 0.1.0"
}

Can break code relying on the presumption that there is no "version " before the actual version. In the same vein, a program not making that presumption doesn't qualify it as "defensive".

2 Likes

Nah. You can not see lags specifically because you have never seen any UI with “instant” reaction speed. Thus don't know where to look.

It's not even about reaction speed. It's about confidence. With “old school” GUI (use emacs or vim in today's world to experience it) I can type 10 or 100 commands without ever looking on the screen.

I can be 100% sure that all my commands would be processed and even if that wouldn't happen instantly — it would be processed correctly.

Just perform a small test: open moderately-sized text file (about 100MB or 10MB, not that big by modern stadards) where you have lots of gibberish and 10 lines with text "foo" (one per each 1MB). Start search in your favorite editor and try to replace half of these with "bar" by quickly alternating between "replace" and "skip" choices.

Most today's editors fail this simple test spectacularly. Most editors from half-century ago handle that correctly.

It's not about reaction speed. You don't perceive modern Java GUI as laggy and unreliable simply because you, most likely, have never worked with non-laggy and reliable GUI.

For you the fact that you may send mouse click to the window "under" dialog box is not a big deal: hey, yeah, window was closed and then you clicked before it can be reopened. For me — this is crazy: computer can do billion operations in 16ms yet it's not enough to close and open the dialog box? Who wrote this crap and why can not it be fixed?

2 Likes

But you are changing the values which function returns. Of course this may break code which rely on the returned value. And a simple test which verified that returned value doesn't contain "version" string is enough to verify that everything is safe.

In my case all inputs and all outputs from x2 an d x3 functions are always the same. You can not distinguish these two versions by any tests — except for the test which overload some methods.

And to meaningfully reason about these you have to have fully documented information about not just what is passed into methods or returned from them, but also when and how they are called in the existing implementation itself.

That information is rarely even mentioned and even if it's discussed at all it's rarely documented precisely enough to make any changes in the base class safe.

In effect the whole implementation of base class becomes it's interface. Which is especially aggravating when you don't have access to it (like when it's implemented as part of closed-source OS).

P.S. Again: it's not as if that's some kind of news for the proponents of OOP. This “leaky” nature of OOP is the reason OOP-languages usually have extensive mock libraries and dependency injection libraries. They are designed to make it possible to write tests which do these versifications. With jMock (Java) or GMock (C++) you can write test which verifies that x3 calls x2 and then exactly once. And dependency injection libraries help to mitigate consequences of OOP design to some degree. But you have to realize that all these tools are only needed because OOP makes coupling between methods important. If you don't make it possible to inherit implementation in Jave/C++-virtual style then all these tools are just not needed. You truly don't need to care about whether the x3 method calls the x2 method or not if that information can not effect any code in other modules.

4 Likes

To add to your post scriptum, to the best of my knowledge OOP proponents themselves have discouraged the usage of inheritance as an extension mechanism for at least 20 years. (that would be when I was around 10 and started to read technical things)

Inheritance, though useful like any feature, is known to be a problematic mechanism.

3 Likes

I personally think it's amusing how these discussions wind up hinged on the question of whether subclassing is a valuable feature or not, when it seemed to me (at least, back in the 1.0 days), that the biggest impediment to Rust adding subclassing was actually technical. A lot of metaphorical ink has been spilled on the topic, mostly because the Servo team needs to implement the DOM, which is subclassing-based by specification (so you can't just come up with a different API).

Essentially, subclasses would require a new kind of unsized type, like dyn objects and slices, since if you have a handle to the base type, it might actually be a subclass, and those are going to be bigger if they add their own member variables. Rust can't just do what Swift does, since Rust is supposed to be usable with no allocator at all, and it can't just do what C++ does, since slicing a subclass in half would likely resulting in leaked memory or worse. Unsized types are second-class in Rust, since it's impossible to hold one without using a pointer, so if you're working in a subclassing-heavy style, you'll have a worse time of it than you would in a language like Java that's designed with subclassing in mind.

Why add a "convenience" feature that isn't actually going to be convenient?

4 Likes

My story is even funnier. My school used Turbo Pascal 5.0 for programming classes long after not just Turbo Pascal 5.0 have become obsolete, but Windows have replaced DOS!

Our teachers all had math degrees and objected a lot against Turbo Pascal's OOP (which was first implemented in Turbo Pascal 5.5). Specifically because of that issue.

Only years later I understood their wisdom… and realized where they were wrong. It's very hard to implement any complex system without interface/implementation OOP style. It's truly ubiquitous, it's everywhere: in compilers, OS kernels and so on. And Rust supports it very well indeed with it's trait/implementation dichotomy. It more-or-less built around it.

But implementation inheritance is flexible yet very-very dangerous and messy. Hard to deal with practically and even harder to reason theoretically.

That's why Rust rejected it, I suspect.

2 Likes

Having done OOP development for nearly 20 years - inheritance sucks and is rarely the correct solution to a problem; it is always the quick and dirty choice (the Dark Side, if you will). Inheritance can limit future development in unforseen ways. Composition is superior in almost every way, a fact which has been acknowledged by most mainstream OOP practioners for about as long as you said.

If I'd responded to the original thread that inspired this one, I would have suggested to create a new type with the third-party type as a public field. This provides access to methods without having to manually wrap them, and without (ab)using Deref in a way it wasn't intended to be used; which probably would lead to suprising behavior at some point. Deref is for smart pointers - not (badly) emulating inheritance.

1 Like

@m51 Someone had suggested that a public member of the third party type could be used instead of the deref polymorphism hack. The original poster just needed something that worked quickly though and took the quick hack.

Re:inheritance as an API
I was taught Python and Java in the university when I got my 4-year degree. Inheritance was everywhere. After I graduated 14 years ago, the computer science department was cancelled a few years later in favor of an IT degree. I'm glad I'm learning these things now.

I don't find it amusing, but more like satisfied. I was not playing with Rust before stable Rust have become a thing, but I really like the fact that features which Rust explicitly rejected are, in most cases are also the features which are theoretically unsound.

Come to think about it: it's, probably, not a coincidence. If some features doesn't have reliable, sound, math as basis then it would, probably, be hard to implement, too. Graydon wasn't around to help shape Rust 1.0 and post-1.0 Rust, but when you read the list of things rust shipped without… you feel similar satisfaction.

And I certainly hope rust world would find a way to develop easy to use GUI crates without implementation inheritance. Because while it's obvious just why this feature is problematic the sad fact remains: all existing popular GUI toolkits are using it. Can you even design a easy-to-use GUI toolkit without this paradigm? IDK. Honestly. But I hope the answer is “yes” because it's not really needed or popular outside of GUI toolkits.

2 Likes

@VorfeedCanal Let's find out. GitHub - redox-os/orbtk: The Rust UI-Toolkit.

I find it amusing, because I don't think the arguments about inheritance being unintuitive or easy-to-misuse are going to be very convincing, because these things are intuition-driven, not something that can be deductively proven. The best you could possibly do would be some sort of statistical study on defect rates, and nobody has linked one yet.

But I also think the whole discussion about the relative merits of classical inheritance are moot. It's like the arguments about adding monads — even if you actually like the feature, nobody's figured out a way to add it to Rust without it being a pain in the neck to use.

2 Likes