This morning I plan to show, in small steps, how and why we move toward objects and classes in SLua and other languages that support the object notion. Wish me luck.
All that we have available in LSL is variables and lists. We do have a few variable types that are in fact what I’d call objects: the vector is one example. A vector in LSL behaves a bit like a number, in the sense that you can add one vector to another with +
:
vector where = <100, 100, 100>;
vector move = <10, 20, 30>;
vector where_now = where + move;
// where_now is <110, 120, 130>
But of course a vector really is a lot like a number, being made up of three numbers, which are each processed separately as we perform operations on vectors. And while adding two vectors is just adding the individual coordinates, other operations, such as multiplying a vector times a rotation, are quite different from ordinary arithmetic.
Still, a vector is a perfectly good example of an object: a bit of computer stuff that contains one or more other things. In the case of the vector, it contains the x, y, and z values of the vector. And an object typically “understands” how to perform certain operations, which can be specified by operators like +
, or by functions that we call methods
of the object. LSL does not have methods on its objects. As we’ll see, SLua does.
In what follows, we’ll “solve” the same problem using variables, arrays, tables, objects, and classes, working up through the chain.
What is the problem? Good question.
Suppose we are writing an SL object that follows a path. We’ll not concern ourselves with the details of motion, just looking at a simple way of describing a path, and a simple way of accessing its information.
Our user wants to express a path as a series of moves, a count of the number of steps, and a vector indicating the direction and amount of motion, like this:
count = 5, direction = <0.25, 0, 0>
count = 10, direction = <0.1, 0.1, 0>
...
Our object moves from time to time and keeps a sort of cursor indicating where it is in the series. We may have done 3 of the 5 steps in the first item, then we do the 4th, then the 5th, then the first step of the second item.
There is, of course, a glitch: when the object gets to the end of the list, if it has not returned to where it started from, we want it to return there.
We’ll write a test, and then a function that produces the sequence of positions we need. We’re assuming for this version that all the variables are at the top level of our script, as they might be in LSL.
Here is my test. I used integer steps for convenience:
function Tests:test_stepper()
base_pos = vector(0,0,0)
current_pos = base_pos
local s1 = {count=3, direction=vector(1,0,0)}
local s2 = {count=2, direction=vector(0,1,0)}
steps = {s1, s2}
step_index=1
move_index=1
local results = {
vector(1,0,0), vector(2,0,0),vector(3,0,0),
vector(3,1,0), vector(3,2,0),
vector(0,0,0)
}
for i, v in ipairs(results) do
next = next_position()
self:assert_equals(next, v, `index {i}`)
end
-- should repeat
for i, v in ipairs(results) do
next = next_position()
self:assert_equals(next, v, `index {i}`)
end
end
And here is the code that passes the test, using variables that are open to the entire script.
local steps
local base_pos
local current_pos
local step_index
local move_index
local function next_position()
if step_index <= #steps then
current_pos += steps[step_index].direction
move_index += 1
if move_index > steps[step_index].count then
move_index = 1
step_index += 1
end
return current_pos
else
step_index = 1
move_index = 1
current_pos = base_pos
return base_pos
end
end
Coded as we usually would in LSL, or as we might, by habit, do in SLua, this function requires five variables at the top level. If we were going to move two such prims, we would additionally need some kind of separate arrangement, perhaps a strided list, which we would use to prime these five variables with the data for the prim we wanted to move.
(There are other possibilities, of course, but suffice it to say that if we have very many things to do that are like this one, the code tends to get littered with global variables in LSL and, in SLua, variables visible to the whole script.)
We can improve the situation quite a bit by using a table to contain all the information for a given mover, like this:
local step_info = {}
local function next_position()
if step_info.step_index <= #step_info.steps then
step_info.current_pos += step_info.steps[step_info.step_index].direction
step_info.move_index += 1
if step_info.move_index > step_info.steps[step_info.step_index].count then
step_info.move_index = 1
step_info.step_index += 1
end
return step_info.current_pos
else
step_info.step_index = 1
step_info.move_index = 1
step_info.current_pos = step_info.base_pos
return step_info.current_pos
end
end
We did have to update the test to do this, and the update was a bit tricky. It looks like this now:
function Tests:test_stepper()
step_info.base_pos = vector(0,0,0)
step_info.current_pos = step_info.base_pos
local s1 = {count=3, direction=vector(1,0,0)}
local s2 = {count=2, direction=vector(0,1,0)}
step_info.steps = {s1, s2}
step_info.step_index=1
step_info.move_index=1
local results = {
vector(1,0,0), vector(2,0,0),vector(3,0,0),
vector(3,1,0), vector(3,2,0),
vector(0,0,0)
}
for i, v in ipairs(results) do
next = next_position()
self:assert_equals(next, v, `index {i}`)
end
-- should repeat
for i, v in ipairs(results) do
next = next_position()
self:assert_equals(next, v, `index {i}`)
end
end
It’s tricky because SLua will be perfectly happy to let you say, which I did:
step_info.base_pos = vector(0,0,0)
step_info.current_pos = base_pos
That compiles just fine, and we get a run-time error because base_pos
is nil because SLua doesn’t find the variable anywhere in the program.
The code in the function is pretty nasty, but I think a lot of the issue is the use of the name step_info
. If we pass the table into the function we can make it look better:
local function next_position(data)
if data.step_index <= #data.steps then
data.current_pos += data.steps[data.step_index].direction
data.move_index += 1
if data.move_index > data.steps[data.step_index].count then
data.move_index = 1
data.step_index += 1
end
return data.current_pos
else
data.step_index = 1
data.move_index = 1
data.current_pos = data.base_pos
return data.current_pos
end
end
That’s pretty good, and even better, if now we have multiple things to move we can give each one its own table and they can all be used independently.
We could do something similar in LSL, passing a list or something to the function, but it would be really messy with all the llList2ThisOrThat
calls. We probably wouldn’t go that way.
So part of our assessment so far surely is “SLua tables can make code more convenient than LSL”. Faint praise, perhaps, but praise.
This section represents something one should probably know how to do, but I myself would likely never do it. Instead, I would do what’s in a following section, namely create a class, even if I only ever wanted one instance. We’ll discuss that below. We can make a small improvement to this code by making our next_position
function into an element of the data table.
We can put the function next_position
right into the step_info
table, like this:
local step_info = {}
step_info.next_position = function(data)
if data.step_index <= #data.steps then
data.current_pos += data.steps[data.step_index].direction
data.move_index += 1
if data.move_index > data.steps[data.step_index].count then
data.move_index = 1
data.step_index += 1
end
return data.current_pos
else
data.step_index = 1
data.move_index = 1
data.current_pos = data.base_pos
return data.current_pos
end
end
And we need to change our test lines that call the next_position
function, to this:
next = step_info:next_position()
There are two aspects to the above that need to be pointed out. First, the next_position
function needs a parameter pointing to the data table, which happens to be the table that also contains the next_position
function.
Second, for our test to find the function, it needs to refer to the step_info
table to find it. We could have said:
next = step_info.next_position(step_info)
But the colon notation actually means “and, by the way, pass what’s in front of me to the function as an argument”.
Functions inside objects, like our next_position
function, are commonly called methods.
What one might like about this is that now the main name space of the program is cluttered only by the single table variable step_info
. All the other names, next_position
, current_pos
, and so on, are hiding inside the table, so we need not be quite so careful as we code other aspects of the script.
One concern, alluded to above, is “Yabbut what if the table already has a key named ‘next_position’, this won’t work!” And yes, that is correct.
The more nearly correct thing to do, if we really wanted to build a single-instance object, would be to put any functions into the object’s metatable and set the object up to use that metatable.
I prefer not to think about that, and much prefer not to do it manually. It is not attractive code and it is easy to get wrong. So I’m including what we just did in the “Object a Table with Functions” section as a thing to understand but generally not to do.
OK, you might do it to learn. I would suggest that our final step, Object via class() is the way to go.
But first, the initialization of our table in the test is rather messy. Let’s provide a handy init
method.
init
Let’s begin by noticing that the setup in our test is rather messy:
function Tests:test_stepper()
step_info.base_pos = vector(0,0,0)
step_info.current_pos = step_info.base_pos
local s1 = {count=3, direction=vector(1,0,0)}
local s2 = {count=2, direction=vector(0,1,0)}
step_info.steps = {s1, s2}
step_info.step_index=1
step_info.move_index=1
Our test really does want to define the table with the two steps in it. It probably wants to define the starting base location, which we set for testing purposes to vector(0,0,0). It doesn’t want to know about the current_pos
, and it certainly has no business knowing how to set up the step_index
and move_index
items.
So let’s provide another method in our table, to do the initialization, given a table of steps and a base location:
step_info.init = function(data, base, steps)
data.base_pos = base
data.current_pos = base
data.steps = steps
data.move_index=1
data.step_index = 1
end
And we use init
like this:
function Tests:test_stepper()
local s1 = {count=3, direction=vector(1,0,0)}
local s2 = {count=2, direction=vector(0,1,0)}
step_info:init(vector(0,0,0), {s1, s2})
local results = {
vector(1,0,0), vector(2,0,0),vector(3,0,0),
vector(3,1,0), vector(3,2,0),
vector(0,0,0)
}
for i, v in ipairs(results) do
...
Adding the init method is convenient and allows the table step_info
to manage its own internal affairs, while allowing its users to concern themselves only with what they care about, the base location and the steps to be followed.
I think I’d argue that this arrangement of our step_info
table is easier to use, but rather obscure and tricky to set up. Which leads us to my preferred way.
class()
My SLua framework includes a function named class()
, and for our present situation we would use it like this:
local Stepper = class()
function Stepper:init(base, steps)
self.base_pos = base
self.current_pos = base
self.steps = steps
self.move_index=1
self.step_index = 1
end
function Stepper:next_position()
if self.step_index <= #self.steps then
self.current_pos += self.steps[self.step_index].direction
self.move_index += 1
if self.move_index > self.steps[self.step_index].count then
self.move_index = 1
self.step_index += 1
end
return self.current_pos
else
self.step_index = 1
self.move_index = 1
self.current_pos = self.base_pos
return self.current_pos
end
end
This is almost exactly like what we had before, except that we define Stepper as a class, and use self
where we had data
before. And, notably, we define the functions using Stepper:
, which means that they are automatically defined by SLua to have the variable self
set up to be the table object used in the calls to the function. So in our test, our only change is this:
function Tests:test_stepper()
local s1 = {count=3, direction=vector(1,0,0)}
local s2 = {count=2, direction=vector(0,1,0)}
step_info = Stepper(vector(0,0,0), {s1, s2})
Notice that we put the parameters for the init directly after th class name, as if we sere calling a function named Stepper
that calls Stepper:init
. (And that is exactly what is going on behind the curtain, by the way.)
This says “make step_info
an instance of the class Stepper, with base at <0,0,0> and these steps”.
In the references to next_position
we make no change from last time. They are still:
next = step_info:next_position()
With the previous setup, the object with an init
and next_position
function, we had everything encapsulated in step_info
. If we wanted to move two things, we would have had to set up a second table like the step_info
table, including adding those functions to it.
By simply saying Stepper=class()
, we define Stepper to be a thing such that when we say Stepper(vector(0,0,0),{s1,s2})
, we get back a table that behaves just like step_info
, but we can have as many as we wish.
Furthermore, the way we define our classes will always be just the same:
Stepper=class()
.Stepper:init
to take whatever initial values are needed and store them into self
members.next_position
needed in your object, define a function like Stepper:next_position()
.my_stepper=Stepper(base, tab)
.Naturally, I think that what I do is really good and that everyone should do as I do. I also know that what I really do is try to do the things that work well for me, and that they work well, not just because they are pretty good ideas—none of which I have originated by the way—but also because I have practiced them a lot.
So I invite you to practice the things that seem to work for you, and maybe to spice up your work by trying a few things that seem like maybe good ideas, so that you can advance in new directions. If you want to.
I try to do everything with simple tests. That’s why my starting Lua framework includes the Tests object. With today’s work, four or five different ways of doing the same thing, I used my single test, adjusting it to the new scheme only slightly, and watched it first fail—because I do make mistakes—and then succeed, at which point I wrote up what we had.
Writing small tests, seeing them fail, then seeing them work, gives me more confidence in what I’m doing. I have them set up to run every time I save the script, and I save the script as soon as I think they’ll all run, or when I want to see how they’ll fail.
I much prefer to work with small classes and small objects, with clearly-defined responsibilities and carefully tested methods. Just as small tests help me make continuous progress, small classes and methods capture that progress in little packages that are easier to understand, use, and, when necessary, modify.
Our little Stepper class is an example. In an typical LSL script, there would be data items somewhere in the script, possibly at least grouped together textually, representing each time we needed a stepper. And there would be functions, perhaps grouped together, for doing the work. But there is no other grouping in LSL, and we resort often to comment blocks, alphabetization, or just searching around.
Our stepper class is compact, its members and methods are all readily identifiable, as they are all inside Stepper, and, best of all, it has an associated test so that if we set out to change or improve it, we can be confident that we’ve done what we wanted.
Small is beautiful to me. Small tests, small objects, defined via classes These ideas can’t always apply but when I manage to do it, I get results that I can feel proud of, and I get things working sooner than when I don’t work that way.
Safe paths!