Inferenza dei tipi in Luau

Dal 2006, gli sviluppatori di Roblox utilizzano il linguaggio di programmazione Lua per creare giochi ed esperienze interattive su Roblox. Gli sviluppatori di Roblox provengono da ogni ambito e hanno diversi livelli di esperienza, e le loro creazioni sono altrettanto varie.
Alcune di queste creazioni sono software davvero sofisticati. Molti contano decine di migliaia di righe di codice.
Sebbene Lua sia un linguaggio di programmazione meraviglioso e ci piaccia molto, abbiamo imparato la stessa lezione che ha imparato la comunità degli sviluppatori web: scrivere applicazioni di grandi dimensioni con linguaggi di programmazione a tipizzazione dinamica è difficile!
Abbiamo creato Luau per colmare questa lacuna.
In sostanza, Luau è innanzitutto un motore di inferenza dei tipi e, in secondo luogo, un verificatore di tipi. Traiamo questa filosofia da lavori precedenti di successo come i compilatori OCaml e TypeScript.
Tuttavia, Lua non è proprio uguale né a OCaml né a JavaScript. Per rendere Luau il più efficace possibile, è molto importante modellare accuratamente gli aspetti che rendono Lua speciale.
Per prima cosa, spieghiamo alcune nozioni di base.
Inferenza dei tipi 101
L'inferenza dei tipi consiste quasi sempre nell'osservare che un tipo A deve essere uguale a un altro tipo B. Si tratta di una semplificazione, ma meno di quanto si possa pensare.
Partiamo da un esempio molto semplice:
function id(x)
return x
end
local a = 5
local b = id(a)Quando controlliamo il tipo di unide, creiamo due tipi segnaposto: il tipo dell'xe e il tipo di ritorno della funzione.
Questi segnaposto indicano solo una cosa: non sappiamo nulla di loro.
Analizziamo quindi l'istruzione return. Osserviamo che, qualunque sia il tipo restituito dalla funzione, è uguale al tipo di x. Poiché x non ha altri vincoli, il tipo finale dedotto di id è la funzione generica (T) -> T.
(Luau non fornisce ancora una sintassi per le funzioni generiche, ma le strutture dati interne sono in grado di rappresentarle.)
Quando arriva il momento di dedurre un tipo per b, possiamo mettere a frutto la nostra conoscenza di id. Prendiamo il tipo di id e associamo il suo tipo di parametro al tipo di argomento concreto di cui disponiamo. Poiché il tipo di ritorno della funzione è già associato al suo tipo di parametro, (T) -> T viene istanziato come (number) -> number. Da ciò, possiamo dedurre che il tipo di b è number.
Quasi tutto il lavoro di verifica dei tipi svolto da Luau è un'estensione di questa idea.
Ritorni multipli
In quasi tutti i linguaggi di programmazione di uso comune, le funzioni restituiscono esattamente un valore. Molti linguaggi (soprattutto quelli funzionali) offrono tuple leggere per rendere i ritorni multipli facili e convenienti.
Lua va contro questa tendenza consentendo a ogni funzione di restituire 0 o più valori. Qui non è in gioco alcun meccanismo di tupla, poiché non c'è modo di associare l'intera tupla a un singolo nome.
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())Questo pone alcune sfide nuove. Qual è il tipo di compose qui sotto?
function compose(f, g)
return function(...)
return f(g(...))
end
endIn altri linguaggi, potremmo concludere che il tipo di ritorno di `g` deve essere lo stesso del tipo di argomento di `f`. In Lua, `f` deve accettare lo stesso numero e gli stessi tipi di argomenti di quelli restituiti da `g`.
In Lua, rappresentiamo questo concetto con qualcosa che chiamiamo "type pack". È molto simile alla struttura dati che usiamo per descrivere un tipo, ma rappresenta 0 o più tipi.
Come i tipi, i pacchetti di tipi supportano il concetto di segnaposto che possono essere successivamente associati ad altri pacchetti. I pacchetti di tipi hanno alcune altre proprietà: la loro lunghezza può essere nota o sconosciuta e, se nota, possono avere dimensioni fisse o variabili.
Luau modella le funzioni come una coppia di pacchetti di tipi: uno per l'elenco degli argomenti e uno per i valori di ritorno.
Se ipotizziamo la sintassi A... per indicare un pacchetto di tipi generico, possiamo scrivere un tipo per compose:
((B...) -> C..., (A...) -> B...) -> (A...) -> C...
(Luau non supporta ancora questa sintassi. In arrivo!)
Tabelle
Le tabelle sono molto importanti quando si scrive in Lua. Sono i nostri array, le nostre mappe hash e i nostri oggetti, tutto in uno. È logico che dedurre correttamente i tipi di tabella dal codice Lua sia piuttosto importante.
In Luau, suddividiamo le tabelle in 4 categorie:
- Tabelle di cui conosciamo la struttura esatta
- Tabelle costruite pezzo per pezzo
- Oggetti simili a tabelle che vengono passati come parametri di funzione e
- Tipi di dati dell'API di Roblox
Li chiamiamo tabelle sigillate, tabelle non sigillate, tabelle generiche e classi native.
Tabelle sigillate
Un bug molto comune che vogliamo essere in grado di rilevare è un nome di proprietà scritto in modo errato nell'assegnazione di una proprietà della tabella.
local some_table = {some_property=0}
-- oops. I got the name of the property wrong
some_table.sone_property = 55Le tabelle sono generalmente sigillate per impostazione predefinita.
Tabelle non sigillate
Per lavorare senza intoppi con il Lua idiomatico, abbiamo bisogno di un modo per supportare funzioni e moduli che costruiscono tabelle su più istruzioni.
local Counter = {}
Counter.value = 0
function Counter.increment()
Counter.value = Counter.value + 1
return Counter.value
endIn questo esempio, sarebbe sciocco da parte nostra guardare la prima riga, dedurre il tipo {} e generare un errore di tipo nella seconda riga perché ci aspettiamo che Counter rimanga vuoto per sempre.
Adottiamo quindi l'approccio secondo cui Counter è una tabella non sigillata. Luau è facilmente in grado di conoscere la forma esatta di questa tabella, ma la consideriamo aperta all'estensione.
Non vogliamo che sia troppo facile rendere una tabella non sigillata, quindi applichiamo alcune semplici regole empiriche:
- Il tipo di una tabella è non sigillato quando viene inizializzato con una tabella vuota letterale, e
- le tabelle non sigillate vengono convertite in tabelle sigillate ogni volta che ne incontriamo una in una firma di funzione.
Questo ci garantisce un'ottima usabilità.
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.Tabelle generiche
Quando si ha a che fare con parametri di funzione non annotati, raramente è possibile individuare la forma esatta di un argomento utilizzato in modo simile a una tabella:
local function print_point(p)
print(‘X =’, p.X, ‘Y =’, p.Y)
endPossiamo sapere che p ha X e Y, ma, dato che la funzione print può stampare qualsiasi cosa, questo è tutto. Quelle proprietà potrebbero avere qualsiasi tipo. Potrebbero essere presenti anche un numero qualsiasi di altre proprietà. Non è nemmeno necessario che sia effettivamente una tabella; potrebbe essere un tipo API Roblox come Vector3.
(Maggiori informazioni sull'API Roblox tra un attimo)
Come in OCaml e in alcuni altri linguaggi di programmazione, i parametri delle tabelle Luau sono polimorfici per riga. In Luau, li chiamiamo tabelle generiche. I campi che si deduce siano presenti in una tabella generica sono requisiti imposti ai chiamanti. Altre proprietà sono consentite purché sia presente la struttura richiesta:
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 di Roblox
L'API di Roblox mette a disposizione della nostra ambiziosa comunità di sviluppatori una serie di strumenti molto potenti. L'API è costituita da numerose classi C++ che sono state riflesse in Lua.
È ovvio che Luau debba essere a conoscenza di questa API. Parte di tale consapevolezza è la conoscenza del fatto che le istanze delle classi Roblox non sono in realtà tabelle Lua. Ad esempio, la funzione integrata `pairs()` non può essere utilizzata per iterare sulle proprietà di un tipo API Roblox.
Il sistema di tipi che abbiamo descritto finora per Luau è un sistema di tipi completamente strutturale. Lua (e Luau) considerano le tabelle nient'altro che l'insieme delle proprietà che contengono. L'API di Roblox non si adatta a questo modello. Il C++ fornisce un sistema di tipi nominali in cui ogni classe ha una propria "identità". È del tutto normale avere due classi distinte nonostante condividano esattamente la stessa struttura. Esistono classi Roblox reali che soddisfano questa proprietà e Luau deve modellarle correttamente.
Risolviamo questo problema introducendo un tipo simile a una tabella per le istanze delle classi Roblox integrate. I tipi di classe sono diversi dai tipi di tabella in quanto hanno identità che li distinguono anche se supportano tutti gli stessi metodi con tutti gli stessi tipi. Supportano anche il concetto di ereditarietà, proprio come le classi C++ che sono state create per modellare.
Conclusione
Ricavare tipi statici da un linguaggio dinamico come Lua pone molte sfide. Molte di queste sfide sono specifiche di Lua, ma sono abbastanza risolvibili. Pensiamo che funzioni piuttosto bene! 🙂
Andy Friesen è il responsabile tecnico del type checker Luau. È entusiasta di lavorare all'intersezione tra videogiochi, strumenti di sviluppo e linguaggi di programmazione.
Né Roblox Corporation né questo blog promuovono o sostengono alcuna azienda o servizio. Inoltre, non vengono fornite garanzie o promesse riguardo all'accuratezza, all'affidabilità o alla completezza delle informazioni contenute in questo blog.


