I dislike the attributes API.
I think the idea behind attributes is great. Being able to create values specific to objects without the use of ValueBases seems like a dream come true. However, there are some pretty nasty issues within the API that don’t really make sense. Here’s the list of things I gripe about:
-
Instance::GetAttribute has the capability to return 0 values instead of just nil, which means you can easily produce unsafe code. e.g
table.insert(t, instance:GetAttribute("AAA"))
(assuming the attribute doesn’t exist) -
The no “RBX” prefix rule seems pointless. There’s probably a better way for Roblox to stop people from changing attributes set by CoreScripts. Not to mention Roblox managed attributes are few and far between to my knowledge.
-
The inability to save certain datatypes to attributes that could very easily be serialized like a
CFrame
is pretty disappointing. It doesn’t make sense to me why attributes would be much more restricting than ValueBases considering they are the alternative for on the fly variable adjustment.
Instead of sitting and complaining though, I decided to write my own API wrapper that fully emulates and adds features on top of the existing API.
Introducing AttributeManager, the one stop solution for lifting some of those nasty restrictions on attribute names and values. Please note that I only specified some restrictions. There were some hard limits and other things that I am not clever enough to get around that I will go over later.
Lets start with the API provided by the module. That’s the beautiful part though. It is almost exactly the same as the Roblox API. Every function has been recreated to behave how you’d expect the normal API to behave minus some minor QOL tweaks. The most notable of the tweaks is ManagerObj::GetAttribute
is guaranteed to return at least 1 value. This means code like table.insert(t, ManagerObj:GetAttribute("AAA"))
is no longer unsafe. Here’s a list of the actual modified API:
AttributeManager.new(instanceToWrap: Instance): ManagerObject
Creates a new ManagerObject that contains the wrapper functions
ManagerObject:BulkSet(t: {[string]: any})
Creates new attributes from every key-value pair in the dictionary t
ManagerObject:Destroy()
Cleans up connections and objects created by the ManagerObject
ManagerObject:GetAttribute(name: string): any
Functionally similar to Instance::GetAttribute but guarantees at least 1 return value for safe use
For the rest of the API, you can refer to the Developer Wiki page for Instance as the rest of the ManagerObject functions perfectly emulate the parameters and return values of those attribute functions.
I also implement custom serialization routines for certain datatypes. With AttributeManager it is now possible to save CFrame
s, table
s and Instance
s to attributes with some restrictions. Tables are JSON encoded so you can only really have primitives inside the serialized table to have it work correctly but its better than nothing. Instances also use a mechanism that is similar to how RemoteEvents
serialize Instances where it is done by an ID. Instance attributes will replicate over the Client-Server boundary correctly assuming the client or server can index the part in question. Otherwise, ManagerObject::GetAttribute
will just return nil.
As for caveats, here is a list of the ones I know about.
-
Attribute names are limited to 97 characters (because of how the manager stores the attributes) and still cannot contain special characters like
&
or*
with an exception for underscore. -
While AttributeManager can still interface with attributes not created through
ManagerObj::SetAttribute
,ManagerObj::GetAttribute
will still prioritize managed attributes over unmanaged ones. This means that if you create an attribute manually called “test” and then create one through AttributeManager’s API,ManagerObj::GetAttribute
will always return the value of the one created by the module. -
Because of how
ManagerObj.AttributeChanged
andManagerObj::GetAttributeChangedSignal
are implemented, changes to unmanaged attributes that are named the same thing as managed attributes will still fire the signal.
If you wish to try out this module, I uploaded it to Roblox here. I will also include the source code below just in case you want to look at that or ask questions about how things are implemented. If you have any questions or comments let me know and I’ll try to get back to you as fast as possible.
Source Code
local AttributeManager = {
prototype = {}
}
AttributeManager.__index = AttributeManager.prototype
local Http = game:GetService("HttpService")
local Collection = game:GetService("CollectionService")
local conversion = {
["table"] = function(mode, t)
if mode == "set" then
return "AM_table | "..Http:JSONEncode(t)
elseif mode == "get" then
return Http:JSONDecode(t)
end
end,
["CFrame"] = function(mode, cf)
if mode == "set" then
return "AM_CFrame | "..Http:JSONEncode({cf:GetComponents()})
elseif mode == "get" then
return CFrame.new(unpack(Http:JSONDecode(cf)))
end
end,
["Instance"] = function(mode, inst)
if mode == "set" then
local uuid = Http:GenerateGUID(false)
Collection:AddTag(inst, uuid)
return "AM_Instance | "..uuid
elseif mode == "get" then
return Collection:GetTagged(inst)[1]
end
end,
native = {
"string",
"boolean",
"number",
"UDim",
"UDim2",
"BrickColor",
"Color3",
"Vector2",
"Vector3",
"NumberSequence",
"ColorSequence",
"NumberRange",
"Rect",
"nil"
}
}
local function serialize(value)
local toSerialize, isNative = conversion[typeof(value)], table.find(conversion.native, typeof(value))
if not toSerialize and not isNative then
error("Value cannot be serialized. No conversion exists for type "..typeof(value), 3)
end
return (not isNative) and toSerialize("set", value) or value
end
local function deserialize(vtype: string, str: string)
local toSerialize, isNative = conversion[vtype], table.find(conversion.native, vtype)
if not toSerialize and not isNative then
error("Value cannot be deserialized. No conversion exists for type "..vtype, 3)
end
return isNative and str or toSerialize("get", str)
end
local function prefix(name: string)
return string.format("AM_%s", name)
end
function AttributeManager.prototype:SetAttribute(name: string, value: any)
self.Instance:SetAttribute(prefix(name), serialize(value)) --no error handling because roblox error is good
end
function AttributeManager.prototype:BulkSet(t: {[string]: any})
for k,v in pairs(t) do
self.Instance:SetAttribute(prefix(k), serialize(v))
end
end
function AttributeManager.prototype:GetAttribute(name: string)
local real = prefix(name)
local val = self.Instance:GetAttribute(real)
if not val then --no prefixed attribute, check for non managed and force a return value
return self.Instance:GetAttribute(name) or nil
end
if type(val) == "string" then --managed attributes can return non strings for native types
local isSerialized, rawValue = val:match("^AM_(.-) | (.+)")
return deserialize(isSerialized or "string", rawValue)
end
return val --catch for native and managed types
end
function AttributeManager.prototype:GetAttributes()
local rawAttr = self.Instance:GetAttributes()
local clone = table.create(#rawAttr)
for k,v in pairs(rawAttr) do
local isManaged = k:match("^AM_(.+)")
if not isManaged then
clone[k] = v --non managed attributes will never have to be serialized
else
if type(v) == "string" then
local isSerialized, rawValue = v:match("^AM_(.-) | (.+)")
local converted = deserialize(isSerialized or "string", rawValue)
clone[isManaged] = converted
else
clone[isManaged] = v -- given value is native (non-string)
end
end
end
return clone
end
function AttributeManager.prototype:GetAttributeChangedSignal(name: string)
if self._signals[name] then
return self._signals[name].event --mimics the caching behavior of real GACS
end
local event, container = Instance.new("BindableEvent"), {}
container.event = event.Event
container.obj = event
self._signals[name] = container
return container.event
end
function AttributeManager.prototype:Destroy()
self._mgr:Disconnect()
for k,v in pairs(self._signals) do
v.obj:Destroy()
end
for k,v in pairs(self) do
self[k] = nil --i probably dont need to do this but better safe than sorry
end
end
function AttributeManager.new(inst: Instance)
local obj = {
Instance = inst;
_attrchanged = Instance.new("BindableEvent"),
_signals = {},
}
obj.AttributeChanged = obj._attrchanged.Event
-- this connection is fine because it will be cleaned up on destroy
obj._mgr = obj.Instance.AttributeChanged:Connect(function(name)
local isManaged = name:match("^AM_(.+)")
local resolvedName = isManaged or name
local hasSignal = obj._signals[resolvedName]
if hasSignal then
hasSignal.obj:Fire(resolvedName)
end
obj._attrchanged:Fire(resolvedName)
end)
return setmetatable(obj, AttributeManager)
end
return AttributeManager
Usage Example
local AttributeManager = require(path.to.module)
local inst = AttributeManager.new(workspace)
inst:SetAttribute("RBXTest", workspace.Baseplate)
print(inst:GetAttribute("RBXTest") == workspace.Baseplate) --> true