Improving code readability and reducing repetition using functional programming

You’re probably used to passing values to functions, which allow you to specify What you want to operate on. In Lua, and indeed most languages, you can also pass functions as arguments which let you specify How something is operated on. Armed with this you can abstract your code to a much higher degree and keep your statements tighter, containing more relevant information per statement. A common example of this is finding the maximum element of a table. Here is a typical way of doing this:

local oldestIndex = 0
local oldestTime = 0
local oldestEntry = nil

for i, oldItemEntry in spawnedItems do
	if oldestEntry == nil or oldItemEntry.spawnedAtTime < oldestTime then
		oldestTime = oldItemEntry.spawnedAtTime
		oldestEntry = oldItemEntry
		oldestIndex = i
	end
end

This finds the item in spawnedItems whose spawnedAtTime was the longest ago. If we wanted to do the same check but instead find the most recently spawned item, or use a different member for comparison, we would have to repeat most of this code. We can avoid this by abstracting the comparison step and looping step as their own functions:

local function GreaterThan(a, b)
	return a - b
end

local function LessThan(a, b)
	return b - a
end

local function MaxSpawnedAtTime(t, comparator)
	local maxValue = nil
	local maxIndex = nil
	
	for i, v in pairs(t) do
		if maxValue == nil or comparator(v.spawnedAtTime, t[maxIndex].spawnedAtTime) > 0 then
			maxValue = v.spawnedAtTime
			maxIndex = i
		end
	end
	
	return maxIndex, maxValue
end

maxi, maxv = MaxSpawnedAtTime(spawnedItems, GreaterThan)

In this example, the function comparator passed to MaxSpawnedAtTime determines the order that the two items being compared are subtracted. When GreaterThan is used, the comparison is:

v.spawnedAtTime - t[maxIndex].spawnedAtTime > 0

which is equivalent to

v.spawnedAtTime > t[maxIndex].spawnedAtTime

When LessThan is used as the comparator, the comparison is reversed. This type of ‘subtraction’ comparator pattern is a classic example used in C’s strcmp() and sort(). Lua’s table.sort() also accepts this exact kind of comparator. We can do one better by providing a ‘selector’ function that determines the value chosen for selection. We could just provide a string, such as “spawnedAtTime”, to select this, but using a function allows us to make our comparison much more versatile, as we will see later:

local function SpawnedAtTime(e)
	return e.spawnedAtTime
end

local function Max(t, selector, comparator)
	local maxValue = nil
	local maxIndex = nil
	
	for i, v in pairs(t) do
		if maxValue == nil or comparator(selector(v), selector(t[maxIndex])) > 0 then
			maxValue = selector(v)
			maxIndex = i
		end
	end
	
	return maxIndex, maxValue
end

maxi, maxv = Max(spawnedItems, SpawnedAtTime, GreaterThan)

Our selector can be very advanced. For example, it could contain a distance check, letting us choose the closest item instead of the oldest:

local function DistanceFromButton(e)
	return (e.instance.PrimaryPart.Position - button.Position).Magnitude
end

local function Max(t, selector, comparator)
	local maxValue = nil
	local maxIndex = nil
	
	for i, v in pairs(t) do
		if maxValue == nil or comparator(selector(v), selector(t[maxIndex])) > 0 then
			maxValue = selector(v)
			maxIndex = i
		end
	end
	
	return maxIndex, maxValue
end

maxi, maxv = Max(spawnedItems, DistanceFromButton, GreaterThan)

Our function is now called such that it returns the element in the table whose instance (an Instance in the workspace) is furthest from the button. Note that we assume button has been defined previously. However, we can make this more powerful using anonymous functions. Anon functions can capture variables in their parent scope. In that case, button only needs to be defined right before the call to Max with an anonymous function as the selector.

Our decision to use this functional pattern has some apparent tradeoffs. The first disadvantage is that the Max function is harder to understand initially, especially if you did not have an example usage. If you choose to use this pattern, make sure to adequately explain what your function parameters need to do in a comment above the Max function.
The advantage of this pattern is we now have a high level of clarity whenever we call Max. Reading our code we can read the following line:
Max(spawnedItems, DistanceFromButton, GreaterThan)

as simply “Find the item in spawnedItems whose DistanceFromButton is Greatest
This code has good self-documenting properties as opposed to original example, which requires careful reading of several lines and cannot be adapted to different tasks. Our finished code thus greatly reduces code re-use.

Here is the full example, as I have used it in some respawning code:

local function SpawnedAtTime(e)
	return e.spawnedAtTime
end

local function DistanceFromButton(e)
	local modelPrimaryPart = e.instance.PrimaryPart
	local distance = 0

	if modelPrimaryPart ~= nil then
		distance = (modelPrimaryPart.Position - button.Position).Magnitude
	else
		warn("Spawned model does not have a PrimaryPart set, can't calculate distance from spawn button!")
	end
	
	return distance
end

local function Max(t, selector, comparator)
	local maxValue = nil
	local maxIndex = nil
	
	for i, v in pairs(t) do
		if maxValue == nil or comparator(selector(v), selector(t[maxIndex])) > 0 then
			maxValue = selector(v)
			maxIndex = i
		end
	end
	
	return maxIndex, maxValue
end

--Remove oldest spawned item
local function RemoveOldestSpawn()
	local oldestIndex = Max(spawnedItems, SpawnedAtTime, function(a, b) return b - a end)
	
	RemoveSpawn(oldestIndex)
end

--Remove furthest spawned item from button
local function RemoveFurthestSpawn()
	local furthestIndex = Max(spawnedItems, DistanceFromButton, function(a, b) return a - b end)

	RemoveSpawn(furthestIndex)
end

A note about DRY, or why we want to reduce code repetition:
If you had duplicated the original code and discovered a bug in one instance of it, you now have to worry if the other copy of the same code has the same bug. The small variations between the copies might make it very difficult to tell for sure. The problem only gets worse the more repetitions there are. When you keep code non-repeating, not only will you avoid this scenario, but the same bug will probably be discovered earlier, and only needs to be ironed out in one place. Additionally, writing code this way gives you more places to verify assumptions, since it is clear what is going into each call to Max and what should come back out.

2 Likes