Issue within my custom Class module - Lua

Viewed 314

I'm currently working on a module that allows JS-Like classes to be implemented easily in Lua

I've added the ability to extend classes but I'm having an issue with extending classes twice, it seems to be some sort of issue within the super constructor function that's causing a recursive call however I can't seem to figure out where the bug is coming from, any help would be appreciated

Module:

local ClassCreator = {}
ClassCreator.extends = {__extends = true}

local function modifyFnEnv(f, env)
    local fEnv = getfenv(f)
    for i,v in pairs(env) do fEnv[i] = v end
    setfenv(f, fEnv)
end

local ClassMt = {
    __call = function (self, ...)
        local newClass = {}
        setmetatable(newClass, {__index = self.prototype})
        modifyFnEnv(newClass.constructor, {
            self = newClass
        })
        newClass.constructor(...)
        return newClass
    end
}

local function makeClass(self, targs)
    local Class = {
        prototype = {}
    }
    setmetatable(Class, ClassMt)
    local constructor, SuperClass = function() end, Class

    for i,v in pairs(targs) do
        if i == ClassCreator.extends then
            SuperClass = v
            setmetatable(Class.prototype, {__index = SuperClass.prototype})
        elseif i == "constructor" then 
            constructor = v
        else
            Class.prototype[i] = v 
        end
    end
    if SuperClass ~= Class then
        modifyFnEnv(constructor, {
            super = SuperClass.prototype.constructor
        })
    end

    Class.prototype.constructor = constructor
    return Class
end

setmetatable(ClassCreator, {
    __call = makeClass
})



return ClassCreator

Test Code:

local Class = require("Class")
local Human = Class {
    constructor = function(name, age)
        self.name = name
        self.age = age
    end,
    
    describe = function(self)
        return string.format(
            "My name is %s and I am %d years old",
            self.name,
            self.age
        )
    end
}

local Programmer = Class {
    [Class.extends] = Human,
    constructor = function (name, age, yearsOfExperience)
        super(name, age)
        self.yearsOfExperience = yearsOfExperience
    end,

    describe = function(self)
        return string.format(
            "My name is %s and I am %d years old, I have %d years of experience",
            self.name,
            self.age,
            self.yearsOfExperience
        )
    end
}

local LuaProgrammer = Class {
    [Class.extends] = Programmer,
    constructor = function (name, age, yearsOfExperience)
        super(name, age, yearsOfExperience)
    end,

    describe = function(self)
        return string.format(
            "My name is %s and I am %d years old, I have %d years of experience in Lua",
            self.name,
            self.age,
            self.yearsOfExperience
        )
    end
}

local John = Human("John", 35)
local JohnProgrammer = Programmer("John", 35, 5)
local JohnLuaProgrammer = LuaProgrammer("John", 35, 5)

print(John:describe())
print(JohnProgrammer:describe())
print(JohnLuaProgrammer:describe())

Expected output:

My name is John and I am 35 years old
My name is John and I am 35 years old, I have 5 years of experience
My name is John and I am 35 years old, I have 5 years of experience in Lua

Current output:

lua: src/test.lua:20: stack overflow
stack traceback:
        src/test.lua:20: in function 'super'
        src/test.lua:20: in function 'super'
        src/test.lua:20: in function 'super'
        src/test.lua:20: in function 'super'
        src/test.lua:20: in function 'super'
        src/test.lua:20: in function 'super'
        src/test.lua:20: in function 'super'
        src/test.lua:20: in function 'super'
        src/test.lua:20: in function 'super'
        src/test.lua:20: in function 'super'
        ...
        src/test.lua:20: in function 'super'
        src/test.lua:20: in function 'super'
        src/test.lua:20: in function 'super'
        src/test.lua:20: in function 'super'
        src/test.lua:20: in function 'super'
        src/test.lua:20: in function 'super'
        src/test.lua:20: in function 'constructor'
        ./src/Class.lua:17: in function 'Programmer'
        src/test.lua:51: in main chunk
        [C]: ?
1 Answers

The error message tells you that Programmer.constructor recurses infinitely through the call to super. That is, super is Programmer.constructor but should be Human.constructor.

The reason for this is simple: You're messing with function environments (fenv) assuming getfenv to return a different environment for each function. This isn't the case though: By default all functions have the same environment, the global environment _G. You're thus constantly writing to the global environment, overriding previous self and super global variables.

If you fix this, you get a second error since every constructor creates its own table - but only if the metatable is called! Thus when you call super, bypassing the metatable, the function environment still uses the old self instance, overwriting the instance variables.

In general, I'd advise against messing with function environments. It's often bad both for performance and readability. This is another case of messing with function environments gone horribly wrong.

If you really want this as a nice hack to show off Lua's metaprogramming capabilities, this is how I'd rewrite modifyFnEnv:

local function modifyFnEnv(f, env)
    setfenv(f, setmetatable(env, {__index = getfenv(f)}))
end

The metatable with __index set allows accessing the previous function environment, but env takes precedence. Note: If this is called multiply times on the same function, it will grow the index chain each time; you'll probably rather want to replace the function environment.

You don't need the __extends key in the Class.extends table; Lua tables are compared by reference, not by value. You can thus move the key checks out of the loop to improve performance.

Here's the fixed Class.lua:

local ClassCreator = {}
ClassCreator.extends = {}

local function modifyFnEnv(f, env)
    setfenv(f, setmetatable(env, {__index = getfenv(f)}))
end

local ClassMt = {
    __call = function(self, ...)
        local newInstance = {}
        setmetatable(newInstance, {__index = self.prototype})
        getfenv(newInstance.constructor).self = newInstance -- set self
        newInstance.constructor(...)
        return newInstance
    end
}

local function makeClass(self, targs)
    local Class = {
        prototype = {}
    }
    setmetatable(Class, ClassMt)

    local constructor = targs.constructor or function() end
    local constructorEnv = {}
    modifyFnEnv(constructor, constructorEnv)

    local SuperClass = targs[ClassCreator.extends]
    if SuperClass then
        setmetatable(Class.prototype, {__index = SuperClass.prototype})
        function constructorEnv.super(...)
            local super = SuperClass.prototype.constructor
            getfenv(super).self = constructorEnv.self
            super(...)
        end
    end

    -- Copy prototype
    for k, v in pairs(targs) do
        Class.prototype[k] = v
    end
    -- Remove special non-method fields
    Class.prototype[ClassCreator.extends] = nil
    Class.prototype.constructor = nil

    Class.prototype.constructor = constructor
    return Class
end

setmetatable(ClassCreator, {
    __call = makeClass
})

return ClassCreator

Note that I'm changing the constructor function environment exactly once and only modifying it afterwards, e.g. by setting self or super fields.

test.lua is fine as-is. Output as expected:

$ lua5.1 test.lua
My name is John and I am 35 years old
My name is John and I am 35 years old, I have 5 years of experience
My name is John and I am 35 years old, I have 5 years of experience in Lua

Let me repeat: Don't do this in production code.

Related