Pular para o conteúdo
Tables and loops

Tables and loops

Building arrays without table.insert, numeric for against pairs, caching repeated lookups, keeping a size counter, and not generating garbage every frame.

Por enquanto, esta documentação está em inglês.

Last updated

Tables are the only data structure Lua has, so almost every hot loop in a FiveM resource is a table loop. None of the changes below are dramatic on their own. Together, in code that runs per frame or over a few thousand rows, they add up.

Let arrays index themselves

Writing the indexes out is noise, and it is a chance to get them wrong:

Lua
-- Noise, and nothing checks that the numbers are in order
local spawns = {
    [1] = vector3(-269.4, -955.3, 31.2),
    [2] = vector3(195.1, -933.5, 30.6),
    [3] = vector3(-1037.7, -2737.5, 20.1),
}

-- Same table
local spawns = {
    vector3(-269.4, -955.3, 31.2),
    vector3(195.1, -933.5, 30.6),
    vector3(-1037.7, -2737.5, 20.1),
}

Write explicit keys only when the number means something, such as a seat index or a component id where [4] skipping [3] is deliberate.

Dot for fixed keys, brackets for variable keys

Lua
local price = items.water.price      -- key is known while you write it
local price = items[itemName].price  -- key comes from a variable

They compile to the same lookup, so this is purely about the reader. items["water"] in the middle of a line makes people stop and check whether the key is dynamic.

Cache repeated lookups

Every a.b.c is two hash lookups. Repeat it five times in one function and you have done ten:

Lua
-- Four lookup chains
if Config.Shops[id].stock > 0 and Config.Shops[id].open then
    Config.Shops[id].stock = Config.Shops[id].stock - 1
end

-- One chain, then a local
local shop = Config.Shops[id]
if shop.stock > 0 and shop.open then
    shop.stock = shop.stock - 1
end

The second version is faster and much easier to read, and because tables are references, writing to shop.stock still writes to the real table.

Appending: t[#t + 1] over table.insert

For a plain append, table.insert(t, v) and t[#t + 1] = v have the same result, but table.insert is a C library function: it validates the argument, takes the table length through the C API, and assigns through the C API. Direct indexing is a few VM instructions and never leaves the interpreter.

Lua
-- Slightly more work per call
table.insert(results, value)

-- The direct form
results[#results + 1] = value

Setting a named key with table.insert does not work at all, whatever you may have seen. table.insert(t, 'key', 'value') is interpreted as “insert 'value' at position 'key'” and errors. Write t.key = value.

Keep table.insert for what it is actually for: inserting into the middle of an array and shifting the rest up, which is the one thing the direct form cannot do.

Note

The difference is small. It is worth writing the direct form by default because it costs nothing to type, and worth changing inside a loop over thousands of rows. It is not worth a pull request across a working resource.

Keep a counter when you are filling an array

Lua
-- The length is looked up on every pass
local out = {}
for i = 1, #source do
    out[#out + 1] = transform(source[i])
end

-- No length lookup at all
local out, n = {}, 0
for i = 1, #source do
    n = n + 1
    out[n] = transform(source[i])
end

You will find claims that #t is a linear scan. It is not: Lua looks for the array boundary with the size it already tracks and a binary search at worst, so the cost is small and does not grow the way a scan would. The counter is still worth it in a tight loop because it replaces that search with an addition, and there is a correctness reason too: if the array can contain nil holes, #t is allowed to return any boundary, so t[#t + 1] can overwrite an existing value. A counter you control never does.

Numeric for over pairs for arrays

Lua
-- Slower, and the order is not defined
for _, shop in pairs(shops) do
    drawShop(shop)
end

-- Fastest, and in order
for i = 1, #shops do
    drawShop(shops[i])
end

pairs returns the next function and calls it on every iteration, and the standard makes no promise about the order it walks a table in. For an array where the order matters, that alone is a reason to use a numeric loop. ipairs keeps the order and is close in speed, but it stops at the first nil, which silently truncates an array with a hole in it.

Use pairs for what it is for: hash tables with string keys, where there is no index to count.

Do not build garbage every frame

The collector is the one cost that shows up as a stutter rather than a number. Anything allocated in a per frame loop is collected later, in a frame you did not choose:

Lua
-- A new table and a new string, 60 times a second
CreateThread(function()
    while true do
        Wait(0)
        local pos = { x = 0.5, y = 0.9 }
        drawText('Speed: ' .. math.floor(speed) .. ' km/h', pos)
    end
end)

-- One table, reused, and the string only rebuilt when it changes
local pos = { x = 0.5, y = 0.9 }
local lastSpeed, label = -1, ''

CreateThread(function()
    while true do
        Wait(0)
        local s = math.floor(speed)
        if s ~= lastSpeed then
            lastSpeed = s
            label = 'Speed: ' .. s .. ' km/h'
        end
        drawText(label, pos)
    end
end)

Strings in Lua are immutable, so every .. creates a new one. Building a string from many parts belongs in a table plus one table.concat, not in a loop of concatenations:

Lua
local parts, n = {}, 0
for i = 1, #rows do
    n = n + 1
    parts[n] = rows[i].name
end
local line = table.concat(parts, ', ')

Search once, not every time

A linear search through a config table inside a loop is the shape that quietly costs the most. Build the lookup once:

Lua
-- O(n) per call, every call
local function isBlacklisted(model)
    for i = 1, #Config.Blacklist do
        if Config.Blacklist[i] == model then return true end
    end
    return false
end

-- Built once when the file loads, O(1) per call
local blacklist = {}
for i = 1, #Config.Blacklist do
    blacklist[Config.Blacklist[i]] = true
end

local function isBlacklisted(model)
    return blacklist[model] == true
end

If the config holds model names and you compare against hashes, hash the keys once while building the set instead of hashing inside the check. See Cheap native calls.

Clean up what you keep

A table you keep adding to and never remove from is a memory leak with extra steps. Set entries to nil when they are done, especially tables keyed by player server id or entity handle, and clear them on playerDropped and onResourceStop. resmon’s memory column is where you notice.

Next: Cheap native calls.