Проблема:

Полиморфизм важен для любого языка, и, хотя полиморфный код очень легко написать на JavaScript, управлять этим кодом относительно сложно. Без явных типов вы должны либо предположить структуру объекта, либо явно протестировать его структуру перед использованием каких-либо функций. Явное тестирование замедляет ваш код, а неправильные предположения обычно приводят к ошибкам. Это может показаться компромиссом, но это просто еще одна проблема, для которой существуют быстрые и безопасные решения.

Отличное решение - аннотации типов. Вы, вероятно, уже знаете о TypeScript и Flow, поэтому я просто упомяну части, которые относятся к тому, что я хочу обсудить.

Flow и TypeScript не решают конфликтов имен. Если вы пытались реализовать два интерфейса, для которых требовался метод с именем .foo(), но с разным поведением, то, скорее всего, вам понадобится несколько объектов и вы будете переключаться между ними. Вероятно, это очень редкий случай в зависимости от вашего стиля кодирования и библиотек, с которыми вы взаимодействуете. Даже в этом случае конфликты имен могут вызывать ошибки и препятствовать инновациям. #smooshgate - пример худшего случая. Лучшим случаем является Fantasy Land, в которой используются такие имена методов, как fantasy-land/equals и fantasy-land/empty, которые имеют низкую вероятность столкновения.

Разработчики веб-стандартов не могут столкнуться с конфликтами и хотят быстро вводить новшества. Что они могут сделать? Они могут использовать символы - точнее, хорошо известные символы.

Изменение языкового поведения:

Используя хорошо известные символы, мы можем делать все, что угодно. Большинство из них не кажутся полезными - например, Symbol.toStringTag, который позволяет вам мгновенно изменять вывод .toString, - в то время как другие очень полезны, например Symbol.iterator, который позволяет создавать объекты, которые работают с циклами for-of, оператором распространения и т. Д. Независимо от того, мощно это или нет, наличие хорошо известных символов увеличивает согласованность и способность к самоанализу JavaScript. Позже мы будем использовать некоторые хорошо известные символы для создания настраиваемого класса признаков, но сначала давайте воспользуемся Symbol.iterator (обычно сокращенно @@iterator) для создания объекта, который выполняет итерацию по последовательности Фибоначчи, чтобы понять, как они работают. .

Итератор Фибоначчи:

В реальном мире было бы лучше использовать генератор вместо следующего класса, и я уверен, что вы могли бы создать более сжатую версию, чем этот пример. Во всяком случае, вот код:

Когда вы пытаетесь перебрать объект с помощью цикла for-of, среда выполнения проверяет, имеет ли объект метод @@iterator. Если это так, он вызывает его, который должен вернуть объект, который следует протоколу итератора. Протокол итератора требует наличия .next() метода, который возвращает объекты со свойствами value и done. В нашем случае мы реализовали next в самом классе, поэтому наш @@iterator метод просто возвращает this. Если бы произошел конфликт, мы могли бы вернуть другой объект - тот, который, вероятно, имел бы некоторый доступ к корневому объекту. Вот пример этого:

Таким образом, мы всегда можем обойти конфликты имен, потому что мы можем вернуть любой объект из нашей @@iterator функции. Если бы вы не использовали символы, у вас, вероятно, остались бы два объекта, но, возможно, не было бы четкого способа разместить нужный в нужных местах.

Время выполнения и статический анализ:

Это решение для среды выполнения, что означает, что у него есть накладные расходы во время выполнения. В TypeScript мы могли бы просто вызвать .next() для экземпляра нашего класса Fib, потому что дополнительная информация TypeScript сообщает нам, что у Fib есть следующий метод и что его сигнатура соответствует тому, что мы хотим. С хорошо известными символами у нас есть уровень косвенности. Не только косвенное обращение к указателю, но и косвенное обращение к функции - это не то, что следует игнорировать с точки зрения производительности. Косвенность обычно является требованием для полиморфизма, если у вас нет мономорфизации. [Кроме того, технически это два уровня косвенного обращения, потому что у нас уже есть один уровень из-за наличия только ссылок на объекты в JavaScript, но давайте продолжим.]

