Object-Oriented Programming in Lua using Annotations
Despite being a scripting and dynamically typed language, Lua possesses enough flexibility to do object-oriented programming effectively, especially using the power of annotations. This primer explains how core OOP concepts can be implemented. I’m sure that understanding the why behind each line, will help you feel confident about the code you write and do it fast. You can also skip straight to the “Full-featured usable class template” section at the very end of the article.
There is more than one approach to OOP in Lua. Two of the most common ones are using functions and tables — both are present in the official Lua reference. To choose which approach fits you best, figure out what is important for you when writing code. I initially started using the function-based approach because it provided strict privacy benefits: all properties are private and they can only be accessed via getter and setter functions, which are returned as object interface. A class definition will look like this (you can skip this example and continue reading):
local ClassName = {} -- package definition, should be the same as file name
function ClassName.new(arg) -- constructor with optional arguements
-- Properties:
-- All are private and encapsulated in "self", init phase
local self = {
objectPrivateProperty = arg
}
-- Implementation:
-- Create getters and setters to manipulate properties
local objectMethod = function()
-- access properties with "self.objectPrivateProperty"
end
-- Interface / Instance methods:
-- Only return a list of public methods from the Implementation section
return {
objectMethod = objectMethod,
}
end
-- Class methods
function ClassName.classMethod()
end
return ClassName -- the preferred way to return a package in Lua
But later, by taking advantage of annotations and code completion in Lua Language Server (available for VSCode, NeoVim, or as a CLI), I found that table-based approach provides better readability, auto-completion, and type awareness. I’ll explain how this can be done with code examples.
Basic class
Lua is prototype-based, meaning there is no separate class definition. A class is just a prototype object on which other instances are based. Before adding more complexity, let’s see what’s happening here line by line.
local BasicClass = {}
function BasicClass:new()
local newObject = setmetatable({}, self)
self.__index = self
-- Object initialization
return newObject
end
return BasicClass
A class definition resides in its own file, with the filename being the same as the class name + extension, in our case BasicClass.lua. This allows importing the class, or more broadly a package, within other files.
Using the basic class
We can create an object of this class in main.lua file by first (1) importing it and then (2) invoking its constructor like this:
local BasicClass = require "BasicClass" -- (1) import the class
local basicObject = BasicClass:new() -- (2) create new instance of class
We use local keyword to limit the scope. Skipping local defines a variable as global. It’s a good practice to always use local for imported packages and only assigns global variables to specific bits of data.
1. Class definition
First, we define the package in the local scope by creating an empty table {} . Objects, and therefore classes, are tables in Lua. Tables are similar construct to dictionaries or maps in other languages. Later we will add public properties within this table: local BasicClass = {}
2. Constructor function
Next we create a constructor function BasicClass:new(). We can add optional initialization parameters inside parenthesis.
- Using colon: here is purely syntactic sugar for passing the object itself as a first parameter, so that it can be used within the function scope. An interchangeable notation for this is function BasicClass.new(self).
- Adding BasicClass. before the function name defines function attribution to the class/package.
3. Setting a metatable
Each table (and each object) in Lua has a metatable used to modify table’s behavior. This mechanism is used for objects to be able to find and call methods defined in the class definition — technically being a separate table — and also to implement inheritance (a full section is be dedicated to inheritance below).
To assign a metatable to a table/object you call setmetatable({}, self) passing the table as the 1st parameter and its metatable — as the 2nd. Because setmetatable returns the table back, we can combine creating the object local newObject = {} and setting its metatable in one line:
local newObject = setmetatable({}, self).
After that operation all our newly created objects will have their class as a metatable. The next paragraph explains why this is important.
4. Configuring method indexing
Lua uses the__index key of the metatable to search for method definitions that were not found in the object table. We need to set __index value to point to our class BasicClass.
This is done with self.__index = self. As this line is not object-specific, it can be moved from the constructor function into the package/class scope, but I personally find it less readable and requiring to change the class name for every class. Lua docs contain both variants: 1st and 2nd, so there is no hard rule here. The code below will also be valid:
local BasicClass = {}
BasicClass.__index = BasicClass
function BasicClass:new()
local newObject = setmetatable({}, self)
...
__index can also point to a function with custom behavior, e.g. searching only for specific signatures, but when it points to a table, it iterates over all table keys to find the appropriate method declaration.
5. Returning the object
After the object initialization, i.e. when parameters are passed into the constructor and private properties are initialized, we return the newly created object with return newObject.
6. Returning the package
The preferred way in Lua to pass a package via require is by returning it at the end of file with return BasicClass. So in main.lua file:
- BasicClass class is what we get with require ‘BasicClass’
- BasicClass object is what we get with BasicClass:new()
Now that we are done with the basic class definition, let’s see how OOP concepts gradually form its shape into a usable example.
Encapsulation
Lua doesn’t provide built-in access modifiers, but you can hide implementation details with class annotations. You will not get compiler errors for accessing private properties and methods, but neither will they show up in code completion and their improper use will issue verbose warnings.
--- Basic class purpose
---@class BasicClass
---@field private privateProperty boolean
---@field publicProperty integer
---@field anotherPublicProperty integer
local BasicClass = {
publicProperty,
anotherPublicProperty
}
---@private
function BasicClass:privateMethod()
end
function BasicClass:publicMethod()
end
return BasicClass
Properties
Please note that you only add public properties as table elements. You mark private properties with the private modifier in the annotation.
Methods
Methods are public by default. For private methods, you add —@private above the function declaration (before, after or anywhere in between other annotation lines):
--- Adds two values
---@param a integer
---@param b integer
---@return integer result
---@private
function BasicClass:add(a, b)
return a+b
end
Type annotations
In addition to modifiers, you also specify property types and full method signatures, which Lua Language Server uses to check for type consistency and then generates IDE warnings where necessary. Use annotations for functions and local variables as well to prevent unwanted errors and save time debugging.
Well-annotated code will save you a lot of time when programming in a dynamically typed language like Lua, especially if you are used to static-type compiler checks. Here is the full LuaLS guide for annotation.
If you’ve defined classes using the approach presented in this primer, you can also create a function to check the instance’s class (including subclasses) and cast it:
local function member_of_class(object, class)
while object do
object = getmetatable(object)
if object == class then
return true
end
end
return false
end
...
if member_of_class(aObject, TheClass) then
---@cast aObject TheClass
aObject:object_method()
end
Inheritance
Inheritance is implemented by using the metatable mechanism described above. Adding annotation provides proper code completion and warnings.
Basic inheritance
Notice the changes: we pass object as a constructor parameter. And then, if it is not nil (object or {}), we use it to set metatable, initialize, and return.
---@class BaseClass
local BaseClass = {}
---@param object table? Required when subclassing `BaseClass`
---@return BaseClass
function BaseClass:new(object)
-- Required code for instances to find defined methods and inheritance
local newObject = setmetatable(object or {}, self)
self.__index = self
return newObject
end
return BaseClass
This change is needed so we can create SubClass with a syntax similar to BaseClass and add new properties. In the snippet below new {} is equal to new({}) for the last parameter. We do not add object parameter in subclass constructor because we don’t plan to subclass it further, but we can if needed.
local BaseClass = require "BaseClass"
---@class SubClass:BaseClass
local SubClass = BaseClass:new {}
---@return SubClass
function SubClass:new()
-- Required code for instances to find defined methods and inheritance
local newObject = setmetatable({}, self)
self.__index = self
return newObject
end
return SubClass
Adding properties
To add new public properties, we only add those within the table {} and add new@field annotations. An object/table with newProperty is now passed into BaseClass:new(). And as it is not empty, it creates an object with those properties (see BaseClass’s constructor code explanation above) in addition to properties BaseClass may already have (not in our example).
...
---@class SubClass:BaseClass
---@field newProperty string
local SubClass = BaseClass:new {
newProperty
}
...
Adding methods
Adding new methods in a subclass is straightforward:
...
function SubClass:addedMethod()
end
...
Polymorphism
For inherited classes, you can override the method implementation of the base class by just providing the method with the same signature or a default implementation of a prototype class — as a form of protocol conformance.
--- _Prototype_ class
---@class BaseProtocol
local BaseProtocol = {}
---@return BaseProtocol
function BaseProtocol:new(object)
local newObject = setmetatable(object or {}, self)
self.__index = self
return newObject
end
function BaseProtocol:requiredMethod1() end
funciton BaseProtocol:requiredMethod2() end
return BaseProtocol
Now we create a ConformingClass with the same mechanism as a subclass and overload a method with a fallback to default empty implementation:
...
local ConformingClass = BaseProtocol:new {}
...
function ConformingClass:requiredMethod1()
-- Implementation
end
...
The benefit of this is that you can now safely call requireMethod1() on any class conforming to BaseProtocol.
With annotations, you can also use generics — functions with type-agnostic parameters (WIP).
Abstraction
Having already mentioned encapsulation as a mechanism to hide excessive data and implementation details, and therefore provide a level of abstraction on how objects operate, I wanted to highlight just one more practical aspect of abstraction — dependency inversion.
Dependency inversion
In order to prevent higher-level classes (more abstract) from knowing about low-level implementation by other classes, they should only rely on abstractions. This means that a more abstract class defines a so-called protocol or interface, that it will trigger a message for some event, but this abstract class doesn’t know (and doesn’t need to know) if and how this message will be processed.
This can be achieved by overriding a default method implementation as described above in the Polymorphism section or, more commonly, by callbacks.
Callbacks are functions passed as parameters to other functions or objects, which can be called when a certain event happens. Callback functions cannot have parameters of their own in Lua. Workarounds exist passing arguments as a separate parameter together with the callback itself, but it is not necessarily the best option. Let’s see how a callback can be triggered and ask for more context from the triggering higher-level class.
A higher-level class knows nothing of its implementing class. All it knows is that it will call a callback when the listener is triggered:
-- High-level class code
function HighLevelClass:registerObjectListener(listener)
self.triggerListener = listener
end
function HighLevelClass:processingEvent()
self:triggerListener()
end
While a lower-level class creates a function to process the trigger and request more data from higher-level class, and then passes that function as a callback.
-- Low-level (implementing) class code
function ImplentationClass:registerForEvent()
local processEvent = function()
self.higherLevelObject:requestDetails()
end
self.higherLevelObject:registerObjectListener(processEvent)
end
Using local processEvent = function() is equivalent to local function processEvent(), because functions are first-class values. I’m using a different notation here to stress that we further pass processEvent as a parameter. Please note the absence of parenthesis after the function name, otherwise, it would have got called and we would have passed its return value instead of the function itself.
We could further improve the code and make the listener optional and check it before calling from the higher-level class, to prevent it from throwing an exception in case no callback is provided.
Full-featured usable class template
To make the code as close as possible to the classes you will use, I’ve split BaseClass and SubClass in two separate files accordingly. It is not required but removes complexity as classes tend to grow.
BaseClass.lua
--- Describe class purpose
---@class BaseClass
---@field publicProperty1 integer
---@field publicProperty2 string
---@field private privateProperty1 integer
local BaseClass = {
publicProperty1 = 0,
publicProperty2 = ""
}
---@param object table? Required when subclassing `BaseClass`
---@return BaseClass
function BaseClass:new(object)
local newObject = setmetatable(object or {}, self)
self.__index = self
-- Initialization
self.privateProperty1 = 0
return newObject
end
---@private
function BaseClass:privateMethod()
end
--- Method purpose
---@param arg1 integer
---@return boolean
function BaseClass:baseMethod(arg1)
return false
end
function BaseClass:overriddenMethod()
end
return BaseClass
SubClass.lua
local BaseClass = require "BaseClass"
---@class SubClass:BaseClass
---@field newPublicProperty boolean
---@field private newPrivateProperty boolean
local SubClass = BaseClass:new {
newPublicProperty = false
}
local CONSTANT_1 = 0
local CONSTANT_2 = "default"
---@return SubClass
function SubClass:new()
-- Required code for instances to find defined methods and inheritance
local newObject = setmetatable({}, self)
self.__index = self
-- Initialization
self.newPrivateProperty = false
return newObject
end
--- Class function purpose
function SubClass.classFunction(arg1)
end
--- Method purpose
function SubClass:newMethod()
end
function SubClass:overriddenMethod()
-- New implementation
end
return SubClass
This code contains two new concepts, that were not explained above:
Constants
Uppercase variables are not technically constant and protected. As a workaround, you can put those in a table and make it read-only (out of the scope of this article).
Class function
Notice the difference in notation with a dot SubClass.classFunction, meaning that self object is not passed through and it is not accessible in the scope of this function. Such a function doesn’t (need to) know about the instance of the object it was called upon.
Final notes
File structure
This might not seem important, but having the same order of sections in each class provides consistent code readability. The only rule is to define an entity — be it a package, variable, or function — before referencing it. The rest is completely up to you. The following structure works well for me:
- Required imports
- Class definition (with annotation and public properties)
- Constants
- Class functions
- Private methods
- Public methods
- Package return
Annotations
Keep annotations descriptive, but concise. Although you can go multi-line, I personally find it best to write one-line descriptions and provide full references separately, e.g. in a GitHub wiki. To learn about the full syntax and capabilities of annotations, visit the Lua Language Server Wiki.
This article is a part of the series on game development. In addition to this primer, it covers the theory and practice of developing a game engine. It also includes public domain GitHub repos: a template repo to create iOS game (little to no experience needed) and a minimalist game engine (intermediate/advanced coding proficiency) you can use as is or fork and modify to your needs.
OOP in Lua was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.