When we program, many if not most of our mistakes fall into a few categories. I set out to get help with one of mine by improving my tools.
One of my most common mistakes is to mistype a class member name. When it’s the name of a method, I quickly get an error about trying to call a nil. When it’s a fetch, it fetches a nil and I quickly get an error trying to add nil to a number or something. But when it’s an assignment, Luau just goes ahead and does it, and then later on, the value of the thing I was trying to store into isn’t updated. And yet, I can see the updating code Right There. It often takes me a long time to recognize the typo.
One of the great things about Lua and Luau is that the language’s metatables often allow us to change how things work to our benefit. We have already created a class()
function that helps us create object-oriented code, and the little tests
and expectations
frameworks that facilitate writing tests that exercise our code and help us ensure that it does as we intend.
Among the metamethods in Luau, there is __newindex
. This is a function that will be called when our code tries to create a new keyed entry in a table, and there is not already an entry with that key there. Normally, __newindex
just goes ahead and adds the key. But it could do anything … including throw an error.
Now there are really two ways of programming a class in Lua, in this context, and I don’t want to force people into a particular way. The two I have in mind are:
init()
;I find that my code is easier to understand when I use the first method. Naturally, I am evolving the code, so I go back many times and add a new member or remove an old one from my init
, but I like having everything that the class is going to reference being declared right there. It looks, for example, like this:
Bezier = class()
function Bezier:init(p0, p1, p2, p3)
self.p0 = p0
self.p1 = p1
self.p2 = p2
self.p3 = p3
end
To the extent that I follow my rule above, I can read the class definition and init
and be confident that the only data members in a Bezier are the four points p0-p3.
Now it happens that in programming the Bezier, I occasionally found myself thinking about points p1-p4, probably because Lua indices start with one, not zero. I’d like to ensure that I’d get a run-time error if some code down below were to say
self.p4 = something
A more strict language would give a compile-time error, but that’s not Lua. I, for one, prefer it that way, but others prefer a more strict language. But I do want to be able to catch that error.
My plan was to add a method to every class, so that we can say this:
Bezier = class()
function Bezier:init(p0, p1, p2, p3)
self.p0 = p0
self.p1 = p1
self.p2 = p2
self.p3 = p3
self:final()
end
Saying self:final()
should change the object we’re working on so that attempts to define new variables, like p4
, will be disallowed.
With this code:
Bezier = class()
function Bezier:init(p0, p1, p2, p3)
self.p0 = p0
self.p1 = p1
self.p2 = p2
self.p3 = p3
self:final()
end
function Bezier:at(t)
-- de casteljau algorithm
self.p4 = 0 -- intentional error to demonstrate final
local r1, r0 = self:tangent_at(t)
return r1*t + r0*(1-t)
end
My tests issue this error:
lua:634: cannot create field p4=0. [310]
Test: 'test_pathfinder_polyline_path'
And that is just what I wanted. Here’s how the class
function was modified to do the job. Scan down to where I’ve put all the dashes:
function class.class(base)
local c = {} -- a new class instance
if type(base) == 'table' then
-- our new class is a shallow copy of the base class!
for i,v in pairs(base) do
c[i] = v
end
c._base = base
end
-- the class will be the metatable for all its objects,
-- and they will look up their methods in it.
c.__index = c
-- expose a constructor which can be called by <classname>( <args> )
local mt = {}
mt.__call = function(class_tbl, ...)
local obj = {}
setmetatable(obj,c)
if class_tbl.init then
class_tbl.init(obj,...)
else
-- make sure that any stuff from the base class is initialized!
if base and base.init then
base.init(obj, ...)
end
end
return obj
end
-- -----------------------------------
c.final = function(self)
local parent_mt = getmetatable(self)
local child_mt = {
__index = c,
__newindex = function(t,k,v)
error("cannot create field "..tostring(k)..'='..tostring(v), 2)
end
}
setmetatable(self, child_mt)
end
-- ----------------------------------
c.is_a = function(self, klass)
local m = getmetatable(self)
while m do
if m == klass then return true end
m = m._base
end
return false
end
setmetatable(c, mt)
return c
end
Let’s look at that bit in detail:
c.final = function(self)
local parent_mt = getmetatable(self)
local child_mt = {
__index = parent_mt,
__newindex = function(t,k,v)
error("cannot create field "..tostring(k)..'='..tostring(v), 2)
end
}
setmetatable(self, child_mt)
end
Defining a method final
on c
adds that method to the metaclass of our object, which means that all instances will understand final
. What does the method do?
It gets the metatable of the object in question. (This is probably c
but let’s not assume that.)
It creates a new metatable, whose __index
is the parent metatable. This ensures that our new metatable will understand all the methods of the class.
The new metatable also includes a function __newindex
which simply raises an error, printing some of the information that is sent to __newindex
by Lua. (One looks that up in the documentation.)
We set the object’s metatable to be our new one, which gives it the new method __nexindex
plus everything in the parent.
Henceforth this object will give that error message if we try to store into a member that doesn’t already exist.
In use, we can close out our object’s initialization with a call to final
and it is protected against accidentally storing into non-existent members.
It will not suffice to declare a member like self.member=nil
. We must provide a non-nil value if we want to use final
.
We cannot set a member to nil as a signal or for any other reason, if we intend to set it non-nil later.
For now, I’ll live with this feature and see if I like it. If not, we can change it or remove it. We’re here to make our job easier, not to follow some arbitrary rules.
Safe paths!