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

    Задача


    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. :-)
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 20
    • –1
      Понаписааааали =) Хорошая статья, правда встречаются синтаксические ошибки, но это по большей части опечатки.

      Скажите, а где вы используете этот язык?
      • +2
        Lua часто используется в скриптинге для игр.
        • +1
          Интересно было бы узнать где автор поста использует Lua.
          • –3
            Ага, куча assert() только ускорит игру!
            • 0
              Ассерты — полезный механизм. Но пользоваться ими, безусловно, нужно с умом. ;-)
          • 0
            Спасибо. Ошибки хотелось бы поправить, можете ткнуть в них пальцем?

            Язык очень активно используем для написания бизнес-логики. В основном игры, да. Довольны как стадо слонов. :-)
            • 0
              у меня он в оконном менеджере используется :). wmii
            • 0
              Всё-таки статическая типизация это очень хорошо :)
              • +3
                Всему своё место. И статическая типизация — хорошо и динамическая — хорошо.

                Когда нужно писать быстро, либо отдавать код в руки скрипторам — динамическая типизация спасает.
              • +1
                Спасибо за карму, перенёс в «Разработку».
                • –2
                  > a = «the meaning of life» --> была строка,
                  > a = 42 --> стало число

                  Откуда Вы знаете Великий Вопрос?
                  • 0
                    The Ultimate question of Life, Universe and Everything
                  • 0
                    А Вы видели Contracts в PLT Scheme? Позволяет решить поставленную Вами задачу, кроме всего прочего. Contracts основывается на design by contract.

                    Например,

                    > (provide/contract
                    > [create (string? number? boolean?. ->. account?)])

                    (и все, теперь мы определили сигнатуру для функции create: три аргумента, строка, число и булева переменная, возвращает пользовательский «тип» account).

                    Еще тут: docs.plt-scheme.org/guide/contracts.html
                    • 0
                      Упомянутая в конце статьи схема с декораторами как раз примерно про это.
                    • +1
                      Актуально для меня сейчас, побольше бы статей про Lua. Выбор стоит между Lua, Python, и JS…
                      • +1
                        Спасибо.

                        Если есть какие-то конкретные вопросы про Луа, готов взяться их раскрыть в следующей статье ;-)
                      • 0
                        Приятно наблюдать на хабре свежеприглашённого человека, встретившегося на ServerFault :) Wellcome!
                        • 0
                          о_О За мной следят? ;-)
                          • 0
                            o_O ещё как: и RSS, и хабр… Договор с операторами спутников пока в процессе :)))
                            всё-таки первый русский, встреченный там :)

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.