batteries/functional.lua
2024-09-20 15:38:52 +10:00

555 lines
12 KiB
Lua

--[[
functional programming facilities
be wary of use in performance critical code under luajit
absolute performance is this module's achilles heel;
you're generally allocating more garbage than is strictly necessary,
plus inline anonymous will be re-created each call, which is NYI
this can be a Bad Thing and means probably this isn't a great module
to heavily leverage in the middle of your action game's physics update
but, there are many cases where it matters less than you'd think
generally, if it wasn't hot enough to get compiled anyway, you're fine
(if all this means nothing to you, just don't worry about it)
]]
local path = (...):gsub("functional", "")
local tablex = require(path .. "tablex")
local mathx = require(path .. "mathx")
local functional = setmetatable({}, {
__index = tablex,
})
--the identity function
function functional.identity(v)
return v
end
--simple sequential iteration, f is called for all elements of t
--f can return non-nil to break the loop (and return the value)
--otherwise returns t for chaining
function functional.foreach(t, f)
for i = 1, #t do
local result = f(t[i], i)
if result ~= nil then
return result
end
end
return t
end
--performs a left to right reduction of t using f, with seed as the initial value
-- reduce({1, 2, 3}, 0, f) -> f(f(f(0, 1), 2), 3)
-- (but performed iteratively, so no stack smashing)
function functional.reduce(t, seed, f)
for i = 1, #t do
seed = f(seed, t[i], i)
end
return seed
end
--maps a sequence {a, b, c} -> {f(a), f(b), f(c)}
-- (automatically drops any nils to keep a sequence, so can be used to simultaneously map and filter)
function functional.map(t, f)
local result = {}
for i = 1, #t do
local v = f(t[i], i)
if v ~= nil then
table.insert(result, v)
end
end
return result
end
--maps a sequence inplace, modifying it {a, b, c} -> {f(a), f(b), f(c)}
-- (automatically drops any nils, which can be used to simultaneously map and filter)
function functional.map_inplace(t, f)
local write_i = 0
local n = #t --cache, so splitting the sequence doesn't stop iteration
for i = 1, n do
local v = f(t[i], i)
if v ~= nil then
write_i = write_i + 1
t[write_i] = v
end
if i ~= write_i then
t[i] = nil
end
end
return t
end
--alias
functional.remap = functional.map_inplace
--maps a sequence {a, b, c} -> {a[k], b[k], c[k]}
-- (automatically drops any nils to keep a sequence)
function functional.map_field(t, k)
local result = {}
for i = 1, #t do
local v = t[i][k]
if v ~= nil then
table.insert(result, v)
end
end
return result
end
--maps a sequence by a method call
-- if m is a string method name like "position", {a, b} -> {a:m(...), b:m(...)}
-- if m is function reference like player.get_position, {a, b} -> {m(a, ...), m(b, ...)}
-- (automatically drops any nils to keep a sequence)
function functional.map_call(t, m, ...)
local result = {}
for i = 1, #t do
local v = t[i]
local f = type(m) == "function" and m or v[m]
v = f(v, ...)
if v ~= nil then
table.insert(result, v)
end
end
return result
end
--maps a sequence into a new index space (see functional.map)
-- the function may return an index where the value will be stored in the result
-- if no index (or a nil index) is provided, it will insert as normal
function functional.splat(t, f)
local result = {}
for i = 1, #t do
local v, pos = f(t[i], i)
if v ~= nil then
if pos == nil then
pos = #result + 1
end
result[pos] = v
end
end
return result
end
--filters a sequence
-- returns a table containing items where f(v, i) returns truthy
function functional.filter(t, f)
local result = {}
for i = 1, #t do
local v = t[i]
if f(v, i) then
table.insert(result, v)
end
end
return result
end
--filters a sequence in place, modifying it
function functional.filter_inplace(t, f)
local write_i = 0
local n = #t --cache, so splitting the sequence doesn't stop iteration
for i = 1, n do
local v = t[i]
if f(v, i) then
write_i = write_i + 1
t[write_i] = v
end
if i ~= write_i then
t[i] = nil
end
end
return t
end
-- complement of filter
-- returns a table containing items where f(v) returns falsey
-- nil results are included so that this is an exact complement of filter; consider using partition if you need both!
function functional.remove_if(t, f)
local result = {}
for i = 1, #t do
local v = t[i]
if not f(v, i) then
table.insert(result, v)
end
end
return result
end
--partitions a sequence into two, based on filter criteria
--simultaneous filter and remove_if
function functional.partition(t, f)
local a = {}
local b = {}
for i = 1, #t do
local v = t[i]
if f(v, i) then
table.insert(a, v)
else
table.insert(b, v)
end
end
return a, b
end
-- returns a table where the elements in t are grouped into sequential tables by the result of f on each element.
-- more general than partition, but requires you to know your groups ahead of time
-- (or use numeric grouping and pre-seed) if you want to avoid pairs!
function functional.group_by(t, f)
local result = {}
for i = 1, #t do
local v = t[i]
local group = f(v, i)
if result[group] == nil then
result[group] = {}
end
table.insert(result[group], v)
end
return result
end
--combines two same-length sequences through a function f
-- f receives arguments (t1[i], t2[i], i)
-- iteration limited by min(#t1, #t2)
-- ignores nil results
function functional.combine(t1, t2, f)
local ret = {}
local limit = math.min(#t1, #t2)
for i = 1, limit do
local v1 = t1[i]
local v2 = t2[i]
local zipped = f(v1, v2, i)
if zipped ~= nil then
table.insert(ret, zipped)
end
end
return ret
end
--zips two sequences together into a new table, alternating from t1 and t2
-- zip({1, 2}, {3, 4}) -> {1, 3, 2, 4}
-- iteration limited by min(#t1, #t2)
function functional.zip(t1, t2)
local ret = {}
local limit = math.min(#t1, #t2)
for i = 1, limit do
table.insert(ret, t1[i])
table.insert(ret, t2[i])
end
return ret
end
--unzips a table into two new tables, alternating elements into each result
-- {1, 2, 3, 4} -> {1, 3}, {2, 4}
-- gets an extra result in the first result for odd-length tables
function functional.unzip(t)
local a = {}
local b = {}
for i, v in ipairs(t) do
table.insert(i % 2 == 1 and a or b, v)
end
return a, b
end
-----------------------------------------------------------
--specialised maps
-- (experimental: let me know if you have better names for these!)
-----------------------------------------------------------
--maps a sequence {a, b, c} -> collapse { f(a), f(b), f(c) }
-- (ie results from functions should generally be sequences,
-- which are appended onto each other, resulting in one big sequence)
-- (automatically drops any nils, same as map)
function functional.stitch(t, f)
local result = {}
for i, v in ipairs(t) do
v = f(v, i)
if v ~= nil then
if type(v) == "table" then
for _, e in ipairs(v) do
table.insert(result, e)
end
else
table.insert(result, v)
end
end
end
return result
end
--alias
functional.map_stitch = functional.stitch
--maps a sequence {a, b, c} -> { f(a, b), f(b, c), f(c, a) }
-- useful for inter-dependent data
-- (automatically drops any nils, same as map)
function functional.cycle(t, f)
local result = {}
for i, a in ipairs(t) do
local b = t[mathx.wrap(i + 1, 1, #t + 1)]
local v = f(a, b)
if v ~= nil then
table.insert(result, v)
end
end
return result
end
functional.map_cycle = functional.cycle
--maps a sequence {a, b, c} -> { f(a, b), f(b, c) }
-- useful for inter-dependent data
-- (automatically drops any nils, same as map)
function functional.chain(t, f)
local result = {}
for i = 2, #t do
local a = t[i-1]
local b = t[i]
local v = f(a, b)
if v ~= nil then
table.insert(result, v)
end
end
return result
end
functional.map_chain = functional.chain
--maps a sequence {a, b, c, d} -> { f(a, b), f(a, c), f(a, d), f(b, c), f(b, d), f(c, d) }
-- ie all distinct pairs are mapped, useful for any N^2 dataset (eg finding neighbours)
function functional.map_pairs(t, f)
local result = {}
for i = 1, #t do
for j = i+1, #t do
local a = t[i]
local b = t[j]
local v = f(a, b)
if v ~= nil then
table.insert(result, v)
end
end
end
return result
end
-----------------------------------------------------------
--generating data
-----------------------------------------------------------
--generate data into a table
--basically a map on numeric values from 1 to count
--nil values are omitted in the result, as for map
function functional.generate(count, f)
local result = {}
for i = 1, count do
local v = f(i)
if v ~= nil then
table.insert(result, v)
end
end
return result
end
--2d version of the above
--note: ends up with a 1d table;
-- if you need a 2d table, you should nest 1d generate calls
function functional.generate_2d(width, height, f)
local result = {}
for y = 1, height do
for x = 1, width do
local v = f(x, y)
if v ~= nil then
table.insert(result, v)
end
end
end
return result
end
-----------------------------------------------------------
--common queries and reductions
-----------------------------------------------------------
--true if any element of the table matches f
function functional.any(t, f)
for i = 1, #t do
if f(t[i], i) then
return true
end
end
return false
end
--true if no element of the table matches f
function functional.none(t, f)
for i = 1, #t do
if f(t[i], i) then
return false
end
end
return true
end
--true if all elements of the table match f
function functional.all(t, f)
for i = 1, #t do
if not f(t[i], i) then
return false
end
end
return true
end
--counts the elements of t that match f
function functional.count(t, f)
local c = 0
for i = 1, #t do
if f(t[i], i) then
c = c + 1
end
end
return c
end
--counts the elements of t equal to v
function functional.count_value(t, v)
local c = 0
for i = 1, #t do
if t[i] == v then
c = c + 1
end
end
return c
end
--true if the table contains element e
function functional.contains(t, e)
for i = 1, #t do
if t[i] == e then
return true
end
end
return false
end
--return the numeric sum of all elements of t
function functional.sum(t)
local c = 0
for i = 1, #t do
c = c + t[i]
end
return c
end
--return the numeric mean of all elements of t
function functional.mean(t)
local len = #t
if len == 0 then
return 0
end
return functional.sum(t) / len
end
--return the minimum and maximum of t in one pass
--or zero for both if t is empty
-- (would perhaps more correctly be math.huge, -math.huge
-- but that tends to be surprising/annoying in practice)
function functional.minmax(t)
local n = #t
if n == 0 then
return 0, 0
end
local max = t[1]
local min = t[1]
for i = 2, n do
local v = t[i]
min = math.min(min, v)
max = math.max(max, v)
end
return min, max
end
--return the maximum element of t or zero if t is empty
function functional.max(t)
local min, max = functional.minmax(t)
return max
end
--return the minimum element of t or zero if t is empty
function functional.min(t)
local min, max = functional.minmax(t)
return min
end
--return the element of the table that results in the lowest numeric value
--(function receives element and index respectively)
function functional.find_min(t, f)
local current = nil
local current_min = math.huge
for i = 1, #t do
local e = t[i]
local v = f(e, i)
if v and v < current_min then
current_min = v
current = e
end
end
return current
end
--return the element of the table that results in the greatest numeric value
--(function receives element and index respectively)
function functional.find_max(t, f)
local current = nil
local current_max = -math.huge
for i = 1, #t do
local e = t[i]
local v = f(e, i)
if v and v > current_max then
current_max = v
current = e
end
end
return current
end
--alias
functional.find_best = functional.find_max
--return the element of the table that results in the value nearest to the passed value
--todo: optimise, inline as this generates a closure each time
function functional.find_nearest(t, f, target)
local current = nil
local current_min = math.huge
for i = 1, #t do
local e = t[i]
local v = math.abs(f(e, i) - target)
if v and v < current_min then
current_min = v
current = e
if v == 0 then
break
end
end
end
return current
end
--return the first element of the table that results in a true filter
function functional.find_match(t, f)
for i = 1, #t do
local v = t[i]
if f(v) then
return v
end
end
return nil
end
return functional