Вам может быть интересно, чем использование символа лучше, чем просто проверка, есть ли у экземпляра следующий метод. Если бы мы сделали это (так называемое «утиная печать»), нам пришлось бы много чего тестировать. Нам нужно будет проверить, была ли следующая функция, затем посмотреть, вернул ли следующий вызов объект (если нет, то, надеюсь, мы не сработали никаких побочных эффектов), а затем проверить, имеет ли этот объект значение и свойства done. , и что свойство done является логическим и т. д. Мы должны выполнить все эти проверки, потому что мы не уверены, что объект, имеющий следующий метод, похожий на наш протокол итератора, не просто совпадение. Кто-то может случайно реализовать протокол итератора, когда никогда не планировал его повторение. Маловероятно, но возможно.

С помощью символов можно быть уверенным, что автор намеревался реализовать протокол итератора. Они, должно быть, получили наш символ от Symbol.iterator - уникальный символ, который нельзя получить другим способом (по большей части). Даже если у вас есть методы с правильными именами и даже правильными подписями, вы также должны иметь @@iterator метод, чтобы его можно было итерировать.

Поскольку мы знаем, что разработчик намеренно сделал объект итерируемым, нам не нужно проверять, есть ли у возвращаемого объекта следующий метод, потому что он должен (иначе это ошибка). Мы все еще можем выполнять проверки типа «утка», но, возможно, только в отладочной сборке, и мы могли бы удалить их в выпуске. Кроме того, если вы используете TypeScript, это поможет убедиться, что реализация ручного итератора имеет следующий метод с действительной подписью.

Наконец, протокол итератора прекрасно сочетается с другими. Все, что можно итерировать, можно повторить либо с помощью цикла for-of, spread, Array.from(), либо вручную .next() независимо от того, из какой библиотеки оно взято. Точно так же, если мы создаем наши собственные символы - хорошо известные тем, что их можно получить только путем импорта модуля es6 - с ясным протоколом, тогда весь код, использующий его, должен быть совместимым.

Паттерн: имитируйте и расширяйте известные символы

Чтобы начать исследовать, на что похоже наличие наших собственных хорошо известных символов (которые я теперь также буду называть чертами), давайте сначала представим помощника, который упростит работу с ними. Это простой класс, который использует несколько хорошо известных символов для настройки поведения JS.

Мы используем @@toPrimitive, чтобы он превращался в базовый символ, когда мы используем его в выражении имени метода / свойства, и мы используем @@hasInstance для переопределения поведения instanceof. При его использовании это выглядит так:

Это показывает, насколько легко проверить, реализует ли что-либо признак, и что протокол, который представляет признак, может быть чем угодно (функциями, объектами, просто свойством и т. Д.), Кроме undefined.

Состояние в протоколах и реализациях признаков:

Следует отметить, что протокол для Drivable отличается от протокола итератора. В протоколе итератора объект, возвращаемый из @@iterator, может иметь состояние, поэтому, когда вы используете цикл for-of для итерации по массиву, а затем используете другой цикл for-of для этого массива, вторая итерация начнется с начала. не там, где вы в последний раз останавливались - итерации независимы. Это происходит, даже если вы выйдете из первого цикла перед чтением до конца массива. В нашей трейте Drivable мы получаем объект, который реализует Drivable для нашего автомобиля, но мы не сохраняем его перед вызовом .steer(). Это означает, что если нам нужно будет снова вызвать .steer(), нам придется снова получить реализацию Drivable. В случае с Horse возвращенной реализацией будет совершенно новый объект вместо того, который был возвращен ранее, но он будет функционировать идентично первому возвращенному значению, поскольку он имеет нет состояния / применяет все изменения непосредственно к лошади.

Для многих характеристик протоколу имеет смысл требовать безгражданства в реализации. Если у вас есть массив объектов и ваш протокол не имеет состояния, вам может понадобиться второй массив для хранения объектов реализации черт, удваивающих используемую память. Вот пример того, что происходит:

Видите ли, поскольку нам нужен был повторный доступ к реализации признака, но он был с сохранением состояния, и, наконец, поскольку получение реализации признака для автомобиля каждый раз возвращало новую реализацию, нам потребовались два массива. Эта проблема возникает только тогда, когда эти три вещи объединяются. Раньше я делал это случайно, поэтому хотел упомянуть об этом. В этом случае, вероятно, лучше сработала бы слабая карта (либо вместо второго массива, либо для того, чтобы каждый раз возвращать одну и ту же реализацию), но она все же иллюстрирует ловушку.

