Metatables, told by a guy obsessed with Lua

(I’m aware starmaq also made a post on metatables, I’ve tried to take my own twist on addressing the problem)

Introduction

Metatables, oh boy. The thing that makes tables suddenly work like magic.

So, how do they work? Sit tight, I’m taking you through a guide from a half-educated guy who cares
way too much about Lua.

I’ll be making some less used terms when talking here, so here’s a short glossary
metatable - a table containing metamethods
hashmap - a table with non-numeric keys

Metatables. How do they even?

So, I’m sure you’re aware of setmetatable and getmetatable. These are cool functions, I guess. It’s worth noting that a table and a metatable aren’t the same table. Though they are related.

A metatable is a sort of backend table that the base table doesn’t have, but Lua itself knows exist.
Consider the following:

local m = {foo2 = "bar1"}

local h = setmetatable({foo = "bar"}, {__index = m})

h.__index --> nil

To the interpreter (as far as the programmer is concerned), the ‘h’ table doesn’t have the metatable publically accessible, the only way to get it would be to use getmetatable, where you could then index the __index metamethod.

setmetatable, as seen above, allows you to merge a hashmap, with a metatable, to tell Lua that ‘hey, this table should now index m’

What metamethods have we got to our disposal.

I’ll start with the two most commonly used ones, __index and __newindex.
__index fires when the table is indexed, go figure. But it set it to another table. Lua suddenly says "oh so this table does have key ‘foo’, does the __index in the metatable have a key named ‘foo’. If it cant find one in the __index it will return nil.

local table1 = {a = 1}
local table2 = setmetatable({b = 2}, {__index = table1})
local table3 = setmetatable({a = 3}, {__index = table1})

