GitHub Repo

I feel that I understand something when I can do it, not when I can just use it. Let’s try to learn Hill’s latest idea.

Interaction between objects has caused me some trouble throughout the history of this game universe. At base, the issue is “simple”. A given object, say the WaveChecker, only wants to deal with certain types of objects. In the case of the WaveChecker, it’s really only interested in asteroids. The ScoreKeeper, on the other hand, only cares about instances of Score.

The issue is exacerbated, made worse, and compounded by the fact that our space objects generally don’t even know their type. But imagine that they did.

The core issue with an interaction, say animal1.interactWith(animal2) is that if animal1 is a dog, they want to interact with cats very differently from how they interact with dogs. And similarly for cats. There are two main ways of dealing with this situation, and one of them is thought to be better than the other.

The not so good way is to put a dispatch table in each animal, switching to the right behavior based on the type of the argument. This gets messy quickly. The better way is:

DoubleDispatch

If in the Dog and Cat classes we do this:

class Dog
  fun interactWith(anAnimal) = { anAnimal.interactWithDog(this) }
  fun interactWithDog(dog: Dog) = ...
  fun interactWithCat(cat: Cat) = ...

class Cat
  fun interactWith(anAnimal) = { anAnimal.interactWithCat(this) }
  fun interactWithDog(dog: Dog) = ...
  fun interactWithCat(cat: Cat) = ...

With this scheme, every interaction boils down in one step to an interaction between two animals of known type. The switching is done by the object-oriented message dispatching.

Now, we could implement this scheme. Arguably my old interactWithOther scheme was a start at this, but it became clear that it wasn’t very helpful with no automatic type breakout, which was not possible when that function was in play. We did manage to work it to ensure that given a pair of objects, the “right one” got the message. It was messy, it polluted the interface, and somewhere a few versions back, we got rid of it. The scheme in place now, however is making decisions based on type by explicitly referring to type. It is nearly right, but not quite right, and changes started yesterday make it more and more problematic.

We’re on a path to break out all the individual kinds of space object into their own classes. That will have two effects of note.

First, it will allow me to remove all the implementation inheritance that Hill dislikes, and that I tolerate but am not in love with.

Second, it will require every object to implement every method that might ever be sent to it. That would mean that if we break out our interactions so that they all send interactWithXYZ, where XYZ is a class name, there’ll be a growing number of methods that everyone will have to implement. And everyone will ignore most of them.

Hill has devised a scheme, which he displayed last night, that will allow our objects to accept only the dispatches that they want … without implementation inheritance in the hierarchy, which for reasons buried in his past, are both terrifying and repugnant to him. There will be inheritance, but not in the object hierarchy. And that will be OK with Hill. These inner fears are weird like that.

Aside
Unfortunately, I’ve started to write about this before I can fully do it. My individual classes aren’t fully broken out. I think we can work from where we are now and evolve the rest. But we may break some things.

Going both ways …

The first big change with the new scheme is that, for reasons, given a pair (a,b) to interact, we’re going to interact them both ways: a.interactWith(b) and b.interactWith(a). We have to do this because we want to be sure that all the right people get to interact. For example, today, given, say, Pair(asteroid,waveChecker), we do some type-checking so that we call waveChecker.interactWith(asteroid) and not the other way around. Since we only call once, we have to sort out the right one.

In the new scheme, we’ll have both calls, and it will be up to asteroid to pay no attention to interactions with waveChecker, while waveChecker is all about interacting with asteroids.

The Menu? Dispatcher?

There will be a growing list of messages that objects will send. We might consolidate some of these, but clearly we don’t want to implement a lot of null messages in every object. So we’re going to build a new object, whose name I don’t know yet, that works like this. We’ll call it a Dispatcher for now.

Every object type will have two responsibilities. One is that they must implement a dispatch table specifying what messages they want to receive, and what they’re to do. Second, they must implement a standard interaction function that they each turn into a call unique to their class, which will be one of the messages that can possibly appear in the dispatch table.

I think we’d do better with some code. Let’s begin with a test, where I’ll try to build something like what Hill showed last night. Building it will help me understand the idea. Given the many lines so far, I hope it’ll help you, too.

Begin With a Test

I’m going to build two classes and make them interact. The rules will be that ClassOne interacts with both instances of ClassTwo and ClassOne, but ClassTwo only interacts with instances of Class One, and ignores other instances of ClassTwo.

