7

Until recently I used to think that it was preferred to reference objects by pointers or references than to keep objects in some sort of a central, authoritative array or dictionary and only keep indices or keys of members of such an array.

However, recently I started reading something opposite. I started reading that it was preferred to rather avoid pointers/references and stick to the latter appproach because it (a) allows painless serialization / deserialization of data; (b) is the only reasonable approach if the functional paradigm is followed (and FP is praised nowadays); (c) others?

Is what I wrote so far correct?

My problem with keeping only ids of objects rather than references to object is that at least for me this leads to either unreadable or cluttered code.

Let me show an example. This is a piece of code I wrote for the turn-based game I'm trying to make as a hobby:

for (let i = 0; i < monsAndMoves.mons[monname].moves.length; i++) {
  for (let j = 0; j < monsAndMoves.moves[monsAndMoves.mons[monname].moves[i]].prerequisites.length; j++) {
    g.setEdge(monsAndMoves.moves[monsAndMoves.mons[monname].moves[i]].prerequisites[j], monsAndMoves.mons[monname].moves[i], {
      /*...*/
    })
  }
}

What happened here?

  • monsAndMoves - an object storing informations about monster species and moves monsters can perform.
  • monsAndMoves.moves - An array that stores informations about moves. (Should be a dictionary whose keys should be move names to keep consistency with the below)
  • monsAndMoves.mons - a dictionary mapping monster species names to informations about these species.
  • monname - should be called currentMon.speciesName - id of the species of the current monster
  • prerequisites - indices of moves a monster must know to learn the given move

The above piece of code is unreadable.

We can make it readable at the cost of cluttering it:

const currentSpecies = monsAndMoves.mons[currentMon]
const moves = currentSpecies.moves
for (const moveName in moves) {
  const move = moves[moveName]
  for (const prereqName in move.prerequisites) {
    g.setEdge(moveName, prereqName, {
      /* ... */
    })
  }
}

Problem is, given this approach, we will have to repeat stuff like const currentSpecies = monsAndMoves.mons[currentMon] at the beginning of almost every function. And if we stick to the general advice that functions should be as short as possible ("do one thing and one thing only") then we will have very many functions to clutter in this way. This seems to me annoying and very boilerplate-ish.

And this is how this piece of code would look like if references were kept instead of ids:

for (const move of currentMon.species.moves) {
  for (const prereq of move.prerequisites) {
    g.setEdge(prereq.name, move.name, {
        /* ... */
    })
  }
}

Looks to me both readable and boilerplate free.

Thus, let me ask you:

  • Is it correct that for the reasons I outlined above avoiding references and embracing ids of objects is considered the preferred approach?
  • If yes, is there any way to reap the benefits of keeping ids vs references while at the same time avoiding the pains of this approach, namely writing either cluttered or unreadable code?

3 Answers 3

6

Is it correct that for the reasons I outlined above avoiding references and embracing ids of objects is considered the preferred approach?

If you want to implement a save button in your game, you will have to implement seralizers in many of your objects and yes, indeed, having your objects stored in a central repository you access by key is of great convenience for such use case. There are others : suppose you want to call an update method for each and every object, once and only once.

But what I believe is a seller for this pattern is the ability it provides to seperate game design from game engine, and I believe the main reason it got adopted. The game script and engine can both work with the repository as a central reference, while they don't even need to be coded in the same language.

This design pattern is why, for example, in Unity, your scripts would extensively call getcomponent to solve references. Of course, it's not the only way to go, but it's a sensible one.

If yes, is there any way to reap the benefits of keeping ids vs references while at the same time avoiding the pains of this approach, namely writing either cluttered or unreadable code?

