Optimized the inst constructor a little bit, cleaned up the shared

bucket metatable, and added a shared class metatable that has a helpful
tostring.
This commit is contained in:
Bruce Hill 2018-02-11 16:45:26 -08:00
parent 0f72014571
commit df98ddebd0
3 changed files with 205 additions and 58 deletions

View File

@ -4,12 +4,10 @@ This is a native Lua library that allows the creation of immutable tables.
## Build
Lua 5.1/5.2/5.3+ or LuaJIT 2.0+ built from source code is a prerequisite. Lua can be downloaded from the [lua.org downloads page](https://www.lua.org/ftp/), and LuaJIT can be downloaded from the [LuaJIT.org downloads page](http://luajit.org/download.html).
`make` or for LuaJIT: `make LUA=luajit` (you can also optionally specify the path to the directory containing lua.h and lauxlib.h with `LUA_INC` and the path to the directory containing the lua binary with `LUA_BIN`).
This code has been tested with Lua 5.1.5, Lua 5.2.3, Lua 5.3.5, and LuaJIT 2.0.5. To build, simply `make` or for LuaJIT: `make LUA=luajit` (you can also optionally specify `LUA_INC=<path to the directory containing lua.h and lauxlib.h>` or `LUA_BIN=<path to the directory containing the lua binary>`).
## Usage
Here's a simple implementation of a 2-d vector using immutable tables:
```
immutable = require "immutable"
Vec = immutable({"x","y"}, {
@ -71,3 +69,73 @@ end
assert(Tuple(5,6,7) == Tuple(5,6,7))
assert(tostring(Tuple(8,9)) == "(8, 9)")
```
## General purpose immutable table recipe
Using the tuple recipe above, you can make a function that returns an immutable version
of a table with arbitrary keys.
```
local immutable_classes = {}
Immutable = function(t)
local keys = {}
for k,_ in pairs(t) do keys[#keys+1] = k end
keys = Tuple(unpack(keys))
if not immutable_classes[keys] then
immutable_classes[keys] = immutable(keys, {name="Immutable"})
end
return immutable_classes[keys]:from_table(t)
end
assert(Immutable{x=1} == Immutable{x=1})
assert(Immutable{a=1,b=2,c=3} == Immutable{a=1,b=2,c=3})
```
## Implementation details
Under the hood, immutable tables are implemented in C as a userdata (that stores a hash value) with a metatable. That metatable has a weak-keyed mapping from hash -> userdata -> associated data. Immutable tables *are* garbage collected, so if you no longer have any references to the userdata, the userdata will get garbage collected, which will result in the entry being removed from the metatable's mapping. When new instances are created, a hash value is computed, and all the data in the associated hash bucket is scanned to look for duplicates. If a match is found, that existing instance is returned, otherwise a new one is created and added to the hash bucket. The following lua pseudocode approximates the C implementation's behavior:
```
function immutable(fields, class_fields)
local cls = {
__index = function(self, key)
local cls = getmetatable(self)
if cls.indices[key] then
local data = cls.__instances[extract_hash(self)]
return data[cls.indices[key]]
else
return cls[key]
end
end,
__gc = function(self)
local __instances = getmetatable(self).__instances
local hash = extract_hash(self)
__instances[hash][self] = nil
if not next(__instances[hash]) then __instances[hash] = nil end
end,
-- also: __len, __pairs, __ipairs, __tostring, from_table, is_instance
}
for k,v in pairs(class_fields) do cls[k] = v end
cls.__fields, cls.__indices = fields, {}
for i,f in ipairs(fields) do cls.__indices[f] = i end
cls.__instances = setmetatable({}, {__index=function(self, key)
local bucket = setmetatable({}, {__mode='k'})
self[key] = bucket
return bucket
end})
return setmetatable(cls, {
__call=function(cls, ...)
local hash = make_hash(...)
local bucket = cls.__instances[hash]
for userdata, data in pairs(bucket) do
local match = true
for i=1,select('#',...) do
if data[i] ~= select(i, ...) then
match = false
break
end
end
if match then return userdata end
end
local userdata = setmetatable(new_userdata(hash), cls)
bucket[userdata] = {...}
return userdata
end
})
end
```

View File

@ -28,6 +28,14 @@
#define LUAI_HASHLIMIT 5
#endif
// This is used to create a unique light userdata to store in the registry to allow all
// hash buckets to share the same {__mode='k'} metatable
static int SHARED_BUCKET_METATABLE;
// This is used to create a unique light userdata to store in the registry to allow all
// constructed classes to have the same metatable:
// {__new=Lcreate_instance, __tostring=function(cls) return cls.name or 'immutable(...)' end}
static int SHARED_CLASS_METATABLE;
static int Lcreate_instance(lua_State *L)
{
int n_args = lua_gettop(L);
@ -107,7 +115,7 @@ static int Lcreate_instance(lua_State *L)
// Stack: [inst, buckets]
lua_createtable(L, 1, 0);
// Stack: [inst, buckets, bucket]
lua_pushlightuserdata(L, (void*)Lcreate_instance); // Shared bucket metatable
lua_pushlightuserdata(L, (void*)&SHARED_BUCKET_METATABLE);
lua_gettable(L, LUA_REGISTRYINDEX);
// Stack: [inst, buckets, bucket, {'__mode'='k'}]
lua_setmetatable(L, -2);
@ -123,26 +131,20 @@ static int Lcreate_instance(lua_State *L)
while (lua_next(L, -2) != 0) { // for hash_collider_inst, hash_collider in pairs(bucket) do
// Stack: [inst, buckets, bucket, hash_collider_inst, hash_collider]
int bucket_item_matches = 1;
// Perform a full equality check
lua_pushnil(L);
while (lua_next(L, -2) != 0) { // for collider_key, collider_value in pairs(hash_collider) do
// Stack: [inst, buckets, bucket, hash_collider_inst, hash_collider, collider_key, collider_value]
lua_pushvalue(L, -2);
// Stack: [inst, buckets, bucket, hash_collider_inst, hash_collider, collider_key, collider_value, collider_key]
lua_gettable(L, -8); // inst[collider_key]
// Stack: [inst, buckets, bucket, hash_collider_inst, hash_collider, collider_key, collider_value, inst_value]
if (!lua_rawequal(L, -1, -2)) {
// go to next item in the bucket
while (lua_next(L, -2) != 0) { // for i, collider_value in pairs(hash_collider) do
// Stack: [inst, buckets, bucket, hash_collider_inst, hash_collider, i, value]
if (!lua_rawequal(L, -1, 1+lua_tonumber(L, -2))) { // If the i'th entry doesn't match the i'th arg
bucket_item_matches = 0;
// Stack: [inst, buckets, bucket, hash_collider_inst, hash_collider, collider_key, collider_value, inst_value]
lua_pop(L, 4);
// Stack: [inst, buckets, bucket, hash_collider_inst, hash_collider, i, value]
lua_pop(L, 3);
// Stack: [inst, buckets, bucket, hash_collider_inst]
break;
break; // go to next item in the bucket
} else {
// Stack: [inst, buckets, bucket, hash_collider_inst, hash_collider, collider_key, collider_value, inst_value]
lua_pop(L, 2);
// Stack: [inst, buckets, bucket, hash_collider_inst, hash_collider, collider_key]
// Stack: [inst, buckets, bucket, hash_collider_inst, hash_collider, i, value]
lua_pop(L, 1);
// Stack: [inst, buckets, bucket, hash_collider_inst, hash_collider, i]
}
}
if (bucket_item_matches) {
@ -453,13 +455,53 @@ static int Lpairs(lua_State *L)
return 3;
}
static const luaL_Reg R[] =
static int Lgc(lua_State *L)
{
// Unfortunately, a __gc metamethod is needed here, because the data storage format
// is cls.__instances[hash][udata] = data, and even though cls.__instances[hash]
// is a weak-keyed table, cls.__instances will leak hash key -> empty table mappings
// over time.
lua_getmetatable(L, 1);
// Stack: [mt]
lua_getfield(L, -1, "__instances");
// Stack: [mt, buckets]
lua_Integer* hash_address = (lua_Integer*)lua_touserdata(L, 1);
if (! hash_address) {
return 0;
}
lua_rawgeti(L, -1, *hash_address);
// Stack: [mt, indices, i, buckets, bucket]
if (lua_isnil(L, -1)) {
return 0;
}
// Set cls.__instances[hash][udata] = nil
lua_pushvalue(L, 1);
// Stack: [mt, indices, i, buckets, bucket, inst_udata]
lua_pushnil(L);
// Stack: [mt, indices, i, buckets, bucket, inst_udata, nil]
lua_settable(L, -3);
// Stack: [mt, indices, i, buckets, bucket]
lua_pushnil(L);
// If next(cls.__instances[hash]) == nil, then set cls.__instances[hash] = nil
// Stack: [mt, indices, i, buckets, bucket, nil]
if (lua_next(L, -2) == 0) {
lua_pop(L, 1);
// Stack: [mt, indices, i, buckets]
lua_pushnil(L);
// Stack: [mt, indices, i, buckets, nil]
lua_rawseti(L, -2, *hash_address);
}
return 1;
}
static const luaL_Reg Rinstance_metamethods[] =
{
{ "__len", Llen},
{ "__index", Lindex},
{ "__tostring", Ltostring},
{ "__ipairs", Lipairs},
{ "__pairs", Lpairs},
{ "__gc", Lgc},
{ "from_table", Lfrom_table},
{ "is_instance", Lis_instance},
{ NULL, NULL}
@ -472,7 +514,7 @@ static int Lmake_class(lua_State *L)
lua_newtable(L);
// Stack: [CLS]
// Populate CLS.__len, CLS.__index, CLS.__pairs, etc.
luaL_register(L,NULL,R);
luaL_register(L,NULL,Rinstance_metamethods);
// If methods were passed in, copy them over, overwriting defaults if desired
if (lua_type(L, 2) == LUA_TTABLE) {
@ -559,24 +601,43 @@ static int Lmake_class(lua_State *L)
}
}
// Stack: [CLS]
// setmetatable(CLS, {__new=CLS.new})
lua_createtable(L, 0, 1);
lua_pushcfunction(L, Lcreate_instance);
lua_setfield(L, -2, "__call");
lua_pushlightuserdata(L, (void*)&SHARED_CLASS_METATABLE);
lua_gettable(L, LUA_REGISTRYINDEX);
lua_setmetatable(L, -2);
// Stack [CLS]
return 1;
}
static int Lclass_tostring(lua_State *L)
{
lua_getfield(L, 1, "name");
if (lua_isnil(L, -1)) {
lua_pushfstring(L, "immutable type: %p", lua_topointer(L, 1));
}
return 1;
}
static const luaL_Reg Rclass_metamethods[] =
{
{ "__call", Lcreate_instance},
{ "__tostring", Lclass_tostring},
{ NULL, NULL}
};
LUALIB_API int luaopen_immutable(lua_State *L)
{
lua_pushlightuserdata(L, (void*)Lcreate_instance);
lua_pushlightuserdata(L, (void*)&SHARED_BUCKET_METATABLE);
lua_createtable(L, 0, 1);
lua_pushstring(L, "k");
lua_setfield(L, -2, "__mode");
lua_settable(L, LUA_REGISTRYINDEX);
lua_pushlightuserdata(L, (void*)&SHARED_CLASS_METATABLE);
lua_createtable(L, 0, 2);
luaL_register(L,NULL,Rclass_metamethods);
lua_settable(L, LUA_REGISTRYINDEX);
lua_pushcfunction(L, Lmake_class);
return 1;
}

View File

@ -117,20 +117,29 @@ collectgarbage()
collectgarbage()
test("Testing garbage collection", function()
local collected = false
local GCSnooper = immutable({}, {
__gc=function(self)
collected = true
end,
})
local g = GCSnooper()
collectgarbage()
collectgarbage()
assert(not collected)
g = nil
local Foo = immutable({"x"})
local function countFoos()
local n = 0
for h,bucket in pairs(Foo.__instances) do
for _ in pairs(bucket) do
n = n + 1
end
end
return n
end
local f1, f2, also_f2 = Foo(1), Foo(2), Foo(2)
assert(countFoos() == 2)
f1, f2 = nil, nil
collectgarbage()
collectgarbage()
assert(collected)
assert(countFoos() == 1)
also_f2 = nil
collectgarbage()
collectgarbage()
assert(countFoos() == 0)
assert(next(Foo.__instances) == nil)
end)
test("Testing stupid metamethods", function()
@ -172,34 +181,43 @@ test("Testing spoofing", function()
assert(not pcall(function() return Vec.__tostring(t) end))
end)
test("Testing unpacking", function()
if table.unpack then
local a, b = table.unpack(Vec(5,6))
assert(a == 5 and b == 6)
end
end)
if _VERSION == "Lua 5.3" then
test("Testing unpacking", function()
if table.unpack then
local a, b = table.unpack(Vec(5,6))
assert(a == 5 and b == 6)
end
end)
test("Testing pairs()", function()
local copy = {}
for k,v in pairs(Vec(3,4)) do
copy[k] = v
end
assert(copy.x == 3 and copy.y == 4)
end)
test("Testing pairs()", function()
local copy = {}
for k,v in pairs(Vec(3,4)) do
copy[k] = v
end
assert(copy.x == 3 and copy.y == 4)
end)
test("Testing ipairs()", function()
local copy = {}
for k,v in ipairs(Vec(3,4)) do
copy[k] = v
end
assert(copy[1] == 3 and copy[2] == 4)
end)
test("Testing ipairs()", function()
local copy = {}
for k,v in ipairs(Vec(3,4)) do
copy[k] = v
end
assert(copy[1] == 3 and copy[2] == 4)
end)
end
test("Testing immutable(n)", function()
local Tup3 = immutable(3, {name="Tuple"})
assert(tostring(Tup3(1,2,3)) == "Tuple(1, 2, 3)")
end)
test("Testing tostring(class)", function()
local C1 = immutable(0, {name="MYNAME"})
assert(tostring(C1) == "MYNAME")
local C2 = immutable()
assert(tostring(C2):match("immutable type: 0x.*"))
end)
if num_errors == 0 then
print(green.."All tests passed!"..reset)
else