blog.oat.zone
λ
cd /metatables-the-best-and-worst-lua-feature/

Metatables - the best (and worst) Lua feature

· by jill

Please note that a very small portion of this 10 minute article is spent actually explaining metatables.


I love Lua. It's a really nice, simple and elegant language, but is still extremely powerful. It's tiny, embeddable, scriptable and generally very nice to use. It's approachable to beginners, yet still extremely powerful. It's a good language.

Now, I know that this is somewhat of a controversial take - the language has its fair share of weird gimmicks and flaws, both in its design and in practice. For example, arrays are 1-indexed, the standard library is very tiny and it lacks a good type system. But in my heart, it'll still be the comfiest, best language to work with.

Why am I talking about all this? Well, because, at its core, Lua is a very simple language. At a first glance, it'd be odd to call it powerful. Its most complicated feature is tables, which are basically the language's dictionary class. That is, unless you've spent some time and discovered metatables.

Metatables can be used to shapeshift the language and build it into your own little funky being; metatables can convert the syntax of your codebase and metatables are how you create truly intuitive library interfaces. Metatables are what live in my blood.

Have you ever wanted to do this in Lua?

local a = (x + 6) * 8
print(a) --> '(x + 6) * 8'
-- note how `a` is a string, not a number!

No? Well how about this!

class 'Person' {
  name = string,
  age = int
}

class 'Student' extends 'Person' {
  school = string
}

Still no? How about a Lua HTML DSL?

save 'index.html' (function()
  return {
    head = {
      title 'my website'
    },
    body = {
      h1 'welcome to my website!', br,
      a {'check out yugoslavia.best', href = 'https://yugoslavia.best'}
    }
  }
end)

Still no? Well, regardless of what you think of these examples, these are all valid Lua syntax, which, furthermore, you can make work functionally. And it's all possible with the magic of metatables.

What is a metatable???

Hold on, don't get too ahead of yourself there. First, we've got to learn about tables.

What is a table?

A table, in Lua, is equivalent to a dictionary in other languages. It stores key-value pairs, and both the key and value can be of any type. Except this type is also used for arrays, except with the keys set to integer values. For example, to define an array in Lua, you use this syntax:

local arr = {'one', 'two', 'three'}

print(arr[1]) --> 'one'
print(arr[2]) --> 'two'

But dictionaries are defined in a similar fashion:

local arr = {
  one = 1,
  two = 2,
  three = 3
}

print(arr.one) --> 1
print(arr['two']) --> 2

Tables are everything in Lua. Lua doesn't have classes or any way to define custom types, so if you want to pass around a custom type, you'd want to do it like so:

local type = {
  printHello = function()
    print('hello!')
  end
}

type.printHello() --> 'hello!'

This is bad, inconvenient, and overall just, not great. However Lua recommends using metatables for this purpose.

What is a metatable?

A metatable is a table which is another table assigned to a table which defines custom properties. But that's only half-understandable, so let's get an example!

Let's define the metatable. Metatable have special keys you can use to define behavior for the table we'll assign the metatable to. For example:

local mt = {
  __index = function(tab, idx)
    print(idx)
    return 'hi'
  end
}

The __index key defines a function to call when a key on the table needs to be accessed. So, now, if we assign our table this metatable:

local tab = {}
setmetatable(tab, mt)

And try indexing our table:

local a = tab.something --> 'something'
print(a) --> 'hi'

Wow! Our code gets called! Isn't that completely useless?

Let us try something more advanced:

local mt = {
  __index = function(tab, idx)
    print(idx)
    return setmetatable({}, mt)
  end
}

local tab = {}
setmetatable(tab, mt)

tab.one.two.three.four --> 'one' 'two' 'three' 'four'

You have quite a lot of power over the language now! Suddenly, you can tell when something is indexed, and automatically return values based on the key, rather than pre-defining them.

Another key that's highly useful is the __call key. It's pretty self-explanatory - if you try to call the table (something that might be very foreign to someone not familiar with Lua trickery), it'll call your function:

