#StandWithUkraine

Russian Aggression Must Stop


Programming fundamentals - Part 10: Object-oriented programming

2026/04/20

Tags: programming tech programming fundamentals

Object-oriented programming is an approach for structuring programs in a way that models concepts and things the program interacts with as containers, which have methods that interact with the information related to those concepts and things. At a very basic - and arguably somewhat incorrect - level it means representing each of your things as a table, which contains both data and functions.

Specifically, object-oriented programming aims to compartmentalize the state of the program such that the logic of the programs can be imagined as discrete stateful entities interacting with each other. It also allows these entities to declare shared behaviors, so that similar entities can share some of the code between them or allow certain parts of the program to be generic over multiple different types of entities that appear externally as similar by providing a common interface.

In Lua you can encounter it fairly often, especially in the file I/O functionality or when you are dealing with tables. So, on some level you have already been exposed to object-oriented programming. In this post we will delve a bit deeper in order for you to be able to create your own object-oriented behaviors and systems.

Objects and methods

Each of the instances of a thing is called an object. And one key principle that object-oriented programming typically tries to maintain is that the object is responsible for providing access to its own data via its own interface, which consists of the methods associated with that object.

As a basic, example, let's say that we are building a game engine and we want to represent different game states we might be in. For example, we have a main menu, our gameplay loop and maybe a pause menu. We could do this by creating a lot of bits of state and checking for them, but that would get messy pretty quickly. We also may need to transition smoothly between them, especially for a pause menu.

Each of our game states needs to be able to do at least two things:

  1. Draw on the display all of the necessary bits that make up the current state.
  2. React to player input to modify the current state or transition to a new state.

So, in Lua terms, we basically have two functions that we would need to fill up for each of our states:

function draw(state)
   -- ... some drawing logic here...
end

function input(state, input)
   -- ... reacting to player input here ...
end

But obviously since we don't want these two functions to handle everything for every state, we will need to make them state-specific. We could just copy them and give each of them a unique name, but since we don't expect to be calling those functions in that way, let's just create a table for each state and store those functions into each of the tables:

MainMenu = {}

function MainMenu.draw(state)
   print("Drew main menu")
end

function MainMenu.input(state, input)
   print("Updated main menu")
end

Gameplay = {}

function Gameplay.draw(state)
   print("Drew gameplay")
end

function Gameplay.input(state, input)
   print("Updated gameplay")
end

This way, if we track our current state in a variable and then simply call the draw() and input() functions of that variable, we can easily get the state-specific functionality by just modifying the variable.

<<game-states>> -- The previous game state declaration go here

currentState = MainMenu -- We start in the main menu

currentState.draw()

currentState = Gameplay -- We switch to gameplay

currentState.draw()
Drew main menu
Drew gameplay

However, for now we aren't really taking much advantage of objects. Basically all we have done is just wrap up our variations on the draw() and input() functions in separate tables. For some solutions this is enough, but since we want to talk about object-oriented programming, let's have a look at a concept called encapsulation.

Some gameplay states might be really simple, but typically they need to keep track of special state, such as which menu element the player currently has selected or where the player character and the enemies are. We can store this information internally into the game state and use the method syntax when we're calling draw() and input() to have access to that internal data when we need it.

Let's modify our states so that we have some internal data we're holding onto:

MainMenu = { menuButtons = {"Play", "Options", "Quit"},
             currentSelection = 1 }

function MainMenu.draw(state)
   print(state.menuButtons[state.currentSelection] .. " selected")
end