I think what I’ll do is implement that behavior directly first, and then see about doing the dispatch idea.

Because our interactions are moving in the direction of an accumulating parameter, the Transaction (and, probably, because I know Hill did it this way), I’m going to pass in an accumulating object and use it to see what the objects have done.

I should mention that Hill’s code is surely in the repo and you might want to look at it and see how it looks done nicely. I always start rough and may or may not wind up at nice.1

Two Reversions Later …

I make a few false starts, then devise the following tests. The idea here is that ClassOne interacts both with its own members and with ClassTwo members, and ClassTwo only interacts with ClassOne.

I am supposing that given a pair of objects, we interact them both ways.

class InteractionTest {
    fun interactBothWays(o1: SO, o2:SO, trans: FakeTransaction) {
        o1.interactWith(o2, trans)
        o2.interactWith(o1, trans)
    }
    @Test
    fun `ClassOne sees two interactions with self`(){
        val c1 = ClassOne()
        val trans = FakeTransaction()
        interactBothWays(c1,c1,trans)
        assertThat(trans.message).isEqualTo("C1:C1 C1:C1 ")
    }
    @Test
    fun `ClassTwo sees no interactions with self`(){
        val c2 = ClassTwo()
        val trans = FakeTransaction()
        interactBothWays(c2,c2,trans)
        assertThat(trans.message).isEqualTo("")
    }
    @Test
    fun `c2 c1 gets one of each`() {
        val c1 = ClassOne()
        val c2 = ClassTwo()
        val trans = FakeTransaction()
        interactBothWays(c1,c2,trans)
        assertThat(trans.message).contains("C1:C2 ")
        assertThat(trans.message).contains("C2:C1 ")
    }
}

Supporting that, after some work, is this scheme, using double dispatch:

interface SO {
    fun interactWith(other: SO, trans: FakeTransaction)
    fun interactWithOne(classOne: ClassOne, trans: FakeTransaction)
    fun interactWithTwo(classTwo: ClassTwo, trans: FakeTransaction)
}
class ClassOne: SO {
    override fun interactWith(other: SO, trans: FakeTransaction) {
        other.interactWithOne(this, trans)
    }
    override fun interactWithOne(classOne: ClassOne, trans: FakeTransaction) {
        trans.add("C1:C1 ")
    }
    override fun interactWithTwo(classTwo: ClassTwo, trans: FakeTransaction) {
        trans.add("C1:C2 ")
    }
}
class ClassTwo: SO {
    override fun interactWith(other: SO, trans: FakeTransaction) {
        other.interactWithTwo(this, trans)
    }
    override fun interactWithOne(classOne: ClassOne, trans: FakeTransaction) {
        trans.add("C2:C1 " )
    }
    override fun interactWithTwo(classTwo: ClassTwo, trans: FakeTransaction){
    }
}

class FakeTransaction() {
    var message = ""
    fun add(msg: String) {
        message += msg
    }
}

The FakeTransaction just accumulates strings. Thanks to Hill for that idea. (And the idea behind all of this.)

The scheme so far implements double dispatch. We see that there will be N methods in each SO subclass, one method for each type of SO there is. As we add more types, we will have to implement a corresponding method in each SO subclass.

Enter the solution, deus ex machina2, lowered from the ceiling with smoke and flames.

Let’s rename the method interactWith to callOther, for clarity and because I want to match Hill’s scheme a bit better for my own sanity, such as is left.

Let’s make the FakeTransaction keep strings in a list, which will simplify some testing.

class FakeTransaction() {
    var messages = mutableListOf<String>()
    fun add(msg: String) {
        messages += msg
    }
}

Recast the tests:

    fun interactBothWays(o1: SO, o2:SO, trans: FakeTransaction) {
        o1.callOther(o2, trans)
        o2.callOther(o1, trans)
    }
    @Test
    fun `ClassOne sees two interactions with self`(){
        val c1 = ClassOne()
        val trans = FakeTransaction()
        interactBothWays(c1,c1,trans)
        assertThat(trans.messages).containsExactlyInAnyOrder("C1:C1 ", "C1:C1 ")
    }
    @Test
    fun `ClassTwo sees no interactions with self`(){
        val c2 = ClassTwo()
        val trans = FakeTransaction()
        interactBothWays(c2,c2,trans)
        assertThat(trans.messages.size).isEqualTo(0)
    }
    @Test
    fun `c2 c1 gets one of each`() {
        val c1 = ClassOne()
        val c2 = ClassTwo()
        val trans = FakeTransaction()
        interactBothWays(c1,c2,trans)
        assertThat(trans.messages).containsExactlyInAnyOrder("C1:C2 ", "C2:C1 ")
    }

