Using metatables in Lua or functions for OOP

75 Views Asked by At

I am about to try to create simple 2D game using Lua. I have watched a couple of videos and tutorials and also checked the syntax, but I am confused by multiple developers pursuing different approaches to OOP.

Some uses metatables, some consider it too much complex and create object using like functions that return table.

And I am lost... It is not straightforward what I should use.

I want to use real OOP, like for example Java or C# have.

I saw many videos where they are creating 2D games where they don't use metatables because of the difficulty to read and work with metatables.

What shall I choose though?

1

There are 1 best solutions below

2
Luatic On BEST ANSWER

As I like to quote, "Lua gives you the power, you build the mechanisms". It is no different for OOP in Lua. This is probably what's leaving you confused.

I want to use real OOP, like for example Java or C# have.

I think this is the first misconception we need to eliminate: Java / C# OOP isn't "real" OOP. It is a particular implementation / variant of OOP called "class-based OOP" which has good language support both in Java and C#. It is as real as Lua's (or JavaScript's, for that matter) prototype-based OOP implementations are, though these tend to leave you more degrees of freedom.

Let's get started. First, we'll look at some ways to build class-based OOP in terms of metatable-based OOP. For that, we first need to understand what a metatable is.

Metatables

Consider the following snippet:

local my_math = {tau = 2 * math.pi}
...
print(my_math.pi) -- oops, nil!

Suppose we want to throw an error if we try to access an absent field. We can't do that, right? Because . is not a normal function. It's built in to the language. We can't change what it does. We could introduce a function, but writing get(my_math, "pi") or my_math.get"pi" or similar would be pretty verbose. No, we want to keep the concise my_math.pi notation, but we want to change what it does.

Enter metatables: They let you do just this - change the behavior of t.name (technically, more generally the behavior of t[k]; t.name is just syntactic sugar for t["name"]).

When you do t[k], Lua actually does the following work behind the scenes:

  1. Look up the value corresponding to key k in t. If this is not nil, t[k] evaluates to this value.
  2. Otherwise, look up the metatable. If no metatable is set, t[k] evaluates to nil.
  3. If there is a metatable, and this metatable has an __index field which stores a function f, call t[k] evaluates to f(t, k). This lets you customize what happens when absent keys are accessed! That's exactly what we want. my_math.tau should do nothing special, it should just give us the value of 2 pi.

But if and only if we access an absent field - as is the case for my_math.pi - the __index "metamethod" of the metatable of my_math will be called! This lets us throw an error:

local metatable = {}
function metatable.__index(tab, key)
    error("no such field. if you're looking for pi, it's not here!")
end
setmetatable(my_math, metatable)

Now, if you access my_math.pi, you'll get an error. But __index is more powerful than that even. You can return another value. The most frequent use for __index is for "defaults" however, which is why Lua provides language support for this: __index can be a table mt, in which case t[k] evaluates to t[k] if t[k] is not nil and mt[k] otherwise. Consider the following example:

local defaults = {x = 1}
local t = {y = 2}
setmetatable(t, defaults)
print(t.x) -- 1, default! without the metatable, this would be nil
print(t.y) -- 2

