CC: Tweaked

CC: Tweaked

42M Downloads

Modify _G.type, cc.expect, etc. with custom types

sorucoder opened this issue ยท 12 comments

commented

As CC:Tweaked has included custom "classes," it would be useful to check types for those classes (as well as user defined ones). I imagine this could work using a __type metatable field of type string, and then _G.type would return that if it has one.

Example:

local currentTerm = term.current()
print(type(currentTerm))

Output:

term.Redirect
commented

I don't think type's behaviour should change, but we could switch to Lua 5.3+'s behaviour of using using the __name metamethod in error messages (see the bottom of this manual section.

commented

@SquidDev

we could switch to Lua 5.3+'s behaviour of using using the __name metamethod in error messages (see the bottom of this manual section.

I agree with this suggestion. Instead of modifying _G.type, how about a module that checks for types using the __name metafield?

commented

type does not return the name from the metatable's __name field. The code needs to check against this display name explicitly.

commented

No, that's intentional. __name is only used for error messages, it shouldn't be used to drive any logic - there's no guarantee that two values with the same __name are the same type.

commented

No, that's intentional. __name is only used for error messages, it shouldn't be used to drive any logic - there's no guarantee that two values with the same __name are the same type.

I was hoping for a way to detect for things like term.Redirect for custom libraries and such. Sure, I could check for the methods I need, but that seems to be more work than it would be to just check the __name field. I wasn't looking for a guarantee per se, just a verification. If some one wants to pull a dirty trick to change the name field, I'd consider that undefined behavior, and going out of one's way to get code to run.

For a concrete example, I would like to do:

function texture:render(parent, ...)
   parent = expect(1, parent, "nil", "term.Redirect") or term.current()
   -- render the texture to parent...
end

instead of

function texture:render(parent, ...)
   parent = expect(1, parent, "nil", "table") or term.current() do
       expect.field(parent, "setCursorPos", "function")
       expect.field(parent, "blit", "function")
       -- whatever else would be needed...
   end
   -- render the texture to parent...
end
commented

Checking the names is what term.redirect does. The lazy way for user code to check is to just call redirect so that it does the check and then if you didn't want to switch term object then redirect back to what you were using.

commented

Checking the names is what term.redirect does. The lazy way for user code to check is to just call redirect so that it does the check and then if you didn't want to switch term object then redirect back to what you were using.

Okay, but what about http.Websocket or io.Handle? Calling pcall to see if they are what they say they are is even more intense than just checking for needed methods (or at least, more code than necessary).

Returning these objects with the metatable information necessary seems like a trivial feature to implement. Someone HAS to go out of their way to set the type to something identical. And it also does not harm the original functionality.

Also, I just tested that. If passed an empty table, term.Redirect throws an error stating that it does not have the supported method, and terminates the computer. So, no, that's not the way to go about it.

commented

Okay, but what about http.Websocket or io.Handle? Calling pcall to see if they are what they say they are is even more intense than just checking for needed methods (or at least, more code than necessary).

Why are you needing to pcall these values? If you run http.websocket, it returns either a websocket object or false. You can just do

local ws, err = http.websocket("bla")
if ws then
  -- It returned a websocket object
else
  printError(err)
end

I'm not seeing why you would need to pcall it.

Edit: Oh, in a library implementation, so you can check the inputted object is what is expected. I understand now.

commented

If some one wants to pull a dirty trick to change the name field, I'd consider that undefined behaviour, and going out of one's way to get code to run.

I think my concern is less people doing dirty tricks, and more that it's not very fool-proof. I think the risk is less people impersonating types, and more two libraries accidentally using the same type name - unless you force people to write the fully qualified type name, that's going to happen :).

An alternative solution here would be to check value's metatable directly (or some equivalent type handle). This is what what we do in cc.pretty and io1, and it would be pretty easy to extend cc.expect to support that more directly. I'm not entirely sure this is the right path either - it's pretty clunky, especially for consumers of these libraries wanting to check types.

Footnotes

  1. Not sure why this doesn't use cc.expect, it really should. โ†ฉ

commented

Of course, all of this is made much nastier by the fact that CC uses a mixture of nominal and structural types, and there's not really any rule about which one to prefer. I think my vague thoughts are

  • Functions which work with the internal/private structure of an object should use nominal types. Examples here are cc.pretty or io, as they rely on all sorts of implementation details that arbitrary objects won't conform to.

    The natural follow-on from there, is that anything which calls these functions should also use nominal types for its type checks.

  • Anything which cares about the public API an object exposes, rather than its internal details, should just validate the object has the correct structure. This is mostly what Lua does already (e.g. table functions check the object supports indexing and the length operator).

    We should probably make this sort of thing easier by adding various methods to check an object quacks like it should (e.g. term.isRedirect).

commented

SquidDev wrote:

I think my concern is less people doing dirty tricks, and more that it's not very fool-proof. I think the risk is less people impersonating types, and more two libraries accidentally using the same type name - unless you force people to write the fully qualified type name, that's going to happen :).

