Time and time again, programming newcomers, through no fault of their own, are introduced to object-oriented programming (OOP) in languages that lead to a significant obstruction of the core principles of OOP.

Additionally, Lua newcomers have trouble understanding how to implement OOP in Lua, often resorting to replicating "patterns" found on the internet, leading to a very fuzzy notion of how it works and resulting in confusion when this does not behave as expected.

This is an attempt to clear both up by re-introducing the core principles of OOP along with particular implementation styles in Lua.

Objects

At the core of OOP lies the object: A collection of fields (per-instance variables) and methods (functions operating on instances and additional parameters).

Suppose you were implementing simple 2d vectors. Your Vector "constructor" could look something like this:

local function Vector(x, y)
	return {
		-- "Fields"
		x = x,
		y = y,
		-- "Methods"
		scale = function(self, scalar)
			return Vector(self.x * scalar, self.y * scalar)
		end,
		add = function(self, other)
			return Vector(self.x + other.x, self.y + other.y)
		end,
	}
end

Usage is a bit cumbersome, since we always need to remember to pass instances as the first parameter (conventionally called self):

local u = Vector(1, 2)
u = u.scale(u, 2)
local v = Vector(3, 4)
local w = u.add(u, v)
print(w.x, w.y) -- 5 8

... which is why Lua offers syntactic sugar to mimic the "method call" syntax of other languages: self:method(...) is (roughly) equivalent to self.method(self, ...). 1 Using this, our code becomes much cleaner:

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

Polymorphism

What we've seen so far is just a convenient way to collect data in a table (a "struct") and a way to call associated methods.

At the core of object-oriented programming is the idea of polymorphism: That different objects can be used interchangeably, so long as they adhere to the same "interface" of "operations" (field access, method calls, or other operations, as we will see later).

Our current implementation naturally provides polymorphism: If we have

local function Square(side_length)
	return {
		side_length = side_length,
		area = function(self)
			return self.side_length^2
		end,
	}
end
local function Circle(radius)
	return {
		radius = radius,
		area = function(self)
			return math.pi * self.radius^2
		end,
	}
end

then the following works as expected, summing up the areas of the different "shape" objects in a "list":

local shapes = {Square(3), Circle(4), Square(5)}
local total_area = 0
for _, shape in ipairs(shapes) do
	total_area = total_area + shape:area()
end
print(total_area) -- 84.265482457437 (3^2 + math.pi * 4^2 + 5^2)

Note that shape:area() calls a different function depending on whether shape is a square or a circle.

At this point, we realize that we have implemented the essence of OOP: Polymorphic objects. 2

This is also the "OOP" implementation which, for example, the Go programming language provides. The only difference is that Go, being statically typed, has to introduce interface types, whereas Lua, being a dynamically typed language, simply relies on duck typing: The set of operations applied to an object implicitly specifies the interface - "if it walks like a duck and quacks like a duck, it is a duck". If you do not implement this interface properly, you will get a runtime error along the lines of "attempt to call a nil value". 3

Note that inheritance is not a requirement for OOP. 4

Metatables

While this implementation works, it is not quite ideal:

Under the hood, objects have to be bigger tables since they are populated with a bunch of methods (which should be common to all instances of the same type).

The constructor also runs slower, since it now has to populate this table. We also reduce the separation of data and code: If we were to iterate over the entries of an object, say for debugging purposes, we would be confronted with all methods. 5

Effectively, there is a distinction between instance variables, which vary across objects, and methods, which are common across objects, which we do not yet leverage to our advantage.

It would be good if we could somehow "share" common fields across tables.

Enter metatables.

A metatable is a table containing metadata for an object (usually a table, but other types like userdata can have metatables too) which lets you (re)define built-in Lua operations. For example t[k] is the "indexing" operation; t.name is just syntactic sugar for t["name"].

Using the __index field of the metatable, Lua lets us provide a table of "defaults": If t[k] is nil, Lua will evaluate it to defaults[k] instead. For example:

local defaults = {b = 2}
local t = {a = 1}
print(t.a) -- 1
print(t.b) -- nil (absent field)
setmetatable(t, {__index = defaults})
print(t.a) -- 1 (still working)
print(t.b) -- 2 (defaulting to `defaults.b` now)

We can leverage this by moving common methods or default values to a common table, which is used as the __index field in the metatable of all objects:

local Vector -- "forward declaration" such that the constructor is visible inside the methods.
local vector_methods = {
	scale = function(self, scalar)
		return Vector(self.x * scalar, self.y * scalar)
	end,
	add = function(self, other)
		return Vector(self.x + other.x, self.y + other.y)
	end,
}
local metatable = {__index = vector_methods}
Vector = function(x, y)
	local self = {x = x, y = y}
	setmetatable(self, metatable)
	return self
end

Usage is still as before; we lose no flexibility: We can still arbitrarily "override" methods for specific objects.

We traded a tiny bit of simplicity and a usually negligible bit of performance when accessing fields for (relatively speaking) massively reduced memory usage and a much faster constructor.

This concept of "defaulting" is central to most OOP implementations in scripting languages: This table of "defaults" is called a prototype, and this style of implementation hence is called prototype-based OOP.

Concepts from class-based OOP map very well to prototype-based OOP: Construction of instances of classes simply registers the class as prototype for the instance. Inheritance boils down to using the parent class as the prototype for the child class. 6

Operators

As the cherry on top, recall that metatables let you redefine more than just __index. We can overload arithmetic operators, for example. In this particular case, we could use __mul for scalar multiplication and __add for vector addition:

local Vector
local metatable = {
	__mul = function(self, scalar)
		return Vector(self.x * scalar, self.y * scalar)
	end,
	__add = function(self, other)
		return Vector(self.x + other.x, self.y + other.y)
	end,
}
Vector = function(x, y)
	return setmetatable({x = x, y = y}, metatable)
