I share your frustrations about that kind of code—like, deeply confusing huge lattices of interdependent objects where it’s hard to track down the origin of any given piece of behavior and so on. I would say that imperative OO Ruby code (or code like that in other languages) can be really easy to both reason about and change without surprising regressions, but it can be challenging to figure out how to structure a large codebase that way, and I think in a group it really helps to have close collaboration between the developers to achieve it (like, via pair programming and stuff, or at least a lot of detailed regular discussion) so that everyone has a good sense of the evolving big picture and so on. Comprehensive and fine-grained automated testing can also really help, as can people being disciplined about documentation etc. (Obviously not every commercial environment has a culture like this.) Because Ruby is a language with few safeguards and tremendous freedom for the programmer, I think it’s easy to make an unworkable codebase with it, but it’s also possible to have an especially excellent, well-behaved codebase when conditions allow.
As far as writing pure functions goes, in some ways I have the instinct to say that you can still produce daunting spaghetti that way (I’ve seen some deeply confusing Haskell codebases with long sprawling opaquely-named functions and so on…in some ways I think you have to be careful if you want clear, easy-to-work-with code regardless of the language or design). It does seem to me like there’s something about pure functions though that helps people code-design-wise. I think maybe it’s just that it’s obvious what the surrounding context is when the function runs, whereas with mutable state it can be a huge struggle to know that comprehensively—at least if the codebase hasn’t ended up being very pleasant.
Actually, speaking of, I think for me one of the most helpful mental tools I’ve picked up for writing easy-to-work-with imperative classes, in basically any language with classical OO, is to figure out the invariants the object expects, document them, ensure that they’re established during the object’s initialization, and then be careful to maintain and/or check them as necessary in each of the object’s methods. If you’re writing tests for the class, the invariants let you know what sort of test harness you need, and trying to keep the invariants lightweight helps you write classes that aren’t too tightly-coupled. I also think, in a dynamic language like Ruby, duck typing, and in general focusing on the interface over the concrete type (e.g. using public_method_defined? over class == and that sort of thing), helps a lot to keep tight coupling to a minimum. (Something I think is kind of unfortuante about static typing is that the path of least resistance is generally to depend on an entire other concrete type; you have to go out of your way to define a pure abstract class or interface or w/e to break that dependency, and even then, you still have to consume the entire interface defined therein. In a dynamic language you can easily specify only the parts of an interface you actually need to depend on case-by-case, which can make the whole codebase more resilient and flexible.)
Is inheritance really a form of nonlocal goto any more than any other procedure call? I guess like, I see what you’re saying in sort of qualitative terms though I think—like, I guess, the classic objection that it breaks encapsulation and so on.
I’m tempted to say that I really like inheritance, but I think it kind of depends on what sort of inheritance. I think like, I find way more use for interface inheritance, as you get with a C++ pure abstract class or by includeing a Ruby module or that sort of thing, than I do for implementation inheritance, as you get from inheriting from a C++ base class with data members or by using < in Ruby. C++ affords you amazing code design possibilities by combining interface inheritance and templates—sometimes you have to think really hard but you can come up with abstract class lattices that are both very technically satisfying (performant, type-safe, etc.) and require the minimum of work from implementors of derived classes. In Ruby, the metaprogramming possibilities afforded to you at module include time allow you to basically rewrite the entire including class based on whatever information is available in the program at that moment during runtime, so it’s kind of like C++ except that you can go even further (although of course you have less say about what happens close to the metal in the process). You can write libraries that will do lots of the coding for you this way.
I think implementation inheritance is a lot more dangerous and kind of dubious, and I tend to use it more as a quick crutch than as something I leave in a codebase long-term, I think. As a general rule, I feel like it’s more useful and flexible in the long run to have an interface that can work with a variety of underlying implementations, and just leave the inheriting class to tell the interface how it satisfies its requirements, rather than have the base class force an implementation on the derived one. However, I do think there are cases where it’s nice to have a “default implementation” available in the base class, in situations where there’s an obvious default, to save implementors of derived classes from having to write boilerplate code. In a language like C++, this has to be clearly documented I think because it can impact the representation of the derived class in memory, and I think in some ways that speaks directly to its dubiousness (because you might rather not document an implementation regardless of what it is). The benefits can outweigh the downsides sometimes but you have to be careful I would say.
The major downside I can think of to composition is that if you want to expose part or all of the interface of a wrapped object in the interface of a wrapper object, there’s not always a straightforward way to achieve the delegation without having to duplicate parts of the wrapped interface or at least write and maintain some kind of boilerplate code. There are a lot of situations where you basically want some kind of standard data structure with a bit of specialized functionality added on, and it can be convenient in those cases to be able to tell the world that that’s what it’s holding onto without having to actually expose the wrapped data structure object directly. Inheritance can provide a straightforward way to get that. (Of course, if you can inherit the appropriate interface for the data structure, you can often just do a tiny bit of delegation to the wrapped type to satisfy the requirements of the interface and then get the best of both worlds.)
You can do this okay in Godot—at least as I understand them a service object isn’t really anything special necessarily. Like, let’s say you want to have something that can hold onto a Color and generate other Colors by randomly shuffling the components of the Color it’s holding. It’ll keep track of the last color it generated so that it doesn’t do the same one in a row (there are arguably more sophisticated ways of doing this but just for the sake of a simple example)
extends RefCounted
class_name ColorShuffler
var source_col = null
var last_col = null
func _init(src_col):
source_col = src_col
last_col = source_col
func gen_col():
var col = Color()
var i = 0
var ns = [0, 1, 2]
ns.shuffle()
for n in ns:
col[i] = source_col[n]
i += 1
col.a = source_col.a
return col
func color():
var col = last_col
while col == last_col:
col = gen_col()
last_col = col
return col
Having ColorShuffler inherit from RefCounted ensures that it will get garbage collected without having to incur the overhead of Node.
Then, if you have a MeshInstance3D with the StandardMaterial3D on it, we can give it this other script to change its color every so often, making use of a ColorShuffler:
extends MeshInstance3D
@export var period = 1.92
var elapsed = 0
var shuffler = null
var mat = get_active_material(0)
func _ready():
shuffler = ColorShuffler.new(mat.albedo_color)
func _process(delta):
elapsed += delta
if elapsed > period:
elapsed = fmod(elapsed, period)
mat.albedo_color = shuffler.color()