Раздел 31 · Janet на практике

Прототипы, методы и полиморфизм

middle-senior~35 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Прототипы, методы и полиморфизм

Возвращаем долг: в третьем уроке мы отметили, что (:a {:a 1}) даёт nil, и отложили объяснение. Пора его дать, потому что это не случайность, а отдельный механизм языка.

Вызов метода

(:key obj) это вызов метода. Janet ищет :key в объекте и вызывает найденное значение как функцию, передавая ей сам объект первым аргументом:

Параметр self это и есть объект. По смыслу он соответствует this или self из других языков, но здесь он не магический: обычный первый параметр, который ты обязан объявить сам. Никакого неявного связывания контекста, из-за которого в JavaScript выросла целая тема про this и bind.

Дополнительные аргументы идут следом:

Через self метод меняет объект:

Три случая, которые надо различать

СитуацияРезультат
Ключ есть, значение функциявызывается, объект первым аргументом
Ключ есть, значение не функцияnil, молча
Ключа нетошибка unknown method
(:a {:a 1})           # nil, под :a лежит число, вызвать его нельзя
(:missing {:a 1})     # error: unknown method :missing invoked on <struct ...>
(:a 5)                # error: unknown method :a invoked on 5

Средняя строка самая опасная. Отсутствующий метод даёт громкую ошибку, и это хорошо. А вот ключ с не-функцией под ним возвращает nil без единого предупреждения.

Именно на это натыкаются пришедшие из Clojure: там (:key dict) стандартный способ достать значение, и код выглядит совершенно нормально. В Janet он тихо вернёт nil и уронит логику где-то дальше.

Для чтения данных бери get или вызов самой структуры: (get dict :key), (dict :key).

Прототипы

Таблице можно назначить прототип, другую таблицу, куда Janet заглянет, если своего ключа не нашлось:

Прототип у прототипа даёт цепочку произвольной длины. Поиск ключа это вопрос, заданный по цепочке: таблица не знает ответа сама, спрашивает родителя, тот своего. Отвечает первый, кто знает, поэтому своё значение всегда заслоняет родительское, и переопределение работает само собой:

Потыкай ключи и посмотри, на каком уровне цепочки они находятся:

Узнать прототип можно через table/getproto, у таблицы без прототипа он nil.

Ловушка: keys и length не видят прототип

Разные функции по-разному относятся к цепочке:

Это логично: иначе each по объекту выдавал бы вперемешку данные и методы. Но забывается это легко. Нужны все доступные ключи, поднимайся по цепочке через table/getproto руками.

Классы и конструкторы

Из прототипов собирается привычная объектная модель. Роль класса играет обычная таблица с методами, а конструктор это метод, создающий экземпляр с собой в качестве прототипа:

Наследование это прототип от прототипа: подкласс получает Point своим прототипом и переопределяет нужные методы.

Никакого отдельного синтаксиса классов в Janet нет, и он не нужен: всё выражается таблицами и одной функцией table/setproto.

Полиморфизм стандартной библиотеки

Прототипы не единственный полиморфизм в языке. Многие функции работают с разными типами:

next это основа всего перебора: each, loop, map построены на нём. Он умеет обходить массивы, таблицы, структуры и строки:

Раз есть next, перебор пишется руками, без each:

Именно этот цикл прячется внутри each, loop и map.

Абстрактные типы, приходящие из C, подключаются к тем же протоколам на стороне C. Файл это значение из мира C, но на нём работает вызов метода из начала этого урока:

(def f (file/open "project.janet"))
(type f)              # :core/file
(:read f :line)       # первая строка файла, метод у C-значения
(:close f)

С точки зрения твоего кода такие типы ничем не отличаются от встроенных.

Прототипы или замыкания

У прототипов есть альтернатива: замыкание, хранящее состояние в лексической области видимости.

# Через замыкание
(defn make-counter []
  (var n 0)
  {:increment (fn [] (++ n))
   :value (fn [] n)})

# Через прототипы
(def Counter @{:increment (fn [self] (put self :n (inc (self :n))) self)
               :value (fn [self] (self :n))})

Замыкания бери, когда объектов немного, состояние действительно приватное, а наследование не нужно. Состояние физически недоступно снаружи, и это надёжнее.

Прототипы бери, когда объектов много (методы хранятся один раз, а не копируются в каждый), нужно наследование или переопределение поведения, либо объект должен быть обычными данными: его можно напечатать, сериализовать через marshal из 31-janet/05 · Значения и ссылки, разобрать в match из 31-janet/14 · Ветвления и match.

Не начинай с классов. Соблазн построить иерархию стоит придержать. Чаще всего хватает обычной структуры данных и набора функций над ней, так код проще тестировать и читать. Прототипы окупаются там, где правда нужен полиморфизм: разные реализации одного интерфейса, выбираемые во время выполнения.

Что запомнить

  • (:key obj) это вызов метода, объект передаётся первым аргументом.
  • self это обычный параметр, объявляемый вручную, а не магический this.
  • Ключ с не-функцией даёт nil молча, отсутствующий ключ даёт громкую ошибку.
  • Для чтения данных бери get, а не (:key ...).
  • table/setproto задаёт прототип, поиск идёт вверх по цепочке до первого совпадения.
  • get и has-key? идут по цепочке, keys и length видят только своё.
  • Класс это таблица с методами, конструктор ставит её прототипом экземпляру.
  • Замыкания для приватного состояния, прототипы для полиморфизма и множества объектов.

Упражнения

Дальше

Язык пройден целиком. Следующие два урока про границу с C: как вызвать чужой код через FFI и как встроить Janet в свою программу. Оба требуют компилятора C, и при желании их можно отложить.

домашка

Домашка