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!


IXnewindex