__index is not the only metamethod - there are also metamethods for arithmetic, concatenation, comparisons, table length (#), each with their own rules (see the metatable docs in the 5.1 reference manual) - but it is the only one that's really necessary for implementing metatable-based OOP. Some scripting languages, like JavaScript, only have the equivalent of __index (called __prototype__ in JS).

Some syntactic sugar

It's also helpful to be aware of the following frequently used syntactic sugar:

  • function class:method(...) ... end is equivalent to function class.method(self, ...) ... end: It adds an implicit self as a first parameter. (As my naming suggests, this is frequently used for methods.)
  • instance:method(...) is roughly equivalent to instance:method(instance, ...). This is usually used for method calls, passing the object as the first argument (note how this corresponds to the class:method syntactic sugar in the function definition: the instance is passed for self).

Your first class

Now we can get back to the core of the issue: How can we implement a class? Well, what is an instance, if not a table where absent keys should be looked up as "method" names - as opposed to fields - in the parent (class) table? Since you're interested in gamedev, let's start work on a rudimentary 2d vector class. I will meticulously separate all tables here to make it clear which is which. There are various variants on this. Some variants conflate metatable & class table. Others conflate class table & method table. All of this works, but is not helpful in understanding it.

First, let's make the tables. They are all closely tied: The methods may need to call constructors or other "static" (using Java terminology) functions, which are stored in the class table. The constructors will need to set the metatable. The metatable needs to have its __index field set to the methodtable, such that accessing instead.method gives us the proper method:

local vector2d = {} -- "class" table
local methods = {}
local metatable = {__index = methods}

Let's start with the method table. This will be the table behind the __index field in our metatable. Now we can write the constructors.

function vector2d.new(x, y)
    local instance = {x = x, y = y}
    setmetatable(instance, metatable)
    return instance
end
function vector2d.new_zero()
    return vector2d.new(0, 0)
end

Let's have a method to add two vectors, and a method to multiply a vector by a scalar.

function methods.add(v, w)
    return vector2d.new(v.x + w.x, v.y + w.y)
end
function methods:multiply(scalar)
    return vector2d.new(self.x * scalar, self.y * scalar)
end

Et voilà! Your first class! You can now do:

local v = vector2d.new(1, 2)
local w = vector2d.new(3, 4)
local u = v:add(w):scale(2)
print(u.x, u.y) -- 8    12

This is really the fundament of metatable-based (prototype-based) OOP. You can now build on this, adding support for:

  • "typeof" checks (just compare getmetatable(instance) against metatable)
  • "inheritance" (effectively set the metatable of methods to the parent's methods table) - I'd urge you to consider "composition over inheritance" though;
  • operator overloading (just define the respective metamethods in metatable);
  • even fancier features like multiple inheritance (prototype-based OOP is more flexible than any class-based OOP systems)

Note that you get polymorphism "for free" through the metatable, and field access "for free" through the fact that your object is just a table.

But, this is indeed not the only way to implement OOP. Another popular way is what I call "closure-based OOP", which has its advantages and disadvantages. Many consider it simpler to understand.

Closures

Lua has first-class closures: These functions hold references to the variables surrounding them ("upvalues"), allowing them to read & write these "upvalues". A simple example:

local foo = 1
function set_foo(x)
    foo = x
end
function get_foo()
    return foo
end
set_foo(2)
print(get_foo()) -- 2

set_foo can write foo, and get_foo can read it. foo is what I call a "shared" upvalue of set_foo and get_foo: Both could read & write it, and the changes are visible in the respective other closure.

Closures - and with them, the (fresh) set of upvalues - get created by function definitions. Closures are what allows Lua's lexical scoping to work so seamlessly.

We can use this to write a vector class using closures. Here's how it looks:

local vector2d = {}
function vector2d.new(x, y)
    instance = {x = x, y = y} -- you would usually call this "self"
    function instance.add(other_instance)
        return vector2d.new(instance.x + other_instance.x, instance.y + other_instance.y)
    end
    function instance.multiply(scalar)
        return vector2d.new(instance.x * scalar, instance.y * scalar)
    end
    return instance
end
function vector2d.new_zero()
    return vector2.new(0, 0)
end

This lets us change the way we call methods; we no longer have to pass the instance, because the methods already know the instance as an upvalue:

local v = vector2d.new(1, 2)
local w = vector2d.new(3, 4)
local u = v.add(w).scale(2) -- note: . instead of :
print(u.x, u.y) -- 8    12

Comparison

I'll let you be the judge which of these two approaches you consider simpler. Since you're still new to Lua, I would recommend starting with that one. After that, learn the other one; you should know both.

The metatable-based approach shines in terms of performance: You don't need to create many upvalues or closures for each instantiation. It also enables operator overloading further down the road (you will likely want this for vectors, for example). I'd prefer this the more objects and instantiations you expect there to be (for example I would prefer it for vectors, which may be used very frequently).

Where metatable-based OOP doesn't shine is encapsulation: There are no "private" variables. You can only have "private by convention" table fields. In the closure-based approach, you can use local variables as true private fields only accessible to the set of closures (methods).

(There are some more nuances, but this is the gist of it.)