Retour au cours

games / lua

Tables avancées : structures de données

Leçon 41 exercice

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é -> true pour 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éeImplémentation LuaCoût d'accès typique
Pile (LIFO)Table + insert/remove en finO(1)
File (FIFO)Table + deux index (first/last)O(1)
Ensemble (set)Table clé -> trueO(1)
Cache sans fuiteTable 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

lua
-- 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
lua
-- 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"
lua
-- 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
lua
-- 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
lua
-- 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)
lua
-- 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 3

Ré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

1 disponible
1

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 :

lua
local buildQueue = {}

local function enqueue(action)
  table.insert(buildQueue, action)
end

local function processNext()
  local action = table.remove(buildQueue, 1)
  return action
end

Sur 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.

Résoudre l’exercice →