Pull to refresh

Контроль типов аргументов функций в Lua

Reading time 12 min
Views 11K

Задача


Lua — язык с динамической типизацией.

Это значит, что тип в языке связан не с переменной, а с её значением:

= "the meaning of life" --> была строка,<br/>
= 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"<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. :-)
Tags:
Hubs:
+20
Comments 20
Comments Comments 20

Articles