Scripting HelpersARCHIVE
A little piece of Roblox history. Preserved for everyone, one question at a time.Read about the archive →
✓ ANSWER ACCEPTED

Metatables to lock all nested tables?

So this one might seem a bit tricky, but I'm trying to figure out how to make a fully "read only" table. I know to fully lock a table, I could just say something like this:

local realTable = {x = 1,y = 2,z = 3}

local fake = setmetatable({},{
	__index = realTable, -- Reference to original table
	__newindex = function(t,k,v)	
		-- Print this message, and ignore setting any values
		print("Cannot change protected table")
	end
})

print(fake.x) -- > 1
fake.x = 5 -- > "Cannot change protected table"
fake.x = nil -- > "Cannot change protected table"

So the code above works, however this is still a problem when using nested tables. Say I had a library with the following content:

-- Now I want to make it so you can't change library.workspace, OR any value within the library.workspace table.
local library = {
	workspace = {x = 1, y = 2, z = 3},
}

If I applied my original method in my first example to the code above, it will only work for that library table, not the tables within it. For example:

local library = {
	workspace = {x = 1,y = 2,z = 3}
}

local fake = setmetatable({},{
	__index = library, -- Reference to original table
	__newindex = function(t,k,v)	
		-- Print this message, and ignore setting any values
		print("Cannot change protected table")
	end
})

print(fake.workspace) -- > "table:hex"
fake.workspace = nil -- > "Cannot change protected table")

-- But...

fake.workspace.x = nil -- > This will change the value, I don't want to allow that.
print(fake.workspace.x) -- > Now this will be nil

Question

So, is there any way to make it so you can lock nested tables the same way as the initial table? Or would I have to create a metatable for each individual table inside each table? Because that seems like a fairly impractical method if you're working with a large scale of nested tables (which I am).

So if anyone out there has any insight on this, I'd really appreciate it!

2 answers

✓ Accepted answer

Magic
Anything is magic if you believe

I thought BlueTaslem was just about right, but at the same time I thought he was really incredibly wrong. So I wanted to chime in.

First, you want to make your reference holding table. This is of course a nice simple weak table so that you can have gc'able references.

local ref = setmetatable({},{__mode='k'});

Then, you want to make your metatable. You only want a single metatable, so that you don't have to make new tables for every single readonly table.

local mt = {
	__index = function(t,k)
		return ref[t][k];
	end;
	__newindex = function(t,k,v)
		error "Table is read-only.";
	end;
	__metatable = "Locked metatable" -- For good measure
};

You could make it so that you then proxy all of your metamethods, but I won't add that here quite yet regardless.

After making your metatable, making the locking function is as simple as checking if it's already locked and setting the metatable if it's not. I'll just handle tables right here, but Taslem does make a very valid point about methods (which is solved by wrapping). In this version of the lock, I'm going to move all of the object members out of the table instead of cloning the table, which avoids most of the issues caused by Taslem's implementation.

local function lock(t)
	if ref[t] then
		return t;
	end;
	if type(t) == 'table' then
		local tmp = {};
		ref[t] = tmp; -- Avoid infinite loops.
		for k,v in next, t do
			tmp[k] = lock(v);
		end;
		for k in next, t do
			t[k] = nil; -- A second loop because the behaviour of next is undefined if the table is modified
		end;
		return setmetatable(t,mt);
	end;
	return t;
end;

As stated above, because this returns the same table this won't have the same issues as Taslem's implementation, but it will introduce its own more obscure issues (Choose the lesser of evils).


Bonus Round
Wrappers

Your original use case for this looks like you might benefit from a wrapper, either in your environment or just on the table.

Why?
You appear to be trying to make something appear to be something it isn't. Wrappers are used in this case to manipulate stuff behind the interface to make things appear to be what they aren't, both to the user and to the API it is wrapping.

On the environment level, this could be as simple as modifying rawset and rawget to respect read-only tables, all the way to making functions treat userdata table proxies as though they were regular tables.

On the tables, wrapping would be used in Taslem's implementation to make functions which treat the new read-only clones as the original table, by taking in all of the arguments and replacing them with their vanilla equivalents if applicable before passing them through to the original function. You can even capture the returns inside of the wrapper function and then lock them before they're passed back to the script.

A simple way is to make the __index metamethod automatically lock all objects it returns:

function lock(obj)
	if type(obj) ~= "table" and type(obj) ~= "userdata" then
		return obj
	end
	return setmetatable({}, {
		__index = function(self, key)
			return lock( obj[key] )
		end,
		__newindex = function()
			error("value is read-only", 2)
		end,
	})
end

Methods on these objects may be slightly problematic, however, since

a = {someobject}

b = lock(a)

b:method()
-- same as
a.method(b)

The biggest problem is that a ~= b which may cause problems in some cases.

You could modify lock to change functions that are returned to look for whether or not it was given the first parameter and filtering that to become the original object... but that will only be sometimes what is wanted anyway.

1 comment

0

Close enough (*)

This discussion is preserved as it was. New answers, comments, and votes are closed.