Lemmings Boards > Tech & Research

Object-oriented design with Simon

(1/7) > >>

Simon:
Let's learn object-oriented design with Simon, part 1.

I feel this would benefit the NL codebase, and even the Lix codebase has a few classes that are still a bit too long. No idea who else will benefit, but maybe it's entertaining.

Two disclaimers

* If this were 1995, OO would be hailed as the panacea of coding. In 2015, I will remind everyone that it's a design choice suitable for some parts of your program. It's your job to see whether it fits, or if it adds complexity that nobody needs.
* NL might not get additional skills. In that case, don't trade a debugged design for undebugged theoretical soundness. The point of a design to minimize the cost of changing and maintaining it. If you aren't going to change much, don't refactor yet.Code will be written in a Python-D-C++-Hybrid that I make up on the spot, but I hope stuff will be most clear like this. Indentation will replace begin/end.

The main principle is to replace if-else-if-else-if-else by dynamic dispatch. Whenever you have this:

if (lem.action == walker)
    lem.updateLemmingWalker()
else if (lem.action == faller)
    lem.updateLemmingFaller()
else if (lem.action == builder)
    lem.updateLemmingBuilder()
else if ...

We can do the following instead (a naive approach, with problems still). But it will dispose of the error-prone if-else chains for now.

abstract class Lemming:
    void update():
        do nothing

class Walker extends Lemming:
    override void update():
        check ground
        move ahead or turn

class Builder extends Lemming:
    override void update():
        produce brick or not
        check if out of bricks

Then we make a container object that collects Lemmings, and put the walkers, fallers, builders, ..., inside.

foreach (lem in container):
    lem.update();

If the lemming is a walker, this code will not call Lemming.update(), which would do nothing, but instead call Walker.update(). If the lemming is a builder, the code will call Builder.update(). The code calls the correct method without us knowing what the correct method is. The code calls the correct method without us having made an if-else chain to select the correct method.

There is a problem here, because the lemmings will want to change between classes. When the builder runs out of bricks, it wants to become a walker. But a class object shall never self-destruct and replace itself with another class, because it doesn't know which references to it should be updated.

Idea for that in the next post.

-- Simon

NaOH:
Is the idea composition?

I had the poor luck to be introduced to coding with object-oriented design. This was great when I was a naïve youngster, but now that I have explored different languages and paradigms, I have lost a reference point and I don't really appreciate the benefits of OO. Increasingly I'm viewing OO design principles as more limiting than they are helpful in shaping my code.

I realized something was wrong when a friend asked me, "so what is object-oriented design anyway?" and I couldn't give a clear answer. I'm no longer sure why it is that we put functions inside classes sometimes and call them "methods." The only benefit I can really see is polymorphism, but suddenly I feel like I'd be more comfortable with function pointers than dynamic dispatching.

Now, this opinion can't possibly be credible, because
--- Quote from: Simon ---If this were 1995, OO would be hailed as the panacea of coding
--- End quote ---
so I'd like to watch this thread closely; maybe you can rekindle my affection for OO.

ccexplore:
The Wikipedia article on OOP does have a nice criticism section, so you're hardly alone in questioning the benefits of the paradigm.  Like Simon said, it's design choice.  People working on certain programming languages that have since become widely adopted in the software industry may have been smitten with the paradigm back in the 90s and introduced many of its features into their languages, but of course, it takes time and experience using the paradigm to fully understand the benefits and shortcomings.

Adding features to a language will of course increase its complexity, so in that sense it is understandable that some may prefer a language with less built-in features--you may have to write a little more code to do the same thing, but there's also less of a chance of getting tripped up by subtleties in the language's features.

In any case, in practice you rarely have a choice--except for the times when you're starting with no code written, whenever you have to work off from an existing codebase, the choice of language is usually taken out of your control at that point.  Even when you have the luxury to start from scratch, the choice of language may be limited by practical concerns, like how many people you can find that is proficient with a language.

Simon:
Awesome, this got replies. :lix-blush:


--- Quote ---Is the idea composition? [...] Increasingly I'm viewing OO design principles as more limiting than they are helpful in shaping my code.
--- End quote ---

The idea is explicitly about when to choose polymorphic inheritance over composition. I found that useful for 2 parts of my own program: GUI, which is the classic application domain of class hierarchies. The second is lix activities, which have a very rigid, small interface -- switch to, perform, hooks on becoming and de-becoming -- and comparatively many implementations of these.

Inheritance incurs a design debt: By the Liskov substitution principle, "B extends A" requires that wherever an A is desired, we can pass a B to that, and the receiver can treat it like an A. We shall not curb functionality. This makes the resulting class hierarchy very rigid. Everything depends on the classes at the beginning, which become very hard to change.

