lovatt.dev

March 9th, 2019
09/03/19

Table Service

In which we get our hands dirty and relocate the GUI's table functions, adding some Lua magic to make future changes easier.


Time to get our hands dirty. Well, dirty in a metaphorical sense because my keyboard is pretty clean and I'd like to keep it that way.

I learned Javascript last year (Yay, Javascript? Boo, Javascript? Take your pick.) and one of the first things I noticed was just how many incredibly useful functions it gives you for dealing with arrays and objects. Ruby too. It's downright GLORIOUS the things they let you do and the ease with which they let you do it.

But not Lua. Lua prides itself on being a really tiny language, great for embedding in microwaves or drones or your neighbour's Labradoodle, but that tiny package comes at the expense of a standard library. Lua is a powerful language, but it's powerful in the sense that you can make it do all sorts of crazy stuff if you put in some effort.

So, uh, let's put in some effort.

The GUI currently has a number of helpful table functions, so before doing anything new we might as well move them to a separate module.

This Could Not Have Gone More Smoothly

  • Move all of the table functions into a separate module.
  • Rename them all from things like GUI.table_copy to Table.copy.
  • Use Visual Studio Code's beautiful "Search In All Files" window to replace all references to GUI.table_copy with references to Table.copy. Seriously, I cannot possibly say enough good things about this feature, especially with the option of regular expressions on top. I did in five minutes what would have probably taken half an hour without it.
  • Run the test script.
  • Watch it crash... hard.
  • Realize that none of the existing modules actually load Table.lua, go back and import it.
  • Success!

Alright, that's the refactoring done. Now onto some new stuff.

WOO! NEW STUFF!

As I mentioned, Javascript offers a whole pile of nice functions here. Why not implement a few of them to save ourselves some time down the road?

Their usage will be as close to their Javascript equivalents as we can get. That is:

// Javascript
const filtered = myTable.filter((value, index, array) => {
  if (somethingSomething(value)) return true;
});
-- Lua
local filtered = Table.filter(myTable, function(value, key, theTable)
  if (somethingSomething(value)) return true
end)

Table.filter()

Returns a new table containing only those items for which the function cb(value, key, table) was true.

Table.filter = function(t, cb)
  local filtered, l = {}, 0

  for k, v in pairs(t) do
    if cb(v, k, t) then
      filtered[l] = v
      l = l + 1
    end
  end

  return filtered
end

The use of l here is an efficiency trick - using insert or filtered[#filtered + 1] requires Lua to calculate the length each time, so by tracking it ourselves we save a little bit of time.

Table.map()

Runs cb(value, key, table) for each item and returns them in a new table with the same keys.

Table.map = function(t, cb)
  local mapped = {}

  for k, v in pairs(t) do
    mapped[k] = cb(v, k, t)
  end

  return mapped
end

Table.reduce()

This one's a little awkward to explain - it uses the given cb function to combine all of a table's elements into one output value. The most basic use case would be summing a bunch of numbers, but you can actually do some pretty crazy stuff too.

Table.reduce = function(t, cb, acc)
  if acc == nil then acc = 0 end

  for k, v in pairs(t) do
    acc = cb(acc, v, k, t)
  end

  return acc
end

At this point we should note one important difference between Lua and Javascript - Lua's pairs iterator doesn't give a damn about the order of a table's items. It will process them based on how they're currently positioned in memory, and so any operations we're running may not run in the order we expect.

In this case, a separate set of functions are necessary.

Table.orderedMap()

Table.orderedMap = function(t, cb)
  local mapped = {}
  local l = #t

  for i = 1, l do
    mapped[i] = cb(t[i], i, t)
  end

  return mapped
end

Etc., etc. Not particularly exciting, I know.

Something Particularly Exciting

One of Lua's quirks is that while strings come with the following syntactic sugar:

string.sub(string.gsub(string.match(myString, "hello"), "l", "1"), 3)

-- can be written more legibly as
myString:match("hello"):gsub("l", "1"):sub(3)

...tables don't. You're stuck with:

table.concat(myTable, ", ")

(I couldn't actually find another reasonable table function to chain it with because the library is so small.)

What we CAN do, however, is attach that functionality ourselves when we create them, and use some other sugar to make it really simple to use.

Note: In the code and descriptions below Table refers to our custom module and table refers to Lua's native table library.

Table.lua

local Table = {}
Table.map = ...
Table.reduce = ...
-- etc.

setmetatable(Table, {__index = table})

local function T (t) return setmetatable(t, {__index = Table}) end

return T{Table, T}

"Uh... what the hell is that supposed to be?"

  • We create Table and fill it with all of our new table functions.
  • We set Table's metatable (Lua's equivalent of "prototype" in other languages like JS) to be table. This means that if a script tries to call Table.sort(), which doesn't exist, it can check if table has a sort() function and use that.
  • We create a wrapper function, T, that accepts a table and sets its metatable to Table, giving it access to all of our custom functions and, by extension, the table library as well.
  • Since we've got that sexy little T now, we use it to return Table and the wrapper itself since Lua modules can only return one value. Why wrap the output instead of just returning {Table, T}? To make the require statement below a little shorter:

Other Module

local Table, T = require('table'):unpack()

local fancyTable = T{2, 4, 6}

Msg(
  fancyTable
    :map(function(val) return val / 2 end)
    :filter(function(val) return val % 2 ~= 0 end)
    :concat(" and ")
)
--> 1 and 3

There, that's a bit better. For comparison, without our helpers and sugar that last line would look like:

local halvesTable = {}
for i = 1, #fancyTable do
  local half = fancyTable[i] / 2
  if (half % 2 ~= 0) then
    halvesTable[#halvesTable + 1] = half
  end
end

Msg( table.concat(halvesTable, " and ") )

I should note that the : syntax does come with performance penalties if you aren't careful - each map, filter, etc loops over the entire table that was given to it, so it's easy to find yourself doing a lot more work than you intended. The example above is guilty of this.

In situations where you expect to be using these to operate on a large number of items, it's worth trying to consolidate your logic down to use as few loops as possible. For instance, the callback for reduce could quite easily incorporate the check from filter and only contribute to the result if the filter passes.

But I digress.

You might be asking "why did we return Table as well? Why not just T?" Well, it's likely that we'll have to deal with tables returned by other functions that weren't created by T, in which case our modified Table will still let us filter, map, and reduce them.


Next up: More refactoring! I bet you didn't see that coming.