First, there is nothing that says, in that design, that you have to only use the ids. Especially if calling the repository is a bit costy in performance (although it shouldn't), you are perfectly "allowed" to cache results in an instance variable, in a function constant and whatnot. It just shouldn't drive design: there shouldn't be references in you function calls, builders, etc.

Second, you can use specialised getters in your repository. Code like monsAndMoves.moves[monsAndMoves.mons[monname].moves[i]] can be abstracted by a function in your repository that could be called get_monster_move(monname, i). You can also work with a flat repository. This way you could have a generic get_entity(id) that would return a move or a monster, and a get_move_ids() in your monster classes that would return the move ids.

It does, to some degree, involve a bit of boilerplate, but this is the cost of flexibility of this design. Whether or not you want to pay this price is up to you.

1
  • "there shouldn't be references in you function calls, builders, etc." Hmm, it would seem to me that at least I have to pass the reference to the repository, or else I have to make the repository a singleton or a global variable and both are anti-patterns?
    – gaazkam
    Aug 16, 2021 at 14:18
0

The main issue with references is that objects get scattered around, and on modern architectures this can result in cache misses. Since fetching data from memory is orders of magnitude slower, this is often a primary performance bottleneck.

In many applications, this is not necessarily a problem, and objects and pointers provide a nice way to structure the system and control coupling levels and directions. Now, in high-end games, performance is critical, so they came up with ECS. The Data Oriented Design community has been saying things like "OOP is dead", but IMO that's because they don't understand what OOP is; regardless, if we get past the terminology and philosophical arguments, they do raise valid points.

From this perspective, the problem with OO capabilities of modern languages is that they take away control when it comes to object storage and representation. An object is basically just some data structure + some metadata and boilerplate code generated by the compiler that works together to dispatch a "message" (a method call) to the actual concrete procedure that implements it (and that's stored somewhere else), based on the type of the instance on which the call was made. This enables subtype polymorphism and OOP-style abstraction.

Now, under some circumstances, you may want to bypass that built-in mechanism. Maybe you want your data to be cache-friendly, so you devise a scheme where you can have control over the storage mechanism. Maybe you don't need virtual methods, and you can leverage that fact to make some things simpler. But, regardless, to make this happen, you have to write some amount of boilerplate code you normally don't even see.

So, there are different ways you could do this, depending on why you are doing it. ECS is basically one realization of that idea, that's especially well suited for the problems that game engines are trying to solve. They want cache-friendliness, flexibility in terms of composition, and they generally want to run the same operation on a collection of data of the same type/structure (same component). So, one way to look at it is that they use components to compose instances in a super lightweight way, and then apply an operation to them represented by a system. It also models their problem domain well (designers need to fiddle with these components constantly), and it may be general enough to be applicable to other domains, but probably not all (e.g., some aspects of it may not be suitable for business applications that need to be maintained).

Now, remember that a method call like this

someObject.SomeMethod(/* params */)

can be thought of as this:

SomeClass.SomeMethod(someObject, /* params */)

If you abandon the built-in OO capabilities, your code will tend to look more like the second version (free method that you pass your instance to (now basically just a data structure). Note that someObject can also be a opaque handle of some sort (like an index), and that the underlying implementation of a method (or a system) can use some mechanism to obtain the data it needs based on that handle. So if you squint, you can treat it as passing ordinary variables to functions. This does require some developer discipline, though, in the sense that you have to be careful to write client code in terms of these well defined functions and not poke inside the data structures when that is not appropriate, since encapsulation is now your problem.

When it comes to syntax, this style of programming may take some getting used to, but it's not that bad. If you really hate it though, some languages provide ways to bring back the familiar .-notation via syntactic sugar, like the extension methods in C# - so you may be able to come up with something.

Now, this doesn't mean that this is the only sensible way to develop software. OOP is certainly not dead, and functional and OOP paradigms can coexist just fine. In fact, generally speaking (so, we're not necessarily talking games), if you do apply this, it will likely be to a specific part or aspect of your system that has constraints that make the approach beneficial - you'll probably want to use OO to structure the code that's around that.

0

Indices have the following nice properties:

  1. Wider latitude for "tagging". A 32 bit id could use 8 bits as flag data that might be sufficient to prevent dereferencing in many cases. 24 bits would still be left over for counting indices and it would fit in half the space of a 64 bit pointer.

  2. Indices are one-to-many. A single index can become something of an id that references related data in separate arrays. SoA.

  3. It's easier to take advantage of adjacency in an array to implement segmented structures. For example, a pairing link between two nodes can be implemented as two halfpairs where each half points to its respective node. The opposite halfpair can be found by flipping the low bit of the index. SoA/AoS and striding through an array to e.g. find all the y coordinates in {x,y,z,x,y,z,x,y,z} is just another way to exploit regular patterns and adjacency that cannot be done with a single pointer.

  4. You can divide an array index by a constant to reference whole parts of a segmented structure. For example the halfpair array above will contain elements twice the number of whole pairs. If some data only makes sense to the structure as a whole you can place that in a separate array and use halfpair_index / 2.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.