What's the point/use-case in metatables?

In the wiki it uses this as a use case for metatables:

local function mathProblem(num)
	for i = 1, 20 do
		num = math.floor(num * 10 + 65)
	end
	for i = 1, 10 do
		num = num + i - 1
	end
	return num
end

local metatable = {
	__index = function (object, key)
		local num = mathProblem(key)
		object[key] = num
		return num
	end
}

local t = setmetatable({}, metatable)

print(t[1]) -- Will be slow because it's the first time using this number, so it has to run the math function.
print(t[2]) -- will be slow because it's the first time using this number.
print(t[1]) -- will be fast because it's just grabbing the number from the table.

BUT I can get the same functionality by doing this:

local t = {}

local function mathProblem(num)
	for i = 1, 20 do
		num = math.floor(num * 10 + 65)
	end
	for i = 1, 10 do
		num = num + i - 1
	end
	return num
end

local function checkTable(tab, val)
	if not tab[val] then
		local num = mathProblem(val)
		tab[val] = num
	end
	return tab[val]
end

print(checkTable(t, 1)) -- Will be slow because it's the first time using this number, so it has to run the math function.
print(checkTable(t, 2)) -- will be slow because it's the first time using this number.
print(checkTable(t, 1)) -- will be fast because it's just grabbing the number from the table.

I’m not sure I understand why it’s needed. For me, it seems like it needlessly adds a layer of complexity. It seems like nothing more than a function that’s called if a value doesn’t exist.

I’ve seen other examples of using different metamethods, but they still seemed replaceable by using a function with a simple if statement. Is there anything that can ONLY be accomplished by metatables/metamethods? If so, I could really use a code example.

2 Likes

Here’s an old post of mine on a topic talking about this:

If I have no intention of adopting the OOP approach, does that mean metatables are useless to me?

[EDIT] Almost all of my code is in module scripts. I can pretty much access any function from any module script.

1 Like

From Lua’s official documentation:

Metatables allow us to change the behavior of a table. For instance, using metatables, we can define how Lua computes the expression a+b, where a and b are tables. Whenever Lua tries to add two tables, it checks whether either of them has a metatable and whether that metatable has an __add field. If Lua finds this field, it calls the corresponding value (the so-called metamethod, which should be a function) to compute the sum.

For me, metatables are best used when you treat a table as an object. Otherwise, I do not see much point in using them.

1 Like

I read that as well, but the _add is nothing more than a function that’s called when you try to add the two tables together. Instead you can replace the _add with a function that’s called right when you want to add the tables.

I guess it’s just an OOP thing that really has no use for anything outside of OOP.

Sorry to tell you, but that is incorrect. the most important is __Index, not just for OOP modules.

For example, if you would. You can use __Index if you are indexing something in a table that might not be there. Roblox would instantly terminate the script if the error is groundbreaking. Then you will have a broken game. With index though, you can intercept the index if its nil, and add value.

I’ve found it very useful for larger games or laggy ones

1 Like

No, of course not. You can do everything without loops or functions, too. They just make certain tasks easier.

In practice, you can probably ignore them if you’re not using OOP or memoizing things. They have other niche uses besides that, but you probably don’t need those either.

1 Like

Didn’t this do the exact same thing? It checked if the value was nil/false inside the table, and if it was then it created the entry, otherwise it didn’t do anything.

That is extra lines, and __Index is a lot easier as it is in-built by roblox and would be faster then your algorithms

Could you provide an example of which tasks are easier?

I still haven’t seen how they are worth it, if it makes something easier that I could possibly hit, then I’ll definitely start using them. Loops and functions have actual use-cases: drastically reducing code (loops) or preventing repeatable code (functions). I’m just not seeing the use-case for metatables and I do apologize, I’m not trying to be difficult or anything … I just don’t see it yet.

1 Like

Its very true metatables aren’t required, other then OOP, there is no area where meta tables are a necessity.

Its hard for me to explain ngl, you should ask another person who does metatables more.

4 Likes

I really do appreciate your help.

The real reason I asked about metatables is I was going through Quenty’s Nevermore module and learning how he created his TimeSyncService (I’m creating a cooldown). I understand what he did, but I was curious about the metatables and such (he used OOP).

I suppose my takeaway is that metatables are just a way to do something and not a solution.

