An in depth look at OOP - Roblox LUA Part 3

At this point, I assume everyone reading this article has looked over my prior posts. I also assume that there is a firm understanding of the OOP paradigm. Therefore, I will not be explaining concepts not discussed directly in this article. Instead, this post will be written to educate willing individuals on how exactly metatables function in Roblox LUA. I will specifically be talking about metamethods and how exactly the metatable works. Please note that it is essential to understand what HashMaps, Arrays, Stacks, and Heaps are, as these concepts will not be directly mentioned but theoretically applied. So, now that the pre-requisites above have been established, let us get started.

Let us start with why metatables exist. Metatables came to be because the creators of Lua(Roberto Ierusalimschy, Luiz Henrique de Figueiredo, and Waldemar Celes) wanted a way for programmers to change the default and static behavior of tables.

Now, this idea raises a question; why exactly do we as developers need to manipulate and change the default behavior of tables? Well, let us say we needed to create the concept of a set. In python, sets allow us to compare and group objects based on our unique specifications. Most of the time, this data structure can be applied when attempting to decrease the time complexity of code.

With that, let us attempt to make a set:

local Set_1, Set_2 = {1,2}, {1,2}

print(Set_1 == Set_2)

The output of this code would be false. This is because Lua does not have a comparable method when dealing with tables. In most languages, there are built-in methods apart from the class with which the associated object can use. For example, in this case, Python 3 has a comparable operation apart from sets, allowing us to check if two sets are equal. So, that brings us to the idea of how we can compare two tables if there is no built-in method to do the logic we want.

This is where metamethods come to play. Simply put, metamethods are methods that allow the user to change tables to add custom behavior and implement particular logic which could not be used otherwise. For example, this extraordinary logic and behavior can be used to allow us to call the table as we would call a method(__call). In addition, some methods allow us to change the behavior when indexing or creating a new index apart in a table(__index, __newindex). Please note that these are just a few of the metamethods, and I will be going over each metamethod in detail with a usage case later in this article. However, before that is mentioned, let us understand how we can create a metatable to use these metamethods.

In Lua and Lua U, the way to instantiate a metatable is the same. However, before we do so, we need to remember a few things; we need to have a table that the metatable will use for its fields and a table that the metatable will index.

Let us take a look at some code:

local MyTable = {}
local MyIndexedTable = {}
setmetatable(MyTable, MyIndexedTable)

The code snippet above is a simple way to create a metatable around two tables quickly. Of course, these tables are empty, so there will be no fields or functionality to the object we attempt to create, but we can add such behavior if we would like. However, for now, we need to understand how these tables work together.

The first parameter we pass into our setmetatable function is the table in which each unique field and method of the object will be held. This unique behavior essentially is what gives metatables their object-oriented appearance. We can use this first table to change the properties only for that metatable and not the others. For example, if we wanted to create a character object, we would put the character’s Health and armor on that table.

Here is a code example:

setmetatable({
Health = 100;
Armor = 100;
}, {})

Now this object we created has two fields, Health, and armor, which can be manipulated just like the values in a dictonary. Usually, we would also include getters and setters to uphold the concept of encapsulation, but for the sake of simplicity, it is not necessary for theoretical usage.

The second table, in this case, is the table from which the object gains its behavior. We can put all of our metamethods in this table, and the methods and properties we want to be shared across all the objects that reference that table.

Here is an example of some code:

setmetatable({
Health = 100;
Armor = 100;
}, {

Damage = function(self, Amount)
	self.Health -= Amount
end

})

In this usage case, we create a method apart from the table that will damage the field of Health. However, this method will not be able to be referenced if we do not use the metamethod index. Now, suppose we ignore the index metamethod and attempt to call object.Damaged(25) to deal 25 damage. This refusal to add the metamethod will result in an error stating that we attempted to call a nil value. This error can confuse many new and old developers, especially if they forget to add it to the table.

The correct way to do the aforementioned logic is as follows:

local Fields = {
Health = 100;
Armor = 100;
}

local OtherLogic = {}
OtherLogic.__index = OtherLogic
function OtherLogic:Damage(Amount)
	self.Health -= Amount
end

local Object = setmetatable(Fields, OtherLogic)
Object:Damage(25)

We have now created an object using our first metamethod. This will allow us to create a character object based on the field table we assign once we instantiate the object. However, if we attempt to use the same fields table in two separate objects we will change the fields for both objects. This logic is due to the idea that tables in lua, like python, pass values by reference to direct memory addresses rather than the Value itself. This is also why changing a value inside a table in another script updates globally in a heap. Be aware of that problem when using metatables and implementing custom logic and behavior.

Now that we understand how exactly the metatable works externally, let us talk about its usage internally.

I will be adding some C code, so do not be afraid, I will talk about how exactly this works.

