Skip to content

Instantly share code, notes, and snippets.

@DatZach
Created May 13, 2020 01:00
Show Gist options
  • Save DatZach/cb5fcae7f784c8644e13c49e2da87d0d to your computer and use it in GitHub Desktop.
Save DatZach/cb5fcae7f784c8644e13c49e2da87d0d to your computer and use it in GitHub Desktop.

Methods

So far we have explored the usage of functions in GML 2.3; however, this is not the full picture. In the examples we've looked at so far, there is actually a decent amount of work that the compiler is hiding from us. In order to fully understand the intricacies of functions and how they deal with scoping we must intimately understand what methods are and how they work under the hood.

Let's first revisit the definition of a function in GML: code to be executed. Recall that functions are actually resources, with a resource index stored in a real.

A method is a function bound to a struct (or instance) via the variable self.

So what does this mean exactly? On the surface this may seem like an insignificant distinction. This distinction is critically important when we start considering the context in which functions are called and execute from.

Let's begin by exploring how the compiler hides methods from us when dealing with global functions:

/// @script LogUtility

function trace(message) {
    show_debug_message("[" + string(id) + "] " + message);
}


/// oExample : Create

trace("Instance created!");


/// @script spawn_examples

instance_create_layer(0, 0, "Instances", oExample);
instance_create_layer(0, 0, "Instances", oExample);
// => [1000000] Instance created!
// => [1000001] Instance created!

Notice how in the global function trace is contextually printing the correct id depending on what instance of the object oExample is calling it. In order for this to work, there's actually some additional magic the compiler is doing for us behind the scenes.

Let's look at the code it's hiding from us:

/// @script LogUtility

globalvar trace;
trace = method(undefined, function (message) {
    show_debug_message("[" + string(id) + "] " + message);
});


/// oExample : Create

trace("Instance created!");


/// @script spawn_examples

instance_create_layer(0, 0, "Instances", oExample);
instance_create_layer(0, 0, "Instances", oExample);
// => [1000000] Instance created!
// => [1000001] Instance created!

The only changes we can see have to do with the function declaration. Notice how the function name becomes an implicit globalvar, and is assigned to the return value of a call to method passed undefined and an Anonymous Function!

To understand this let's take a look at the built-in function method. This function takes a struct or instance id for its first parameter and an inline function for its second. When undefined is passed, it tells the GML Runtime to bind whoever is the caller to self rather than retain a strong reference to a single instance/struct. This is why global functions always bind to the scope of whatever instance or struct has called them. When functions are declared inside of objects, they are assigned as an instance variable with self bound to the id of that instance.

We can explore a similar example but with the trace function being declared inside an object:

/// oExample : Create

function trace(message) {
    show_debug_message("[" + string(id) + "] " + message);
}

trace("Instance created!");


/// @script spawn_examples

var foo = instance_create_layer(0, 0, "Instances", oExample);
instance_create_layer(0, 0, "Instances", oExample);
foo.trace("Called from outside!");
// => [1000000] Instance created!
// => [1000001] Instance created!
// => [1000000] Called from outside!

The behavior demonstrated above should now make sense given what we know about what the compiler is doing for us behind the scenes. Let's take a closer look at what exactly is being compiled though:

/// oExample : Create

trace = method(id, function (message) {
    show_debug_message("[" + string(id) + "] " + message);
});

trace("Instance created!");


/// @script spawn_examples

var foo = instance_create_layer(0, 0, "Instances", oExample);
instance_create_layer(0, 0, "Instances", oExample);
foo.trace("Called from outside!");
// => [1000000] Instance created!
// => [1000001] Instance created!
// => [1000000] Called from outside!

Again, we can see that the only real change to the code is that we are really assigning to the instance variable trace the result of a call to method passed id and the Inline Function equivelent of our Named Function.

An important distinction to understand is that when we use the () operator, we are calling a method not a function. In the same way we work with instances not an object during runtime. In fact, this is a very good way to conceptualize the differences between a method and function. Keeping this in mind will make understanding function scopes significantly easier. Simply ask yourself "What is self bound to? An id, struct or undefined?"

