New "import" function for making packages easier

What follows are some thoughts on what I’d like from scripting in studio. I don’t think this idea is good enough to be included as-is to ROBLOX, but it’s something I’d like so I wanted to share it and hear other’s thoughts.


A new import function

I try to keep my code very modular, and split it into many (nested) ModuleScripts.

This inevitably leads to a big block of code at the top of every script that looks like this:

local Rectangle = require(script.Parent.Parent:WaitForChild("Geometry"):WaitForChild("Rectangle"))
local Approximator = require(script.Parent.Parent:WaitForChild("Terrain"):WaitForChild("Approximator"))
local Roughness = require(script.Parent.Parent:WaitForChild("Terrain"):WaitForChild("Roughness"))
local Union = require(script.Parent.Parent:WaitForChild("Geometry"):WaitForChild("Union"))
local FastMap = require(script.Parent.Parent:WaitForChild("Data"):WaitForChild("FastMap"))

local Fence = require(script:WaitForChild("Fence"))
local Tree = require(script:WaitForChild("Tree"))

Here is my proposal to improve the ergonomics of this:

local Rectangle = import "Geometry.Rectangle"
local Approximator = import "Terrain.Approximator"
local Roughness = import "Terrain.Roughness"
local Union = import "Geometry.Union"
local FastMap = import "Data.FastMap"

local Fence = import "Zones.Forest.Fence"
local Tree = import "Zones.Forest.Tree"

Several things would be going on here.

First, there would be two new services: ClientLibraries and ServerLibraries. The import function searches these services for the given module name. A LocalScript searches ClientLibraries only (ServerLibraries is not replicated).

Some optional features of import:

  • use import "Folder.*" to get a dictionary that imported all of the children
  • import can either prefer the name in ServerLibraries or error if there is a conflict
  • import automatically :WaitForChild (to some extent) to allow safely loading modules on e.g., LocalScripts on a bad connection

Lazy loading for recursive imports

One additional feature that I’d like would be lazy loading. This would allow modules to recursively refer to each other.

The object import returns would be an empty table; the ModuleScript itself is not run upon importing it. When you access the metatable for the first time, (i.e., upon __index; other uses like __newindex, __add, etc should probably assert) the ModuleScript is run, and the keys/values returned are copied into this object.

This would apply some restrictions to what these “library” modules can do:

  • can only return plain tables (not userdata, strings, numbers, or functions)
  • tables with some kinds of metatables couldn’t be returned (just __index would be OK I think)
  • cannot yield in the “definition” of modules (outside any functions)
  • still cannot refer recursively to other modules outside of any functions (though now this can be detected and trigger a descriptive error!)

If you’re not familiar with what I mean by modules recursively referring to each other:

-- this does NOT work

-- ModuleScript A --------------------------------------------------------------

local B = require(script.Parent.B)

function f(x)
	B.f(Vector3.new(x / 2, x / 2, x / 2))
end

return {f = f}

-- ModuleScript B --------------------------------------------------------------

local A = require(script.Parent.A) -- no good:
-- we have to wait for A to finish running,
-- but A is waiting on B to finish running.

function f(size)
	if size.magnitude > 10 then
		A.f(size.x - 11)
		A.f(size.x - 12)
	else
		local p = Instance.new("Part", workspace)
		p.Size = x
	end
end

return {f = f}

One solution to this problem is to just move the require into every function:

function f(size)
	local A = require(script.Parent.A) -- OKAY!
	-- A has already finished since this doesn't block B's object from being returned
	
	if size.magnitude > 10 then
		A.f(size.x - 11)
		A.f(size.x - 12)
	else
		local p = Instance.new("Part", workspace)
		p.Size = x
	end
end

but is kind of ugly. import could simply do that delaying for us.

How it’s implemented

Here’s basically what the implementation of import might look like

-- locate the given module in the given 
local function findModule(module, parent)
	assert(typeof(parent) == "Instance")
	assert(type(name) == "string")
	for name in module:gmatch("[^.]+") do -- XXX: accepts invalid formats
		parent = parent:WaitForChild(name)
	end
	assert(parent:IsA("ModuleScript")) -- TODO: allow `.*` for folders
	return parent
end

function import(name)
	local module = findModule(name, game:GetService("ClientLibaries"))
		or findModule(name, game:GetService("ServerLibraries"))

	local context = nil
	return setmetatable({}, {
		__index = function(_, key)
			if context then
				return context[key]
			else
				context = require(module)
				assert(context ~= nil)
				return context[key]
			end
		end,
		__newindex = function() error("libraries cannot be changed", 2) end,
		__add = function() error("cannot added library", 2) end,
		__sub = function() error("cannot added library", 2) end,
		__pow = function() error("cannot added library", 2) end,
		-- etc...
	})
end

Yes, this could be written by myself and just included in every script, but that is exactly the problem, because right now ModuleScripts incur quite a bit of boilerplate.

19 Likes