Оглядываясь на протокол итератора, можно сказать, что итерация обычно недолговечна, что снижает вероятность того, что вам понадобится удерживать ссылку на объект реализации. Итерация не влияет на контейнер - в отличие, например, от фильтра или сортировки. Эти два свойства делают сохранение состояния в объекте реализации хорошим выбором для итератора. Выбор того, объединить или разъединить состояние в реализации признака - это техническое решение, и знание других реализаций / протоколов, похожих на ваши собственные, может помочь. Есть много мест, где можно узнать о хороших протоколах. Посмотрите на жизненный цикл в React или других фреймворках. Посмотрите на асинхронные итераторы. Мой любимый источник - это стандартная библиотека Rust, которая имеет множество особенностей и хорошую документацию.

Итерация может повлиять на контейнер, как мы это сделали в нашем примере Фибоначчи. Это было не очень полезно для Фибоначчи, но очень полезно, если вы создаете что-то вроде очереди, в которой каждый элемент должен быть показан один раз, даже если между итерациями могут быть паузы. Создание потребляющего итератора - хороший способ преобразовать события / обратные вызовы в асинхронный «поток», чем я уже много занимался.

Общие реализации:

Хорошо известные символы помогают избежать столкновений или обойти их. Они вселяют в нас уверенность в том, что наш протокол был реализован намеренно (или, по крайней мере, пытался быть реализован). Однако одного этого недостаточно, чтобы хотеть использовать этот паттерн. Это становится более привлекательным, когда вы видите, что можете писать функции для реализации признаков для класса на основе других признаков, которые он реализует - общих реализаций.

Вот пример, в котором ИИ сущности выводится из того, является ли сущность нежитью:

Это объединяет всю логику ИИ и сокращает цепочки прототипов. Любые изменения в AI нужно вносить только в одном месте, и они будут видны для всех объектов. Это сила вывода.

Немного неудобно вызывать implement_ai() для каждого класса, но как только мы получим декораторы, такие вещи станут проще и распространятся.

Взаимодействие с TypeScript:

Последнее, что я хочу упомянуть, это то, что с этим шаблоном сложно работать в TypeScript. Я вкратце попытался добавить типы для некоторого кода, который его интенсивно использовал, но это не сработало. Если вы знаете, как это сделать, я хочу узнать, как это сделать.

Проблема в основном в том, что мы добавляем метод / свойство с динамическим именем. Класс Trait - это постоянное выражение, оценивающее символ, но TypeScript его не видит - возможно, не без частичной оценки. Если я не ошибаюсь, даже стандартные стандартные символы требуют специальной поддержки в TypeScript, которую нельзя распространить на наши символы.

Я не пробовал использовать символ, зарегистрированный в строке с помощью Symbol.for(). Это альтернатива тому, чтобы он был в модуле es6 и импортировал этот модуль во весь код, реализующий эту черту. Лично мне не нравится подход к регистрации, потому что он возвращается к использованию строк, чтобы избежать коллизий. Это похоже на использование fantasy-land/empty в качестве имени функции - столкновение маловероятно, но возможно.

Вывод:

Надеюсь, вы кое-что узнали. Возможно, вы раньше не переопределяли поведение по умолчанию, используя известные символы. Может быть, у вас есть идея получше, instanceof , чем та, которую я вам показал. Может быть, ты не согласен со всем, что я сказал. Это честно, и я хотел бы поговорить с вами о том, что вы думаете.

Я думаю, что этот шаблон имеет приоритет в стандарте, и поэтому его стоит изучить. В будущем мы можем увидеть больше известных символов, и работа с ними должна быть естественной. При разработке характеристик и создании хороших протоколов нужно многое сделать, и некоторые из них мы можем узнать, наблюдая за разработкой JavaScript. К сожалению, этот шаблон не кажется дружественным к типизированному супернабору.

Спасибо за чтение. Хорошего дня и удачного кодирования.

Ресурсы:

Символы TypeScript: https://www.typescriptlang.org/docs/handbook/symbols.html
Страна фантазий: https://github.com/fantasyland/fantasy-land
Итератор Протокол: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols
Код, который активно использует этот шаблон: https://github.com/evan-brass / js-min / tree / 1bad89fcf41c6746d9b3d429a6e154f20c564e8f / src / templating / users
#Smooshgate: https://developers.google.com/web/updates/2018/03/smooshgate