I don’t see a way to do what follows in small steps. I’ll do it in big steps instead and then see if that enlightens me. First commit this working test.

What we’re going to do is build a “dispatcher” object that each SO class has, such that when any other class wants to call its forwarding method, like interactWithClassOne, that dispatcher will accept the message and do the right thing. We’ll see in a moment what the right thing is and how to make that happen.

So the new rule will be, in your callOther method you must do this:

class ClassOne: SO {
    override fun callOther(other: SO, trans: FakeTransaction) {
        other.dispatcher.interactWithOne(this, trans)
//        other.interactWithOne(this, trans)
    }

And, you must implement a dispatcher val, returning your dispatcher, listing the messages you accept and saying what you want to do with them.

We need a class Dispatch to instantiate The class must be able to provide a function for each possible interactWith:

class Dispatcher(
    val interactWithOne: (item:ClassOne, trans: FakeTransaction) -> Unit,
    val interactWithTwo: (item:ClassTwo, trans: FakeTransaction) -> Unit
)

Now let’s begin by having that Dispatcher return empty functions for all cases:

class Dispatcher(
    val interactWithOne: (item:ClassOne, trans: FakeTransaction) -> Unit = {_, _, -> },
    val interactWithTwo: (item:ClassTwo, trans: FakeTransaction) -> Unit= {_, _, -> },
)

One more step: each of us must implement dispatcher.

interface SO {
    val dispatcher: Dispatcher
    fun callOther(other: SO, trans: FakeTransaction)
    fun interactWithOne(classOne: ClassOne, trans: FakeTransaction)
    fun interactWithTwo(classTwo: ClassTwo, trans: FakeTransaction)
}

Now we’re forced to implement these. Let’s start with empty ones:

class ClassOne: SO {
    override val dispatcher = Dispatcher()
    override fun callOther(other: SO, trans: FakeTransaction) {
        other.dispatcher.interactWithOne(this, trans)
//        other.interactWithOne(this, trans)
    }
    override fun interactWithOne(classOne: ClassOne, trans: FakeTransaction) {
        trans.add("C1:C1 ")
    }
    override fun interactWithTwo(classTwo: ClassTwo, trans: FakeTransaction) {
        trans.add("C1:C2 ")
    }
}

Now at this moment, our tests should be broken, containing no messages, because no one will be calling any of our lovely interactWithWhatever methods. Test.

Expecting actual:
  []
to contain exactly in any order:
  ["C1:C2 ", "C2:C1 "]
  Expecting actual:
  []
to contain exactly in any order:
  ["C1:C1 ", "C1:C1 "]

Right. But now … let’s have ClassTwo implement a slightly more reasonable dispatcher:

