I'm also learning metatables, so let me try to work you through as straightforward an explanation as I can manage, building a jury-rigged class-ish thing one step at a time. Maybe explaining to you will improve my own understanding. Please follow along as I go; you're more likely to feel it really
click for you if you do the examples and mess around than if you just passively read.
Making objects using metatables (and why it works)
We're going to make a kind of object called a BadGuy. For now, a BadGuy has an xPosition (defaults to 0), a yPosition (defaults to 0), and a behavior function (arbitrary code).
We want to be able to:
* Make multiple instances of the BadGuy class.
* Make different kinds of BadGuy
* Change the details of a BadGuy without actually worrying about how they're implemented too much.
Of course, we can add more stuff later, but let's start simple. I'm going to go through several versions of BadGuy, each with a
problem, and fix the problems one-by-one until we have a prototype.
Step 1: Making the namespace.
We first create a table called BadGuy to hold the names of all the BadGuy object-related stuff. This table is BadGuy's
namespace.
Code: Select all
-- Define our namespace
BadGuy = {}
-- Test this:
function love.load()
print(BadGuy)
end
Tables are the workhorse of all data types in Lua. You can even store functions in tables. This is important.
Step 2: The first steps towards a constructor - dealing with syntactic sugar
Now, we want to be able to spawn new instances of BadGuy, right? So let's do that. Normally, my function declaration for this constructor might look like this:
Code: Select all
-- Constructor
function BadGuy:new()
-- constructor code
end
But this obscures what's going on! Let's take that line "function BadGuy:new()" apart, piece by piece. First, what's the colon doing? It's syntactic sugar, a shorthand for:
Code: Select all
-- Constructor
BadGuy.new = function(self)
-- constructor code
end
But we don't really want to mess around with this "self" thing yet. We'll get to that later; remove it for now. But what does that BadGuy.new mean?
Well, in .lua, a dot is another piece of syntactic sugar. table.key is the same thing as table["key"]
In other words, this prints the string:
Code: Select all
testTable = {["testKey"] = "This string is stored in this table under the key testKey"}
print(testTable.testKey)
There's another piece of syntactic sugar that can impede understanding:
Code: Select all
testTable = {["stringKey"] = "stringValue"}
...is the same thing as...
Code: Select all
testTable = {stringKey = "stringValue"}
stringKey is not a variable, even though it looks like one! It's a string, but the quotes are removed for convenience. In my example, I will NOT use that shortcut, but most examples you see will.
Here's where our code stands when we remove the sugar:
Code: Select all
-- Define our namespace
BadGuy = {}
-- Constructor
BadGuy["new"] = function()
-- constructor code
end
-- Test this:
love["load"] = function()
print(BadGuy)
end
Did you see what I did with our love.load() function? Why did that work?
(Answer: We defined a function with no arguments to run when LOVE loads. And when LOVE loads, apparently it checks the table love for the key "load" and runs the associated function!)
3: What should our constructor construct? It's not obvious at first...
Now, our constructor isn't very useful yet. It doesn't return anything! So... let's try a first crack at this. We want to make BadGuy instances, so... maybe it should return BadGuy? We'll test this, too, using the load routine.
Code: Select all
-- Define our namespace
BadGuy = {}
-- Constructor
BadGuy["new"] = function()
return(BadGuy)
end
-- Test this:
love["load"] = function()
testBadGuy = BadGuy["new"]()
print(testBadGuy)
end
This prints that there's a table, as expected. Did it work? Well, it's too early to pop the champagne, because take a look at what happens when we test a little more carefully:
Code: Select all
-- Define our namespace
BadGuy = {}
-- Constructor
BadGuy["new"] = function()
return(BadGuy)
end
-- Test this:
love["load"] = function()
testBadGuy = BadGuy["new"]()
print(testBadGuy)
anotherBadGuy = BadGuy["new"]()
print(anotherBadGuy)
end
If you run this, you'll see that both variables, testBadGuy and anotherBadGuy... are referring to the exact same table in memory. See, we never actually made a new copy of BadGuy. When you say x = {"1"} and y = x, you just assign the variable y to refer to the table that x is referring to. This can be really messy and confusing when tables start changing, and I still get surprised by what happens.
No, no, what we need is for our constructor to return some kind of BadGuy
object. So let's try that instead. We'll give it default x and y positions, as well as a behavior to perform. Of course, the object will be a table.
Code: Select all
-- Define our namespace
BadGuy = {}
-- Constructor
BadGuy["new"] = function()
local object = {
["xPosition"] = 0,
["yPosition"] = 0,
["behavior"] = function () print("Look! I'm behaving!") end
}
return(object)
end
-- Test this:
love["load"] = function()
testBadGuy = BadGuy["new"]()
print(testBadGuy)
anotherBadGuy = BadGuy["new"]()
print(anotherBadGuy)
anotherBadGuy["behavior"]()
end
Hey, that's pretty cool! We've spawned little objects that have a behavior. But there's STILL a problem. See, a BadGuy instance should have ALL the properties of the BadGuy class, right? It should inherit them. But what about the ability to spawn new BadGuys? Add this line to the test:
and that breaks it. Darn it, we're so close, and we got this far without even using metatables.
4: Metatables!
Here's our dilemma. If we return the internal object table, we can make a bunch of pseudo-BadGuys with the internal properties, but they no longer have the actual properties of a BadGuy. But if we return a BadGuy... it's just the original BadGuy. We need a way to make an instance of BadGuy that inherits the default properties... and any other useful functions, without losing its internal properties. To do that, we need to add just
one more line to our constructor:
What is that line? We'll see soon enough. But first... we need to understand what we're doing! An example may help.
Let's suppose you try the following in your load function:
Code: Select all
testTable = {["testIndex"] = 5}
print(testTable["nonexistentKey"])
This should print nil, because there's no key "nonexistentKey" in the table. But what if we assign it a special behavior when it's checking for indices and doesn't find one? We do this by using the function setmetatable(). The first argument of setmetatable is a table, and the second is basically a list of special behaviors indexed by keys that have double underscores. We're going to affect its "__index" behavior:
Code: Select all
testTable = {["testIndex"] = 5}
setmetatable(testTable, {["__index"] = "This index doesn't exist!"})
print(testTable["nonexistentKey"])
print(testTable["testIndex"])
There! Now it returns "This index doesn't exist" if we give it an index that's not in the table, and returns a 5 if we give it the key. What if we set "__index" to a
table value?
Code: Select all
testTable = {["testIndex"] = "The original value for the key testIndex."}
setmetatable(testTable, {["__index"] =
{["inheritedKey"] = "This key was inherited via a metatable!"},
{["testIndex"] = "Will we see this instead of the original value?"}
}
)
print(testTable["inheritedKey"])
print(testTable["testIndex"])
Well, well, well. We can make it so that a table returns:
*** Values corresponding to its own keys
AND
*** Values corresponding to the keys in another table... if the original table doesn't have those keys.
Do you see how this solves our problem? Our constructor will make an object that is a table of keys and values. If a key is not found... it will return the very table that constructed it instead:
Code: Select all
-- Define our namespace
BadGuy = {}
-- Constructor
BadGuy["new"] = function()
local object = {
["xPosition"] = 0,
["yPosition"] = 0,
["behavior"] = function () print("Look! I'm behaving!") end
}
setmetatable(object, {["__index"] = BadGuy})
return(object)
end
-- Test this:
love["load"] = function()
testBadGuy = BadGuy["new"]()
print(testBadGuy)
anotherBadGuy = BadGuy["new"]()
print(anotherBadGuy)
anotherBadGuy["behavior"]()
cloneBadGuy = anotherBadGuy["new"]()
print(cloneBadGuy["xPosition"])
end
There we go! We're VERY close now... but how do we change the x and y values? We could directly do cloneBadGuy["xPosition"] = newX, but that's not stable. What if you change how your objects store "xPosition"? Should it really be dependent on that specific key name? We can do way better. We'll make it so that every BadGuy has a built-in function to set its x and y positions.
Self: A bit of very useful syntactic sugar.
We gave our BadGuy a constructor function, but there's nothing to stop us from adding another two functions:
Code: Select all
-- Define our namespace
BadGuy = {}
-- Constructor
BadGuy["new"] = function()
local object = {
["xPosition"] = 0,
["yPosition"] = 0,
["behavior"] = function () print("Look! I'm behaving!") end
}
setmetatable(object, {["__index"] = BadGuy})
return(object)
end
-- x, y setter function:
BadGuy["setXY"] = function(objectToSetXYfor, x, y)
-- somehow set the BadGuy's x and y?
end
-- x, y getter function
BadGuy["getXY"] = function(objectToGetXYfrom)
-- somehow return the BadGuy's x and y?
end
-- Test this:
love["load"] = function()
testBadGuy = BadGuy["new"]()
testBadGuy["setXY"]()
testBadGuy["getXY"]()
end
Well, how are we going to fill those out? Well, let's simplify. We know that the object they need to get and set coordinates for is the BadGuy instance itself... so let's just call those arguments
self. And we know what key BadGuy uses to store its x and y positions, so let's add that info. And finally, we know what object we want to get and set the XY of... it's testBadGuy! So let's make those changes. We'll set testBadGuy's coordinates to 50, 100, and print them to see if it worked:
Code: Select all
-- Define our namespace
BadGuy = {}
-- Constructor
BadGuy["new"] = function()
local object = {
["xPosition"] = 0,
["yPosition"] = 0,
["behavior"] = function () print("Look! I'm behaving!") end
}
setmetatable(object, {["__index"] = BadGuy})
return(object)
end
-- x, y setter function:
BadGuy["setXY"] = function(self, x, y)
self["xPosition"] = x
self["yPosition"] = y
end
-- x, y getter function
BadGuy["getXY"] = function(self)
return self["xPosition"], self["yPosition"]
end
-- Test this:
love["load"] = function()
testBadGuy = BadGuy["new"]()
testBadGuy["setXY"](testBadGuy, 50, 100)
print(testBadGuy["getXY"](testBadGuy))
end
There we go! We have some basic getters and setters and an object! Of course, we could make more BadGuys if we wanted. You may now be wondering, "why does this code look so different from examples of objects?" Well, part of the answer is that I removed all the shortcuts and syntactic sugar for clarity. What does it look like with the syntactic sugar replaced?
First, we replace all instances of table["value"] with table.value:
Code: Select all
-- Define our namespace
BadGuy = {}
-- Constructor
BadGuy.new = function()
local object = {
["xPosition"] = 0,
["yPosition"] = 0,
["behavior"] = function () print("Look! I'm behaving!") end
}
setmetatable(object, {["__index"] = BadGuy})
return(object)
end
-- x, y setter function:
BadGuy.setXY = function(self, x, y)
self.xPosition = x
self.yPosition = y
end
-- x, y getter function
BadGuy.getXY = function(self)
return self.xPosition, self.yPosition
end
-- Test this:
love["load"] = function()
testBadGuy = BadGuy.new()
testBadGuy.setXY(testBadGuy, 50, 100)
print(testBadGuy.getXY(testBadGuy))
end
Next we use colon syntax to shorten things further. The colon is just a way of avoiding having to write "self" over and over, or replacing all instances of:
class.functionName = function(self)
with
function class:functionName()
and all instances of
x.functionName(x)
with
x:functionName()
...including any other arguments in parentheses, if need be. And let's make our constructor take self for consistency's sake.
Code: Select all
-- Define our namespace
BadGuy = {}
-- Constructor
function BadGuy:new()
local object = {
["xPosition"] = 0,
["yPosition"] = 0,
["behavior"] = function () print("Look! I'm behaving!") end
}
setmetatable(object, {["__index"] = BadGuy})
return(object)
end
-- x, y setter function:
function BadGuy:setXY(x, y)
self.xPosition = x
self.yPosition = y
end
-- x, y getter function
function BadGuy:getXY()
return self.xPosition, self.yPosition
end
-- Test this:
love["load"] = function()
testBadGuy = BadGuy:new()
testBadGuy:setXY(50, 100)
print(testBadGuy:getXY())
end
And there we go! We now have a simple BadGuy object! Of course, I haven't managed locals vs. globals well, or used the namespace well, or whatever, but this should explain the principles!