print(table1.a) --> 1 (no __index, so it's a direct reference)
print(table2.a) --> 1 (key 'a' is not found in this table, but has an __index metamethod to table1, so it indexes table1)
print(table3.a) --> 3 (table has it's own 'a' key, so it returns 3, completely ignoring the __index metamethod in this case)

__index metamethods can also be stacked, so table1 could index table2 which indexes table3.
__index also allows for an implementation of inheritance in OOP, however this is common shunned upon

__newindex is a metamethod that fires when a new key is added to a table, not pre-existing keys. This method also stops Lua automatically adding keys to tables. One of the most common usecases of this is applying property changes without needing a Setter.

local propertyChangedSignal = Instance.new("BindableEvent")

local real = {a = 1}
local proxy = setmetatable({}, {
  __index = a, 
  __newindex = function(self, k, v)
    --self is the table itself, k is the key and v is the value
    propertyChangedSignal:Fire(k) --fire a signal that the property was changed
    real[k] = v --change the property in the real table.

    --newindex prevents Lua from adding a key to the table itself meaning you can implement a proxy table like this
  end
})

propertyChangedSignal.Event:Connect(print)
proxy.a = 5 --> (output from print connection: a, fired by setting the index)
proxy.a = 10 --> (output from print connection: a, fired AGAIN by setting the index)

Again, __newindex can be set to a table, where it will just set the key in that table than the table itself. Similar to __index.

The other metamethods

There’s a magnitude of metamethods to use, however most of them are pretty basic and self-explanatory, so I’ll only explain two other metamethods here.

You can see the others here

First of all, __tostring. This metamethod is fired when you call tostring() on the table. Most commonly this is used in OOP to return the name of the object.

local o = setmetatable({}, {__tostring = function() return o.Name end})
o.Name = "Foo"

print(tostring(o)) --> Foo

And the second one, __metatable. This can be used to ‘lock’ the table, however saying it locks the metatable is a bit misleading, since you can actually make it return anything that isn’t a function, and even that’s a bit misleading.

Basically, this metamethod makes getmetatable(table) return whatever __metatable is pointing to, instead of the metatable itself

local table1 = setmetatable({}, {__metatable = "This metatable is locked"})

print(getmetatable(table1)) --> This metatable is locked

There’s another metamethod, __len, which fires when the # operator is used on the table, however due to some Lua 5.1 weirdness, this only works on userdatas.

Bypassing metamethods

Sometimes, you want to bypass metamethods, for example, setting the index of a table without firing __newindex. Luckily, you have three new friends: rawset, rawget, and rawequal. It is actually possible to stop these working, but that’s a topic for another day.

These methods work as following

  • rawget - Get the raw value of a key without invoking the __index metamethod
  • rawset - Set the raw value of a key without invoking the __newindex metamethod
  • rawequal - Allows you to compare the raw value of two keys without invoking the __eq metamethod

These methods dont work on userdatas, but again, that’s a topic for another day.

Summary

anyway, there’s metatables, told by someone who is bored

21 Likes

Yes, it does have it’s own a key, maybe make it so you index the a key instead of b haha.

In any good metatable tutorial, I want to see you use __index as a function.

on your request, here’s how __index works as a function, implemented to make it error when a key wasn’t found (which is bad implementation but it’s an example)

local table1 = setmetatable({a = 1}, {
  __index = function(self, k)
    return assert(rawget(self[k]), "Key " .. k .. " not found") --using getraw, otherwise it would re-invoke the __index function, causing a stack overflow
  end
})

print(table1.a) --> 1
print(table1.b) --> ERROR: Key b not found

also the thing is ridden with typos because i didn’t proofread it that well

2 Likes

Oh then, I was totally wrong. I thought there was an infinite recursion problem if .IndexChanged was implemented but I guess there would just be a rawChanged function.

These raw functions are really useful.

You mention that using __index is frowned upon for inheritance in OOP. Are you talking about this?

local Object = {}
Object.__index = Object

function Object.new()
    local newobject = {}
    setmetatable(newobject, Object)
    newobject.Active = true
    return newobject
end)

function Object:Deactivate()
    self.Active = false
end

return Object

local Object = require([something])
local obj = Object.new()
print(obj.Active)
– true
obj:Deactivate()
print(obj.Active)
– false

If this is what you say is “shunned,” please explain why, and explain what the better alternative is.

This is an infinite loop. Did you mean rawget(self, k)?

4 Likes

Actually the __len metatmethod works on anything that’s not a string or a table on Lua 5.1.

debug.setmetatable(0, { __len = function() return 'test' end })
print(#1234) --> test

It’s not shunned upon though, it’s the foundation of OOP for Lua.

I think

the internet

disagrees

But that might have changed with Luau. The point is though, it didn’t work in Lua 5.1 as you claim.

The documentation is slightly incorrect. It also says it requires to have the same metatable in order for __lt, __le and __eq to work, when it actually needs to be the same metamethod.

The len metamethod part of the (seen on part 2.8) Lua 5.1 manual backs my statement up as it only checks for string and table.

My example will work as it’s not a string or a table. Have you tested my example on Lua 5.1?
image

Edit: I also checked the Lua 5.1 source code of lvm.c and it indeed works on anything that’s not a string or a table, first it gets the type, then if checks the type, if it’s a string, it gets the string length, if it’s a table, it gets the table length without the __len metamethod, otherwise it checks for the __len metamethod, as numbers, booleans, nils, functions and threads can have metatables (via debug.setmetatable), this will work for these values:

      case OP_LEN: {
        const TValue *rb = RB(i);
        switch (ttype(rb)) {
          case LUA_TTABLE: {
            setnvalue(ra, cast_num(luaH_getn(hvalue(rb))));
            break;
          }
          case LUA_TSTRING: {
            setnvalue(ra, cast_num(tsvalue(rb)->len));
            break;
          }
          default: {  /* try metamethod */
            Protect(
              if (!call_binTM(L, rb, luaO_nilobject, ra, TM_LEN))
                luaG_typeerror(L, rb, "get length of");
            )
          }
        }
        continue;
      }
1 Like

IMO this is a very poorly structured thread; if you make a resource, write it professionally. Don’t talk all “carelessly” (such as your title, or the summary). I also found it very hard to digest the information, which is especially weird as I’ve been using metatables in my projects for the past two years. You barely explained what __index does, and did even that poorly.

A better formulation of
__index fires when the table is indexed, go figure. But it set it to another table. Lua suddenly says "oh so this table does have key ‘foo’, does the __index in the metatable have a key named ‘foo’. If it cant find one in the __index it will return nil. would be:

I’ll start with the two most commonly used metamethods, __index and __newindex:

  • __index pretty much describes what should happen when a table tries indexing a value that would by default be nil, for example:
local MyTable = {}
print(MyTable.Hello) -- would invoke __index

the key Hello in MyTable would usually be nil, but with the __index metamethod, we can
define and edit the behavior of what should happen - for example returning something other than nil!

  • __newindex is similar to __index, where it describes what should happen when you set a value that was previously nil.
    Example:
local MyTable = {}
MyTable.Hello = 'World' -- would invoke __newindex

the key Hello previously did not exist in the table MyTable, and as such it would refer to __newindex.
If have this scenario, though:

local MyTable = {
    Hello = 'Bar'
}
MyTable.Hello = 'World'

this would not refer to __newindex because the variable Hello had already existed in MyTable since its creation.

This example of doesn’t go in much detail still, but it provides the user much more knowledge in an easier-to-understand way.

3 Likes