Dungeon 224
Are we ready to lock classes? Probably not yet.
All yesterday morning was dedicated to creating the ability to lock an object instance so that it can’t add any more member variables, i.e. can’t store any new items into self, with
self.foo = 31
self["foo"] = 31
I think there is a flaw in this implementation. Let’s imagine how we might want to use it:
Foo = class()
function Foo:init(a,b)
self.a = a
self.b = b
self.memory = nil
lock(self)
end
The Foo object wants to reserve a member variable memory
. But if it sets it to nil, we are surely going to get an error.
Let’s write a test for that.
_:test("Nil members work", function()
local f = Foo()
f:lock()
f.x = "xx"
_:expect(f.x).is("xx")
f.memory = 5
_:expect(f.memory).is(5)
end)
function Foo:init()
self.x = "x"
self.y = "y"
self.memory = nil
end
I expect this to throw on the f.memory assignment. Let’s see if it does.
3: Nil members work -- Tests:80: tried to assign memory
Right. We can’t save a nil, because a nil member gets removed from the table. If we had initialized it to false, it would have worked:
3: Nil members work -- OK
3: Nil members work -- OK
What to do? The easy thing to do would be to tell people to init things to other than nil. They’d quickly learn, since their programs would explode. However, that explosion could come a long time in the future, depending on how they use the item. Suppose the cell memory
was intended to be either nil or the URL of the MongoDB database that we only use when the array size gets bigger than 100K. It could be days or weeks before that happened.
The biggest issue I see is that when the self.memory=nil
is done, we can’t see it. The instance isn’t locked yet, so none of our code is executed on that assignment. If we got a call of some kind, we could perhaps save away the fact that memory
is legal. Maybe we’d have a hidden table of things known to be “present” but possibly nil.
Here’s a possibility. Let’s imagine two locking-related functions, lock
, as we have now, and declare
, that sets the instance in a mode where we do trap all assignments, and we let them all execute, but we check to see if they are storing nil, and if so, we add them to an auxiliary table. Then in lock
mode, when we get an unexpected assignment, we check to see if the symbol is in our auxiliary table, and if it is, we let it go through again.
That’s pretty darn fancy, if you ask me. It’s only 0911, so I reckon I can make it work before finishing up this morning. Let’s set that as the plan: I’ll try to extend the current locking spike to have declare
and if I can make it work, I’ll ship it, otherwise I’ll ship it without.
The declare function
As we stand now, lock
is a method. We don’t really want to package it into all our classes, but I suppose we might make a Lockable
class for everyone to inherit from. Yes, that’s the ticket. We’ll work toward that. So that means we can implement declare.
To that end, let’s change our test Foo
class to be written as we intend, with declare
and lock
called in init
:
function Foo:init()
self:declare()
self.x = "x"
self.y = "y"
self.memory = nil
self:lock()
end
function Foo:declare()
end
Now I’ll modify the tests to assume that of Foo
. The first two tests will need reworking:
_:test("Class members", function()
local f = Foo()
_:expect(f.x).is("x")
_:expect(f.y).is("y")
f:create("a")
_:expect(f.a).is("a")
f:lock()
local canCreate = Foo()
_:expect(function() f:create("b") end).throws("tried to assign b")
_:expect(f.b).is(nil)
_:expect(f:ok()).is("ok")
end)
_:test("Multiple classes", function()
local f1 = Foo()
local f2 = Foo()
f1:create("a")
f2:create("a")
f1:lock()
f2:create("b")
_:expect(f2.b).is("b")
_:expect(function() f1:create("b") end).throws("tried to assign b")
_:expect(f1.b).is(nil)
local f3 = Foo()
f3:create("xxx")
_:expect(f3.xxx).is("xxx")
_:expect(function() f1:create("xxx") end).throws("tried to assign xxx")
end)
No, this is no good. We’ll remove the lock
from the init, so that we can do them explicitly as part of these tests. We’ll keep the declare
in init
:
function Foo:init()
self:declare()
self.x = "x"
self.y = "y"
self.memory = nil
end
Now run tests and see what happens. The first two run fine, as I’d hope, and the last one fails only trying to assign memory
to 5. We want declare
to do something to fix that.
Let’s begin with a hack to the lock
metatable, just to test an idea:
function Foo:lock()
local meta = getmetatable(self)
local mt = {}
mt.__newindex = function(obj,key,val)
if mt.__nils[key] then
rawset(obj,key,val)
return
end
error("tried to assign "..key)
end
mt.__nils = { memory=true }
mt.__index = meta
setmetatable(self, mt)
end
I just patched in a new table, __nils
in mt
, and made it contain memory=true
. Then in the __newindex
function, I check to see if the key is in the table, and if it is, do the rawset
function, which stores without invoking the __newindex
. That’s how you do that.
So if we implement declare
to create this table, we should be fine. And we really only need to save away the ones that are being initialized to nil
, because the others will go into the table just fine.
But somehow, we’ll have to get the table entry back when we do lock
. That might be simple enough.
function Foo:declare()
local meta = getmetatable(self)
local mt = {}
mt.__nils = {}
mt.__newindex = function(obj,key,val)
if val == nil then
mt.__nils[key] = true
end
rawset(obj,key,val)
end
mt.__index = meta
setmetatable(self,mt)
end
That should get the data in there, I think. Let’s write a test for it though.
_:test("declare saves `memory`", function()
local f = Foo()
local mt = getmetatable(f)
local tab = mt.__nils
_:expect(tab).has("memory")
end)
I’ll just untimely rip out the metatable and inspect it.
All my tests broke. I think I need for lock
to expect to find a declare table, which means it needs to look into it to find the real one, to link back to it. I’ll try this:
function Foo:lock()
local declareTable = getmetatable(self)
local nils = declareTable.__nils
if not nils then
error("lock without declare")
end
local meta = declareTable.__index
local mt = {}
mt.__nils = nils
mt.__newindex = function(obj,key,val)
if mt.__nils[key] then
rawset(obj,key,val)
return
end
error("tried to assign "..key)
end
mt.__index = meta
setmetatable(self, mt)
end
Here I’m checking to see if the current metatable has a key __nil
and if not, it’s not the declare
table, so we can’t proceed. If it is the declare
table, then whatever is at __index
is the real class metatable, so we fetch that and then did as we did before, except that we save the nils.
The tests run except for the new one:
3: declare saves `memory` -- Actual: table: 0x2800b8740, Expected: memory
I’m not sure why that happened. Let’s review the test:
_:test("declare saves `memory`", function()
local f = Foo()
local mt = getmetatable(f)
local tab = mt.__nils
_:expect(tab).has("memory")
end)
I suspect the table is empty but I’ll check.
It says it has “memory”->true, as expected, via a print:
print("begin")
for k,v in pairs(tab) do
print(k,v)
end
print("end")
Maybe has
doesn’t work as I expect? Right, it would look for the value, not the key. Duh. Change test:
_:test("declare saves `memory`", function()
local f = Foo()
local mt = getmetatable(f)
local tab = mt.__nils
_:expect(tab.memory).is(true)
end)
Tests all good. The declare
and lock
work as intended.
Now to refactor the Foo
class to pull out the new Lockable
class, which is what we actually want to have in the real system.
Lockable = class()
function Lockable:declare()
local meta = getmetatable(self)
local mt = {}
mt.__nils = {}
mt.__newindex = function(obj,key,val)
if val == nil then
mt.__nils[key] = true
end
rawset(obj,key,val)
end
mt.__index = meta
setmetatable(self,mt)
end
function Lockable:lock()
local declareTable = getmetatable(self)
local nils = declareTable.__nils
if not nils then
error("lock without declare")
end
local meta = declareTable.__index
local mt = {}
mt.__nils = nils
mt.__newindex = function(obj,key,val)
if mt.__nils[key] then
rawset(obj,key,val)
return
end
error("tried to assign "..key)
end
mt.__index = meta
setmetatable(self, mt)
end
Foo = class(Lockable)
Tests run. We’re good.
This has turned from a spike into a real thing. I’d better add it to Working Copy. Done.
Now I’ll just copy-paste the Lockable over to D2. It’ll want to be one of the first tabs. I put it just after Main, basically the first class defined.
Let’s commit it: Adding Lockable, unused so far.
What do you think about the tests? Should I perhaps move the whole tab over, maybe making the Foo class local or something? I guess I’ll hold off on that for now, see if we want to change how it works. And keeping it separate is helpful anyway.
I think I’lll wrap up, so that I can put this thing into play when I’m fresh tomorrow. The tests make me think it should work just fine, but until the rubber meets the road we won’t know for sure.
I suspect the code for Lockable could be tidied up a bit but I feel it’s not bad. Good enough for today.
See you next time!