github link (paste into a module)
Disclaimer: I know the title says “simplest” but this might not be actually simple. I’d consider it simple in the sense that it is a bare-minimum way of creating instance wrappers that “just work”.
The topic of instance wrappers has been covered before, with different techniques to create wrappers with varying levels of functionality, really in-depth wrapper here, but this guide serves to get you a “good enough” wrapper with like 10 lines, that covers all the common use cases (properties + functions + methods) using a little bit of metatable redirection (with exceptions at the bottom of the post).
──────────────────────────────────────────────────────
End Result:
local CustomWorkspace = CreateWrapper(workspace)
CustomWorkspace.MyCustomProperty = 10
function CustomWorkspace.HelloWorld()
print("Hello World!")
end
function CustomWorkspace:PrintCustomProperty()
print(self.MyCustomProperty)
end
print(CustomWorkspace.Gravity) --> 196.199996
print(CustomWorkspace:FindFirstChild("Terrain")) --> Terrain
print(CustomWorkspace:Raycast(Vector3.new(0,5,0), Vector3.new(0,-10,0))) --> RaycastResult{Baseplate...}
print(CustomWorkspace.MyCustomProperty) --> 10
CustomWorkspace.HelloWorld() --> Hello World!
CustomWorkspace:PrintCustomProperty() --> 10
──────────────────────────────────────────────────────
Bare bones instance wrapper (20 lines):
Instance wrapper
local WrapperMetatable = {}
WrapperMetatable.__index = function(wrapper, key)
local instance = rawget(wrapper, _instance)
if type(instance[key]) == "function" then
return function(FirstParameter, ...)
if FirstParameter == wrapper then --this is a method call
return instance[key](instance, ...) --invoke with instance as "self" instead of wrapper
else --normal function call
return instance[key](FirstParameter, ...)
end
end
else
return instance[key]
end
end
local function CreateWrapper(instance: Instance)
local NewWrapper = setmetatable({}, WrapperMetatable)
NewWrapper._instance = instance
return NewWrapper
end
Explanation
When indexing into CustomWorkspace with a key that doesn’t exist inside of the wrapping table (i.e. a key that is a property or function name of the actual workspace), the lua interpreter looks up the metatable and finds the __index
key, which is set equal to a special function. It invokes the function with two parameters: the thing it was trying to index into (i.e. CustomWrapper which is passed into the wrapper param), and the key it was attempting to use.
The first line of this function indexes into the wrapper with the _instance
string key. There is nothing special about this key (not a metamethod), and it could be called anything, but this is just where I decided to put the reference to the actual instance the wrapper is wrapping. It indexes into the instance with the key, and if this ends up grabbing a function (i.e. stuff like FindFirstChild
), it creates and returns new anonymous function.
--normally
local fn = workspace["FindFirstChild"] --> this will grab the FindFirstChild function
--special anonymous function
local custom_fn = CustomWorkspace["FindFirstChild"] --> this grabs the anonymous function
This new anonymous function exists for one single purpose. When invoked, it will check the parameters that it was invoked with and do a check to see if the first parameter is the specific wrapper that it was created for. If this is the case, that means the wrapper was attempting to do a method call, i.e. :
CustomWorkspace:FindFirstChild("Terrain")
is just syntactic sugar for (identical to)
CustomWorkspace.FindFirstChild(CustomWorkspace, "Terrain")
You don’t want that implicit first parameter of FindFirstChild to be the CustomWorkspace, instead it should be the actual workspace, so if the anonymous function sees that the first parameter is the wrapper, it invokes FindFirstChild (i.e. instance[key]
) with the first parameter being instance
(i.e. workspace in this example), then the rest of the ...
parameters afterwards.
If its not a method call (which might be the case if you try to wrap a singleton class that has regular functions instead of methods), then the anonymous function just invokes the instance’s function with no changes to the call (FirstParameter, ...
are directly passed to instance[key]
)
Now in the case where instance[key] is not a function (i.e. the key refers to a property of workspace
such as .Gravity
, or a child such as Terrain
)
Then it just directly indexes into instance[key]
and returns whatever happens to be there
Trivial instance wrapper that doesn’t work with method calls (2 lines):
Trivial wrapper
local WorkspaceWrapperMetatable = {__index = workspace}
local CustomWorkspace = setmetatable({}, WorkspaceWrapperMetatable)
--Tests
CustomWorkspace.MyCustomProperty = 3
print(CustomWorkspace.MyCustomProperty) --> 3
print(CustomWorkspace.Terrain) --> Terrain
print(CustomWorkspace:FindFirstChild("Terrain")) --> ERROR Expected ':' not '.' calling mem...
Explanation
When indexing into CustomWorkspace with a key that doesn’t exist inside of the wrapping table (i.e. a key that is a property or function name of the actual workspace), the lua interpreter looks up the metatable and finds the __index
key, which points to the userdata workspace object. It passes the key to the real workspace
and lets it do its thing. This breaks when you make a method call because:
something:method()
is really just syntactic sugar (the same as) this:
something.method(something)
so any method calls such as FindFirstChild really end up doing
CustomWorkspace.FindFirstChild(CustomWorkspace, "Terrain")
which is not what you want, since even though the lua interpreter is able to retrieve the FindFirstChild
function from the workspace, it’s passing in the wrong first parameter, and the FindFirstChild implementation has no clue what “CustomWorkspace” is.
──────────────────────────────────────────────────────
How to have your custom wrappers inherit custom functions/properties without directly setting it
Don't use multi-inheritance!!!!!
Normally, you can achieve multiple levels of inheritance by doing something like:
bird = {}
bird.__index = bird
pigeon = {}
pigeon.__index = pigeon
setmetatable(pigeon, bird)
billy = setmetatable({}, pigeon)
--interpreter will look for keys in billy first, then its metatable pigeon,
--then pigeon's metatable which is bird
However Warning
DO NOT setmetatable
some table to WrapperMetatable
--DON'T DO THIS!
local CarWrapperClass = {}
CarWrapperClass.__index = CarWrapperClass
setmetatable(CarWrapperClass, WrapperMetatable)
local NewCar = setmetatable({}, CarWrapperClass)
NewCar._instance = workspace.CarModel
print(NewCar.Roof.Color) --> Attempt to index nil with Roof
It encounters an error because when a key cannot be found in NewCar, the interpreter looks in CarWrapper, can’t find it there, then looks invokes the __index function inside the WrapperMetatable. However instead of invoking it with the topmost level NewCar wrapper object as the table, the CarWrapperClass
table is passed instead, which has no _instance
key (the _instance
key is part of the NewCar, NOT CarWrapper).
These wrappers don’t work with multi-inheritance!
Just place any inherited functions/properties/keys directly in the WrapperMetatable, and have the __index
function check for those inherited keys, with this one extra line:
local CarClass = {}
CarClass.__index = function(wrapper, key)
if CarClass[key] then return CarClass[key] end --add this line here
local instance = rawget(wrapper, "_instance")
if type(instance[key]) == "function" then
return function(FirstParameter, ...)
if FirstParameter == wrapper then --this is a method call
return instance[key](instance, ...) --invoke with instance as "self" instead of wrapper
else --normal function call
return instance[key](FirstParameter, ...)
end
end
else
return instance[key]
end
end
local function CreateCar(instance: Instance)
local NewCar = setmetatable({}, CarClass)
NewCar._instance = instance
return NewCar
end
Now you can define any function or property in CarClass
and it will work as expected:
function CarClass:Drive()
print(self.CarName, "is driving")
end
local NewCar = CreateCar(workspace.CarModel)
NewCar.CarName = "honda civic"
NewCar:Drive() --> honda civic is driving
print(NewCar:FindFirstChild("Roof").Material) --> Metal
This technique for key redirection is inspired by this StackOverflow post. I found this post when I was trying to make the trivial wrapper work with method calls.
Exceptions:
Because this wrapper works through redirection, any method calls like Clone() and Destroy() will be passed directly to the underlying instance. That means that you shouldn’t expect Clone() to create a copy of the wrapper object, instead it will just clone the underlying instance and return that instance.
Destroy calls will destroy the underlying instance and remove it from the datamodel, but the instance won’t get garbage collected until the wrapper goes out of scope (since the wrapper points to the instance through its _instance key). This isn’t necessarily a memory leak, since as long as the wrapper isn’t a global and you’re doing cleanup of the wrapper whenever you want to destroy the object its wrapping, there’s no problems, but if this issue is important to you, you can see the in-depth wrapper for a guide on how to use weak references.