function MainMenu.input(state, input)
   if input == "UP" then
      state.currentSelection = math.max(1, state.currentSelection - 1)
   elseif input == "DOWN" then
      state.currentSelection = math.min(#state.menuButtons, state.currentSelection + 1)
   end
end

Gameplay = { playerAirborne = false }

function Gameplay.draw(state)
   if state.playerAirborne then
      print("Player is flying")
   else
      print("Player is on the ground")
   end
end

function Gameplay.input(state, input)
   if input == "UP" then
      state.playerAirborne = true
   else
      state.playerAirborne = false
   end
end

Now we can call the functions with a little bit of extra magic and suddenly that internal state becomes available to our functions. Note how we change the call from using "." to ":" in the function call:

<<game-states-with-internal-state>> -- The previous code block goes here

currentState = MainMenu

currentState:draw() -- Note that we are not passing anything to the function
currentState:input("DOWN")
currentState:draw()
currentState:input("UP")
currentState:draw()

currentState = Gameplay

currentState:draw()
currentState:input("UP")
currentState:draw()
currentState:input("DOWN")
currentState:draw()
Play selected
Options selected
Play selected
Player is on the ground
Player is flying
Player is on the ground

So, what is actually happening here? Turns out that not much, the method syntax with ":" is simply a convenience feature Lua provides us. Basically, currentState:draw() is entirely equivalent with writing the function call as currentState.draw(currentState). The method syntax simply drops in a self-reference as the first argument of the function. And that's basically what a method is: a function that takes the instance of the object as the first parameter, followed by other parameters the function might take. The method syntax just allows us to use a short-hand to represent that kind of a function call.

Instantiation

Ability to cohabit data and functions is an important part of objects, but that alone does not object-oriented programming make. First of all, we rarely deal with just a couple of distinct objects. Even moderately complex systems often deal with dozens or hundreds of objects, although usually they are very similar to one another.

For example, let's say we are building a personnel management system. Our data in this case would be people and we store basically the same kind of data for each person: their name, their age, email address and probably a few other bits of info we might need to keep track of. It would become a bit annoying having to declare entirely distinct objects with duplicated functionality for every person. So, instead of creating objects from scratch every time, we can use instantiation.

Instantiation essentially just means creating an object out of an existing base. In other languages that implement object-oriented programming via a class system, instantiation is the process of turning a description of the fields of an object into an actual object. Lua doesn't use classes, but we can still provide the layout of an object and then creating new objects off of it.

So, let's set up a way to create instances of our people objects.

First, let's approach the problem of instantiation in the simplest way and gradually move up in complexity. If you try to think about the easiest to create data on demand, you will probably quickly think of a function. And we can indeed use a function to create new people objects when we need them. We can accept all of the fields as parameters to said function and construct objects with those fields populated like so:

function greet(person)
   print("Hello " .. person.name .. "!")
end

function makePerson(name, age, email)
   return {
      name=name,
      age=age,
      email=email,
      greet=greet
   }
end

local bob = makePerson("Bob", 45, "bob@bobmail.com")
local alice = makePerson("Alice", 32, "alice@alicemail.net")

bob:greet()
alice:greet()
Hello Bob!
Hello Alice!

As you can see, we defined the greeting function for our people as a shared function and simply pass it into the object in our makePerson() function. By the way, this function is what would be called a "constructor", since it "constructs" objects.

This style of object creation is often all you need and at least I don't think there is that much wrong with it. However, for very complex objects with lots of fields, it may become unwieldy to pass everything into positional arguments of a function.

An alternative way to set up the constructor is to take a kind of stump object as a parameter like this, if you are fine with defining the fields of the parameter up front and only using the constructor to share the methods of the object:

function greet(person)
   print("Hello " .. person.name .. "!")
end

function makePerson(obj)
   obj.greet = greet

   return obj
end

local alice = makePerson({name="Alice", age=32, email="alice@alicemail.net"})

alice:greet()
Hello Alice!

This way you can pass an arbitrary number of fields to the object, although it might become more difficult to remember what parameters can be set for the object in the first place. If you want to see a method to make this kind of complex object construction a bit more robust and easier to deal with, have a look at the builder pattern:

local PersonBuilder = {obj={}}

function PersonBuilder.new(self)
   self.obj = {}
   return self
end

function PersonBuilder.withName(self, name)
   self.obj.name = name
   return self
end

function PersonBuilder.withAge(self, age)
   self.obj.age = age
   return self
end

function PersonBuilder.withEmail(self, email)
   self.obj.email = email
   return self
end

function PersonBuilder.build(self)
   return self.obj
end

local alice = PersonBuilder:new()
   :withName("Alice")
   :withAge(32)
   :withEmail("alice@alicemail.net")
   :build()

print(alice.name)
print(alice.age)
print(alice.email)
Alice
32
alice@alicemail.net

The builder pattern essentially sets up a separate object, which holds onto a work-in-progress version of the new object, allowing it to be modified with the parameter functions. Once you hit build(), you will get the constructed object back with the parameters you set. This might be overkill for some uses, but if you want to make the parameters very explicit, the builder pattern is very handy for it. Just be careful to clear the builder with the new() method, or you will accidentally end up modifying an already existing object!

Polymorphic data and prototypal inheritance

Now we are able to create same sorts of objects quickly and easily. But what if we want to make objects that are almost but not entirely the same kind? Using our personnel management system as a somewhat contrived example, what if we want to keep track of our internal employees and external contributors? Some of the behaviors between the two might be shared, such as greetings, but other things might be special to a specific kind of person.

For this, let's take advantage of Lua's prototypal object system to create polymorphic data: objects that share some behaviors but still provide distinct additions.

What we want to build is something like this: we have a base set of shared behaviors (our greet() function and a paidTotal() function to check money paid), which should work for all kinds of people. Then we have internal employees for whom we pay fixed salary using a paySalary() function. Our external contributors on the other hand are paid by tasks completed with a payCommission() function.

Let's define an object for our base behaviors called Person:

local Person = {}

function Person.greet(self)
   print("Hello " .. self.name .. "!")
end

function Person.paidTotal(self)
   return self.paid
end

function Person.new(self, newPerson)
   setmetatable(newPerson, self)
   self.__index = self

   return newPerson
end
Hello Alice!
0

As you can see, we have done things a bit differently. Our constructor is now in a Person.new() function, which does some kind of weird magic. To turn the magic into the mundane, at least to a degree, we'll have to understand metatables. A metatable is a Lua concept where you can declare that if a particular field is not found in the current table, then the given metatable should be checked next to attempt to find the field. If the field is not found in the metatable, then the metatable of the metatable would be checked et cetera, et cetera. To make that recursion work, we also set the __index of our current object to ourselves to begin the chain.

This Person object is our prototype. To create our special-purpose objects of Employee and External, we simply need to create new objects on top of that base:

<<person>>

local Employee = Person:new({})

function Employee.paySalary(self)
   self.paid = self.paid + 4000
end

local bob = Employee:new({name="Bob", paid=0})

bob:greet()

bob:paySalary()

print(bob:paidTotal())
Hello Bob!
4000

This might seem a bit weird at first. We didn't need to declare the previous behaviors, we can just use those from the Person prototype. The metatable we set up is used to find the right methods we need.

Similarly, we can now create our External by inheriting the behaviors of the Person prototype in the exact same way:

<<person>>

local External = Person:new({})

function External.payCommission(self)
   self.paid = self.paid + self.tasks * 1000
   self.tasks = 0
end

local alice = External:new({name="Alice", paid=0, tasks=5})

alice:payCommission()

alice:greet()
print(alice:paidTotal())
Hello Alice!
5000

As already alluded to, sharing behaviors using prototype objects like this is referred to as inheritance or more specifically prototypal inheritance. You can theoretically nest this inheritance arbitrarily much. For example, you could create a JuniorEmployee by inheriting from Employee and so on. But usually building very deep inheritance hierarchies is not a very good way to build software. It can easily become difficult to keep track of where your behaviors originate from or create conflicts between behaviors that are very annoying to debug and sort out. So, inheritance is something to be used sparingly. The common wisdom these days is to prefer composition over inheritance, meaning that instead of sharing data and behaviors through an inheritance chain, you should create smaller parts of your objects and then store them as members of your compound objects. So, rather than expressing our Employee and External by inheriting from Person, we could simply store an instance of the Person object inside each of them to gain access to the shared behaviors for all people.

Conclusion

Object-oriented programming has a lot of detractors. Some people feel like it complicates problems more than it helps and especially the overuse of inheritance has resulted in some code bases that try to represent all of the data types in the project through complex relationships, which don't really help with solving the actual problems much.

However, inheritance still has its uses and for example a number of GUI frameworks use it to create different variations on buttons for example. So, object-oriented programming and inheritance are still useful tools to have in your toolbox. But they shouldn't be your only tools, after all not every problem is a nail that requires a hammer. You can build a lot of software with just procedural code consisting of functions and objects that only contain data. If that makes it easier for you to create your solution, then you should go with that route. In fact, there are schools of thought that believe functions are the only thing you need to solve problems.

Next time we will delve into that particular school of thought: functional programming.

PS: This chapter ended up in a bit of a limbo for a while, so I apologize if you ended up having to wait for it. As with many things of mine, sometimes I find it hard to get started on a thing and this post ended up being one of them. Either way, I hope it turned out decently good and educational. Thank you to everyone who has been following along this far. :)

>> Home