Methods in Structs / Constructor Functions

TODO Introduce structs and constructors before this section

So far we have only explored how functions get translated to methods behind the scenes in the context of scripts as global functions, or in the context of objects as instance functions. Next let us take a look at methods in structs.

function Inventory() constructor {
	items = ds_list_create();
	
	add = function (itemId) {
		ds_list_add(items, itemId);
	};
	
	printContents = function () {
		for (var i = 0, isize = ds_list_size(items); i < isize; ++i)
			show_debug_message(items[| i]);
	};
}

var foo = new Inventory();
foo.add("Sword");
foo.add("Shield");

var bar = new Inventory();
bar.add("Potion");

foo.printContents();
// => Sword Shield

bar.printContents();
// => Potion

Notice how the instance variable items is accessible from inside of the functions add and printContents. Additionally, we can observe that the foo and bar instances of Inventory contain different items in their respective lists.

Let's explore what the compiler is doing for us behind the scenes:

globalvar Inventory;
Inventory = method(undefined, function () {
	items = ds_list_create();
	
	add = method(id, function (itemId) {
		ds_list_add(items, itemId);
	});
	
	printContents = method(id, function () {
		for (var i = 0, isize = ds_list_size(items); i < isize; ++i)
			show_debug_message(items[| i]);
	});
}

var foo = (method({}, Inventory))();
foo.add("Sword");
foo.add("Shield");

var bar = (method({}, Inventory))();
bar.add("Potion");

foo.printContents();
// => Sword Shield

bar.printContents();
// => Potion

This is a very rough approximation of what the bytecode is doing, this may not compile/run as expected. id does not actually function this way in the runtime, for instance.

Ok, there's a lot more going on here than our previous examples. First of all, we can see that our constructor function is reduced to merely being a global function like what we're used to seeing. However, that constructor keyword is very important, notice how it changes what the inline functions are binding to. Instead of undefined they're bound to id, which during runtime will refer to either foo or bar depending. The next thing we notice is what happened to our new Inventory(). It actually implicitly allocated an empty object and invoked a method bound to it and the function Inventory. This makes a lot of sense when you consider the behavior of new.

Methods in Functions

Finally we have methods in functions. This is an extremely useful pattern, but there's a few caveats to consider. First let's explore the simpliest use case.

function foo() {
    return function () {
        return 1234;
    };
}

var bar = foo();
bar();
// => 1234

We'll forego the deconstruction of the compiler's magic for the time being. We can see that calling foo returns an inline function which we then store in bar. Calling bar then returns 1234. This is interesting, but not particularly useful.

Let's explore a more useful example:

function make_item_use_callback(itemId) {
    return function () {
        chat_add_message("You used a " + itemId);
    };
}

var swordUse = make_item_use_callback("Sword");
swordUse(); // ERROR! Variable itemId not set before reading it.

Ok so we get an error trying to do this. This actually shouldn't surprise you, as it follows the same scoping rules that we observed looking at how method is being used by the compiler. Let's take a look at what the compiler is doing here so we can better understand how to fix this error.

globalvar make_item_use_callback;
make_item_use_callback = method(undefined, function (itemId) {
    return method(undefined, function () {
        chat_add_message("You used a " + itemId);
    });
});

var swordUse = make_item_use_callback("Sword");
swordUse(); // ERROR! Variable itemId not set before reading it.

Even looking at what's going on behind the scenes the problem is still subtle. Notice how the method returned from make_item_use_callback is not bound to an instance, and itemId is a local variable, so it's only accessible in the scope of make_item_use_callback, not the returned inline function.

We can fix this by manually binding the inline function to a proxy object:

function make_item_use_callback(itemId) {
    return method({
        itemId: itemId
    }, function () {
        chat_add_message("You used a " + itemId);
    }};
}

var swordUse = make_item_use_callback("Sword");
swordUse();
// => You used a Sword

The important distinction to understand here is that itemId inside the body of the inline function is actually referencing self.itemId.

TODO Explore limitations of proxy objects

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment