Le contenu de ce site a été traduit à l'aide de l'intelligence artificielle (IA) ou d'une technologie de traduction automatique, et peut contenir des erreurs.

Skip to content

Inférence de type dans Luau

Depuis 2006, les développeurs Roblox utilisent le langage de programmation Lua pour créer des jeux et des expériences interactives sur Roblox. Les développeurs Roblox viennent de tous les horizons et possèdent des niveaux d'expérience variés ; leurs créations sont tout aussi variées.

Certaines de ces créations sont de véritables logiciels sophistiqués. Beaucoup comptent des dizaines de milliers de lignes de code.

Bien que Lua soit un langage de programmation formidable et que nous l'apprécions beaucoup, nous avons tiré la même leçon que la communauté des développeurs web : écrire de grandes applications avec des langages de programmation à typage dynamique est difficile !

Nous avons créé Luau pour combler cette lacune.

À la base, Luau est d'abord un moteur d'inférence de types, puis un vérificateur de types. Nous tirons cette philosophie de travaux antérieurs couronnés de succès, tels que les compilateurs OCaml et TypeScript.

Cependant, Lua n’est pas tout à fait comparable à OCaml ou à JavaScript. Pour que Luau soit aussi performant que possible, il est essentiel que nous modélisions avec précision les éléments qui font la spécificité de Lua.

Commençons par expliquer quelques notions de base.

Introduction à l'inférence de types

L'inférence de types consiste presque toujours à observer qu'un type A doit être identique à un autre type B. C'est une simplification, mais moins que ce que l'on pourrait croire.

Commençons par un tout petit exemple :

function id(x)
    return x
end

local a = 5
local b = id(a)

Lorsque nous vérifions le type d'id, nous créons deux types de remplacement : le type d'x et le type de retour de la fonction.

Ces substituts n'indiquent qu'une seule chose : nous ne savons rien à leur sujet.

Nous analysons ensuite l'instruction `return`. Nous observons que, quel que soit le type renvoyé par la fonction, il est identique au type de `x`. Comme `x` n'est soumis à aucune autre contrainte, le type final déduit de `id` est la fonction générique `(T) -> T`.

(Luau ne fournit pas encore de syntaxe pour les fonctions génériques, mais les structures de données internes peuvent les représenter.)
Lorsque vient le moment de déduire un type pour b, nous pouvons mettre à profit notre connaissance de id. Nous prenons le type de id et lions son type de paramètre au type d’argument concret dont nous disposons. Étant donné que le type de retour de la fonction est déjà lié à son type de paramètre, (T) -> T est instancié comme (number) -> number. À partir de là, nous pouvons en déduire que le type de b est number.

La quasi-totalité du travail de vérification des types effectué par Luau est une extension de cette idée.

Retours multiples

Dans presque tous les langages de programmation couramment utilisés, les fonctions renvoient exactement une valeur. De nombreux langages (en particulier les langages fonctionnels) proposent des tuples légers pour rendre les retours multiples faciles et pratiques.

Lua va à l'encontre de cette tendance en permettant à chaque fonction de renvoyer 0 ou plusieurs valeurs. Aucun mécanisme de tuple n'est utilisé ici, car il n'y a aucun moyen de lier l'ensemble du tuple à un seul nom.

function take_five(a, b, c, d, e)
    print(a, b, c, d, e)
end

function get_five()
    return 1, 2, 3, 4, 5
end
-- a receives 1.  The other return values are discarded.
local a = get_five()
-- foo, bar, and baz receive 1, 2, and 3, respectively.
-- Arguments 4 and 5 are discarded.
local foo, bar, baz = get_five()
-- the 5 return values from get_five are passed as
-- parameters to take_five
take_five(get_five())

Cela pose des défis inédits. Quel est le type de la fonction compose ci-dessous ?

function compose(f, g)
    return function(...)
        return f(g(...))
    end
end

Dans d'autres langages, on pourrait conclure que le type de retour de `g` doit être le même que le type d'argument de `f`. En Lua, `f` doit accepter le même nombre d'arguments et les mêmes types que ceux renvoyés par `g`.

En Lua, nous représentons cela par ce que nous appelons un paquet de types. Il est très similaire à la structure de données que nous utilisons pour décrire un type, mais il représente 0 ou plusieurs types.

Tout comme les types, les paquets de types prennent en charge la notion de placeholders qui peuvent être liés ultérieurement à d'autres paquets. Les paquets de types possèdent d'autres propriétés : leur longueur peut être connue ou inconnue et, si elle est connue, ils peuvent avoir une taille fixe ou variable.

Luau modélise les fonctions comme une paire de type packs : un pour la liste d'arguments et un pour les valeurs de retour.
Si l'on pose la syntaxe A... pour indiquer un type pack générique, on peut écrire un type pour compose :

((B...) -> C..., (A...) -> B...) -> (A...) -> C...

(Luau ne prend pas encore en charge cette syntaxe non plus. À venir bientôt !)

Tables

Les tables sont très importantes lors de l'écriture en Lua. Elles sont à la fois nos tableaux, nos tables de hachage et nos objets. Il va de soi qu'il est assez important de déduire correctement les types de tables à partir du code Lua.

Dans Luau, nous classons les tables en 4 catégories :

  • Les tables dont nous connaissons la structure exacte
  • Les tables construites par morceaux
  • Les objets de type table qui sont passés en paramètres de fonction, et
  • Les types de données de l'API Roblox

Nous appelons ces éléments « tables scellées », « tables non scellées », « tables génériques » et « classes natives ».

Tables scellées

Une erreur très courante que nous souhaitons pouvoir détecter est une faute d'orthographe dans le nom d'une propriété lors de l'affectation d'une propriété de table.

local some_table = {some_property=0}
-- oops.  I got the name of the property wrong
some_table.sone_property = 55

Les tables sont généralement scellées par défaut.

Tables non scellées

Pour fonctionner correctement avec le Lua idiomatique, nous avons besoin d’un moyen de prendre en charge les fonctions et les modules qui construisent des tables sur plusieurs instructions.

local Counter = {}
Counter.value = 0

function Counter.increment()
    Counter.value = Counter.value + 1
    return Counter.value
end

Dans cet exemple, il serait absurde de notre part d'examiner la première ligne, d'en déduire le type {} et de générer une erreur de type à la deuxième ligne, car nous nous attendons à ce que Counter reste vide pour toujours.

Nous partons donc du principe qu’Counter est une table non scellée. Luau est facilement en mesure de connaître la structure exacte de cette table, mais nous la considérons comme susceptible d’être étendue.

Nous ne voulons pas qu’il soit trop facile de déverrouiller une table, nous appliquons donc quelques heuristiques simples :

  • Le type d’une table est non scellé lorsqu’elle est initialisée avec une table vide littérale, et
  • Les tables non scellées sont converties en tables scellées dès que nous en rencontrons une dans la signature d'une fonction.

Cela nous offre une très bonne ergonomie.

function new_counter()
    local Counter = {}
    Counter.value = 0 -- OK.  Counter is unsealed.

    function Counter.increment()
        Counter.value = Counter.value + 1
        return Counter.value
    end

    return Counter
end

local c = new_counter()
c.value_ = 5 -- Not allowed.  c is a sealed table here.

Tables génériques

Lorsqu'on traite des paramètres de fonction non annotés, il est rarement possible de déterminer la forme exacte d'un argument utilisé à la manière d'une table :

local function print_point(p)
    print(‘X =’, p.X, ‘Y =’, p.Y)
end

Nous savons qu’p possède X et Y, mais, comme la fonction print peut afficher n’importe quoi, c’est à peu près tout. Ces propriétés pourraient être de n’importe quel type. Un nombre indéfini d’autres propriétés pourrait également être présent. Il n’est même pas nécessaire qu’il s’agisse réellement d’un tableau ; il pourrait s’agir d’un type de l’API Roblox comme Vector3.

(Nous reviendrons sur l'API Roblox dans un instant)

À l’instar d’OCaml et de certains autres langages de programmation, les paramètres de table Luau sont polymorphes par ligne. En Luau, on les appelle des tables génériques. Les champs dont la présence est déduite dans une table générique constituent des contraintes imposées aux appelants. D’autres propriétés sont autorisées tant que la structure requise est présente :

local a = print_point({X=3, Y=4})
local c = print_point({X=4, Y=3, Name='The Best Point'})
local b = print_point(Vector3.new(3, 4, 0))

L'API Roblox

L'API Roblox met à la disposition de notre ambitieuse communauté de développeurs un large éventail d'outils puissants. L'API se compose d'un grand nombre de classes C++ qui ont été transposées en Lua.

Il est évident que Luau doit prendre en compte cette API. Cela implique notamment de savoir que les instances de classes Roblox ne sont pas réellement des tables Lua. Par exemple, la fonction intégrée `pairs()` ne peut pas être utilisée pour parcourir les propriétés d'un type de l'API Roblox.

Le système de types que nous avons décrit jusqu'à présent pour Luau est un système de types entièrement structurel. Lua (et Luau) considèrent les tables comme n'étant ni plus ni moins que l'ensemble des propriétés qu'elles contiennent. L'API Roblox ne correspond pas à ce modèle. Le C++ fournit un système de types nominaux où chaque classe possède sa propre « identité ». Il est tout à fait courant d'avoir deux classes distinctes bien qu'elles partagent exactement la même structure. Il existe des classes Roblox réelles qui satisfont cette propriété et Luau doit les modéliser correctement.

Nous résolvons ce problème en introduisant un type de type table pour les instances de classes Roblox intégrées. Les types de classes diffèrent des types de tables en ce qu’ils possèdent des identités qui les distinguent, même s’ils prennent en charge toutes les mêmes méthodes avec tous les mêmes types. Ils prennent également en charge la notion d’héritage, tout comme les classes C++ qu’ils sont censés modéliser.

Conclusion

Déduire des types statiques à partir d'un langage dynamique comme Lua pose de nombreux défis. Bon nombre de ces défis sont spécifiques à Lua, mais ils sont tout à fait surmontables. Nous pensons que cela fonctionne plutôt bien ! 🙂

Andy Friesen est responsable technique du vérificateur de types Luau. Il est ravi de travailler à la croisée des chemins entre les jeux vidéo, les outils de développement et les langages de programmation.

Ni Roblox Corporation ni ce blog ne cautionnent ou ne soutiennent aucune entreprise ou service. De plus, aucune garantie ni promesse n'est donnée quant à l'exactitude, la fiabilité ou l'exhaustivité des informations contenues dans ce blog.