Sentinels and Abstract Base Constructors in GML

Sentinels and Abstract Base Constructors in GML

When it comes to returning values that are meant to indicate the absence of a thing, GML frequently uses sentinels values like noone, undefined, and -1 are frequently used. But there are better ways for your own code!

GML's use of sentinels is notoriously inconsistent. Largely owing to GML's long history and stalwart support for backwards compatibility, it's built up lots of functions that all have slightly different conventions for a "null return". Functions like instance_place() this will be noone, but functions like struct_get() it'll be undefined instead. Other times, -1 is used, and for string functions 0 will be used instead. If you want to get really into the nitty gritty of GML and its weird and wonderful quirks, you'll even find the odd -100 being used by object_get_parent() as a sentinel value.

While we simply have to put up with GML's fast and loose use of sentinel return values, we have the freedom to pick our own return values for the functions and libraries that we write. We don't have to stick with noone, undefined or -1 if we don't have to. Let's explore what we can return when there is nothing to return.

Undefined as a sentinel

Let's start with what might be the default choice of return value for things that don't have a return value: undefined.

The defining characteristic of this sort of sentinel is that it's a value you can check for, and it won't be confused for anything else. Take for example -1 , which fulfils this definition but only for index values, because indices cannot be negative, so if you see a -1 returned by a function that normally returns index values, you know it must means something else. Similarly noone (equal to -4) fulfils this definition, because when you expect the return to be an instance or object.

However, undefined is a bit different. There are instances where it's more ambiguous, take for example struct[$ "foo"]. If this returns undefined, that could either mean "foo" does not exist in the struct struct, or it could mean the value of is holding the literal value undefined. So here undefined is ambiguous when used as a null return, because we can't tell if it's the absence of a value or simply the actual value.

Take for example an equipment slot system you build where you want to get what equipment is in the character's equipment slot with equipment.get_slot("arm") and this returns undefined. Does that mean there's no equipment in the arm slot, or does it mean that the arm slot itself doesn't exist on this character? It's ambiguous.