I'm in favor of writing out a FQDN. After all, we're expected to with require. So instead of term.Redirect, http.Websocket, and io.Handle, it would be cc.term.Redirect, cc.http.Websocket, and cc.io.Handle.

SquidDev wrote:

Of course, all of this is made much nastier by the fact that CC uses a mixture of nominal and structural types, and there's not really any rule about which one to prefer. I think my vague thoughts are

* Functions which work with the internal/private structure of an object should use nominal types. Examples here are `cc.pretty` or `io`, as they rely on all sorts of implementation details that arbitrary objects won't conform to.
  The natural follow-on from there, is that anything which calls these functions should also use nominal types for its type checks.

* Anything which cares about the public API an object exposes, rather than its internal details, should just validate the object has the correct structure. This is mostly what Lua does already (e.g. `table` functions check the object supports indexing and the length operator).
  We should probably make this sort of thing easier by adding various methods to check an object quacks like it should (e.g. `term.isRedirect`).

What I would propose is a type registry system in cc.expect. Then cc.expect could register the standard library nominal types and structural types. Then, cc.expect.expect would use the name to either check the metatable field __name for nominal types, or validate the structure for structure types (both could be done using an internal table that maps strings to functions).

commented

Here's a basic proposal of what I was thinking:

local expect = {} do
    -- Registry for nominal and structural types.
    -- Keys are the type names.
    -- If value is a boolean, the type is checked nominally.
    --  If the value is true, the result of _G.type will be checked against.
    --  If the value is false, the __name field of the value's metatable will be checked against.
    -- If value is a function, the type is checked structurally. The function should take on parameter and return a boolean.
    local registry = {}
    registry["nil"] = true
    registry["boolean"] = true
    registry["number"] = true
    registry["string"] = true
    registry["function"] = true
    registry["thread"] = true
    registry["userdata"] = true
    registry["table"] = true
    local function isType(value)
        for registryName, registryPredicate in pairs(registry) do
            if value == registryName then
                return true
            end
        end
        return false
    end
    registry["type"] = isType

    local getPrimitiveType = type

    local function getNominalType(value)
        if type(value) == "table" then
            local metatableOfValue = getmetatable(value)
            if metatableOfValue then
                return metatableOfValue.__name or "object"
            end
        end
    end

    local function expectType(position, type)
        local typeOfValue = getPrimitiveType(type)
        if typeOfValue ~= "string" then
            error(string.format("bad argument #%d (expected type; got %s)", position, typeOfValue), 3)
        elseif not isType(type) then
            error(string.format("bad argument #%d (unregistered type %s)", position, type), 3)
        end
        return type
    end

    local function formatTypeSpecification(...)
        local nonNilTypes = {}
        local optional = false
        for typeIndex = 1, select("#", ...) do
            local type = select(typeIndex, ...)
            if type ~= "nil" then
                table.insert(nonNilTypes, type)
            else
                optional = true
            end
        end

        local specification = table.concat(nonNilTypes, "|")
        if optional then
            specification = specification .. "?"
        end

        return specification
    end

    function expect.register(name, predicate)
        do
            local typeOfName = type(name)
            if typeOfName ~= "string" then
                error(string.format("bad argument #1 (expected string; got %s)", typeOfName), 2)
            end
        end
        do
            local typeOfPredicate = type(name)
            if typeOfPredicate ~= "nil" and typeOfPredicate ~= "function" then
                error(string.format("bad argument #2 (expected function?; got %s)", typeOfPredicate), 2)
            end
        end

        registry[name] = predicate or false
    end

    function expect.expect(position, value, ...)
        do
            local typeOfPosition = type(position)
            if typeOfPosition ~= "number" then
                error(string.format("bad argument #1 (expected number; got %s)", typeOfPosition), 2)
            end
        end

        -- Check types are in registry and check value against type
        local gotPrimitiveType = getPrimitiveType(value)
        local gotNominalType = getNominalType(value)
        for expectedTypeIndex = 1, select("#", ...) do
            local expectedType = expectType(expectedTypeIndex + 2, select(expectedTypeIndex, ...))
            local registryPredicate = registry[expectedType]
            if registryPredicate == true then
                if expectedType == gotPrimitiveType then
                    return value
                end
            elseif registryPredicate == false then
                if expectedType == gotNominalType then
                    return value
                end
            elseif registryPredicate(value) then
                return value
            end
        end

        -- If we can determine the function name with a high level of confidence, try to include it.
        local functionName do
            local success, info = pcall(debug.getinfo, 3, "nS")
            if success and
                info.name and
                info.name ~= "" and
                info.what ~= "C"
            then
                functionName = info.name
            end
        end

        if functionName then
            error(string.format("bad argument #%d to \"%s\" (expected %s; got %s)", position, functionName, formatTypeSpecification(...), gotNominalType or gotPrimitiveType), 3)
        else
            error(string.format("bad argument #%d (expected %s; got %s)", position, formatTypeSpecification(...), gotNominalType or gotPrimitiveType), 3)
        end
    end
end
return setmetatable(expect, {__call = function(_, ...) expect.expect(...) end})