A Weird Imagination

Metatables for Factorio reflection mod

The problem#

Using my Factorio reflection library discussed previously involves interacting with Lua values that are a combination of the actual value and some metadata, so you have to know about those values to use them. Worse, the interactions I defined are quite verbose. The main thing you're like to want to do on a value is lookup a property on it. Normally in Lua that looks like

table[key]

but if instead of table you have a wrapped value from the reflection library, you would look up key on it with

ReflectionLibraryMod.typed_object_lookup_property(
    wrapped, key).value

If you want to do multiple levels of property lookups, then this quickly gets quite unwieldy.

The solution#

Lua supports operator overloading through a mechanism it calls metatables (some additional examples).

Using that mechanism, the library defines a value ReflectionLibraryMod.typed_data_raw that can be indexed as wrapped[key] and assigned to like wrapped[key] = newValue.

The basic setup looks like

local prototype = {} -- table for methods
local mt = {}
mt.__index = function (table, key)
  local res = prototype[key]
    or ReflectionLibraryMod.wrap_typed_object(
      ReflectionLibraryMod.typed_object_lookup_property(
        table._private, key))
  if res == nil then
    if key == "_value" then
      res = table._private.value
    end
  end
  return res
end

mt.__newindex = function (table, key, newValue)
  -- If newValue is a wrapped typed value, then unwrap it.
  if getmetatable(newValue) == mt then
    newValue = newValue._private.value
  end
  table._private.value[key] = newValue
end

function ReflectionLibraryMod.wrap_typed_object(typedValue)
  if typedValue == nil then
    return nil
  end

  local res = {_private = typedValue}
  setmetatable(res, mt)

  return res
end

Any additional properties would be defined next to the definition of _value. And any methods would be defined on prototype.

The details#

Where to store the real data#

Since Lua only lets us intercept table indexing and assignments on keys that are not present in the table, the only real member of the table is _private. Clients could read _private, but the name makes it clear they're not supposed to. Another way of handling that would be to declare the metatable inside a function such that _private was a variable local to the function, sometimes called the closure approach. There's some disagreement on which way of doing things is better; I figured it's not worth trying to do something complicated to protect the data, I just wanted to limit the keys that could conflict.

Using a closure would look something like this:

function ReflectionLibraryMod.wrap_typed_object(typedValue)
  if typedValue == nil then
    return nil
  end

  -- Define metatable inside this function
  --  so typedValue is in scope.
  local prototype = {}
  local mt = {}

  function mt.__index(table, key)
    return prototype[key]
      or ReflectionLibraryMod.wrap_typed_object(
        ReflectionLibraryMod.typed_object_lookup_property(
          typedValue, key))
  end

  setmetatable(res, mt)

  return res
end

Handling iteration#

The other thing that we need to fake to make our table look like the real one is pairs()/ipairs(). Metatables can control the behavior of those functions, allowing us to enhance the illusion. The code is only a slight modification of this example implementation that works like the default behavior, just iterating over table._private.value instead of table.

Debugger display#

The Factorio modding tools debugger separately supports a custom display of values with some additional metatable functions: __debugline for the string to display and __debugcontents for an iterator over the properties to display. That way, values with metatables can show more (or less) of their information in the debugger or just organize their properties differently. ReflectionLibrary uses this to display the type information in the debugger even though pairs() won't find it when iterating the object.

Declaring methods#

As defined above, the prototype table contains the methods available on any object using the metatable. Methods defined like

prototype._pathString = function (tbl)
  return ReflectionLibraryMod.typed_value_name(tbl._private)
end

can be called using typed:_pathString() (note the : which passes the table the lookup is done on as the first argument to the function).

Handling nil#

One issue that remains is that when indexing into the table, if the value is not present, the return value should be nil or at least count as a false boolean value. But we also want to be able to somehow access the type information on nil properties, at least if they're nil because it's an optional value that's not specified as opposed to being an invalid property name.

In the first iteration, this requires checking ._value == nil which is unidiomatic and awkward. A better, more idiomatic solution (suggested by justarandomgeek, the author of the Factorio Modding ToolKit) would be to use Lua's multiple results feature. The first result could be actually nil for nil lookups while the second result would always be a wrapped value using our metatable, so the type information could be looked up on it.

I have not implemented this change yet, but the basic idea would be to change the mt.__index method to something like this:

mt.__index = function (table, key)
  local res = prototype[key]
  if res ~= nil then
    return res
  end
  res = ReflectionLibraryMod.wrap_typed_object(
      ReflectionLibraryMod.typed_object_lookup_property(
        table._private, key))
  if res == nil then
    if key == "_value" then
      res = table._private.value
    end
    return res
  else
    local firstRes = res
    if res._private.value == nil then
      firstRes = nil
    end
    return firstRes, res
  end
end

Then if wrapped[key] then ... works as you'd expect from a real table, but also you can do _, valueWrapped = wrapped[key] to definitely get a wrapped value, even if it's wrapping nil.

Comments

Have something to add? Post a comment by sending an email to comments@aweirdimagination.net. You may use Markdown for formatting.

There are no comments yet.