Object systems
We haven’t spent much time in this course considering either the theory or practice of object-oriented programming. I think OOP-based languages can be intimidating because objects and classes tend to tie together many different programming concepts, patterns, etc. into a single package. This can make it difficult to understand individual language features in isolation. By contrast, both OCaml and Rust adopt a few themes of OOP (e.g. encapsulation, abstraction, polymorphism) but in their own special way. However, the one distinct feature they lack is inheritance (although between default trait methods and trait specialization, Rust is getting closer).
My goal today is twofold: first, I want to show you how implementing a object-oriented programming in Lua is easier than you may think. This ties into yesterday’s theme of benefits of dynamically-typed languages, showing how we can move beyond smaller programming idioms to implement entire models of computation when unrestrained by a type-checker. Second, along the way, I want to demystify inheritance, and show you how it’s actually the same problem as variable lookup in lexical/dynamic scoping.
Before we dive in, let’s take a moment to discuss what exactly an object is, and what features we want out of an object system. Objects are fundamentally comprised of two things: data (or state) and functions on that data (or methods). “Kinds” of objects are classes, where objects are stamped out as “instances” of a class. We associate functions with classes, and then call those functions on the objects through an implicit link from an object back to its class. Lastly, a more version of a class should be able to reuse functionality through inheritance.
Closures
The simplest possible notion of an object in Lua doesn’t even need tables—we can use closures!
local function Account()
local balance = 0
return function(k, v)
if k == "balance" then
return balance
elseif k == "withdraw" then
if balance >= v then
balance = balance - v
return true
else
return false
end
elseif k == "deposit" then
balance = balance + v
end
end
end
local acc = Account()
print(acc("balance")) -- 0
acc("deposit", 3)
print(acc("balance")) -- 3
acc("withdraw", 2)
print(acc("balance")) -- 1
Not only does this provide us a bundle of mutable state, but it even preserves encapsulation since data within the function is only accessible through the function’s interface. However, it doesn’t separate between a notion of classes and objects. Each object contains an inlined version of the object’s methods, which is horribly inefficient. And the story for inheritance isn’t clear, since it’s hard to know what methods are actually defined on an object (can’t enumerate the valid keys).
Tables
As hinted, the ultimate solution must start with tables. As we walk through the implementation of tables-as-objects, I will start with the most basic possible approach, and slowly build up features that are a combination of language features and syntactic sugar. At each step, I will point out (and you should be careful to observe) the mechanisms we use to define a particular feature of our objects. For starters, let’s replicate the closure object from above.
local Account = {}
Account["balance"] = 0
Account["deposit"] = function(v)
Account["balance"] = Account["balance"] + v
end
Account["deposit"](100.0)
Note that this doesn’t perfectly replicate the closure example, since we only have a single global account object. One step at a time. A first improvement is to use syntactic sugar to turn strings into identifiers.
local Account = {balance = 0}
Account.deposit = function(v)
Account.balance = Account.balance + v
end
Account.deposit(100.0)
We can use syntactic sugar for function definitions to make the method seem a little more natural.
function Account.deposit(v)
Account.balance = Account.balance = v
end
As we mentioned, this is still a “singleton” object. For example, this is a problem:
local a = Account
a.deposit(100.0)
Account = nil
a.deposit(100.0) -- attempt to index a nil value
We can work around this by not using a global alias for the account, instead asking the user to give us a reference to the account.
local Account = {balance = 0}
function Account.deposit(self, v)
self.balance = self.balance + v
end
local a = {balance = 0, deposit = Account.deposit}
Account = nil
a.deposit(a, 100.0) -- ok!
Lua comes with a syntactic sugar for these kinds of “self” functions. This is just a syntactic sugar–there is nothing mysterious about self. (If you have nightmares of this
from Javascript, life is simpler here.)
function Account:deposit(v)
self.balance = self.balance + v
end
a:deposit(100.0)
Now we want to get completely rid of the singleton issue. This introduces the notion of a constructor.
local Account = {}
function Account.new(balance)
return {balance = balance}
end
function Account:deposit(n)
self.balance = self.balance + n
end
-- This kind of interface is basically how OCaml works
local a = Account.new(0)
Account.deposit(a, 5)
local b = Account.new(0)
Account.deposit(b, 3)
assert(a.balance == 3)
assert(b.balance == 5)
Lastly, we want to associate methods directly on the account objects. The simplest way is to newly define the methods for each object:
local Account = {}
function Account.new(balance)
local t = {balance = balance}
function t:deposit(n)
self.balance = self.balance = n
end
return t
end
local a = Account.new(0)
a:deposit(5)
assert(a.balance == 5)
We can make this more efficient by defining our methods once, then giving objects a pointer to the methods.
local Account = {}
function Account.new(balance)
local t = {balance = balance}
for k, v in pairs(Account) do
t[k] = v
end
return t
end
function Account:deposit(v)
self.balance = self.balance + v
end
We can even generalize this to fields as well as methods.
local Account = {balance = 0}
function Account:new()
local t = {}
for k, v in pairs(self) do
t[k] = v
end
return t
end
local a = Account:new()
a:deposit(1)
assert(a.balance == 1)
In this, we’ve now seen the essence of prototype-based objects. Here, the Account
table has a very similar function to a class in that it functions as a template (or “prototype”) for instances of the class. Creating an object is as simple as copying the prototype.
Metatables
Lua has one final, crucial mechanism that allow us to generalize this notion of prototypes, called metatables. Metatables are hooks that enable us to override the default behavior for operations like getting and setting values in a table.
local t = {a = 1}
assert(t.a == 1)
assert(t.b == nil)
local function __index(t, k)
return 0
end
setmetatable(t, {__index = __index})
assert(t.a == 1)
assert(t.b == 0)
The __index
function is an example of a special metamethod. When attempting to read a key of a table that does not yet exist, Lua will call the index metamethod and return whatever the function returns. By default, the index metamethod just returns nil
. These metamethods can be arbitrarily complex—for example, here’s a table where t[i]
returns the i-th Fibonacci number:
local fib = {}
function fib_index(t, k)
if k == 0 or k == 1 then
return k
else
return t[k-1] + t[k-2]
end
end
setmetatable(fib, {__index = fib_index})
print(fib[30])
For our use case, the index metamethod enables us to insert a layer of indirection, where lookups on fields of one table can be redirected to another. For example:
local a = {x = 1}
local b = {y = 2}
local c = {z = 3}
setmetatable(b, {__index = a})
setmetatable(c, {__index = b})
assert(c.x == 1)
assert(c.y == 2)
assert(c.z == 3)
Note that Lua allows __index
to also be a table instead of a function, in which case it simply passes along key lookups into the provided table.
Inheritance
Returning to objects, we can apply metatables to generalize the prototype mechanism.
local Account = {balance = 0}
function Account:new()
local t = {}
setmetatable(t, {__index = self})
return t
end
function Account:deposit(n)
self.balance = self.balance + n
end
function Account:print()
print(self.balance)
end
local a = Account:new()
a:deposit(5)
assert(a.balance == 5)
Observe that Account.new
changed to Account:new
. This means {__index = self}
links the object t
to the class Account
, since self = Account
when running Account:new()
. Hence, when we call a:deposit(5)
(i.e. a.deposit(a, 5)
), the a
table does not have a key deposit
, so that lookup is dispatched to the index metamethod, which finds the deposit
method in the Account
table.
With this setup, something odd happens… we get inheritance for free!
local InterestAccount = Account:new()
InterestAccount.interest = 1.01
function InterestAccount:deposit(n)
Account.deposit(self, n * self.interest)
end
local a = InterestAccount:new()
a:deposit(100)
a:print() -- prints 101
Honestly, this is pretty incredible, and blows my mind every time I revisit it. Re-read this code a few times to make sure you appreciate what’s going on. First, to create a new class, we just make an instance of the parent class. Because a class is just a prototype of the objects it creates, that means an object can become a class.
Above, InterestAccount
is an instance of Account
along with an extra interest
field. We override the deposit
method, while still using our superclass method (in this, alas, we must reference Account
explicitly). When we create an instance using InterestAccount:new()
, this calls Account.new(InterestAccount)
, which sets the metatable of a
to InterestAccount
.
Calling a:deposit(100)
finds the method on the InterestAccount
table, calling it with self = a
. Then calling a:print()
lookups print
on InterestAccount
, doesn’t find it, and then looks it up on Account
where the method is found. We never explicitly created a link between InterestAccount
and Account
, but this is done implicitly by the fact that InterestAccount
is an instance of Account
(i.e. Account:new()
sets InterestAccount
index metamethod to Account
).
Let’s recap what’s happened so far. An object is a table with some values and associated methods. Classes are essentially object templates, or prototypes, which we “copy” into the object using the index metamethod to redirect missing key lookups to the class table. Using a cleverly created :new()
method, inheritance is as simple as SubClass = ParentClass:new()
. That’s object-oriented programming in Lua!
Again, the lesson here is that with a dynamically-typed language and the right primitives, you don’t need the language to give you an object system. You can build it yourself! This also gives us the power to customize our object system as necessary. Want multiple inheritance? Privacy? Implement it! You don’t need the language designers to do it for you.