Kotlin 156: Pairing Session Results
GeePaw Hill and I paired remotely for about three hours on the new ‘Interactions’ idea that I am borrowing from him. It went rather well, though differently than I had expected.
As reported in kr-155, I had recreated GeePaw’s idea about an interactions object mediating object interactions. On the face of it, and reading his changes in his long-running learning-and-experimenting branch, it promised to make programming the interactions of a SpaceObject easier. Starting from a simple first case, we converted the whole asteroids program to use the new scheme over the course of three pleasant hours of zoom.
Hill recorded most of it, and after removing the obscenities, probably cutting it down substantially, we might decide to publish it for your amusement. But three hours? That’s long even with a decent script, real starts, and an award-winning director. Still, it might be interesting.
I can imagine a highlights reel or something. I do hope we can edit it into something useful, because seeing how two old highly experienced geeks programmers work together might be of value to folx.
I’ll provide some snippets, observations, ideas, or notions here. Let’s begin with the fundamental idea:
Interactions
Every pair of objects in space (in the mix, as I call it) are given a chance to interact, in both directions, a
with b
and b
with a
. (This is a change from my original scheme, which tried to pick the best of the pair to interact.)
The calls to interact two objects a
and b
look like this:
a.callOther(b)
b.callOther(a)
Every object in the mix must implement callOther
and the implementation is always the same form. If the class of the object is, for example, Score
, the object always implements callOther
like this:
class Score
override fun callOther(other: InteractingSpaceObject, trans: Transaction) {
other.interactions.interactWithScore(this, trans)
}
In every case, whatever your class is, Foo
, your callback message is, by convention, interactWithFoo
.
This means that if there are six object classes in the mix, there are six possible messages. As it happens, right now, there are six unique classes, and in the class Interactions
, they are all represented:
class Interactions (
val interactWithScore: (score: Score, trans: Transaction) -> Unit = { _,_, -> },
val interactWithSolidObject: (solid: SolidObject, trans: Transaction) -> Unit = { _,_, -> },
val interactWithMissile: (missile: Missile, trans: Transaction) -> Unit = { _,_, -> },
val interactWithShipChecker: (checker: ShipChecker, trans: Transaction) -> Unit = { _,_, -> },
val interactWithShipMaker: (maker: ShipMaker, trans: Transaction) -> Unit = { _,_, -> },
val interactWithWaveMaker: (maker: WaveMaker, trans: Transaction) -> Unit = { _,_, -> },
)
So, for each possible class, Interactions has a constructor val
variable of that name, and the value of that variable is a function, whose first parameter is an object of the named class, whose second parameter is a Transaction. The function returns nothing (but may update the transaction). Each of these constructor variables defaults the function to a function that does nothing:
{ _, _, -> }
Now, in the implementation of callOther
that every class implements, remember that it refers to interactions
:
class Score
override fun callOther(other: InteractingSpaceObject, trans: Transaction) {
other.interactions.interactWithScore(this, trans)
}
Therefore, every class in the mix must implement interactions
as well as callOther
. interactions
is implemented as a val
containing the interactions that this class of object wants to deal with.
As it happens, Score
doesn’t want to interact with anyone. (ScoreKeeper, however wants to interact with Score. That’s on him. Point is, Score doesn’t want to receive interaction messages from any other class.) So Score implements interactions
like this:
class Score
override val interactions: Interactions = Interactions()
Since Interactions defaults all its named interaction functions to do nothing, When anyone is paired with a score, the messages go like this. A scoreKeeper
tries to interact with a score
. The interactions loop issues this:
scoreKeeper.callOther(score)
The scoreKeeper
enters its callOther
, with the parameter being a score, and it fetches score’s interactions and sends
score.interactions.interactWithThing(this)
And score
has no non default entries in its interaction, and nothing happens. It’s not taking calls from scoreKeeper, or anyone else.
But the scoreKeeper
does have something in its interactions:
override val interactions: Interactions = Interactions(
interactWithScore = { score, trans ->
totalScore += score.score
trans.remove(score)
}
)
So when the game loop does the other side of the pair:
score.callOther(scoreKeeper)
The score
says, in essence:
scoreKeeper.interactions.interactWithScore(this, trans)
And since scoreKeeper
does have the entry above, this function is executed:
interactWithScore = { score, trans ->
totalScore += score.score
trans.remove(score)
}
And the scoreKeeper fetches the score value from the Score
object and adds it into the total score. It then removes the Score
object from the mix, so that it will never be seen again.
Deep in the bag of tricks?
That this is so simple in use: just implement one val
and one pro-forma function, and you get all the interactions you want, none of the ones you don’t want, and with each interaction you know exactly the class of the object you’re dealing with, so you can access its public values, like score
, or call its methods, whatever you need to do. So far, usually you just count things, register their presence, or, oh, yes, destroy them.
But I find that it takes a lot of words to explain, see above, and this object is not one that emerges incrementally by ordinary refactoring. It has to be thought of and invented. Yes, it makes use of well-known language features, and it’s not much code and it’s quite regular and nice. But “where did that come from?” is my gut reaction to it. I’ll add it to my bag of tricks, and I expect we’ll use it a lot in this game if nowhere else.
But it’s like a gem that you find rather than a thing you’re building along incrementally and suddenly the code says “oh look, I want to be an Interactions object”. At least for me.
The effect
Here’s a commit report, shortened:
Date: Wed Nov 23 15:46:48 2022 -0500
removed interactWith
13 files changed, 6 insertions(+), 60 deletions(-)
Date: Wed Nov 23 15:30:44 2022 -0500
tests clear, probably can remove interactWith
2 files changed, 12 insertions(+), 8 deletions(-)
Date: Wed Nov 23 15:20:27 2022 -0500
shipmaker, splat and wavemaker use Interactions. That's everyone for now.
4 files changed, 25 insertions(+), 8 deletions(-)
Date: Wed Nov 23 14:59:57 2022 -0500
shipchecker uses interactions
4 files changed, 35 insertions(+), 4 deletions(-)
Date: Wed Nov 23 14:29:28 2022 -0500
SolidObject handles itself.
3 files changed, 58 insertions(+), 56 deletions(-)
Date: Wed Nov 23 14:17:07 2022 -0500
solidobject uses interactor
4 files changed, 30 insertions(+), 4 deletions(-)
Date: Wed Nov 23 13:50:52 2022 -0500
score and scorekeeper running on Interactions logic
7 files changed, 36 insertions(+), 76 deletions(-)
Date: Wed Nov 23 13:06:27 2022 -0500
privatize interaction stuff
1 file changed, 5 insertions(+), 5 deletions(-)
That’s 207 adds and 221 deletes, so about a wash in number of lines of code. That’s to be expected: The actual function of each existing interaction got moved from a method into a function with most of the same words, usually a line or two deleted. But we’ve removed an entire required override from the top of the hierarchy. We’ve also added two new required items, the interactions
and the callOther
.
However, using this same approach, we’ll be able to remove other methods required methods and my defaulted overrides that are in the superclass. I still don’t feel as badly as Hill does about them, but there’s no denying that they add to complexity because you have to think about them when thinking how an object works. The interactions thing will subsume most of that complexity without adding more as we add more functions that can be in the interactions object.
Or at least we think that’s what will happen. I welcome readers’ observations, ideas, questions, and opinions about all this, of course.
The session
Hill and I did all this over a period of just under three hours, with eight commits, one every 20 to 25 minutes. And looking at the list there, I see that we could have committed at least two, possibly three more times than we did, green to green.
Because we were changing the core protocol of how objects interact, we broke a lot of tests. Mostly the changes were just boilerplate, creating a Transaction and calling callOther
instead of interactWith
, but they were tedious to change. A couple of tests got removed as no longer relevant.
Hill remarked that my tests are not as atomic as he would recommend, which I’ve noted as well in these articles. A lot of my tests are testing multiple interactions, in a sort of game play flow. I don’t love them, but I love having them and I wasn’t able to see anything better at the time.
And that’s the thing, isn’t it? We can’t always see the best thing, and it’s probably foolish to assume that we ever do. What we can see is a better thing and a worse thing, and we usually do well to choose the better.
We enjoyed the session and were surprised to see that it had gone three hours. We were glad to be done but not whipped by any means. It was good fun.
- A bit of a surprise
- One thing surprised me. I had thought that Hill would see ways to go in much smaller steps than I generally manage. That didn’t seem to happen. We talked about it and we think that the character of the changes we were making meant that each change required us to add at least two methods and to move a block of code and edit it, before we could be back to green. If there were smaller steps possible, we didn’t see them. I was a little disappointed, because I had hoped that watching Hill make tiny steps might inspire me and educate me to do better. Maybe next time.
We did encounter one very confusing issue. The game worked perfectly, and the tests were crashing with really weird messages. We finally realized that I had a class in the tests of the same name as the one in the game, so tests failed that would have worked if only they had used the class we were working on.
It would have been nice had IDEA told us that the test class was shadowing one in main, but it didn’t, and it took us a while to figure out what was happening. The fix, of course, was easy once we found the issue.
Other than that, I think it’s fair to say that even having invented it, setting up this scheme is hard to think about. The thing is not to think, but instead to generate the boilerplate callOther
function by rote, and then in the interactions
val, just list the objects you want to hear from and the functions you want to do when called. Trying to think through the ping-pong is just too hard. Another sign that this idea is deep in the bag.
But deep or shallow, it’s making things better. Oh and I just found some code needing deletion. In the Interactor object, we have this:
class Interactor(private val p: Pair<ISpaceObject, ISpaceObject>) {
fun findRemovals(): List<ISpaceObject> {
val newP = prioritize(p)
val first = newP.first
val second = newP.second
val trans = Transaction()
first.callOther(second, trans)
second.callOther(first, trans)
return trans.removes.toList()
}
private fun prioritize(p: Pair<ISpaceObject, ISpaceObject>): Pair<ISpaceObject, ISpaceObject> {
val first = p.first
val second = p.second
if (first is Score) return Pair(second,first) // could be ScoreKeeper
if (second is Score) return Pair(first, second) // could be ScoreKeeper
if (first is SolidObject) return Pair(second,first) // others want a chance
return p
}
}
We don’t need to prioritize the pair … particularly since we now call both ways. So that should become this:
class Interactor(private val p: Pair<ISpaceObject, ISpaceObject>) {
fun findRemovals(): List<ISpaceObject> {
val first = p.first
val second = p.second
val trans = Transaction()
first.callOther(second, trans)
second.callOther(first, trans)
return trans.removes.toList()
}
}
From 21 lines down to 11. Nice. Test. Commit: remove unnecessary prioritize in Interactor.
But you know what? How is this Interactor used?
fun removalsDueToInteraction(): MutableSet<ISpaceObject> {
val result = mutableSetOf<ISpaceObject>()
knownObjects.pairsToCheck().forEach {p ->
val interactor = Interactor(p)
val removes = interactor.findRemovals()
result.addAll(removes)
}
return result
}
We can do Interactor’s now trivial job inline. IDEA does this for me:
fun removalsDueToInteraction(): MutableSet<ISpaceObject> {
val result = mutableSetOf<ISpaceObject>()
knownObjects.pairsToCheck().forEach { p ->
val interactor = Interactor(p)
val first = interactor.p.first
val second = interactor.p.second
val trans = Transaction()
first.callOther(second, trans)
second.callOther(first, trans)
val removes = trans.removes.toList()
result.addAll(removes)
}
return result
}
That doesn’t quite work, but I think I can do the rest:
fun removalsDueToInteraction(): MutableSet<ISpaceObject> {
val result = mutableSetOf<ISpaceObject>()
knownObjects.pairsToCheck().forEach { p ->
val first = p.first
val second = p.second
val trans = Transaction()
first.callOther(second, trans)
second.callOther(first, trans)
val removes = trans.removes.toList()
result.addAll(removes)
}
return result
}
Better. But we can do better yet.
fun removalsDueToInteraction(): MutableSet<ISpaceObject> {
val trans = Transaction()
knownObjects.pairsToCheck().forEach { p ->
val first = p.first
val second = p.second
first.callOther(second, trans)
second.callOther(first, trans)
}
return trans.removes
}
Green. We can remove Interactor entirely. Safe delete, remove a redundant test. Commit: Remove Interactor, folding its now-trivial operation back into Game.
More deletions, including an entire class, made possible by the new Interactions approach. Nice.
Summary
Nice. See you next time!