Задача
Lua — язык с динамической типизацией.
Это значит, что тип в языке связан не с переменной, а с её значением:
a = "the meaning of life" --> была строка,<br/>
a = 42 --> стало число
Это удобно.
Однако, нередко встречаются случаи, когда хочется жёстко контролировать тип переменной. Самый частый такой случай — проверка аргументов функций.
Рассмотрим наивный пример:
function repeater(n, message)<br/>
for i = 1, n do<br/>
print(message)<br/>
end<br/>
end<br/>
<br/>
repeater(3, "foo") --> foo<br/>
--> foo<br/>
--> foo
Если перепутать местами аргументы функции repeat, получим ошибку времени выполнения:
> repeater("foo", 3)
stdin:2: 'for' limit must be a number
stack traceback:
stdin:2: in function 'repeater'
stdin:1: in main chunk
[C]: ?
«Какой такой for?!» — скажет пользователь нашей функции, увидев это сообщение об ошибке.
Функция внезапно перестала быть чёрным ящиком. Пользователю стали видны внутренности.
Ещё хуже будет, если мы случайно забудем передать второй аргумент:
> repeater(3)
nil
nil
nil
Ошибки не возникло, но поведение потенциально неверное.
Это происходит из-за того, что в Луа внутри функций непереданные аргументы превращаются в nil.
Другая типичная ошибка возникает при вызове методов объектов:
foo = {}<br/>
function foo:setn(n)<br/>
self.n_ = n<br/>
end<br/>
function foo:repeat_message(message)<br/>
for i = 1, self.n_ do<br/>
print(message)<br/>
end<br/>
end<br/>
<br/>
foo:setn(3)<br/>
foo:repeat_message("bar") --> bar<br/>
--> bar<br/>
--> bar
Двоеточие — синтаксический сахар, неявно передающий первым аргументом сам объект, self. Если убрать весь сахар из примера, получим следующее:
foo = {}<br/>
foo.setn = function(self, n)<br/>
self.n_ = n<br/>
end<br/>
foo.repeat_message = function(self, message)<br/>
for i = 1, self.n_ do<br/>
print(message)<br/>
end<br/>
end<br/>
<br/>
foo.setn(foo, 3)<br/>
foo.repeat_message(foo, "bar") --> bar<br/>
--> bar<br/>
--> bar
Если при вызове метода написать вместо двоеточия точку, self не будет передан:
> foo.setn(3)
stdin:2: attempt to index local 'self' (a number value)
stack traceback:
stdin:2: in function 'setn'
stdin:1: in main chunk
[C]: ?
> foo.repeat_message("bar")
stdin:2: 'for' limit must be a number
stack traceback:
stdin:2: in function 'repeat_message'
stdin:1: in main chunk
[C]: ?
Слегка отвлечёмся
Если в случае с setn сообщение об ошибке достаточно понятно, то ошибка с repeat_message с первого взгляда выглядит мистически.
Что же произошло? Попробуем посмотреть внимательнее в консоли.
В первом случае мы записать в число значение по индексу «n_»:
> (3).n_ = nil
На что нам совершенно законно ответили:
stdin:1: attempt to index a number value
stack traceback:
stdin:1: in main chunk
[C]: ?
Во втором случае мы попытались прочесть значение из строки по тому же индексу «n_».
> return ("bar").n_
nil
Всё просто. На строковый тип в Луа навешена метатаблица, перенаправляющая операции индексации в таблицу string.
> return getmetatable("a").__index == string
true
Это позволяет использовать сокращённую запись при работе со строками. Следующие три варианта эквивалентны:
a = "A"<br/>
print(string.rep(a, 3)) --> AAA<br/>
print(a:rep(3)) --> AAA<br/>
print(("A"):rep(3)) --> AAA
Таким образом, любая операция чтения индекса из строки обращается в таблицу string.
Хорошо ещё, что запись отключена:
> return getmetatable("a").__newindex
nil
> ("a")._n = 3
stdin:1: attempt to index a string value
stack traceback:
stdin:1: in main chunk
[C]: ?
В таблице string нет нашего ключа «n_» — поэтому for и ругается, что ему подсунули nil вместо верхней границы:
> for i = 1, string["n_"] do
>> print("bar")
>> end
stdin:1: 'for' limit must be a number
stack traceback:
stdin:1: in main chunk
[C]: ?
Но мы отвлеклись.
Решение
Итак, мы хотим контролировать типы аргументов наших функций.
Всё просто, давайте их проверять.
function repeater(n, message)<br/>
assert(type(n) == "number")<br/>
assert(type(message) == "string")<br/>
for i = 1, n do<br/>
print(message)<br/>
end<br/>
end<br/>
Посмотрим, что получилось:
> repeater(3, "foo")
foo
foo
foo
> repeater("foo", 3)
stdin:2: assertion failed!
stack traceback:
[C]: in function 'assert'
stdin:2: in function 'repeater'
stdin:1: in main chunk
[C]: ?
> repeater(3)
stdin:3: assertion failed!
stack traceback:
[C]: in function 'assert'
stdin:3: in function 'repeater'
stdin:1: in main chunk
[C]: ?
Уже ближе к делу, но не очень наглядно.
Боремся за наглядность
Попробуем улучшить сообщения об ошибках:
function repeater(n, message)<br/>
if type(n) ~= "number" then<br/>
error(<br/>
"bad n type: expected `number', got `" .. type(n) <br/>
2<br/>
)<br/>
end<br/>
if type(message) ~= "string" then<br/>
error(<br/>
"bad message type: expected `string', got `"<br/>
.. type(message) <br/>
2<br/>
)<br/>
end<br/>
<br/>
for i = 1, n do<br/>
print(message)<br/>
end<br/>
end
Второй параметр у функции error — уровень на стеке вызовов, на который нужно показать в стектрейсе. Теперь «виновата» не наша функция, а тот, кто её вызвал.
Сообщения об ошибках стали намного лучше:
> repeater(3, "foo")
foo
foo
foo
> repeater("foo", 3)
stdin:1: bad n type: expected `number', got `string'
stack traceback:
[C]: in function 'error'
stdin:3: in function 'repeater'
stdin:1: in main chunk
[C]: ?
> repeater(3)
stdin:1: bad message type: expected `string', got `nil'
stack traceback:
[C]: in function 'error'
stdin:6: in function 'repeater'
stdin:1: in main chunk
[C]: ?
Но теперь обработка ошибок занимает в пять раз больше полезной части функции.
Боремся за краткость
Вынесем обработку ошибок отдельно:
function assert_is_number(v, msg)<br/>
if type(v) == "number" then<br/>
return v<br/>
end<br/>
error(<br/>
(msg or "assertion failed") <br/>
.. ": expected `number', got `" <br/>
.. type(v) .. "'",<br/>
3<br/>
)<br/>
end<br/>
<br/>
function assert_is_string(v, msg)<br/>
if type(v) == "string" then<br/>
return v<br/>
end<br/>
error(<br/>
(msg or "assertion failed") <br/>
.. ": expected `string', got `" <br/>
.. type(v) .. "'",<br/>
3<br/>
)<br/>
end<br/>
<br/>
function repeater(n, message)<br/>
assert_is_number(n, "bad n type")<br/>
assert_is_string(message, "bad message type")<br/>
<br/>
for i = 1, n do<br/>
print(message)<br/>
end<br/>
end
Этим уже можно пользоваться.
Более полная реализация assert_is_* — здесь: typeassert.lua.
Работа с методами
Переделаем теперь реализацию метода:
foo = {}<br/>
function foo:setn(n)<br/>
assert_is_table(self, "bad self type")<br/>
assert_is_number(n, "bad n type")<br/>
self.n_ = n<br/>
end
Сообщение об ошибке выглядит несколько смущающе:
> foo.setn(3)
stdin:1: bad self type: expected `table', got `number'
stack traceback:
[C]: in function 'error'
stdin:5: in function 'assert_is_table'
stdin:2: in function 'setn'
stdin:1: in main chunk
[C]: ?
Ошибка с использованием точки вместо двоеточия при вызове метода встречается очень часто, особенно у неопытных пользователей. Практика показывает, что в сообщении для проверки self лучше на неё прямо указывать:
function assert_is_self(v, msg)<br/>
if type(v) == "table" then<br/>
return v<br/>
end<br/>
error(<br/>
(msg or "assertion failed")<br/>
.. ": bad self (got `" .. type(v) .. "'); use `:'",<br/>
3<br/>
)<br/>
end<br/>
<br/>
foo = {}<br/>
function foo:setn(n)<br/>
assert_is_self(self)<br/>
assert_is_number(n, "bad n type")<br/>
self.n_ = n<br/>
end
Теперь сообщение об ошибке максимально наглядно:
> foo.setn(3)
stdin:1: assertion failed: bad self (got `number'); use `:'
stack traceback:
[C]: in function 'error'
stdin:5: in function 'assert_is_self'
stdin:2: in function 'setn'
stdin:1: in main chunk
[C]: ?
Мы добились желаемого результата по функциональности, но можно ли ещё повысить удобство использования?
Повышаем удобство использования
Хочется наглядно видеть в коде, какого типа должен быть каждый аргумент. Сейчас тип зашит в имя функции assert_is_* и не очень выделяется.
Лучше уметь писать вот так:
function repeater(n, message)<br/>
arguments(<br/>
"number", n,<br/>
"string", message<br/>
)<br/>
<br/>
for i = 1, n do<br/>
print(message)<br/>
end<br/>
end
Тип каждого аргумента наглядно выделен. Кода нужно меньше, чем в случае с assert_is_*. Описание даже чем-то напоминает Old Style C function declarations (ещё их называют K&R-style):
void repeater(n, message)<br/>
int n;<br/>
char * message;<br/>
{<br/>
/* ... */<br/>
}
Но вернёмся к Луа. Теперь, когда мы знаем чего хотим, это можно реализовать.
function arguments(...)<br/>
local nargs = select("#", ...)<br/>
for i = 1, nargs, 2 do<br/>
local expected_type, value = select(i, ...)<br/>
if type(value) ~= expected_type then<br/>
error(<br/>
"bad argument #" .. ((i + 1) / 2)<br/>
.. " type: expected `" .. expected_type<br/>
.. "', got `" .. type(value) .. "'",<br/>
3<br/>
)<br/>
end<br/>
end<br/>
end
Попробуем, что получилось:
> repeater("bar", 3)
stdin:1: bad argument #1 type: expected `number', got `string'
stack traceback:
[C]: in function 'error'
stdin:6: in function 'arguments'
stdin:2: in function 'repeater'
stdin:1: in main chunk
[C]: ?
> repeater(3)
stdin:1: bad argument #2 type: expected `string', got `nil'
stack traceback:
[C]: in function 'error'
stdin:6: in function 'arguments'
stdin:2: in function 'repeater'
stdin:1: in main chunk
[C]: ?
Недостатки
У нас пропало настраиваемое сообщение об ошибке, но это не так страшно — чтобы понять, про какой аргумент идёт речь, достаточно его номера.
Нашей функции не хватает проверок на корректность самого вызова — на то, что передано чётное число аргументов, и на то, что все типы правильные. Эти проверки читателю предлагается добавить самостоятельно.
Работа с методами
Вариант для методов отличается только тем, что мы должны дополнительно проверить self:
function method_arguments(self, ...)<br/>
if type(self) ~= "table" then<br/>
error(<br/>
"bad self (got `" .. type(v) .. "'); use `:'",<br/>
3<br/>
)<br/>
end<br/>
arguments(...)<br/>
end<br/>
<br/>
foo = {}<br/>
function foo:setn(n)<br/>
method_arguments(<br/>
self,<br/>
"number", n<br/>
)<br/>
self.n_ = n<br/>
end
Полную реализацию семейства функций *arguments() можно посмотреть здесь: args.lua.
Заключение
Мы создали удобный механизм для проверки аргументов функций в Луа. Он позволяет наглядно задать ожидаемые типы аргументов и эффективно проверить соответствие им переданных значений.
Время, потраченное на assert_is_*, тоже не пропадёт зря. Аргументы функций — не единственное место в Луа, в котором нужно контролировать типы. Использование функций семейства assert_is_* делает такой контроль более наглядным.
Альтернативы
Существуют и другие решения. См. Lua Type Checking в Lua-users wiki. Наиболее интересное — решение с декораторами:
random =<br/>
docstring[[Compute random number.]] ..<br/>
typecheck("number", '->', "number") ..<br/>
function(n)<br/>
return math.random(n)<br/>
end
Metalua включает расширение types для описания типов переменных (описание).
С этим расширением можно делать вот так:
-{ extension "types" }<br/>
<br/>
function sum (x :: list(number)) :: number<br/>
local acc :: number = 0<br/>
for i=1, #x do acc=acc+x[i] end<br/>
return acc<br/>
end
Но это уже не совсем Lua. :-)