Прототип (Prototype)
Last updated
Last updated
Первый раз я узнал о существовании слова "прототип" из Паттернов проектирования. Сейчас это слово достаточно популярно. Но обычно его используют без привязки к шаблону проектирования GOF. Мы еще к этому вернемся, но для начала я хочу показать вам другое, более интересные области, где можно встретить термин "прототип" и стоящую за ним концепцию. А для начала давайте рассмотрим оригинальный шаблон.
Давайте представим что мы делаем игру в стиле Gauntlet. У нас есть всякие существа и демоны, роящиеся вокруг героя и норовящие откусить кусочек его плоти. Эти незванные сотрапезники появляются через "спаунер (spawners)" и для каждого типа врагов есть отдельный тип спаунера.
Для упрощения примера давайте сделаем предположение что для каждого типа монстра в игре имеется отдельный тип. Т.е. у нас есть C++ классы для Ghost
, Demon
, Sorcerer
и т.д.:
Я умышленно не пишу здесь "оригинальный". Паттерны проектирования цитируют легендарный проект Sketchpad 1963-го года за авторством Ивана Сазерленда, который можно считать первым примером применения шаблона в природе. Когда все остальные слушали Дилана и Битлз, Сазерленд был занят всего навсего изобретением базовых концепций CAD, интерактивной графики и объектно-ориентированного программирования.
Можете посмотреть демо и впечатлиться
Спаунер конструирует экземпляры одного из типов монстров. Для поддержки всех монстров в игре мы можем использовать прямолинейный подход и заведем класс спаунер для каждого класса монстра. В результате получится следующая иерархия:
Реализация будет выглядеть так:
Если вам конечно не платят за каждую строчку кода, использовать такой подход совсем не весело. Куча классов, куча похожего кода, куча избыточности, куча дублей, куча самоповторов...
Шаблон прототип предлагает решение. Ключевой мыслью является создание объекта, который может порождать объекты, похожие на себя. Если у вас есть один призрак, вы можете с его помощью получить кучу призраков. Если есть демон, можно сделать больше демонов. Любого монстра можно трактовать как прототипируемого монстра, используемого для генерации новых версий его самого.
Для реализации этой идеи, мы дадим нашему базовому классу Monster абстрактный метод clone()
:
Каждый подкласс монстра предоставляет свою реализацию, которая возвращает объект, идентичный по классу и состоянию ему самому. Например:
Как только все монстры будут его поддерживать, нам больше не нужен будет отдельный класс спаунер для каждого класса монстров. Вместо этого мы обойдемся всего одним:
Внутри себя он содержит монстра, скрытого извне, который используется спаунером в качестве шаблона для штамповки новых монстров ему подобны. Получается нечто наподобие матки пчел, никогда не покидающей своего улья.
Для создания спаунера призраков, мы просто создаем прототипируемый экземпляр призрака и затем создаем спаунер, который будет хранить этот прототип:
Интересна одна особенность этого шаблона заключается в том что он не просто клонирует класс прототипа, но и клонирует его состояние. Это значит что мы можем сделать спаунер для быстрых призраков, для слабых, для медленных, просто создавая соответствующего прототипируемого призрака.
На мой взгляд этот шаблон одновременно и элегантен и удивителен. Я не могу представить чтобы дошел до него своим умом, но теперь я просто не могу себе представить что я мог бы о нем не знать.
Итак нам не нужно создавать отдельный класс спаунер для каждого монстра и это хорошо. Но при этом нам нужно реализовывать метод clone()
в каждом классе монстров. Кода там примерно столько же сколько и в спаунере.
К сожалению если вы попытаетесь написать корректную реализацию clone()
, вы быстро наткнетесь на несколько подводных камней. Должен это быть глубокий клон или приблизительный? Другими словами, если демон держит вилы, должен ли клонированный демон тоже держать вилы?
Это не просто выглядит как выдуманная проблема, это действительно выдуманная проблема. Нужно принять как должное то что у нас есть отдельные классы для каждого монстра. В наше время так игровые движки писать не принято.
Большинство из нас не раз убеждались на собственном опыте что поддержка такой организации иерархии классов крайне болезненна, поэтому вместо этого для моделирования различных сущностей без отведения под каждую отдельного класса мы используем шаблоны наподобие Компонент(Component) или Тип объекта (Type Object).
Даже если у нас для каждого типа монстра имеется свой класс, есть другой способ поймать кота. Вместо того чтобы делать отдельный класс спаунер для каждого монстра, можно огранизовать функцию спаунер:
Это уже не настолько примитивный подход, как создание отдельного класс для каждого нового типа монстров. Теперь единственный класс-спаунер может просто хранить указатель на функцию:
И для создания спаунера призраков нужно будет всего лишь вызвать:
Сейчас большинство C++ разработчиков знакомы с концепцией шаблонов. Нашему классу спаунеру нужно создать экземпляр определенного класса, но мы не хотим жестко прописывать в коде определенный класс монстра. Естественным решением этой задачи будет воспользоваться возможностями шаблонов и добавить параметр типа:
Я не могу утверждать что программисты C++ научились их любить или что некоторых они настолько пугают, что люди просто отказываются от C++. В любом случае все кто сегодня использует C++, используют и шаблоны тоже.
Класс Spawner в данном коде не интересуется какой тип монстра он будет создавать. Он просто работает с указателем на
Monster
.Если бы у нас был только класс SpawnerFor, у нас не было бы ни одного экземпляра супертипа, разделяемого между шаблонами так что любому коду, работающему со спаунерами разных типов монстров, тоже пришлось бы принимать в качестве параметров шаблоны.
Применение выглядит следующим образом:
Предыдущие два решения требовали от нас иметь класс Spawner
, параметризируемый типом. В C++ классы в общем не являются объектами первого класса, так что это требует некоторых усилий. А вот если вы используете язык с динамическими типами наподобие JavaScript, Python или Ruby, где классы - это просто обычные объекты, которые можно как угодно передать, задача решается гораздо проще.
Если вам нужно соорудить спаунер - просто передайте ему класс монстра, которых он должен клонировать, т.е. по сути обычный объект, представляющий класс монстра. Проше пареной репы.
В некотором роде шаблон Объект тип (Type Object) - это очередной способ обхода проблемы отсутствия класса первого типа. В языке с таким типом он тоже может быт полезен, потому что позволяет вам самостоятельно определять что такое "тип". Вам может пригодится семантика отличная от той, что предоставляют встроенные классы.
Имея столько возможностей, я не могу припомнить случай, в котором паттерн проектирования прототип был бы лучшим вариантом. Может ваш опыт немного отличается от моего, но давайте лучше перейдем к следующей теме: прототипу как языковой парадигме.
Многие думают, что "объектно-ориентированное программирование" - это синоним слова "классы". Определения ООП напоминают кредо совершенно противоположных религий. Единственным бесспорным фактом является признание того факта что ООП позволяет вам определять "объект", объединяющий данные и код в единое целое. По сравнению со структурированными языками наподобие C и функциональными языками типа Scheme, ключевой особенностью ООП является способность связки состояния и поведения.
Вам может показаться что единственным способом это осуществить является использование классов, но некоторые люди, включая Дейва Унгара и Ренделла Смита думают иначе. Еще в 80-е они создали язык Self. Несмотря на то что это ООП язык, классов в нем нет.
На самом деле Self даже более объектно-ориентированный, чем языки с классами. Под ООП мы подразумеваем неразлучность состояния и поведения, а в языках с классами между ними на самом деле есть большое разделение.
Вспомните семантику своего любимого языка с классами. Чтобы получить доступ к состоянию объекта, вы ищете в памяти его экземпляр. Состояние содержится в экземпляре.
Для вызова метода вы сначала ищете класс экземпляра и затем ищете метод в нем. Поведение содержится в классе. Всегда присутствует этот уровень косвенности для доступа к методу, отделяющий поля от методов.
Например чтобы вызвать виртуальный метод в C++, вы ищете его через указатель на экземпляр в виртуальной таблице и затем уже ищете в нем метод.
Self убирает это различие. Чтобы найти что угодно, вы просто ищете это в объекте. Экземпляр может хранить как состояние так и поведение. Вы можете иметь отдельный объект с совершенно уникальным для него методом.
Никто из людей не остров, кроме этого объекта (No man is an island, but this object is.. Отсылка к сериалу Девочки Гилмор http://www.imdb.com/title/tt0238784/)
Если бы это было все что делает Self, пользоваться им было бы довольно сложно. Наследование в языках с классами, несмотря на свои недостатки, дает вам удобный механизм для полиморфного повторного использования кода и избегания дублирования. Для получения подобных результатов в Self есть делегирование.
Чтобы получить доступ к полю или вызвать метод определенного объекта, мы сначала должны получить доступ к самому объекту. Если получилось - дальше все просто. Если нет - мы ищем родителя объекта. Это просто ссылка на другой объект. Если не удалось найти свойство у самого объекта, мы попробуем его родителя, и родителя родителя и т.д. Другими словами, неудавшийся поиск делегируется родителю объекта.
Здесь допущено небольшое упрощение. Self помимо всего прочего поддерживает еще и несколько родительских объектов. Родители - это всего лишь специальным образом помеченные поля, дающие вам возможность использовать штуки типа наследования родителей или изменять их во время работы. Такой подход называется динамическим наследованием (dynamic inheritance).
Родительский объект дает нам возможность повторно использовать поведение (и состояние!) между несколькими объектами, так что мы уже перекрыли некоторую функциональность классов. Еще одна ключевая особенность классов заключается в том, что они позволяют нам создавать экземпляры классов. Когда вам нужен новый ThingamaBob, вы просто пишете new Thingamabob()
ну или нечто подобное, если используете другой язык. Класс - это фабрика экземпляров самого себя.
Как можно создать нечто без класса? А как мы на самом деле делаем обычно новые вещи? Также как и в рассмотренном нами шаблоне проектирования, Self делает это с помощью клонирования.
В Self каждый из объектов поддерживает шаблон проектирования Прототип автоматически. Любой объект можно клонировать. Чтобы наделать кучу одинаковых объектов нужно просто:
Привести один из объектов в нужное вас состояние. Можно просто взять за основу встроенный в систему базовый объект Object и дополнить его нужными полями и методами.
Клонировать его и получить столько... клонов, сколько вам нужно.
Таким образом мы получаем элегантность шаблона Прототип, но без необходимости писать реализацию clone()
для каждого класса самостоятельно. Он просто встроен в систему.
Это настолько прекрасная, разумная и минималистская система, что как только я узнал об этой парадигме, я сразу принялся за написание языка на основе прототипов просто чтобы разобраться в парадигме получше.
Я пришел к выводу что написание языка с нуле - не лучший способ что либо выучить, но это одна из моих странностей. Если вам любопытно, язык называется Finch.
Играться с языком на базе прототипов было замечательно, но как только мой собственный язык заработал, я обнаружил один малоутешительный факт: программировать на нем было не особо весело.
Конечно язык был простым для реализации, но только потому что я переложил всю сложность на плечи пользователя. Как только я начала пробовать им пользоваться, я обнаружил что мне очень не хватает структурированности, которую дают классы. Я закончил тем, что стал пытаться компенсировать их отсутствие в самом языке написанием специальной библиотеки.
Возможно все дело в том что я слишком привык пользоваться языками с классами и мой мозг слишком привык к этой парадигме. Но у меня есть большое подозрение что многим людям такой "порядок вещей" нравится.
И в продолжение истории ошеломительного успеха языков на основе классов. Посмотрите как много игр страдают от избытка классов персонажей, полного перечня различных типов врагов, предметов, навыков, каждый из которых старательно подписан. Не думаю что вы найдете много игр, где каждый монстр представляет собой уникальную снежинку в духе "нечто среднее между троллем и гоблином и небольшой примесью змея".
С тех пор я часто слышу что многие программисты на Self приходят к тому же выводу. Впрочем это не означает, что проект был совсем провальный. Self был настолько динамичен что для того чтобы работать с нормальной скоростью ему реально необходимы все современные инновации в области виртуализации.
Изобретенные ими идеи относительно компиляции на ходу, сборщика мусора и оптимизации вызова методов - это именно те технологии, которые сделали (зачастую усилиями тех же самых людей) многие современные языки с динамическими типами достаточно быстрыми для того чтобы писать на них популярные приложения.
Несмотря на то что прототипы - это действительно очень мощная парадигма, и я хочу чтобы об этом узнало как можно больше людей, я рад что большинство из нас все таки не использует ее в повседневной работе. Потому что тот код с реализаций прототипов что я видел, представлял из себя настолько ужасное месиво, что я так и не смог его понять.
Также это говорит о том, что на самом деле существует очень мало кода, написанного в стиле прототипирования. Я смотрел.
Ну хорошо, если языки на основе прототипов настолько недружественны, то как я могу объяснить существование Java Script? Ведь это язык с прототипами, которым ежедневно пользуются миллионы людей. Код JavaScript выполняет больше компьютеров чем код на любом другом языке в мире.
Брендан Айк - создатель JavaScripts черпал вдохновение прямиком из Self и поэтому большая часть семантики JavaScripts основана на прототипах. Каждый объект может иметь произвольный набор свойств, которые в свою очередь могут быть как полями, так и "методами" (которые на самом деле просто функции, хранящиеся в виде полей). У каждого объекта может быть другой объект, называемый его "прототипом", к которому происходит делегирование если нужное поле не найдено.
Для разработчика языка привлекательной особенностью прототипов является то что реализовывать их легче чем классы. Эйх тоже этим пользовался: первая версия JavaScript была написана всего за десять дней.
И все таки, несмотря на все это, я считаю что на практике у JavaScript гораздо больше общего именно с языками на основе классов, чем с основанными на прототипах. Это заметно уже хотя бы потому ,что в JavaScript предпринято большое отступление от Self - ключевой операции любого языка на основе прототипов - клонирования - нигде не видно. В JavaScript не существует метода для клонирования объекта.
Самая близкая по смыслу операция из существующих - это Object.create
, позволяющая вам создать новый объект, делегирующий к уже существующему. И даже эта возможность появилась только в спецификации ECMAScript 5, через четырнадцать лет после выхода JavaScript. Давайте я покажу как обычно определяют типы и создают объекты в JavaScript вместо клонирования. Начинается все с функции конструктора(constructor function):
С ее помощью создается новый объект и инициализируются его поля. Вызов выглядит следующим образом:
В этом коде new вызывает тело функции Weapon
, внутри которой this
связано с новым пустым объектом. Внутри тела функции к объекту добавляется куча полей, а потом новосозданный объект автоматически возвращается.
new
делает за вас еще одну вещь. Когда он создает чистый объект, он сразу делает его делегатом объекта-прототипа. Доступ к объекту прототипу можно получить через Weapon.prototype
.
Так как состояние добавляется в теле конструктора, для определения поведения вы обычно добавляете методы к прототипу объекта. Примерно таким образом:
Здесь мы добавляем прототипу оружия свойство attack
, значением которого будет функция. И так как каждый объект, возвращаемый new Weapon()
, делегируется к Weapon.prototype
, вы можете теперь сделать вызов sword.attack()
и он вызовет нужную нам функцию. Выглядит это примерно так:
Давайте еще раз:
Новые объекты вы создаете с помощью операнда "new", который вы вызываете используя объект, представляющий собой тип - функцию-конструктор.
Состояние хранится в самом экземпляре.
Поведение задается через уровень косвенности - делегирование к прототипу и хранится в виде отдельного объекта, представляющего собой набор методов, разделяемый между всеми объектами данного типа.
Вы можете назвать меня психом, но это крайне похоже на мое определение классов, которое я привел выше. Вы имеете возможность писать код в стиле прототипов в JavaScript (без клонирования), но синтаксис и идиоматика языка предполагают подход, основанный на классах.
Я лично считаю что это хорошо. Как я уже сказал, я убедился на собственном опыте что прототипы усложняют работу с кодом, так что мне нравится то как JavaScript оборачивает свое ядро в более похожую на классы форму.
Итак я продолжаю перечислять вещи за которые я не люблю прототипы. Депрессивная глава получается. Я задумывал эту книгу скорее как комедию, а не как трагедию, так что покончим с этим и перейдем к областям, где на мой взгляд прототипы или говоря конкретнее делегирование может быть полезным.
Если вы посчитаете все байты в игре приходящиеся на код и сравните с объемом остальных данных, вы увидите что с момента появления игр доля данных постоянно увеличивается. Ранние игры практически все генерировали процедурно и как следствие могли поместиться на дискетку или картридж. В большинстве современных игр код - это всего лишь "движок", который позволяет игре работать, а сама игра полностью определена в данных.
Это конечно здорово, но перемещение контента в файлы данных вовсе не означает, что мы избавляемся от организационных сложностей большого проекта. Скорее наоборот усложняем себе жизнь. Одной из причин почему мы используем языки программирования является то что они предоставляют нам инструменты по снижению сложности.
Вместо того чтобы копировать и вставлять кусок кода в десяти местах, мы помещаем его в отдельную функцию и вызываем ее по имени. Вместо того чтобы копировать метод в кучу классов, мы просто помещаем его в отдельный класс , а остальные классы от него наследуем.
Когда объем данных в игре достигает некоторого предела, вам сразу начинает хотеться обладать подобными возможностями. Моделирование данных - это слишком большая область чтобы обсуждать ее на поверхностном уровне, но я хочу показать вам одну из возможностей, которая пригодится вам в вашей игре: использование прототипов и делегирование для повторного использования данных.
Давайте представим себе, что мы определяем модель данных для бессовестного клона Gauntlet, о котором я писал выше. Геймдизайнеру нужны какие-то файлы, в которые он сможет поместить описание атрибутов монстров и предметов.
Я имею в виду полностью оригинальную игру, никоим образом не напоминающую хорошо известную ранее многопользовательскую аркадную игру с видом сверху. Так что не подавайте на меня в суд пожалуйста.
Можно использовать JSON: сущности данных будут представлены в виде maps или мешков со свойствами (property bags) или еще дюжиной терминов, потому что программисты просто обожают придумывать для одного и того же разные имена.
Мы так часто их переизобретаем что Стив Йегге решил назвать их Универсальным шаблоном проектирования(“The Universal Design Pattern”.).
Итак гоблин в игре описан следующим образом:
Довольно прямолинейный подход и даже не любящие писать дизайнеры могут справиться. Можно например добавить еще парочку сестринских описаний в славном семейном дереве зеленых гоблинов:
Если бы это был обычный код, наше чувство прекрасного уже заставило бы нас беспокоиться. У этих сущностей слишком много общей дублирующейся информации, а хорошо натренированные программисты это просто ненавидят. Данные занимают слишком много места и требуют слишком много времени на написание. Даже для того чтобы выяснить одинаковые ли это данные вам нужно тщательно их прочитать. Их поддержка - настоящая головная боль. Если мы захотим сделать всех гоблинов в игре сильнее, нам нужно будет не забыть обновить значение здоровья для них всех. Плохо, плохо, плохо.
Если бы это был код, мы могли бы создать абстракцию "гоблин" и использовать ее между всему типами гоблинов. Но тупой JSON ничего об этом не знает. Давайте попробуем сделать его чуточку умнее.
Определим для каждого объекта поле "prototype
" и поместим туда имя объекта, к которому он делегирует. Любые свойства, отсутствующие у первого объекта нужно будет смотреть в прототипе.
Это позволит нам упростить описание нашей оравы гоблинов:
Таким образом
prototype
переходит из разряда обычных данных в метаданные. У каждого гоблина есть бородавчатая кожа и желтые зубы. У него нет прототипа. Прототип - это свойство объекта данных, описывающего гоблина, а не самого гоблина.
Так как и лучник и чародей имеют в качестве прототипа пехотинца, нам не нужно указывать заново здоровье, сопротивляемости и уязвимости для каждого из них. Добавленная нами в данные логика предельно проста - мы просто добавили простейшее делегирование и сразу смогли избавиться от кучи повторов.
Хочу обратить ваше внимание на то что мы не стали добавлять четвертого "базового гоблина" в качестве абстрактного прототипа, к которому будут делегировать остальные три. Вместо этого мы просто взяли одного из гоблинов, который является простейшим и делегируем к нему.
Такой подход является естественным для систем на основе прототипов, где каждый объект можно использовать для клонирования нового объекта с уточненными свойствами и смотрится натуральным и здесь. Применительно к игровым данным такой подход тоже удобен потому что здесь часто приходится создавать объекты, лишь немного отличающиеся от остальных.
Подумайте о боссах и уникальных предметах. Очень часто они являются лишь немного измененной версией обыкновенных игровых объектов и прототипирование с делегированием очень хорошо подходит для их описания. Магический Меч-голова с плеч можно описать как длинный меч с определенными бонусами:
Такие дополнительные возможности для описания данных могут облегчить жизнь вашим дизайнерам и добавить больше вариативности предметам и популяции монстров в игре, а это именно то, что может понравиться игрокам.