(Note: It's entirely valid to do a check to see if a slot exists here before drawing it, and I'd actually argue that some solutions like that are actually better ways to solve this particular problem, however for the purpose of demonstrating sentinels in this blog post, let's roll with using the return value instead.)

If at some point you accidentally forget to handle the undefined, then you have an error that looks like:

Variable <unknown_object>.draw(100573, -2147483648) cannot be resolved.

This sort of error is pretty annoying to look at, and sometimes if you propagate that _arm value deep into other code before this error manifests, it can be difficult to trace back and figure out that sometime earlier you got an undefined value.

This, by the way, is one of the reasons why people say "null values are bad", or more dramatically "nulls are the worst mistake in computer science", like ok, fine, but no use telling us that now, where were you when computer science was invented?

Primitives as Sentinels

So, the value noone is equal to -4, that's an example of a number being used as a sentinel. We could come up with our own value to use, and that's equally good/bad compared to using noone

Here, we're using a static on the Equipment constructor to keep the value nicely namespaced, but we could equally use a #macro NO_SLOT -99 instead. Two different ways of achieving the same thing but with slightly different impact on code organization. Sometimes one is better than the other.

It does feel a bit weird using some arbitrary value as a sentinel, but from a technical point of view, it's not particularly worse than using undefined, and if you accidentally let one through to the rest of your code, your error starts looking like:

Unable to find instance for object index -99
at gml_GlobalScript_init_site (line 32) - a.draw();

Which... actually isn't the worst in the world. The error type "unable to find instance" is misleading, but if you searched for -99 in your code, you'd find the NO_SLOT value. So it's a slight boon on debugging. We now have a sentinel that, though a bit weird and magic-numbery, is slightly more descriptive than undefined

We could take this further and use a string, let's say a static NO_SLOT = "NO SLOT DUMBASS"; as the sentinel, with the rest of the code looking the same, and the errors start getting a bit more helpful:

unable to convert string "NO SLOT DUMBASS" to integer
at gml_GlobalScript_init_site (line 32) - a.draw();

It's still weird to do it, the error type "unable to convert string" is misleading, but at least the string itself gives you further clues and probably even more unique than the number -99

Typing issues

One of the big issues with using primitives like strings and numbers as a return type is that they make the type interface more complex to look at. With Feather not particularly handling a situation like this very well.

For example, we could establish that the return type for get_slot() is Struct OR a String:

But, with something like this, Feather, at least for 2023.11 (let's hope it gets better), makes a variety of weird guesses as to the type depending on how you write the checks.

Oops, Feather thinks _arm is a string here, which isn't very helpful. In general, mixing types is not a good thing, and even if feather were better at guessing, this sort of thing brings more problems than it solves.

Structs as sentinels

So if we can't mix types, what if we return the same type as what we normally expect, and use that as the sentinel?

The primary criteria for a sentinel is a value that can easily be distinguished from the rest. Let's say we had a constructor Slot() that implements the interface for any equipment slot, we can actually just instantiate a new slot whose sole job is to represent an empty one. Just like we previously assigned a primitive to NO_SLOT, we can assign this sentinel struct:

And in doing so, our check against the sentinel value continues to work the same as before, we are just now checking against a struct reference. NO_SLOT is always the same struct since it's a static, so as long as we don't accidentally reassign that value, it will always remain the same reference to check against. (Note: in this case, you can't use a #macro, and the static define here is required to ensure that the value is consistent everywhere)

Importantly, Feather is now happy. It can correctly infer that _arm is a Struct.Slot, and it will bring up all the correct method definitions associated with it, even giving us an error if we try to do an invalid type operation

No crash!?

This now seems a lot better of a situation to be in, but it introduces a far more subtler set of problems: where before, returning a primitive sentinel made it extremely obvious when you tried to use the variable in a way it couldn't be used - you'd cause an error and crash, which is actually pretty vital during development since you want to know about these problems. Now, since our sentinel is itself a valid Slot struct, it can behave in all the ways that Slot structs can behave, including having a draw method. This means that if you accidentally forgot a check for the special NO_SLOT sentinel, you'd have a piece of code that didn't fail as expected.

So, crashing is actually important, and we want our program to crash when we try to do something invalid, because the alternative is a hellish purgatory where your code is apparently working fine, but it doesn't do what it should, and you have no error message helpfully pointing you at the place where things started to go wrong.

Sentinels that crash

The solution? Make it crash. Make the NO_SLOT sentinel-struct deliberately crash whenever it's used in a way that is not allowed. Have every possible method that NO_SLOT could feasibly contain just throw an error.

Now, NO_SLOT is an instance of the new NoSlot constructor that inherits from Slot, so it can be used as the same type (feather is okay returning a NoSlot where the function signature requires Slot). But any time you try to draw(), you'll get a crash, and you're free to put whatever error message you want in there that is maximally descriptive of how it fucked up.

The only downside is that the NoSlot constructor needs to be maintained carefully, because not overriding values can lead to continued confusion. Say you added an use() method to Slot but forgot to override it with a method that throws in NoSlot, then you have the same problem as before: a sentinel that doesn't error when it should.

Abstract Base Constructors

A modification to this approach is to turn the problem upside down. Instead of NoSlot inheriting from Slot and overriding every method with one that crashes, we can do the opposite, and start with a base constructor whose only job is to establish all the capabilities of a Slot, but to not implement any of them. In other words, an abstract base constructor.

Ok, I'll admit it. Abstract Base Constructor is a term I made up to sound smart. I'm modelling this after Abstract Base Classes from Python, which behave in a similar way. But the idea of abstract classes is common in other languages, though with slightly different semantics of their use, and so I feel justified in calling this something similar. It's a Base Constructor because other constructors inherit from it, and it's an Abstract Constructor because it doesn't implement any actual behaviours. So it's an Abstract Base Constructor.

The significant benefit of using ABCs is that not only can you use it as a sentinel, since every method on it will crash when used (you may make a child constructor called NoSlot for more clarity, since it's semantically weird to be instantiating an abstract constructor), but it also helps you ensure all of your child implementations, like Slot or perhaps if you have multiple different types of slot like ArmSlot or LegSlot that have slightly different implementations in them, all correctly implement the required methods. Because if you forget to implement one, and try to use it, you'll get a crash alerting you to the mistake.

This is now a much better sentinel than we had before, since it has all the desirable properties:

  • It is a distinct value and type that can be checked to distinguish it from valid data
  • Its type is consistent and simple
  • Using it in an invalid way results in a descriptive and not misleading error message
  • Low chance of the sentinel no longer being a valid sentinel if you forget to override a method.

Special mention: Null Structs/Null Constructors

The above focuses on Sentinels. Things that represent special things like missing values, which can be checked for, and which will crash if used badly. However, in the specific example given, the lack of an equipment slot, there is an alternative that is perhaps even easier. A null struct.

Because in a lot of cases, the intended behaviour when a slot isn't there, or when a slot is unoccupied, is to simply do nothing, we could drastically simplify our code and remove all the checks for NO_SLOT by having a constructor that implements all the usual methods, but have the methods literally do nothing. This is a Null-constructor, it's good for nothing, like that cousin your parents warned you about to scare you into doing schoolwork.

However, the tradeoff of not having to check for sentinels is that your null struct will go through whatever code and behaviours that a regular value will, and that could be a worse tradeoff than just doing the check. It's one thing to have a null struct with an empty draw() method, it's another to have a null struct go through a substantial amount of code that does exactly nothing just to avoid writing an if to check for a sentinel

I hope this has been useful discourse for the breadth of options when it comes to sentinels, and that undefined are not the only option, and there exists a range of different ways to express non-existence.