    override val dispatcher = Dispatcher(
        interactWithOne = { obj: ClassOne, trans -> trans.add("C2:C1 ") }
    )

Now I think the class two test should run. Well, almost:

Expecting actual:
  ["C2:C1"]
to contain exactly in any order:
  ["C1:C2 ", "C2:C1 "]

We really need to complete the pair, since we’re also interacting the c1 with the c2 and it needs to get its say. So:

class ClassOne: SO {
    override val dispatcher = Dispatcher(
        interactWithOne = { obj: ClassOne, trans: FakeTransaction -> trans.add("C1:C1 ")},
        interactWithTwo = { obj: ClassTwo, trans: FakeTransaction -> trans.add("C1:C2 ")}
    )

Now all the tests ought to pass. And they do. And the interface no longer needs those random interactWithXYZ things. So we clean up, removing the spurious spaces from the answers, renaming callOther back to interactWith (I just like it better), renaming Dispatcher to Interactions, and here we are:

class Interactions(
    val interactWithOne: (item:ClassOne, trans: FakeTransaction) -> Unit = {_, _, -> },
    val interactWithTwo: (item:ClassTwo, trans: FakeTransaction) -> Unit= {_, _, -> },
)

interface SO {
    val interactions: Interactions
    fun interactWith(other: SO, trans: FakeTransaction)
}

class ClassOne: SO {
    override val interactions = Interactions(
        interactWithOne = { obj: ClassOne, trans: FakeTransaction -> trans.add("C1:C1")},
        interactWithTwo = { obj: ClassTwo, trans: FakeTransaction -> trans.add("C1:C2")}
    )
    override fun interactWith(other: SO, trans: FakeTransaction) {
        other.interactions.interactWithOne(this, trans)
    }
}
class ClassTwo: SO {
    override val interactions = Interactions(
        interactWithOne = { obj: ClassOne, trans -> trans.add("C2:C1") }
    )
    override fun interactWith(other: SO, trans: FakeTransaction) {
        other.interactions.interactWithTwo(this, trans)
    }
}

class FakeTransaction() {
    var messages = mutableListOf<String>()
    fun add(msg: String) {
        messages += msg
    }
}

I’ll put the supporting tests below but let’s go through this now and see if we can explain it.

SpaceObjects

SpaceObjects must implement one val and one function. The function, named interactWith, must have this form:

class ClassTwo
    override fun interactWith(other: SO, trans: FakeTransaction) {
        other.interactions.interactWithTwo(this, trans) // use your class name here
    }

The name can be anything and we’ll probably use interactWithSolid for a while as we go forward. But the essential idea is that we are telling this interactions thing what our type is.

Our val dispatcher must create an instance of Dispatcher, and it should include only the names of the functions, interactWithXYZ, to which we wish to respond. We need only mention those: the others are defaulted by the Dispatcher constructor to do nothing.

So how does this work? Dispatcher has in its constructor all the names of all the interact functions we can ever have. When we come up with a new one, interactWithObsidianPlanetOfDoom, we have to add that to Dispatcher, with the standard empty default function. But no other object has to do anything. If their dispatcher doesn’t explicitly ask for interactions with the OPOD, they’ll never see it. Presumably some objects do want to interact with OPOD, and they will add a function to their dispatcher.

N.B.
I now see that I could have done all this without the interactBothWays, just interacting one way at a time, and it might have been easier to understand the tests. I think we’re going to go to interacting both ways, so I did it that way here. Why are we doing to do that?

Presently we’re doing type checks to decide which way to call interactWith on each pair. It’ll be easier to just call it both ways, because if one way doesn’t want to interact, it won’t have put that entry into its dispatcher, and it just won’t interact. But I think we could have skipped over that aspect and brought it into mind later.

One more note to underline. Because all the functions saved in Dispatcher are of the form interactWithmyClass, the object parameter in each function is of that known type:

class Interactions(
    val interactWithOne: (item: ClassOne, trans: FakeTransaction) -> Unit = {_, _, -> },
    val interactWithTwo: (item: ClassTwo, trans: FakeTransaction) -> Unit= {_, _, -> },
)

That means that in the implementation of the returned function, the actual type of both objects can be known. Somehow, magically, the context of your provided function includes this, the current instance being used.

Let’s sum up. I think this afternoon, Hill and I will pair on installing this idea into the actual game. That should be amusing.

Summary

I’m not sure this is supremely easy to understand. I feel pretty sure that you can see how to use it, and hope you see how it works.

However, I’m not sure quite how one invents it incrementally; it seems to me that I have to understand the idea in order to do it. But it works deliciously. I think it’ll improve things in the actual program. We’ll start doing that later today. For now … I’d like a break and a snack. See you soon!



class InteractionTest {
    fun interactBothWays(o1: SO, o2:SO, trans: FakeTransaction) {
        o1.interactWith(o2, trans)
        o2.interactWith(o1, trans)
    }
    @Test
    fun `ClassOne sees two interactions with self`(){
        val c1 = ClassOne()
        val trans = FakeTransaction()
        interactBothWays(c1,c1,trans)
        assertThat(trans.messages).containsExactlyInAnyOrder("C1:C1", "C1:C1")
    }
    @Test
    fun `ClassTwo sees no interactions with self`(){
        val c2 = ClassTwo()
        val trans = FakeTransaction()
        interactBothWays(c2,c2,trans)
        assertThat(trans.messages.size).isEqualTo(0)
    }
    @Test
    fun `c2 c1 gets one of each`() {
        val c1 = ClassOne()
        val c2 = ClassTwo()
        val trans = FakeTransaction()
        interactBothWays(c1,c2,trans)
        assertThat(trans.messages).containsExactlyInAnyOrder("C1:C2", "C2:C1")
    }
}


  1. Kind of the opposite of Tina Turner doing Proud Mary

  2. Latin today. Aren’t we fancy?