games / lua
Tables avancées : structures de données
Explication
Construire des structures de données classiques avec un seul outil
La leçon précédente a montré que la table est l'unique brique de données de Lua. Cette leçon va plus loin : comment assembler cette brique pour reconstruire des structures de données classiques (pile, file, ensemble, matrice) que d'autres langages fournissent tout faits.
Ce que vous allez apprendre
- Implémenter une pile (LIFO) et une file (FIFO) efficacement avec des tables Lua
- Simuler un ensemble (set) avec une table clé ->
truepour une recherche en temps constant - Comprendre pourquoi
table.remove(t, 1)en boucle est coûteux à grande échelle - Utiliser les tables faibles (
__mode) pour construire un cache sans fuite mémoire - Représenter une grille 2D avec une table de tables
Dans quel contexte ?
Un système de crafting de jeu doit vérifier rapidement si un joueur possède déjà un ingrédient rare parmi des centaines possibles, gérer une file d'actions à traiter dans l'ordre (une file de construction), et mettre en cache le résultat de calculs coûteux sans bloquer indéfiniment la mémoire du serveur. Ces trois besoins, très courants dans un moteur de jeu, se résolvent avec les patterns présentés ici, construits uniquement à partir de tables.
| Structure recherchée | Implémentation Lua | Coût d'accès typique |
|---|---|---|
| Pile (LIFO) | Table + insert/remove en fin | O(1) |
| File (FIFO) | Table + deux index (first/last) | O(1) |
| Ensemble (set) | Table clé -> true | O(1) |
| Cache sans fuite | Table faible (__mode) | O(1), libéré par le GC |
Piège fréquent
Implémenter une file en appelant table.remove(t, 1) à chaque retrait fonctionne, mais force Lua à décaler tous les éléments restants d'une position à chaque appel — un coût qui grandit avec la taille de la table. Utiliser deux index (first/last), comme montré plus bas, évite ce problème.
Pourquoi le choix d'implémentation a un impact réel sur la performance
Une pile (LIFO) s'implémente naturellement en ajoutant/retirant à la FIN d'une table, une opération rapide. Mais implémenter une file (FIFO) en retirant systématiquement le PREMIER élément avec table.remove(t, 1) a un coût caché : chaque retrait force Lua à décaler tous les éléments restants d'une position, ce qui rend l'opération de plus en plus coûteuse à mesure que la table grandit. Comprendre cette différence évite d'écrire du code qui devient lent sans raison apparente à grande échelle.
L'astuce du "set" en Lua
Il n'existe pas de type "ensemble" natif, mais on peut le simuler efficacement avec une table où chaque clé pointe vers true : tester "est-ce que cet élément existe" devient une simple recherche de clé, en temps constant, bien plus rapide qu'une boucle qui parcourt une liste pour chercher une correspondance.
Les tables faibles, un concept avancé mais important
Une table normale empêche le ramasse-miettes (garbage collector) de libérer la mémoire d'un objet tant que la table le référence — même si plus rien d'autre dans le programme n'en a besoin. Une table "faible" (via la métatable __mode) dit explicitement au ramasse-miettes "ignore cette référence si c'est la seule qui reste", ce qui est précieux pour construire un cache qui ne fuit pas la mémoire indéfiniment.
Commandes & code
Tables avancées : structures de données
-- Pile (stack) LIFO avec une table Lua : table.insert/remove en fin sont O(1)
local Stack = {}
Stack.__index = Stack
function Stack.new()
return setmetatable({ items = {}, size = 0 }, Stack)
end
function Stack:push(value)
self.size = self.size + 1
self.items[self.size] = value
end
function Stack:pop()
if self.size == 0 then return nil end
local value = self.items[self.size]
self.items[self.size] = nil
self.size = self.size - 1
return value
end
local s = Stack.new()
s:push(1); s:push(2); s:push(3)
print(s:pop()) -- 3-- File (queue) FIFO : utiliser deux index (first/last) pour éviter le coût O(n) de table.remove(t, 1)
local Queue = {}
Queue.__index = Queue
function Queue.new()
return setmetatable({ items = {}, first = 1, last = 0 }, Queue)
end
function Queue:enqueue(value)
self.last = self.last + 1
self.items[self.last] = value
end
function Queue:dequeue()
if self.first > self.last then return nil end -- file vide
local value = self.items[self.first]
self.items[self.first] = nil
self.first = self.first + 1
return value
end
local q = Queue.new()
q:enqueue("a"); q:enqueue("b")
print(q:dequeue()) -- "a"-- Set (ensemble) : utiliser une table comme dictionnaire clé -> true, recherche O(1)
local allowedRoles = { admin = true, moderator = true, editor = true }
local function hasRole(role)
return allowedRoles[role] == true -- accès direct, pas de boucle de recherche
end
print(hasRole("admin")) -- true
print(hasRole("guest")) -- false (nil == true -> false)
-- Ajout / suppression dans le set
allowedRoles.guest = true
allowedRoles.editor = nil -- retirer une clé = l'assigner à nil-- Table pondérée (weak table) : ne retient pas ses clés/valeurs pour le garbage collector
-- Utile pour un cache qui ne doit pas empêcher la libération mémoire des objets référencés
local cache = setmetatable({}, { __mode = "v" }) -- "v" = valeurs faibles, "k" = clés faibles, "kv" = les deux
local function getExpensiveResource(id)
if cache[id] then return cache[id] end
local resource = computeExpensiveResource(id) -- fonction coûteuse fictive
cache[id] = resource
return resource
end
-- Si plus aucune autre référence ne pointe vers "resource", le GC peut le libérer même si cache[id] existe-- Table comme "record"/struct léger avec valeurs par défaut via une fonction constructeur
local function newCharacter(overrides)
local defaults = { hp = 100, mana = 50, level = 1, name = "Sans nom" }
local character = overrides or {}
for key, defaultValue in pairs(defaults) do
if character[key] == nil then
character[key] = defaultValue
end
end
return character
end
local hero = newCharacter({ name = "Alice", level = 5 })
print(hero.name, hero.level, hero.hp) -- "Alice" 5 100 (hp vient du défaut)-- Multi-dimension : matrice 2D représentée par une table de tables
local function newGrid(width, height, fill)
local grid = {}
for y = 1, height do
grid[y] = {}
for x = 1, width do
grid[y][x] = fill
end
end
return grid
end
local map = newGrid(5, 3, 0)
map[2][3] = 1 -- ligne 2, colonne 3Résumé
- Pile et file s'implémentent efficacement en table sans jamais utiliser
table.remove(t, 1)en boucle (coût O(n)). - Un set Lua est une table clé ->
true, offrant une recherche O(1) au lieu d'une boucle de recherche. - Les tables faibles (
__mode) évitent qu'un cache empêche le garbage collector de libérer la mémoire. - Une table de tables représente naturellement une grille/matrice 2D.
Exercices pratiques
Mission : la file de construction qui ralentit le serveur
Objectif : Identifier pourquoi une file d'actions de construction devient de plus en plus lente et la remplacer par une implémentation efficace.
Contexte
Un système de construction RP traite les actions des joueurs dans l'ordre où elles arrivent, avec ce code :
local buildQueue = {}
local function enqueue(action)
table.insert(buildQueue, action)
end
local function processNext()
local action = table.remove(buildQueue, 1)
return action
endSur un petit serveur de test, tout semble fluide. Une fois en production avec des centaines d'actions accumulées dans la file en période de rush, les administrateurs remarquent que processNext() devient de plus en plus lent à mesure que la file grossit, provoquant des lags perceptibles.