Skip to content

Instantly share code, notes, and snippets.

@appgurueu
Created April 6, 2024 23:21
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save appgurueu/d72fbebfa947bc11f8806e14cc250b83 to your computer and use it in GitHub Desktop.
Save appgurueu/d72fbebfa947bc11f8806e14cc250b83 to your computer and use it in GitHub Desktop.
Object-oriented programming (in Lua)

Object-oriented programming (in Lua)

Time and time again, programming newcomers struggle with grasping the essence of OOP, often confusing it with particularly restrictive implementations. Additionally, Lua newcomers struggle to understand how to implement OOP in Lua, often blindly copying "patterns" and expecting to miraculously obtain whatever they believe is OOP. This is an attempt to clear both up.

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(x * scalar, 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, ...). 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)

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. This is also what Go provides (modulo static interface types versus dynamic ducktyping in Lua).

(Polymorphism comes in all shapes and forms. POSIX file descriptors, and the common operations on them, are also an example of polymorphism.)

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 an object, say for debugging purposes, we would be confronted with all methods.

(Even worse, with our current implementation, due to Lua functions being closures, we always create a bunch of closures each time, and on top of this, the namespaces of our methods are polluted with the constructor parameters. These latter two issues are fixable without using metatables, but the former two aren't.)

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. 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 for our example 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_methods = {
	scale = function(self, scalar)
		return vector(x * scalar, y * scalar)
	end,
	add = function(self, other)
		return vector(self.x + other.x, self.y + other.y)
	end,
}
local metatable = {__index = vector_methods}
local function vector(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.

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 metatable = {
	__mul = function(self, scalar)
		return vector(x * scalar, y * scalar)
	end,
	__add = function(self, other)
		return vector(self.x + other.x, self.y + other.y)
	end,
}
local function vector(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(x * scalar, 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.

Conclusion

We have seen that polymorphic objects comprise the essence of object-oriented programming. We have studied the basics of the two major "implementation styles" in Lua: Prototype-based and closure-based, each with its own advantages and disadvantages.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment