Wnioskowanie o typach w Luau

Od 2006 roku programiści Roblox używają języka programowania Lua do tworzenia gier i interaktywnych doświadczeń na platformie Roblox. Programiści Roblox pochodzą z różnych środowisk i mają różny poziom doświadczenia, a ich dzieła są równie zróżnicowane.
Niektóre z tych dzieł to naprawdę zaawansowane oprogramowanie. Wiele z nich ma dziesiątki tysięcy linii kodu.
Chociaż Lua jest wspaniałym językiem programowania i bardzo go lubimy, nauczyliśmy się tego samego, czego nauczyła się społeczność programistów internetowych: pisanie dużych aplikacji w językach programowania o typowaniu dynamicznym jest trudne!
Stworzyliśmy Luau, aby wypełnić tę lukę.
W swej istocie Luau jest przede wszystkim silnikiem wnioskowania o typach, a dopiero w drugiej kolejności narzędziem do sprawdzania typów. Filozofię tę czerpiemy z udanych wcześniejszych prac, takich jak kompilatory OCaml i TypeScript.
Jednak Lua naprawdę nie jest taka sama jak OCaml czy JavaScript. Aby Luau było jak najlepsze, bardzo ważne jest, abyśmy dokładnie odwzorowali cechy, które sprawiają, że Lua jest wyjątkowa.
Najpierw wyjaśnijmy kilka podstawowych kwestii.
Wprowadzenie do wnioskowania o typach
Wnioskowanie o typach prawie zawsze polega na zaobserwowaniu, że jakiś typ A musi być taki sam jak jakiś inny typ B. To uproszczenie, ale nie aż tak duże, jak mogłoby się wydawać.
Zacznijmy od naprawdę prostego przykładu:
function id(x)
return x
end
local a = 5
local b = id(a)Kiedy sprawdzamy typ funkcji `id`, tworzymy dwa typy zastępcze: typ argumentu `x` oraz typ zwracany przez funkcję.
Te symbole zastępcze wskazują tylko na jedną rzecz: nie wiemy o nich nic.
Następnie analizujemy instrukcję return. Zauważamy, że niezależnie od typu, jaki funkcja może zwrócić, jest on taki sam jak typ x. Ponieważ x nie ma żadnych innych ograniczeń, ostateczny typ id jest wnioskowany jako funkcja generyczna (T) -> T.
(Luau nie udostępnia jeszcze składni dla funkcji generycznych, ale wewnętrzne struktury danych mogą je reprezentować.)
Kiedy przychodzi czas na wywnioskowanie typu dla b, możemy wykorzystać naszą wiedzę na temat id. Bierzemy typ id i wiążemy jego typ parametru z konkretnym typem argumentu, który posiadamy. Ponieważ typ zwracany przez funkcję jest już powiązany z typem jej parametru, (T) -> T jest instancjonowane jako (number) -> number. Na tej podstawie możemy wywnioskować, że typem b jest liczba.
Prawie cała praca związana ze sprawdzaniem typów wykonywana przez Luau jest rozszerzeniem tej idei.
Wielokrotne zwracanie wartości
W prawie każdym powszechnie używanym języku programowania funkcje zwracają dokładnie jedną wartość. Wiele języków (zwłaszcza języków funkcyjnych) oferuje lekkie krotki, aby wielokrotne zwracanie wartości było łatwe i wygodne.
Lua odbiega od tego trendu, pozwalając każdej funkcji zwracać 0 lub więcej wartości. Nie ma tu mechanizmu krotki, bo nie ma sposobu, żeby przywiązać całą krotkę do jednej nazwy.
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())Stwarza to pewne nowe wyzwania. Jaki jest typ poniższej funkcji `compose`?
function compose(f, g)
return function(...)
return f(g(...))
end
endW innych językach moglibyśmy stwierdzić, że typ zwracany przez `g` musi być taki sam jak typ argumentu funkcji `f`. W Lua funkcja `f` musi akceptować taką samą liczbę argumentów i typy, jak te zwracane przez `g`.
W Luau przedstawiamy to za pomocą czegoś, co nazywamy pakietem typów. Jest on bardzo podobny do struktury danych, której używamy do opisania typu, ale reprezentuje 0 lub więcej typów.
Podobnie jak typy, pakiety typów obsługują pojęcie symboli zastępczych, które mogą być później powiązane z innymi pakietami. Pakiety typów mają kilka innych właściwości: ich długości mogą być znane lub nieznane, a jeśli są znane, mogą mieć rozmiar stały lub zmienny.
Luau modeluje funkcje jako parę pakietów typów: jeden dla listy argumentów, a drugi dla wartości zwracanych.
Jeśli przyjmiemy składnię A... do wskazania pakietu typów generycznych, możemy zapisać typ dla compose:
((B...) -> C..., (A...) -> B...) -> (A...) -> C...
(Luau również nie obsługuje jeszcze tej składni. Wkrótce!)
Tabele
Tabele są bardzo ważne podczas pisania w Lua. Są one jednocześnie naszymi tablicami, mapami skrótów i obiektami. Oczywiste jest, że prawidłowe wnioskowanie typów tabel z kodu Lua jest dość ważne.
W Luau dzielimy tabele na 4 kategorie:
- Tabele, których dokładną strukturę znamy
- Tabele tworzone fragmentami
- Elementy podobne do tabel, które są przekazywane jako parametry funkcji oraz
- Typy danych API Roblox
Nazywamy je tabelami zamkniętymi, tabelami otwartymi, tabelami generycznymi i klasami natywnymi.
Tabele zamknięte
Bardzo częstym błędem, który chcemy być w stanie wykryć, jest błędnie wpisana nazwa właściwości przy przypisywaniu właściwości tabeli.
local some_table = {some_property=0}
-- oops. I got the name of the property wrong
some_table.sone_property = 55Tabele są zazwyczaj domyślnie zamknięte.
Tabele niezamknięte
Aby płynnie pracować z idiomatycznym Lua, potrzebujemy sposobu na obsługę funkcji i modułów, które budują tabele w wielu instrukcjach.
local Counter = {}
Counter.value = 0
function Counter.increment()
Counter.value = Counter.value + 1
return Counter.value
endW tym przykładzie głupotą byłoby patrzeć na pierwszą linię, wywnioskować typ {} i wygenerować błąd typu w drugiej linii, ponieważ oczekujemy, że Counter pozostanie puste na zawsze.
Dlatego przyjmujemy założenie, że Counter jest tabelą niezamkniętą. Luau z łatwością rozpoznaje dokładny kształt tej tabeli, ale uważamy ją za otwartą na rozszerzenia.
Nie chcemy, aby odblokowanie tabeli było zbyt łatwe, dlatego stosujemy kilka prostych heurystyk:
- Typ tabeli jest nieuszczelniony, gdy jest inicjowany za pomocą dosłownej pustej tabeli, oraz
- Tabele nieuszczelnione są konwertowane na tabele uszczelnione, gdy tylko napotkamy je w sygnaturze funkcji.
Daje nam to całkiem dobrą użyteczność.
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.Tabele generyczne
W przypadku nieopisanych parametrów funkcji rzadko jest możliwe dokładne określenie kształtu argumentu używanego w sposób podobny do tabeli:
local function print_point(p)
print(‘X =’, p.X, ‘Y =’, p.Y)
endWiemy, że p ma X i Y, ale ponieważ funkcja print może wyświetlać cokolwiek, to wszystko. Te właściwości mogą mieć dowolny typ. Może być też dowolna liczba innych właściwości. To nawet nie musi być faktycznie tabela; może to być typ API Roblox, taki jak Vector3.
(Więcej o API Roblox za chwilę)
Podobnie jak w OCaml i niektórych innych językach programowania, parametry tablicowe w Luau są polimorficzne względem wierszy. W Luau nazywamy je tabelami generycznymi. Pola, których obecność w tabeli generycznej jest wnioskowana, stanowią wymagania nakładane na wywołujące. Inne właściwości są dozwolone, o ile istnieje wymagana struktura:
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))API Roblox
API Roblox udostępnia naszej ambitnej społeczności programistów wiele potężnych narzędzi. API składa się z wielu klas C++, które zostały odzwierciedlone w Lua.
Oczywiste jest, że Luau musi być świadome istnienia tego API. Częścią tej świadomości jest wiedza, że instancje klas Roblox nie są w rzeczywistości tabelami Lua. Na przykład wbudowana funkcja pairs() nie może być używana do iteracji nad właściwościami typu API Roblox.
System typów, który do tej pory opisaliśmy dla Luau, jest w pełni strukturalnym systemem typów. Lua (i Luau) traktują tabele jako nic więcej niż zbiór właściwości, które zawierają. API Roblox nie pasuje do tego modelu. C++ zapewnia system typów nominalnych, w którym każda klasa ma swoją własną „tożsamość”. Całkiem typowe jest posiadanie dwóch klas, które są odrębne, mimo że mają dokładnie tę samą strukturę. Istnieją rzeczywiste klasy Roblox, które spełniają tę właściwość, a Luau musi je poprawnie modelować.
Rozwiązujemy ten problem, wprowadzając typ podobny do tabeli dla wbudowanych instancji klas Roblox. Typy klas różnią się od typów tabel tym, że mają tożsamości, które je odróżniają, nawet jeśli obsługują te same metody z tymi samymi typami. Obsługują również pojęcie dziedziczenia, podobnie jak klasy C++, które mają modelować.
Wnioski
Wyprowadzanie typów statycznych z języka dynamicznego, takiego jak Lua, wiąże się z wieloma wyzwaniami. Wiele z tych wyzwań jest specyficznych dla Lua, ale są one całkiem możliwe do rozwiązania. Uważamy, że to działa całkiem nieźle! 🙂
Andy Friesen jest kierownikiem technicznym ds. narzędzia do sprawdzania typów Luau. Jest podekscytowany możliwością pracy na styku gier wideo, narzędzi programistycznych i języków programowania.
Ani firma Roblox Corporation, ani niniejszy blog nie promują ani nie wspierają żadnej firmy ani usługi. Ponadto nie udziela się żadnych gwarancji ani obietnic dotyczących dokładności, wiarygodności lub kompletności informacji zawartych w niniejszym blogu.


