Search code examples
lualua-tablemetatablelua-5.2

Hiding a Lua metatable and only exposing an object's attributes


How do you create a Lua object that only exposes its attributes and not its methods? For example:

local obj = {
  attr1 = 1,
  attr2 = 2,
  print = function(...)
    print("obj print: ", ...)
  end,
}

Produces:

> for k,v in pairs(obj) do print(k, v) end
attr1   1
attr2   2
print   function: 0x7ffe1240a310

Also, is it possible to not use the colon syntax for OOP in Lua? I don't need inheritance, polymorphism, only encapsulation and privacy.


Solution

  • I started out with the above question and after chasing down the rabbit hole, I was surprised by the limited number of examples, lack of examples for the various metamethods (i.e. __ipairs, __pairs, __len), and how few Lua 5.2 resources there were on the subject.

    Lua can do OOP, but IMO the way that OOP is prescribed is a disservice to the language and community (i.e. in such a way as to support polymorphism, multiple inheritance, etc). There are very few reasons to use most of Lua's OOP features for most problems. It doesn't necessarily mean there's a fork in the road either (e.g. in order to support polymorphism there's nothing that says you have to use the colon syntax - you can fold the literature's described techniques in to the closure-based OOP method).

    I appreciate that there are lots of ways to do OOP in Lua, but it's irritating to have there be different syntax for object attributes versus object methods (e.g. obj.attr1 vs obj:getAttr() vs obj.method() vs obj:method()). I want a single, unified API to communicate internally and externally. To that end, PiL 16.4's section on Privacy is a fantastic start, but it's an incomplete example that I hope to remedy with this answer.


    The following example code:

    • emulates a class's namespace MyObject = {} and saves the object constructor as MyObject.new()
    • hides all of the details of the objects inner workings so that a user of an object only sees a pure table (see setmetatable() and __metatable)
    • uses closures for information hiding (see Lua Pil 16.4 and Object Benchmark Tests)
    • prevents modification of the object (see __newindex)
    • allows for methods to be intercepted (see __index)
    • lets you get a list of all of the functions and attributes (see the 'key' attribute in __index)
    • looks, acts, walks, and talks like a normal Lua table (see __pairs, __len, __ipairs)
    • looks like a string when it needs to (see __tostring)
    • works with Lua 5.2

    Here's the code to construct a new MyObject (this could be a standalone function, it doesn't need to be stored in the MyObject table - there is absolutely nothing that ties obj once its created back to MyObject.new(), this is only done for familiarity and out of convention):

    MyObject = {}
    MyObject.new = function(name)
       local objectName = name
    
       -- A table of the attributes we want exposed
       local attrs = {
          attr1 = 123,
       }
    
       -- A table of the object's methods (note the comma on "end,")
       local methods = {
          method1 = function()
             print("\tmethod1")
          end,
    
          print = function(...)
             print("MyObject.print(): ", ...)
          end,
    
          -- Support the less than desirable colon syntax
          printOOP = function(self, ...)
             print("MyObject:printOOP(): ", ...)
          end,
       }
    
       -- Another style for adding methods to the object (I prefer the former
       -- because it's easier to copy/paste function()'s around)
       function methods.addAttr(k, v)
          attrs[k] = v
          print("\taddAttr: adding a new attr: " .. k .. "=\"" .. v .. "\"")
       end
    
       -- The metatable used to customize the behavior of the table returned by new()
       local mt = {
          -- Look up nonexistent keys in the attrs table. Create a special case for the 'keys' index
          __index = function(t, k)
             v = rawget(attrs, k)
             if v then
                print("INFO: Successfully found a value for key \"" .. k .. "\"")
                return v
             end
             -- 'keys' is a union of the methods and attrs
             if k == 'keys' then
                local ks = {}
                for k,v in next, attrs, nil do
                   ks[k] = 'attr'
                end
                for k,v in next, methods, nil do
                   ks[k] = 'func'
                end
                return ks
             else
                print("WARN: Looking up nonexistant key \"" .. k .. "\"")
             end
          end,
    
          __ipairs = function()
             local function iter(a, i)
                i = i + 1
                local v = a[i]
                if v then
                   return i, v
                end
             end
             return iter, attrs, 0
          end,
    
          __len = function(t)
             local count = 0
             for _ in pairs(attrs) do count = count + 1 end
             return count
          end,
    
          __metatable = {},
    
          __newindex = function(t, k, v)
             if rawget(attrs, k) then
                print("INFO: Successfully set " .. k .. "=\"" .. v .. "\"")
                rawset(attrs, k, v)
             else
                print("ERROR: Ignoring new key/value pair " .. k .. "=\"" .. v .. "\"")
             end
          end,
    
          __pairs = function(t, k, v) return next, attrs, nil end,
    
          __tostring = function(t) return objectName .. "[" .. tostring(#t) .. "]" end,
       }
       setmetatable(methods, mt)
       return methods
    end
    

    And now the usage:

    -- Create the object
    local obj = MyObject.new("my object's name")
    
    print("Iterating over all indexes in obj:")
    for k,v in pairs(obj) do print('', k, v) end
    print()
    
    print("obj has a visibly empty metatable because of the empty __metatable:")
    for k,v in pairs(getmetatable(obj)) do print('', k, v) end
    print()
    
    print("Accessing a valid attribute")
    obj.print(obj.attr1)
    obj.attr1 = 72
    obj.print(obj.attr1)
    print()
    
    print("Accessing and setting unknown indexes:")
    print(obj.asdf)
    obj.qwer = 123
    print(obj.qwer)
    print()
    
    print("Use the print and printOOP methods:")
    obj.print("Length: " .. #obj)
    obj:printOOP("Length: " .. #obj) -- Despite being a PITA, this nasty calling convention is still supported
    
    print("Iterate over all 'keys':")
    for k,v in pairs(obj.keys) do print('', k, v) end
    print()
    
    print("Number of attributes: " .. #obj)
    obj.addAttr("goosfraba", "Satoshi Nakamoto")
    print("Number of attributes: " .. #obj)
    print()
    
    print("Iterate over all keys a second time:")
    for k,v in pairs(obj.keys) do print('', k, v) end
    print()
    
    obj.addAttr(1, "value 1 for ipairs to iterate over")
    obj.addAttr(2, "value 2 for ipairs to iterate over")
    obj.addAttr(3, "value 3 for ipairs to iterate over")
    obj.print("ipairs:")
    for k,v in ipairs(obj) do print(k, v) end
    
    print("Number of attributes: " .. #obj)
    
    print("The object as a string:", obj)
    

    Which produces the expected - and poorly formatted - output:

    Iterating over all indexes in obj:
        attr1   123
    
    obj has a visibly empty metatable because of the empty __metatable:
    
    Accessing a valid attribute
    INFO: Successfully found a value for key "attr1"
    MyObject.print():   123
    INFO: Successfully set attr1="72"
    INFO: Successfully found a value for key "attr1"
    MyObject.print():   72
    
    Accessing and setting unknown indexes:
    WARN: Looking up nonexistant key "asdf"
    nil
    ERROR: Ignoring new key/value pair qwer="123"
    WARN: Looking up nonexistant key "qwer"
    nil
    
    Use the print and printOOP methods:
    MyObject.print():   Length: 1
    MyObject.printOOP():        Length: 1
    Iterate over all 'keys':
        addAttr func
        method1 func
        print   func
        attr1   attr
        printOOP        func
    
    Number of attributes: 1
        addAttr: adding a new attr: goosfraba="Satoshi Nakamoto"
    Number of attributes: 2
    
    Iterate over all keys a second time:
        addAttr func
        method1 func
        print   func
        printOOP        func
        goosfraba       attr
        attr1   attr
    
        addAttr: adding a new attr: 1="value 1 for ipairs to iterate over"
        addAttr: adding a new attr: 2="value 2 for ipairs to iterate over"
        addAttr: adding a new attr: 3="value 3 for ipairs to iterate over"
    MyObject.print():   ipairs:
    1   value 1 for ipairs to iterate over
    2   value 2 for ipairs to iterate over
    3   value 3 for ipairs to iterate over
    Number of attributes: 5
    The object as a string: my object's name[5]
    

    • Using OOP + closures is very convenient when embedding Lua as a facade or documenting an API.
    • Lua OOP can also be very, very clean and elegant (this is subjective, but there aren't any rules with this style - you always use a . to access either an attribute or a method)
    • Having an object behave exactly like a table is VERY, VERY useful for scripting and interrogating the state of a program
    • Is extremely useful when operating in a sandbox

    This style does consume slightly more memory per object, but for most situations this isn't a concern. Factoring out the metatable for reuse would address this, though the example code above doesn't.

    A final thought. Lua OOP is actually very nice once you dismiss most of the examples in the literature. I'm not saying the literature is bad, btw (that couldn't be further from the truth!), but the set of sample examples in PiL and other online resources lead you to using only the colon syntax (i.e. the first argument to all functions is self instead of using a closure or upvalue to retain a reference to self).

    Hopefully this is a useful, more complete example.


    Update (2013-10-08): There is one notable drawback to the closure-based OOP style detailed above (I still think the style is worth the overhead, but I digress): each instance must have its own closure. While this is obvious in the above lua version, this becomes slightly problematic when dealing with things on the C-side.

    Assume we're talking about the above closure style from the C-side from here on out. The common case on the C side is to create a userdata via lua_newuserdata() object and attach a metatable to the userdata via lua_setmetatable(). On face value this doesn't appear like a problem until you realize that methods in your metatable require an upvalue of the userdata.

    using FuncArray = std::vector<const ::luaL_Reg>;
    static const FuncArray funcs = {
      { "__tostring", LI_MyType__tostring },
    };
    
    int LC_MyType_newInstance(lua_State* L) {
      auto userdata = static_cast<MyType*>(lua_newuserdata(L, sizeof(MyType)));
      new(userdata) MyType();
    
      // Create the metatable
      lua_createtable(L, 0, funcs.size());     // |userdata|table|
      lua_pushvalue(L, -2);                    // |userdata|table|userdata|
      luaL_setfuncs(L, funcs.data(), 1);       // |userdata|table|
      lua_setmetatable(L, -2);                 // |userdata|
      return 1;
    }
    
    int LI_MyType__tostring(lua_State* L) {
      // NOTE: Blindly assume that upvalue 1 is my userdata
      const auto n = lua_upvalueindex(1);
      lua_pushvalue(L, n);                     // |userdata|
      auto myTypeInst = static_cast<MyType*>(lua_touserdata(L, -1));
      lua_pushstring(L, myTypeInst->str());    // |userdata|string|
      return 1;                                // |userdata|string|
    }
    

    Note how the table created with lua_createtable() didn't get associated with a metatable name the same as if you would have registered the metatable with luaL_getmetatable()? This is 100% a-okay because these values are completely inaccessible outside of the closure, but it does mean that luaL_getmetatable() can't be used to look up a particular userdata's type. Similarly, this also means that luaL_checkudata() and luaL_testudata() are also off limits.

    The bottom line is that upvalues (such as userdata above) are associated with function calls (e.g. LI_MyType__tostring) and are not associated with the userdata itself. As of now, I'm not aware of a way in which you can associate an upvalue with a value such that it becomes possible to share a metatable across instances.


    UPDATE (2013-10-14) I'm including a small example below that uses a registered metatable (luaL_newmetatable()) and also lua_setuservalue()/lua_getuservalue() for a userdata's "attributes and methods". Also adding random comments that have been the source of bugs/hotness that I've had to hunt down in the past. Also threw in a C++11 trick to help with __index.

    namespace {
    
    using FuncArray = std::vector<const ::luaL_Reg>;
    static const std::string MYTYPE_INSTANCE_METAMETHODS{"goozfraba"}; // I use a UUID here
    static const FuncArray MyType_Instnace_Metamethods = {
      { "__tostring", MyType_InstanceMethod__tostring },
      { "__index",    MyType_InstanceMethod__index },
      { nullptr,      nullptr }, // reserve space for __metatable
      { nullptr, nullptr } // sentinel
    };
    
    static const FuncArray MyType_Instnace_methods = {
      { "fooAttr", MyType_InstanceMethod_fooAttr },
      { "barMethod", MyType_InstanceMethod_barMethod },
      { nullptr, nullptr } // sentinel
    };
    
    // Must be kept alpha sorted
    static const std::vector<const std::string> MyType_Instance___attrWhitelist = {
      "fooAttr",
    };
    
    static int MyType_ClassMethod_newInstance(lua_State* L) {
      // You can also use an empty allocation as a placeholder userdata object
      // (e.g. lua_newuserdata(L, 0);)
      auto userdata = static_cast<MyType*>(lua_newuserdata(L, sizeof(MyType)));
      new(userdata) MyType(); // Placement new() FTW
    
      // Use luaL_newmetatable() since all metamethods receive userdata as 1st arg
      if (luaL_newmetatable(L, MYTYPE_INSTANCE_METAMETHODS.c_str())) { // |userdata|metatable|
        luaL_setfuncs(L, MyType_Instnace_Metamethods.data(), 0); // |userdata|metatable|
    
        // Prevent examining the object: getmetatable(MyType.new()) == empty table
        lua_pushliteral(L, "__metatable");     // |userdata|metatable|literal|
        lua_createtable(L, 0, 0);              // |userdata|metatable|literal|table|
        lua_rawset(L, -3);                     // |userdata|metatable|
      }
    
      lua_setmetatable(L, -2);                 // |userdata|
    
      // Create the attribute/method table and populate with one upvalue, the userdata
      lua_createtable(L, 0, funcs.size());     // |userdata|table|
      lua_pushvalue(L, -2);                    // |userdata|table|userdata|
      luaL_setfuncs(L, funcs.data(), 1);       // |userdata|table|
    
      // Set an attribute that can only be accessed via object's fooAttr, stored in key "fooAttribute"
      lua_pushliteral(L, "foo's value is hidden in the attribute table"); // |userdata|table|literal|
      lua_setfield(L, -2, "fooAttribute");     // |userdata|table|
    
      // Make the attribute table the uservalue for the userdata
      lua_setuserdata(L, -2);                  // |userdata|
      return 1;
    }
    
    static int MyType_InstanceMethod__tostring(lua_State* L) {
      // Since we're using closures, we can assume userdata is the first value on the stack.
      // You can't make this assumption when using metatables, only closures.
      luaL_checkudata(L, 1, MYTYPE_INSTANCE_METAMETHODS.c_str()); // Test anyway
      auto myTypeInst = static_cast<MyType*>(lua_touserdata(L, 1));
      lua_pushstring(L, myTypeInst->str());    // |userdata|string|
      return 1;                                // |userdata|string|
    }
    
    static int MyType_InstanceMethod__index(lua_State* L) {
      lua_getuservalue(L, -2);        // |userdata|key|attrTable|
      lua_pushvalue(L, -2);           // |userdata|key|attrTable|key|
      lua_rawget(L, -2);              // |userdata|key|attrTable|value|
      if (lua_isnil(L, -1)) {         // |userdata|key|attrTable|value?|
        return 1;                     // |userdata|key|attrTable|nil|
      }
    
      // Call cfunctions when whitelisted, otherwise the caller has to call the
      // function.
      if (lua_type(L, -1) == LUA_TFUNCTION) {
        std::size_t keyLen = 0;
        const char* keyCp = ::lua_tolstring(L, -3, &keyLen);
        std::string key(keyCp, keyLen);
    
        if (std::binary_search(MyType_Instance___attrWhitelist.cbegin(),
                               MyType_Instance___attrWhitelist.cend(), key))
        {
          lua_call(L, 0, 1);
        }
      }
    
      return 1;
    }
    
    static int MyType_InstanceMethod_fooAttr(lua_State* L) {
      // Push the uservalue on to the stack from fooAttr's closure (upvalue 1)
      lua_pushvalue(L, lua_upvalueindex(1)); // |userdata|
      lua_getuservalue(L, -1);               // |userdata|attrTable|
    
      // I haven't benchmarked whether lua_pushliteral() + lua_rawget()
      // is faster than lua_getfield() - (two lua interpreter locks vs one lock + test for
      // metamethods).
      lua_pushliteral(L, "fooAttribute");    // |userdata|attrTable|literal|
      lua_rawget(L, -2);                     // |userdata|attrTable|value|
    
      return 1;
    }
    
    static int MyType_InstanceMethod_barMethod(lua_State* L) {
      // Push the uservalue on to the stack from barMethod's closure (upvalue 1)
      lua_pushvalue(L, lua_upvalueindex(1)); // |userdata|
      lua_getuservalue(L, -1);               // |userdata|attrTable|
    
      // Push a string to finish the example, not using userdata or attrTable this time
      lua_pushliteral(L, "bar() was called!"); // |userdata|attrTable|literal|
    
      return 1;
    }
    
    } // unnamed-namespace
    

    The lua script side of things looks something like:

    t = MyType.new()
    print(typue(t))    --> "userdata"
    print(t.foo)       --> "foo's value is hidden in the attribute table"
    print(t.bar)       --> "function: 0x7fb560c07df0"
    print(t.bar())     --> "bar() was called!"