local tab = {}
setmetatable(tab, {__call = function() print('woo!') end}

tab() -- huh?!
--> 'woo!'

There's a lot of keys you can access, but the most notable ones are operators with which you can do operator overloading (+, -, #, etc.) and the ones we discussed above.

That's about it! So, in short, metatables let us define how the table behaves when you do various things to it like call it, multiply it, add it to something, etc, etc.

Why metatables are sick as hell

At their base level, it's a bit hard to find a use for metatables. I mean, cool, I can override how tables work, but what's that useful for? Let me give you a few examples of how I've integrated metatables into my code.


Once, in my code, I've needed to create a translation system. Now, in this codebase, I am unable to read or load files aside from Lua ones. It's an interesting limitation due to the engine, but stick with me here, because this isn't as niche as this sounds.

I was greatly inspired by Minecraft's translation system - it looks something like this:

game.name=Minecraft
titlescreen.play=Play
titlescreen.options=Options
titlescreen.exit=Exit Game
inventory.name=Inventory
inventory.clear=Clear

This is a simple, yet really nice to use system. Everything is separated into different "namespaces" of sorts, so you don't have to use underscores or try to avoid name collisions. My idea was getting it work with Lua files that look like this:

en.game.name = 'Minecraft'
en.titlescreen.play = 'Play'
en.titlescreen.options = 'Options'
en.titlescreen.exit = 'Exit Game'
en.inventory.name = 'Inventory'
en.inventory.clear = 'Clear'

Now, this is easily possible in Lua with metatables - in fact, we looked at the basics of how you'd do it before. The challenge for me was not this - the challenge was how you'd access the translations in code.

See, the boring way to do this would be to create a function - getTranslationPath or something - and it'd get the current language, find the path, and resolve it to a string:

local name = getTranslationPath('game.name')

What if I told you, though, that this is possible:

local name = tl.game.name()

It indexes the tl table, in the same way that translation paths are defined, and then calls it. In reality, tl.game.name is a simple table that holds the path:

{
  _path = '.game.name'
}

Indexing it again will append to the path:

print(tl.game.name.something)
--
{
  _path = '.game.name.something'
}

And then when you call it, __call will trigger, which then resolves this _path to an actual translation.

The really good part of this, is now you can use templates really easily:

en.money = '{1} moners'
--
local str = tl.money(69) -- or something..

And it doesn't look too out of place and it's consistent with the regular syntax. This is a really nice system!

It takes a while to understand, but it's been really nice to work with both for translators and programmers - for both, it's a very simple interface, built in Lua and Lua alone.


Let's implement something a lot more straight-forward - let's make a vector class. Before, we addressed how classes are really dumb to make in Lua, involve a lot of boilerplate code, and aren't fun to work with. So, let's go ahead and create a 2D vector!

How this is going to work is our table is going to store the X and Y coordinate, while our metatable will store how the vector interacts with operators and different methods we can call on it.

Let's start simple and define a constructor and the metatable:

local vectorMetatable = {
  __tostring = function(self)
    return 'vector(' .. self.x .. ', ' .. self.y .. ')'
  end
}

function vector(x, y)
  return setmetatable({x = x, y = y}, vectorMetatable)
end

Now we can do this:

print(vector(2, 0)) -->; 'vector(2, 0)'

In order to define any methods on it, we'll need to create a table of methods and point __index to it:

local methods = {
  -- ...
}

local vectorMetatable = {
  __tostring = function(self)
    return 'vector(' .. self.x .. ', ' .. self.y .. ')'
  end,
  __index = methods
}

Note how in this instance, __index is given a table rather than a function. This is also valid! It just means it'll look for indexes in there if it can't find any in the actual table.

Now, if we define a method, say:

function methods:length()
  return math.sqrt(self.x ^ 2 + self.y ^ 2)
end
methods:length() is a shorthand for methods.length(self), both in function definition and calling.

We'll now be able to see the length of our vector!

print(vec) -->; 'vector(2, 0)'

As one last exercise, let's define an __add method for the metatable. It's called when the table is added to something.

local vectorMetatable = {
  -- ...
  __add = function(a, b)
    -- a is the left-hand side, b is the right-hand side
  end
}

Right. How would we go about doing this? The left-hand side, in this case, since addition is symmetric, will always be our vector type. Let's handle adding vectors to vectors first!

We know a is a vector, but how do we check if b is a vector? That's simple:

function vectorMetatable.__add(a, b)
  if getmetatable(b) == vectorMetatable then
    -- it's a vector!
  else
    -- who knows what it is ...
  end
end

So let's define what happens if we add a vector to a vector:

if getmetatable(b) == vectorMetatable then
  return vector(a.x + b.x, a.y + b.y)
else
  -- ...
end

We initialize a new vector with our handy vector initializer function with the x set to the X of both the vectors added together, and the y set to the Y of them added together.

Let's define what happens to a number next:

if getmetatable(b) == vectorMetatable then
  return vector(a.x + b.x, a.y + b.y)
elseif type(b) == 'number' then
  return vector(a.x + b, a.y + b)
else
  -- throw an error
  error('what')
end

And there we go! We can now add vectors together:

print(vector(2, 0) + vector(0, 2)) --> 'vector(2, 2)'

And add numbers to vectors:

print(vector(0, 0) + 2) --> 'vector(2, 2)'

We've now made a vector class! We can fill it with vector-related methods as we wish. I won't be doing that though, as I've demonstrated my point, which is, to reiterate:

Metatables suck

Wait, what? Hear me out here.

Metatables are powerful. You can do practically anything with them. You can override operators, you can make custom interfaces to anything you wish, you can commit syntax crimes, they're powerful as hell. But they're not intuitive whatsoever. I guess what I'm trying to say is, they're very direct, in the worst ways.

You witnessed me make a vector class out of thin air, sure, but think of how much boilerplate code we had to use. We had to define 2 tables and 1 function just for it to print itself correctly. Not to mention that just to add a vector to a vector, you'll want to create a function that handles adding a vector to anything, and then error out manually if your manually-checked value isn't also a vector.

This kind of pattern follows you everywhere wherever you make metatables. And sure, it's nice to have this much power over everything, but it's not given to you in a nice way - you'll just have to be responsible for everything anyone throws at you, and you'll just have to deal with that.

Now, sure. This isn't a big deal. Metatables are a really powerful tool given to really powerful, uhm, Lua users, I guess, right? You'll use them if you want to, and don't use them if you don't want to.

Except the lack of base features in Lua makes avoiding metatables very hard. Lua is described as a multi-paradigm language, but really, it comes with no paradigms out of the box, and just tells you to assemble your own classes and types and whatever else you may wish for with a Swiss knife like some sort of IKEA table.

If you want anything remotely complex in Lua, you'll be pointed to either a Lua library that uses metatables, or metatables themselves. And this is awful for a so-called beginner language. Sure, libraries aren't that bad, but it's also very hard to use said libraries when the language's community is split between Lua 5.0, 5.1, 5.3 and 5.4, depending on which version their software of choice decided to stick with.

I'm glad metatables exist. I'm not saying their existence isn't good, but I really dislike how everything relies on their existence. At their core - and I'll have to admit - they're extremely jank. And it doesn't help that Lua has no proper type system to account for the potential mistakes of it all. Somebody could've passed a string into our vector class as the x coordinate, and I bet you wouldn't have noticed it either, until you get reports saying that it's trying to add a string to a number.

Conclusion

In short, metatables are powerful in all the worst ways. They let you bend the language to your own will, but the language isn't the best on it's own, so if you decide to use them, you'll just be given the immense power they hold just to let you make a basic type.

Metatables and how they're treated really just highlight the issues with Lua itself. On their own, they're a fine addition, a very good one even, but the problems they solve are those which could be solved in much better ways. Being unable to define custom types or being unable to overload operators are all issues that Lua technically solves with metatables, and you'll be told to use them if you ask for those features, but they're only a patchy, easily breakable solution you'll want to use as rarely as you can.

I want to like Lua. I really do. I like its syntax and I like its semantics; I like the userbase and I like just how simple it really is. But I really wish metatables could be just a tiny bit better by not touching them and instead focusing on solving the issues they solve in ways abstracted much better then metatables.


published
Sciman101

I've heard the stereotype that programmers love reinventing stuff when existing libraries/solutions exist already, and I feel like the problems with metatables you highlighted only make that worse. That said you also showed off a lot of stuff I never even would have considered with metatables before and it's got me excited to try reinventing a bunch of wheels, lmao

ecter

To me it really looks like working with javascript without all modern additions. Tables and metatables in lua more or less translate to objects in prototypes in JS (albeit more clunky than lua). And overrides for __call, __get, __set, __toString etc. remind me of php, a lot.

So all of them are like "go ahead, make your own language out of it" which gave way to Symfony in php, jQuery in js and whatever the "essential" library in lua is. It's always weird to me, because working with some libraries of this kind requires a bit of unlearning the language itself and reserving more headspace to library-specific habits.