FakeTable: Supercharge your metatables

The Scoop

A while back, I created a custom table wrapper called FakeTable for my OOP library and ECS implementation (which was a terrible idea in hindsight). After forgetting about it for a month, I stumbled upon the wrapper again while reviewing old code and decided to make it a resource after some modifications.

Ok, What Even is This Anyways?

FakeTable serves as a table wrapper, intended to replicate the behaviors of metatables along with a few extra features.

FakeTable adds two new custom events (what lua calls things such as __index) plus a default but currently non-functional one: __get, __set, and __len. __get works exactly like __index, except it works only for non-empty fields. The same is true for __set and its counterpart __newindex. __len should behave exactly as it would for normal tables/userdatas.

FakeTable also allows using a custom t1 (or self as I like to call it) for additional control over metamethods (I don’t know how to make this trick work with normal functions though, so self still always gives you the FakeTable object). fakeSelf by default is the original table you give to the constructor.

That means that ft:foo() has ft as its self argument, rather than fakeSelf. However, metatable.__tostring = function(self) would have its self replaced by fakeSelf.

Documentation

--[[
Most of ftable's methods are carried over from table or Lua because they 
don't work with FakeTable, so I'll just mention them here since usage
should basically be the same.

.pairs()
.ipairs()
.getn()
.insert()
.remove()
.sort()
.clear()
.find()

]]

-- Constructs a FakeTable object
ftable.wrap(meat, metatable, fakeSelf)

-- Use for printing an ftable state
ftable.print(...)

Ok, Is There a Catch?

You bet! The construction of FakeTables is very slow, about 4x slower than using Instance.new("Part"). FakeTable is also a “double wrapper” because it wraps a metatable which wraps around an table, making it a little bit slower than a normal table. This can be mitigated, but I didn’t spend the time to do so yet.

Other Problems

  • Using methods such as ftable.insert and ftable.remove do not call any metamethods.
  • Minimal testing. Potentially has tons of unknown bugs/unintended behaviors

Source Code:

Put this in a ModuleScript:

-- FakeTable --
local primeKey = newproxy()
local ftable = {}

local className = "FakeTable (Class)"

local function extract(fakeTable)
	return fakeTable[primeKey]
end

local function fakeTableComMethod(func, ...)
	local to = {...}
	
	return function(fakeTable, ...)
		local t = extract(fakeTable)

		return func(t, ...)
	end
end

--add modified table methods to ftable
local ftable = {
	pairs = fakeTableComMethod(pairs),
	ipairs = fakeTableComMethod(ipairs),

	getn = fakeTableComMethod(table.getn),
	insert = fakeTableComMethod(table.insert),
	remove = fakeTableComMethod(table.remove),
	sort = fakeTableComMethod(table.sort),
	clear = fakeTableComMethod(table.clear),
	find = fakeTableComMethod(table.find),
}

-- Creates a metamethod wrapper
local function dwrapMm(meat, metatable, self)
	return function (event)
		return function(_, ...)
			local metamethod = metatable["__" .. string.lower(event)]

			if metamethod then
				return metamethod(self, ...)
			else
				error("Event '.__" .. event .. "' has no metamethods attached")
			end
		end
	end
end

function ftable.wrap(meat, metatable, self)
	local skin = newproxy(true)
	local skinId = tostring(skin)
	
	local s_metatable = getmetatable(skin)

	--what 'self' to give for metamethods
	self = self or meat

	assert(typeof(meat) == "table", "meat must be a table")
	assert(typeof(metatable) == "table", "metatable must be a table")
	assert(getmetatable(meat) == nil, "meat can not have a metatable")

	local wrapMm = dwrapMm(meat, metatable, self)
	
	s_metatable.info = {
		meat = meat,
		metatable = metatable,
	}
	
	s_metatable.__index = function(_, k)
		--used for protected access to contents
		if k == primeKey then
			return meat, metatable
		end

		local v = rawget(meat, k)
		
		print(metatable)

		if v ~= nil and metatable.__get then
			return metatable.__get(self, k)
		elseif metatable.__index then
			local __index = metatable.__index
			
			if type(__index) == "function" then
				return __index(self, k)
			elseif type(__index) == "table" or type(__index) == "userdata" then
				return __index[k]
			end
		end

		return v
	end

	s_metatable.__newindex = function(_, k, v)
		local old_v = rawget(meat, k)

		if old_v ~= nil and metatable.__set then
			metatable.__set(self, k, v)

			return
		elseif metatable.__newindex then
			metatable.__newindex(self, k, v)

			return
		end

		rawset(meat, k, v)
	end

	s_metatable.__eq = function(t1, t2)
		if metatable.__eq then
			return metatable.__eq(self, t2)
		end

		return rawequal(t1, t2)
	end

	s_metatable.__len = function(_)
		if metatable.__len then
			return metatable.__len(self)
		end

		return #meat
	end

	--Connect all 
	s_metatable.__call = wrapMm("call")
	s_metatable.__concat = wrapMm("concat")

	s_metatable.__umn = wrapMm("umn")
	s_metatable.__add = wrapMm("add")
	s_metatable.__sub = wrapMm("sub")
	s_metatable.__mul = wrapMm("mul")
	s_metatable.__div = wrapMm("div")
	s_metatable.__mod = wrapMm("mod")
	s_metatable.__pow = wrapMm("pow")

	s_metatable.__lt = wrapMm("lt")
	s_metatable.__le = wrapMm("le")

	s_metatable.__tostring = wrapMm("tostring")
	s_metatable.__metatable = metatable.__metatable or className

	return skin, s_metatable
end

--allow printing of a FakeTable
function ftable.print(...)
	local tuple = {...}

	for index, item in pairs(tuple) do
		if type(item) == "userdata" and getmetatable(item) == className then
			local meat, _ = extract(item)
			
			if meat then
				tuple[index] = meat
			end
		end
	end
	
	print(table.unpack(tuple))
end

return ftable
3 Likes