Composition (class B { A a; ... } instead of class B : A { ... }) does not take up this debt. We don't have to expose any A functionality in B's interface. Some newer languages have features to forward any method call to B on to the component A as long as B doesn't implement them. This merges the convenience of not redefining methods from A with the convenience of not taking up any design debt.

The function pointers would work as well, and are idiomatic in C to get dynamic binding. We can make our own virtual table instead of having the compiler generate that from the class code.

Why then use the complex builtin language feature instead of composition or simple pointers? Leverage the compiler, let it check your work statically. This is a widespread mantra in D and C++. In D and the Java world, this is leveraged pretty strongly: On forgetting to implement an abstract method, the compiler will complain. On overriding a method marked final, the compiler will complain. The function pointers would instead crash at runtime to remind of necessary overriding.

In both cases, there's the option to interpret the error/crash as a mistake in the base class design, instead of as a welcome reminder.

When new classes are added, but the root classes remain the same, the if-else-chains would require updating code all over the application. There is no way to check whether one place has been forgotten. With virtual method dispatch, the changes are all bundled together in one specific place, the new class. If we expect our program to grow mainly in this manner -- a reasonable assumption with GUI components and lix skills -- then the class hierarchy might pull its weight.


--- Quote ---In any case, in practice you rarely have a choice
--- End quote ---

Right right. Any kind of overarching design becomes encrusted and hard to change. OO is particularly prone to generate unnecessary rigid complexity.

Choice of problem domain, tooling ecosystem, and language, all of them have an influence on what overarching design is expected. I'm weird in that I love reading about professional software development, but only have a hobby project without any holy design to preserve.

OO with Simon, Part 2
Idea for can't-replace-self in container

We store lemmings, and the lemmings change their behavior during their lifetime. This leads to the following idea: Don't replace lemmings. Replace the behavior of lemmings.

class Lemming:
    private Job job
    void become(Job j):
        assert (j.lemming == this)
        job := j
    void updateCalledByTheGame():
        job.perform()
       
class Job:
    protected Lemming lemming
    abstract void perform():
        do nothing yet
    constructor(Lemming l):
        this.lemming := l
        this.lemming.become(this)
       
We introduce tight coupling between job and lemming here. (Both know of each other, and can access each other's public interfaces.) I have thought about this for weeks, and can't see how to get rid of it.

I'm also not as opposed to public/protected members as it's traditionally recommended to be. The reason is that in D, we can later rename the member and make it private, and write an accessor property method that's callable exactly with the syntax of the previously-public field. In other languages, you might want to encapuslate behind boilerplate immediately.
           
class Faller : Job:
    constructor(Lemming l):
        super(l)
    override void perform():
        lemming.moveDown
        if hit ground:
            lemming.become(new Walker(lemming))

class Walker : Job:
    constructor(Lemming l):
        super(l)
    override void perform():
        lemming turns or walks ahead
        if (no ground):
            lemming.become(new Faller(lemming))

My biggest dread is that this is overkill for the problem at hand. I had function pointers in C++ with a jump table. However, the C++ lixes had a fixed memory size. There were 2 ints reserved for each job to be used however they saw fit.

Using dynamically allocated classes allowed arbitrary private fields for each job. This has lead to much more expressive code inside the job methods. This expressiveness is not visible in my sample code above, because I'm focusing the hierarchy, not the exact method contents of any single job. But inside these methods, I've experienced the biggest gain in readability.

-- Simon

Simon:
OO and general design with Simon, part 3

Question that came up in IRC: In part 2, we wanted to solve the problem of self-replacing lixes. Of the following two solutions, what benefits does a) provide over b)?

a) Game manages non-replacing lixes, lixes have replacing jobs
b) Game manages replacing lixes

The answer is separtion of concerns. The game, or our physics model in general, is pretty complicated. It must deal with land, gadgets, lixes, have some interface to accept player input, etc. Whatever logic we can keep separate from the game at all, we should keep separate.

Compared with the game, the lixes are a smaller part of the program. The Lix class might end up longer, but it has less impact on the overall program.  They're still very complicated, but not as complicated as the entire physics model.

In our solution a), the jobs tell the lixes: We hit terrain, you have to take up another job. In solution b), the lixes tell the game: We hit terrain, you have to replace me. The game is already very busy, we should try not to bother it with such subtleties. However, in our a), writing detailed job-handling inside the lix class seems natural. Lixes are mainly about skills.

This doesn't have to do much with object-orientation. You can have this in any structured language. Classes with access modifiers are merely one way to separate concerns.

-- Simon

Navigation

[0] Message Index

[#] Next page

Go to full version