1 Like

Yep, metatables are useful, but not required, btw its better if you can mark this as solved if you are satisfied

c:

Metatables have many more uses than OOP, it allows you to change the behaviour of a table i.e. you can override operations on the table. Want to add a custom event when a table is changed? Go ahead. Want to change the __add operation so you can concat tables on the fly? Sure etc etc etc

Let’s say we want to implement the custom changed event because we’re tracking some state values across multiple different modules, but we want the script that manages all the states to keep track of any changes without having to recursively check for them (look at the bottom for example usage and output):

local this;
this = {
    _states = { };
    _length = 0;
    _con    = {
        connect = function (_, foo)
            rawset(this, '_changed', foo)
            return this._des
        end;
        Connect = function (_, foo)
            rawset(this, '_changed', foo)
            return this._des
        end;
    };
    _des    = {
        disconnect = function ()
            rawset(this, '_changed', nil)
        end;
        Disconnect = function ()
            rawset(this, '_changed', nil)
        end;
    }
}

setmetatable(this, {
    __index = function (t, k)
        if tostring(k):lower() == 'changed' then
           return this._con
        end
        
        return this._states[k]
    end,
    __newindex = function (t, k, v)
        if not this._states[k] then
            this._length = this._length + 1
        elseif not v then
            this._length = this._length - 1
        end
        
        if this._changed and this._states[k] then
            this._changed(k, v, this._states[k])
        end
        
        this._states[k] = v
    end,
    __len = function ()
       return this._length 
    end
})

-- example:
local connection = this.Changed:connect(function (index, value, old)
    print('this table was changed at index', index, 'to value', value, 'from old value', old)
end)

this['Hello'] = 'metatables' --> Won't fire since we've created a new index instead of changing an old one
this['Hello'] = 'boi' --> Will now fire with 'this table was changed at index	Hello	to value	boi	from old value	metatables'

connection:disconnect() --> Disconnect so we don't hear anymore changed

this['Hello'] = 'world!' --> This won't fire as we're no longer listening

print('Hello,', this['Hello']) --> will print 'Hello,	world!'


-- Similarly, since this is a dictionary, normally #this would return 0
-- However, we added an altered __len operator, so if we call its length, we'll get:

print('The length of this dictionary is #', #this) --> 1

-- Let's prove that further by setting the value of 'Hello' to nil
this['Hello'] = nil

print('The length of this dictionary is now #', #this) --> 0

I converted this code into a sample of non-metatable code that functions the same way for the most part. I also tend to call functions as soon as values change rather than using a connection:

local module = {
	length = 0
}

function module.lookup(key, value, modifier) --module is now a mixed key table, but I really don't mind for this example
	if module[key] then --if there's a value in that key
		if module[key] == value then --value exists
			--do something
		elseif not value then--simulates just checking for a value
			return module[key] --return the value
		else -- value doesn't exist
			if not modifier then --debounces when you want to, could trim it but meh
				module.changed(key, value, module[key])
			else
				module.changed(key, value)
			end
		end
	else --key contains no value
		module.length = module.length + 1
		module.changed(key, value)
	end
end

function module.changed(key, value, old)
	module[key] = value
	if old then
		print('this table was changed at index', key, 'to value', value, 'from old value', old)
	end
end

function module.deleteKey(key)
	module[key] = nil
	module.length = module.length - 1
end

function module.tableLength()
	return module.length
end

return module

Here’s the script that requires the module:

---A different script
local module = require(script.ModuleScript)

module.lookup("Hello", "metatables") --> we've created a new index instead of changing an old one
module.lookup("Hello", "boi")--> this table was changed at index	Hello	to value	boi	from old value	metatables'
module.lookup("Hello", "world!", "stop") --> This won't access the changed function as we've debounced it

print('Hello,', module.lookup("Hello")) --> will print 'Hello,	world!'

print('The length of this dictionary is #', module.tableLength()) --> 1

-- Let's prove that further by setting the value of 'Hello' to nil
module.deleteKey("Hello")

print('The length of this dictionary is now #', module.tableLength()) --> 0

I have to admit I liked your way of getting a dictionary’s length … I’ve had to work around this before, but I don’t think I used a counter.

[EDIT] I know I could move some things around to lessen the lines of code, but I wanted to show functionality rather than making this specific code efficient.

The only issue with this is that module is referenced in memory, so if you call this from multiple scripts then it’ll inherit the same value(s). Just to add some flare, let’s do that by using the __call metamethod:

Module:

local module = { }

setmetatable(module, {
	__call = function ()
		local this;
		this = {
			_states = { };
			_length = 0;
			_con    = {
				connect = function (_, foo)
					rawset(this, '_changed', foo)
					return this._des
				end;
				Connect = function (_, foo)
					rawset(this, '_changed', foo)
					return this._des
				end;
			};
			_des    = {
				disconnect = function ()
					rawset(this, '_changed', nil)
				end;
				Disconnect = function ()
					rawset(this, '_changed', nil)
				end;
			}
		}
		
		setmetatable(this, {
			__index = function (t, k)
				if tostring(k):lower() == 'changed' then
					return this._con
				end
				
				return this._states[k]
			end,
			__newindex = function (t, k, v)
				if not this._states[k] then
					this._length = this._length + 1
				elseif not v then
					this._length = this._length - 1
				end
				
				if this._changed and this._states[k] then
					this._changed(k, v, this._states[k])
				end
				
				this._states[k] = v
			end,
			__len = function ()
				return this._length 
			end
		})
		
		return this
	end
})

return module

Some other script:

local tab = require(script.tab)

local this = tab()

-- example:
local connection = this.Changed:connect(function (index, value, old)
	print('this table was changed at index', index, 'to value', value, 'from old value', old)
end)

this['Hello'] = 'metatables' --> Won't fire since we've created a new index instead of changing an old one
this['Hello'] = 'boi' --> Will now fire with 'this table was changed at index	Hello	to value	boi	from old value	metatables'

connection:disconnect() --> Disconnect so we don't hear anymore changed

this['Hello'] = 'world!' --> This won't fire as we're no longer listening

print('Hello,', this['Hello']) --> will print 'Hello,	world!'


-- Similarly, since this is a dictionary, normally #this would return 0
-- However, we added an altered __len operator, so if we call its length, we'll get:

print('The length of this dictionary is #', #this) --> 1

-- Let's prove that further by setting the value of 'Hello' to nil
this['Hello'] = nil

print('The length of this dictionary is now #', #this) --> 0


-- check whether we've created a new instance
local other = tab()
other['Hello'] = 'Yo'

print(other['Hello'], 'vs', this['Hello']) --> 'Yo vs nil'

Similarly, although I would agree that yours would work, you would need to add listeners for your changed function to truly function as mine does. You would either need to do that by adding a listen of functions to iterate through and call based on those that connect to your module, or by using BindableEvents.

Hope that this has dispelled the myths that metatables and their very useful metamethods are more than what they appear and can be used for much more than OOP :slight_smile:

My game currently uses almost all module scripts, so even if I need a single simple value, I just call the module and request it. I prefer to have one place where the value is changed and that value is called. If it’s a more expensive module, then I cache the value on the calling script and create a function that just checks if the cached value is equal to the current one. I actually didn’t even realize I was memoizing until @nicemike40 brought it up.

If I don’t want the same value used for each calling script, then I just create subkeys like this:

local module = {
    dataTable = {
        data1 = {};
        data2 = {}
}
}

I also don’t need to monitor a connection signal because I control the flow with a modifier. It’s functionally the same as disconnecting that piece of code so it doesn’t run any more. I admit, it is an uglier way to prevent it and takes a slight amount more typing and adds a single check each time.

What honestly sparked this whole thread was reading through the wiki. After I saw the wiki’s justification for using metatables, I then saw that metatables introduce another problem that you have to worry about: C stack overflow. So I thought “wait…they make things a bit more complex AND they create a problem?”

[EDIT] If I need individualized data, then I simply create a new key in the table with the player’s ID whenever a player joins and I clear out their key whenever they leave. Since I use almost all modules, I have a core module script which acts as the only place that catches players being added to the game. I then manually call functions in any module script that needs to be fired when a player is added; this way I can guarantee the order these signals are fired and can thus guarantee the data are there when needed.

[EDIT2] If I need to check the value of any debounce, then I can just create a function that returns the value. This way I can monitor which gates are open or which gates are closed if it’s needed.

1 Like