LUA_API int lua_setmetatable (lua_State *L, int objindex) {
  TValue *obj;
  Table *mt;
  lua_lock(L);
  api_checknelems(L, 1);
  obj = index2value(L, objindex);
  if (ttisnil(s2v(L->top - 1)))
    mt = NULL;
  else {
    api_check(L, ttistable(s2v(L->top - 1)), "table expected");
    mt = hvalue(s2v(L->top - 1));
  }
  switch (ttype(obj)) {
    case LUA_TTABLE: {
      hvalue(obj)->metatable = mt;
      if (mt) {
        luaC_objbarrier(L, gcvalue(obj), mt);
        luaC_checkfinalizer(L, gcvalue(obj), mt);
      }
      break;
    }
    case LUA_TUSERDATA: {
      uvalue(obj)->metatable = mt;
      if (mt) {
        luaC_objbarrier(L, uvalue(obj), mt);
        luaC_checkfinalizer(L, gcvalue(obj), mt);
      }
      break;
    }
    default: {
      G(L)->mt[ttype(obj)] = mt;
      break;
    }
  }
  L->top--;
  lua_unlock(L);
  return 1;
}

This code snippet is how the metatable works internally in Lua. We see we have two parameters; one is the pointer to the lua_state object, which should be a form of a table, and the second is the index of the object in the Lua stack. We notice that there are different usage cases if the developer decides to pass a user data or table which is included in the switch statement. We also see the method hvalue be called to create a metatable. If we look at the core C code that Lua is built on, this method validates the table referenced and creates a place in memory with garbage collection to deal with the newly created object. There is not much else to elaborate on regarding the internals of metatables.

So, when we create a metatable, we essentially create a custom object within the stack we are operating in; this allows us to hold a special place in the heap for our new object to operate. This is also why metatables ignore the built-in Roblox garbage collection, as they do not correctly operate with the additions Roblox added. Roblox also removed the methods for the developer to remove or destroy a metatable because they disabled all GC-related metamethods for both metatables and proxies. However, metatables are still extremely useful despite their shortcomings, as they are the only authentic way to simulate OOP in the language.

Let us go back to the idea of comparing two tables. If we remember, I mentioned that we had a problem comparing two tables because Lua does not have a comparable method when referencing two tables.

So, let us fix that problem with a metamethod called __eq. This metamethod allows us to compare two objects with each other. Here is a usage case where it can be applied.

function ComparableTable(...) 
	return setmetatable({Set = {...}}, {__eq = function(self, Value)
		for N = 1, #self.Set do
			if Value.Set[N] ~= self[N] then
				return false
			end
		end
	return true
end}

We just created a table that allows us to check if they are equal, solving our abovementioned problem. So essentially, we instantiate a new object with two new tables, one holding the data in a table index called set and the other with the metamethod. This process is done every time the function is called. Then if we use the == operator on any two objects, it will compare the values and validate if they are indeed equal.

Now let us talk about why we did not need the index metamethod discussed earlier. First, we did not need the metamethod because we were not indexing the table apart from checking if the two tables were equal. That is because the desired operation was done with another metamethod __eq or equals.

It is also worth noting how the metamethod works. The __index method works by checking the second table passed by the setmetatable function. We check the table by looking for the exact index of the indexed Value. Once found, the hashed key and valued pair are returned to the point where the index is called in memory.

This code snippet is the exact logic applied when we use the index metamethod:


function Index(self, Index)
	return self[index]
end

local Value = Index({Hey = 2}, "Hey")

Now let us move on to the __newindex metamethod. This metamethod allows us to create custom behavior when attempting to create new values in the table. For example, we can use this metamethod and the index method to create a custom hashmap.

Here is a video of exactly that:

A code snippet in that video explains that usage in the case of a HashMap.

Another metamethod allows the user to call a table precisely as we would call a function. That metamethod is called __ call(). We can use this metamethod to void the concept of needing a .new constructor when instantiating our objects. Instead, we can take a more sleek and OOP approach as we see in Java, C++, C#, and C.

local Table = {}
local MethodsAndLogic = {}
MethodsAndLogic.__index = MethodsAndLogic

function MethodsAndLogic:Test()
print(self)
end

Table.__call = function(self, ...)
return setmetatable({...}, MethodsAndLogic)
end

local Wrapper = setmetatable({}, Table)
local NewObj = Wrapper(1,2,3)
NewObj:Test()

Here we skipped needing the .new constructor like this:

function MethodsAndLogic.new()
return setmetatable()
end

Another important metamethod is the tostring metamethod. This method allows the user to avoid having a table printed out when we attempt to convert the object to a string or print the object. Here is a snippet of code where this is applied:

local Table = {}
Table.__tostring = function(self)
return "A" .. " " .. tostring(self.A)
end

print(setmetatable({A = 2}, Table))

There is also a concatenation method that allows us to combine objects with the tostring method.

Here is using just the __concat method:

local Table = {}
Table.__concat = function(self, Value)
return tostring(self.A).. "".. tostring(Value.A)
end

local Ob1, Ob2 = setmetatable({A = 5}, Table), setmetatable({A = 3}, Table)
print(Ob1..Ob2)

Here is with the tostring method:

local Table = {}
Table.__concat = function(self, Value)
return tostring(self).. "".. tostring(Value)
end

Table.__tostring = function(self)
return tostring(self.A)
end

local Ob1, Ob2 = setmetatable({A = 5}, Table), setmetatable({A = 3}, Table)
print(Ob1..Ob2)

And for now, that’s all, folks!

This article is subject to change and alteration and will frequently be updated until every metamethod is completed.

6 Likes

You should consider moving this to #resources:community-tutorials Since it is explaining a concept / topic and not a resource (e.g. model, plugin)