end

This is commonly called "operator overloading" as it overloads the built-in behavior of these operators with custom behavior.

I shortened the code a bit, taking advantage of the fact that setmetatable returns the table it set the metatable on as a convenience feature.

Using this, vector arithmetic reads very naturally, though the inexperienced reader may be confused:

local u = Vector(1, 2)
u = u * 2
local v = Vector(3, 4)
local w = u + v
print(w.x, w.y) -- 5 8

Closures

Believe it or not, but closures alone are fundamentally enough for OOP.

Closures capture the local variables in their scope as "upvalues" (which are mutable; access is shared among all capturing closures).

Every time you're creating a closure, Lua is effectively instantiating a closure "object" comprised of a collection of "upvalue" references behind the scenes.

Every time you call a closure, you get dynamic binding: Which function is called depends on the closure object at hand.

In this sense, functional programming is object-oriented programming.

For objects which only have a single sensible operation, closures are the natural choice - for example as a comparator in table.sort. I personally like to implement "streams" via closures: An input stream simply is a function which returns, say, bytes, or nil at end of input; an output stream is a function which you call with bytes.

Trying to "overload" a single function call with multiple operations is possible, but messy and likely to be inefficient.

For closure-based OOP with a collection of methods, we will go back to our initial implementation, except now that we know about closures, we will leverage them, by not requiring self be passed anymore, instead making self an upvalue of all methods:

local function Vector(x, y)
	local self
	self = {
		-- (Public) "fields"
		x = x,
		y = y,
		-- "Methods"
		scale = function(scalar)
			return Vector(self.x * scalar, self.y * scalar)
		end,
		add = function(other)
			return Vector(self.x + other.x, self.y + other.y)
		end,
	}
	return self
end

We can now use plain . instead of :, since we don't need to pass self anymore:

local u = Vector(1, 2)
u = u.scale(2)
local v = Vector(3, 4)
local w = u.add(v)
print(w.x, w.y) -- 5 8

The constructor performance and object memory usage concerns persist, but will often not be a problem in many practical applications.

The main advantage of this approach over prototype-based OOP is that it boasts proper support for "private" fields and methods, which can simply be common upvalues of all methods. Consider this "bidirectional map":

local function BidiMap()
	local map = {}
	local inverse_map = {}
	return {
		set = function(key, value)
			map[key] = value
			assert(inverse_map[value] == nil)
			inverse_map[value] = key
		end,
		get_value = function(key)
			return map[key]
		end,
		get_key = function(value)
			return inverse_map[value]
		end,
	}
end

By keeping the "map" and "inverse map" private, we can ensure that we maintain consistency of our 1:1-mapping:

All access has to occur through the methods we expose, which together make up the "interface". This is called encapsulation. With prototype-based OOP, language-enforced encapsulation is not possible; the best you can get is a brittle encapsulation "by convention".

If in doubt, prefer prototype-based OOP due to the (relatively speaking) much more limited resource usage. 7

Conclusion

We have seen that polymorphic objects comprise the essence of object-oriented programming. We have started with a naive implementation and later studied the basics of the two major "implementation styles" in Lua: Prototype-based, the by far most popular style, and closure-based, a style with different trade-offs.



  1. Conversely, in a method definition, function class:method(...) is equivalent to function class.method(self, ...): The colon (:) syntactic sugar adds an implicit self parameter.

  2. Polymorphism comes in all shapes and forms. POSIX file descriptors, and the common operations on them, are also an example of polymorphism. A program can read data from a file descriptor irrespective of whether it's backed by a socket, a pipe, a file, or something else. The notion that OOP is thus exclusive to "object-oriented" languages like Java or C# is thus misguided.

  3. Duck typing is not exclusive to runtime. Many "compiled", statically typed languages like Zig or C++ feature compile-time ducktyping, wherein the compiler checks whether all operations used in a snippet of code are provided by the respective types, namely in their respective comptime / template features. Modern C++ is eschewing this compile-time ducktyping in favor of "concepts". "Interface" implementations vary as well. Traditionally, implementing interfaces was declarative: You need to explicitly specify a particular set of interfaces you want to implement and the compiler then checks that you indeed implement the respective sets of methods with the correct signatures. In Go, this is structural instead: Interfaces are automatically implemented if you implement the respective methods. This brings it a tad bit closer to duck typing in that you still need to specify the requirements as an interface, but you don't need to specify what provides these requirements. The downside is that you can "accidentally" implement an interface (though this is easy to address).

  4. Generally, more modern languages like Go or Rust prefer composition over inheritance, to the point where they banish inheritance entirely. When working in a language which supports inheritance, be sure not to use it excessively; if in doubt, do not use inheritance. Implementing inheritance is not hard: You would turn all the fields into table assignments of the form self.field = ... or function self:method(...), and then take care to first call the parent constructor via local self = parent_constructor(...).

  5. Even worse, with our current implementation, due to Lua functions being closures, we always create a bunch of closures each time. On top of this, the namespaces of our methods are also polluted with the constructor parameters, making it easy to use these outdated values rather than the instance variables stored in self. These latter two issues are fixable without using metatables, but the former two aren't.

  6. In Lua, this is as simple as setmetatable(methods, {__index = parent_methods}). Again care needs to be taken to initialize local self = parent_constructor(...), only adding your own fields later. Make sure not to overwrite parent fields (unless intended).

  7. Resource usage is no reason to outright dismiss the closure-based implementation style. Often, there is no significant performance concern at all. But if you have neither good measurements nor good estimates to know this, using prototype-based OOP is the safer bet.