Что такое объектная модель
Объектная модель документа: что такое DOM и чем не является?
Что такое DOM, и где ее искать? Разбираемся, как выглядит объектная модель документа, для чего нужна и чем отличается от простого HTML-кода.
Объектная модель документа, aka DOM, – это интерфейс, с помощью которого программы могут работать с контентом, структурой и стилями веб-страницы. Проще говоря – это набор методов, которые можно вызвать, и свойств, к которым можно обратиться.
Чтобы лучше разобраться, что такое объектная модель документа, давайте посмотрим, как она создается.
Создание веб-страницы
Путь от исходного HTML в вашем файле до отображения в браузере оформленной страницы, с которой можно взаимодействовать, называется критическим путем отрисовки (critical rendering path). Его можно разбить на два больших этапа:
Дерево рендера, или дерево визуализации, – это специальная структура, состоящая из HTML-элементов, которые будут отображены на странице, и связанных с ними стилей. Это дерево собирается из двух компонентов:
Таким образом, формирование DOM предшествует формированию готовой страницы.
Как выглядит DOM?
DOM – это объектное представление исходного HTML-документа, попытка преобразовать его структуру и содержимое в объектную модель, с которой смогли бы работать различные программы.
Для примера возьмем простой документ:
Он может быть представлен в виде дерева узлов:
Чем DOM не является?
Возможно, пример выше заставил вас подумать, что DOM – это то же самое, что и исходный HTML-документ, или то, что вы видите в браузере в консоли разработчика. Это не совсем так. И вместо того, чтобы описывать, что такое DOM, попробуем разобраться, чем DOM не является.
DOM – это не HTML-код
Безусловно, DOM создается из исходного HTML-кода. Но объектная модель не всегда полностью соответствует своему прообразу. Почему так происходит?
Невалидный HTML
DOM всегда валидна, поэтому в процессе создания браузер может поправить некоторые ошибки исходного кода, например, добавить пропущенный tbody или закрыть какой-нибудь незакрытый тег.
Например, такой невалидный HTML-код:
превратится в такую валидную объектную модель:
Браузер самостоятельно добавил теги head и body, которые требуются по спецификации.
Модификация с помощью JavaScript
DOM позволяет не только просматривать содержимое страницы, но и взаимодействовать с ним, изменять. Это не статичное отображение, а живой ресурс.
Например, с помощью JavaScript можно создать дополнительные элементы:
DOM после этого обновится, а исходный HTML, разумеется, останется таким же, как был.
Страница может заполняться контентом, полученным асинхронно с сервера с помощью AJAX-запросов. Эта операция также не затрагивает исходный код, но изменяет объектную модель.
DOM – это не код страницы
Аналогично, то, что вы видите, нажав Просмотреть код страницы (горячая комбинация Ctrl+U ), также не является объектной моделью документа, ведь по сути это и есть исходный HTML-код.
Впрочем, если вы используете какой-нибудь шаблонизатор и рендерите HTML на сервере, исходный код страницы будет отличаться от того кода, который вы видите в браузере.
DOM – это не дерево рендера
Дерево рендера – это то, что вы видите, открыв страницу в браузере. Вы помните, что оно образуется из комбинации DOM и CSSOM.
Дерево рендера состоит только из видимых на экране элементов, и это его ключевое отличие от DOM. Например, вы не найдете здесь элементов, для которых установлено CSS-правило display: none. Они не видны, не занимают места на странице и не участвуют в рендере.
Видите скрытый с помощью инлайнового стиля параграф?
Вы можете найти его в DOM:
но не в дереве рендера:
DOM – это не содержимое DevTools
Тут разница не такая очевидная, как в случае дерева рендеринга. Панель разработчика имеет максимально близкую к DOM реализацию, но все же они не идентичны. В DevTools можно найти информацию, которой нет в DOM.
Так что в инструментах разработчика псевдоэлементы есть:
а в DOM – нет. Именно поэтому с ними нельзя взаимодействовать из JavaScript, ведь они не являются частью объектной модели документа.
Что же такое объектная модель документа?
DOM – это интерфейс HTML-страницы и первый этап ее отрисовки в браузере.
DOM очень близок к другим формам представления исходного HTML-кода, но все же не идентичен им.
Основные характеристики объектной модели:
Объектная модель документа – очень полезная штука. Благодаря ей JavaScript может взаимодействовать со страницей, изменять ее содержимое, структуру и стили. Именно благодаря DOM вы можете отслеживать клиентские события.
Например, повесим обработчик события mouseenter на элемент заголовка:
Элемент заголовка здесь – это узел DOM-дерева, к которому мы смогли обратиться с помощью DOM-метода addEventListener. Когда событие наведения мыши произойдет, DOM-элемент распространит его выше по дереву DOM и сообщит нам об этом.
DOM – понятие, специфичное для браузерной среды выполнения кода. Это не JavaScript, а лишь API, которым JavaScript может пользоваться.
Palantir: Объектная модель
Шрияс Виджайкумар, ведущий инженер по внедрению, расскажет про еще один элемент внутренней кухни системы Palantir.
Гибкость означает, возможность работать с любыми типами данных в одном общем пространстве: от высокоструктурированных, таких как базы данных с выстроенными отношениями, до неструктурированных, таких как хранилище трафика сообщений, а также всех, находящихся между этими крайностями. Это также означает возможность создавать множество разнообразных полей для исследования без привязки к одной модели построения. Как и организация, они могут изменяться и эволюционировать со временем.
Следующей вещью, которую мы спроектировали, стало обобщение данных без потерь. Нам нужна платформа, которая бы отслеживала каждый обрывок информации до его источника или источников. В мультиплатформенной системе важное значение имеет контроль доступа, особенно если такая система, позволяет совершать всю полноту действий с данными.
![]()
Подробнее о методологии тестирования, которую мы используем на проектах в EDISON Software Development Centre.
2:26 Следующая спроектированная нами вещь: открытый формат и API. Подлинная платформа для работы с данными позволяет вам вводить данные в систему, взаимодействовать с данными в этой системе, и выводить данные из системы, чтобы вы могли совершить с этими данными необходимые операции.
2:38 Объектная модель — ядро Palantir, и, так или иначе, её можно увидеть в каждом нашем видео.
2:45 Теперь давайте посмотрим как модель встроится в общую картину.
2:50 Объектная модель — это абстракция, находящаяся между физическим хранилищем данных и конечным пользователем. В нашем случае конечным пользователем может быть аналитик на рабочем месте, разработчик или администратор.
3:07 Через объектную модель все пользователи взаимодействуют с данными, как с абстрактным объектом первого порядка (first order conceptual object), вместо того чтобы собираться за общим столом, делиться видением, заниматься воспроизведением хранимых процедур (store procedure) снова и снова.
3:22 Теперь, когда у нас есть понимание, как модель выглядит в общей картине, давайте перейдем к структуре. Что собой представляют эти объекты?
3:36 Сперва, объект — это пустой контейнер, оболочка, которую мы наполни атрибутами и известной информацией. Примерами объектов могут служить такие сущности, как: люди, места, телефоны, компьютеры, события, такие как встреча, например, телефонные звонки, документы, электронные письма, и другое.
3:54 Все эти объекты обладают тем, что мы называем компонентами объекта (object components).
3:58 Есть четыре типа компонентов объекта, три из них мы сейчас перечислим:
— признаки, то есть текстовые атрибуты, такие как имена, мейлы и прочие;
— медиафайлы, что позволяет ассоциировать с объектом изображения, видео, тексты и любые другие бинарные форматы данных;
— заметки, то есть свободные текстовые поля для аналитиков.
4:18 Теперь у нас есть объекты, в которых мы храним информацию и есть связи, которые соединяют объекты.
4:28 Эта система объектов и компонентов объектов, дает представление об объектной модели. Причина, по которой мы можем моделировать такое количество «полей» (domain — поле, сфера, область; скорее всего, речь идет об отдельном рабочем пространстве в общем Palantir’е) в том, что мы не прописали никакой семантики внутри объекта как такового.
4:42 Я не говорил, что отношения должны быть объединяющими, управляющими или иерархическими, объектная модель существует до этих понятий. Каждая организация индивидуально определяет семантику, используя динамическую онтологию.
4:56 Давайте посмотрим как объектная модель и динамическая онтология взаимодействуют, создавая необходимую организации семантику.
5:03 Воспользуемся примером. Здесь у нас очень простой граф, состоящий из двух объектов, содержащих некоторые компоненты, и отношений.
5:12 Здесь нет семантики. Некая организация сейчас будет выбирать типы объектов, признаков и отношений, нужные ей.
5:22 Если я занимаюсь сетевой безопасностью, это могут быть роутеры и хосты, если контр-терроризмом — это могут быть террористические организации, деньги и члены групп.
5:38 Если теперь совместить объектную модель с онтологией, вы ожидаемо получите некоторую семантику, например: Зак работает в Palantir.
5:50 Такой же, с объектной точки зрения, граф может нести совершенно другой смысл: он может указывать на наличие документа.
6:00 Абстрагировав семантику от структуры, мы получили возможность создавать широкий спектр «полей» доступным и гибким образом.
6:09 Есть дань которую нужно заплатить, если вы хотите гибкости, и эта дань вам должна быть очень знакома.
6:22 Цена за возможность для системы быть гибкой — это почти всегда потеря поддержки возможности создавать схемы.
6:29 Вы можете добавить новый тип объектов или вид связей, но это будет стоить вам пяти отдельных связанных таблиц, с пояснениями, указаниями и прочим.
6:41 Так что это действительно сложно поддерживать и это не то, чего вы, в действительности, хотите от платформы по работе с данными.
6:45 В Palantir мы используем противоположный подход: нет необходимости в создании новых таблиц для типов объектов, отношений, допустимых ограничений.
6:57 Если быть точнее, то в Palantir есть одна схема, которую мы используем в каждой организации и при каждом внедрений.
7:02 Существует пять таблиц, из которых вы можете брать контент для любого объекта и любого компонента объектов, при этом неважно, моделируете ли вы документы на основе трафика сообщений или высокоструктурированной базы данных.
7:15 Так что, если взглянуть на то как объектная модель смотрится в общей картине, на структуру самой объектной модели и то, как она взаимодействует с динамической онтологией, мы увидим высокую гибкость и способность создавать множество «полей».
7:29 Сейчас поговорим о том, как мы внедрили извлечение данных без потерь.
7:35 Самое важное здесь — источники данных, ну, потому что вся информация, которая есть в Palantir, взялась из источников.
7:43 Примеры. Это может быть все что угодно: налоговые документы, электронные таблицы, файлы xml, базы данных, web-страницы. Созданное самим аналитиком во время работы, все равно основано на информации из источников.
7:58 Это почему важно? У вас что-то есть, продукт: вам нужно проследить, откуда он появился, на чем основан, вернуться к источникам и убедиться в отсутствии искажений, — это единственный способ быт уверенным в своих выводах.
8:11 Теперь посмотрим на связи между источниками данных и объектной моделью, которую я вам описал.
8:17 Каждый компонент объекта в Palantir содержит запись об источнике своих данных. Эта запись связывает информацию с источником или несколькими источниками.
8:25 Так что, если я хочу обосновать свой граф, мы увидим что эти два объекта поддерживаются источниками A, B и C.
8:34 Еще вы можете увидеть что несколько источников поддерживают один компонент объекта, и я, таким образом, больше уверен в этом кусочке информации, ведь он опирается на данные из хранилища трафика и логов операторов, например. Оба источника подтверждают информацию, я могу двигаться дальше, основываясь на ней.
9:00 Записи об источниках данных информируют чуть более точно, чем просто указание на источники данных, если мы имеем дело с неструктурированными источниками. Так, например, если это документ, запись укажет на конкретное место в документе. В структурированных базах данных эта запись об источнике может указывать на первичный ключ.
9:17 Теперь когда мы увидели, как источники данных связаны с объектами, давайте посмотрим какие операции мы можем здесь совершить.
9:23 Мы видим граф упрощенный до предела, он состоит из одного объекта, содержащего два компонента, и двух признаков. «Имя: Шрияс», и «мейл: shrey291@aol.com».
9:37 И мы видим, что имя взято из электронной таблицы посетителей (attendee — участник, слушатель, посетитель), и из текстового документа, а мейл взят только из текстового документа.
9:46 Давайте посмотрим на эти источники. Во-первых, список посетителей: мы видим, что имя Шрияс взято из сырого файла, чего-то вроде выдержки из другого источника.
10:00 Представьте, что второй, текстовый файл был отозван (recall), или у пользователя больше нет доступа к этому источнику. Как теперь будет выглядеть объект?
10:11 Если мы уберем текстовый файл, то увидим, что только компонент имя остался, таким образом мы эффективно привели объект к новому виду, так как второй его признак больше не поддерживается.
10:25 Вернемся к изначальному виду и взглянем на другой источник.
10:30 Мы видим текстовый документ, мы видим, что из документа извлечены имя и мейл. В случае, если список посетителей был отозван или в доступе нам отказали, объект наш по-прежнему выглядит так же. Это все потому, что оба компонента имеют подтвержденные источники.
10:55 Теперь мы увидели, что происходит, когда изменения происходят на уровне источников, и что мы можем смотреть и за признаки, туда, откуда они появляются, а это полезная возможность.
10:53 Давайте взглянем на свойства «Имя: Шрияс».
11:07 Мы видим, что имя взято из списка посетителей и из текстового файла. Для аналитика важно понимать, откуда берется информация.
11:18 Еще важно, что все о чем мы говорили, используется при контроле доступа к информации, при защите источников информации. Также, это позволяет нам совершать и другие действия.
11:30 Например, с помощью такого подхода легко поддерживать множественность признаков. Что означает «добавить новый признак» для признака «Имя: Шрияс»?
11:38 Это означает, что мы добавили новую ветку на мой граф, из источника «вручную созданные данные», и я могу совершать с этими данными те же манипуляции, что мы рассматривали раньше.
11:50 Используя объектную модель я могу производить ряд полезных манипуляций с данными.
11:58 Отдельно хочу упомянуть, что такой подход — один из компонентов пересинхронизации данных. Например, у вас есть некий внешний источник данных, который, возможно, меняет значение вашего признака, и не очень понятно, как не упустить это значение в Palantir.
12:12 Все, что нужно знать, — это то, что любые несовпадения возвращают к самому признаку, то есть, если возникло несовпадение, связанное с признаком «Имя: Шрияс», так как, этот признак в другом источнике меняется на «Шрияс Виджайкумар», вы не сможете просто изменить значение, ведь, старое значение опирается на свой собственный источник данных. Вам придется создать новый признак.
12:31 Теперь, когда мы увидели операции, которые можно производить с объектной моделью, давайте посмотрим, как вы сможете взаимодействовать с этой моделью, как будете вводить и извлекать данные.
12:39 Как я говорил в начале, мы поддерживаем очень открытый формат, открытый API, — это наши требования к платформе по работе с данными.
12:48 Обычно в таких средах существует проблема данных и инструментов. Это проблема данных, так как сложно получать данные из разных форматов и взаимодействовать с ними.
13:00 Но это также и проблема инструментов, так как вы можете встретить продукты, удерживающие вас в проприетарном или бинарном формате, и таким продуктам может быть сложно взаимодействовать с вашими данными.
13:08 В Palantir открытый xml формат, который так и называется Palantir XML, это воплощение объектной модели.
13:19 Это означает, что вы можете сделать всю свою работу и извлечь данные из Palantir в виде Palantir XML, или, если у вас есть некий набор данных, от неструктурированных документов, до целых файловых систем, вы можете внести их в Palantir, используя тот же Palantir XML.
13:33 Причина, по которой это возможно — то, что вы вносите данные как объектную модель.
13:36 Причина, по которой это важно — то, что объектная модель эффективно описывает каждую частичку информации в системе.
13:43 Последнее, чего я хочу коснуться — это то, как мы использовали объектную модель, когда проектировали собственную систему Raptor, — интегрированный компонент поиска.
13:57 Идея Raptor в том, чтобы эффективно работать с быстро меняющимися источниками данных, так что они должны быть синхронизированы с вашим Palantir.
14:09 Обычно, Palantir функционирует так: посылает запросы поисковому кластеру, поисковый кластер возвращает результат диспетчерскому серверу.
14:14 Raptor служит мостом между внешними источниками данных и диспетчерским сервером, и его задача распознавать правильные объекты, основываясь на объектной модели.
14:29 Когда вы запустили поиск через Raptor, он объединяет в себе весь поток данных, все корявые объекты отправляет обратно, а для пользователя это выглядит как плавный процесс, без единого разрыва.
(За помощь в подготовке статьи отдельное спасибо Алексею Ворсину, российскому эксперту по системе Palantir)
ООП в картинках
ООП (Объектно-Ориентированное Программирование) стало неотъемлемой частью разработки многих современных проектов, но, не смотря на популярность, эта парадигма является далеко не единственной. Если вы уже умеете работать с другими парадигмами и хотели бы ознакомиться с оккультизмом ООП, то впереди вас ждет немного лонгрид и два мегабайта картинок и анимаций. В качестве примеров будут выступать трансформеры.
Прежде всего стоит ответить, зачем? Объектно-ориентированная идеология разрабатывалась как попытка связать поведение сущности с её данными и спроецировать объекты реального мира и бизнес-процессов в программный код. Задумывалось, что такой код проще читать и понимать человеком, т. к. людям свойственно воспринимать окружающий мир как множество взаимодействующих между собой объектов, поддающихся определенной классификации. Удалось ли идеологам достичь цели, однозначно ответить сложно, но де-факто мы имеем массу проектов, в которых с программиста будут требовать ООП.
Не следует думать, что ООП каким-то чудным образом ускорит написание программ, и ожидать ситуацию, когда жители Вилларибо уже выкатили ООП-проект в работу, а жители Виллабаджо все еще отмывают жирный спагетти-код. В большинстве случаев это не так, и время экономится не на стадии разработки, а на этапах поддержки (расширение, модификация, отладка и тестирование), то бишь в долгосрочной перспективе. Если вам требуется написать одноразовый скрипт, который не нуждается в последующей поддержке, то и ООП в этой задаче, вероятнее всего, не пригодится. Однако, значительную часть жизненного цикла большинства современных проектов составляют именно поддержка и расширение. Само по себе наличие ООП не делает вашу архитектуру безупречной, и может наоборот привести к излишним усложнениям.
Иногда можно столкнуться с критикой в адрес быстродействия ООП-программ. Это правда, незначительный оверхед присутствует, но настолько незначительный, что в большинстве случаев им можно пренебречь в пользу преимуществ. Тем не менее, в узких местах, где в одном потоке должны создаваться или обрабатываться миллионы объектов в секунду, стоит как минимум пересмотреть необходимость ООП, ибо даже минимальный оверхед в таких количествах может ощутимо повлиять на производительность. Профилирование поможет вам зафиксировать разницу и принять решение. В остальных же случаях, скажем, где львиная доля быстродействия упирается в IO, отказ от объектов будет преждевременной оптимизацией.
В силу своей природы, объектно-ориентированное программирование лучше всего объяснять на примерах. Как и обещал, нашими пациентами будут трансформеры. Я не трансформеролог, и комиксов не читал, посему в примерах буду руководствоваться википедией и фантазией.
Классы и объекты
Сразу лирическое отступление: объектно-ориентированный подход возможен и без классов, но мы будем рассматривать, извиняюсь за каламбур, классическую схему, где классы — наше всё.
Таким образом, класс — это описание того, какими свойствами и поведением будет обладать объект. А объект — это экземпляр с собственным состоянием этих свойств.
Мы говорим «свойства и поведение», но звучит это как-то абстрактно и непонятно. Привычнее для программиста будет звучать так: «переменные и функции». На самом деле «свойства» — это такие же обычные переменные, просто они являются атрибутами какого-то объекта (их называют полями объекта). Аналогично «поведение» — это функции объекта (их называют методами), которые тоже являются атрибутами объекта. Разница между методом объекта и обычной функцией лишь в том, что метод имеет доступ к собственному состоянию через поля.
Итого, имеем методы и свойства, которые являются атрибутами. Как работать с атрибутами? В большинстве ЯП оператор обращения к атрибуту — это точка (кроме PHP и Perl). Выглядит это примерно вот так (псевдокод):
В картинках я буду использовать такие обозначения:
Я не стал использовать UML-диаграммы, посчитав их недостаточно наглядными, хоть и более гибкими.
Анимация №1
Что мы видим из кода?
1. this — это специальная локальная переменная (внутри методов), которая позволяет объекту обращаться из своих методов к собственным атрибутам. Обращаю внимание, что только к собственным, то бишь, когда трансформер вызывает свой метод, либо меняет собственное состояние. Если снаружи обращение будет выглядеть так: optimus.x, то изнутри, если Оптимус захочет сам обратиться к своему полю x, в его методе обращение будет звучать так: this.x, то есть «я (Оптимус) обращаюсь к своему атрибуту x«. В большинстве языков эта переменная называется this, но встречаются и исключения (например, self)
2. constructor — это специальный метод, который автоматически вызывается при создании объекта. Конструктор может принимать любые аргументы, как и любой другой метод. В каждом языке конструктор обозначается своим именем. Где-то это специально зарезервированные имена типа __construct или __init__, а где-то имя конструктора должно совпадать с именем класса. Назначение конструкторов — произвести первоначальную инициализацию объекта, заполнить нужные поля.
3. new — это ключевое слово, которое необходимо использовать для создания нового экземпляра какого-либо класса. В этот момент создается объект и вызывается конструктор. В нашем примере, конструктору передается 0 в качестве стартовой позиции трансформера (это и есть вышеупомянутая инициализация). Ключевое слово new в некоторых языках отсутствует, и конструктор вызывается автоматически при попытке вызвать класс как функцию, например так: Transformer().
4. Методы constructor и run работают с внутренним состоянием, а во всем остальном не отличаются от обычных функций. Даже синтаксис объявления совпадает.
5. Классы могут обладать методами, которым не нужно состояние и, как следствие, создание объекта. В этом случае метод делают статическим.
(Single Responsibility Principle / Принцип единственной ответственности / Первый принцип SOLID). С ним вы, наверняка, уже знакомы из других парадигм: «одна функция должна выполнять только одно законченное действие». Этот принцип справедлив и для классов: «Один класс должен отвечать за какую-то одну задачу». К сожалению с классами сложнее определить грань, которую нужно пересечь, чтобы принцип нарушался.
Ассоциация
Традиционно в полях объекта могут храниться не только обычные переменные стандартных типов, но и другие объекты. А эти объекты могут в свою очередь хранить какие-то другие объекты и так далее, образуя дерево (иногда граф) объектов. Это отношение называется ассоциацией.
Анимация №2
this.gun_left.fire() и this.gun_right.fire() — это обращения к дочерним объектам, которые происходят так же через точки. По первой точке мы обращаемся к атрибуту себя (this.gun_right), получая объект пушки, а по второй точке обращаемся к методу объекта пушки (this.gun_right.fire()).
1. Композиция — случай, когда на фабрике трансформеров, собирая Оптимуса, обе пушки ему намертво приколачивают к рукам гвоздями, и после смерти Оптимуса, пушки умирают вместе с ним. Другими словами, жизненный цикл дочернего объекта совпадает с жизненным циклом родительского.
Ортодоксальная ООП-церковь проповедует нам фундаментальную троицу — инкапсуляцию, полиморфизм и наследование, на которых зиждется весь объектно-ориентированный подход. Разберем их по порядку.
Наследование
Наследование — это механизм системы, который позволяет, как бы парадоксально это не звучало, наследовать одними классами свойства и поведение других классов для дальнейшего расширения или модификации.
Что если, мы не хотим штамповать одинаковых трансформеров, а хотим сделать общий каркас, но с разным обвесом? ООП позволяет нам такую шалость путем разделения логики на сходства и различия с последующим выносом сходств в родительский класс, а различий в классы-потомки. Как это выглядит?
Оптимус Прайм и Мегатрон — оба трансформеры, но один является автоботом, а второй десептиконом. Допустим, что различия между автоботами и десептиконами будут заключаться только в том, что автоботы трансформируются в автомобили, а десептиконы — в авиацию. Все остальные свойства и поведение не будут иметь никакой разницы. В таком случае можно спроектировать систему наследования так: общие черты (бег, стрельба) будут описаны в базовом классе «Трансформер», а различия (трансформация) в двух дочерних классах «Автобот» и «Десептикон».
Анимация №3
Сей пример наглядно иллюстрирует, как наследование становится одним из способов дедуплицировать код (DRY-принцип) с помощью родительского класса, и одновременно предоставляет возможности для мутации в классах-потомках.
Перегрузка
Если же в классе-потомке переопределить уже существующий метод в классе-родителе, то сработает перегрузка. Это позволяет не дополнять поведение родительского класса, а модифицировать. В момент вызова метода или обращения к полю объекта, поиск атрибута происходит от потомка к самому корню — родителю. То есть, если у автобота вызвать метод fire(), сначала поиск метода производится в классе-потомке — Autobot, а поскольку его там нет, поиск поднимается на ступень выше — в класс Transformer, где и будет обнаружен и вызван. Следует отметить, что модификация нарушает LSP из набора принципов SOLID, но мы рассматриваем только техническую возможность.
Неуместное применение
Любопытно, что чрезмерно глубокая иерархия наследования может привести к обратному эффекту — усложнению при попытке разобраться, кто от кого наследуется, и какой метод в каком случае вызывается. К тому же, не все архитектурные требования можно реализовать с помощью наследования. Поэтому применять наследование следует без фанатизма. Существуют рекомендации, призывающие предпочитать композицию наследованию там, где это уместно. Любая критика наследования, которую я встречал, подкрепляется неудачными примерами, когда наследование используется в качестве золотого молотка. Но это совершенно не означает, что наследование в принципе всегда вредит. Мой нарколог говорил, что первый шаг — это признать, что у тебя зависимость от наследования.
Как при описании отношений двух сущностей определить, когда уместно наследование, а когда — композиция? Можно воспользоваться популярной шпаргалкой: спросите себя, сущность А является сущностью Б? Если да, то скорее всего, тут подойдет наследование. Если же сущность А является частью сущности Б, то наш выбор — композиция.
Применительно к нашей ситуации это будет звучать так:
Наследование статично
Еще одно важное отличие наследования от композиции в том, что наследование имеет статическую природу и устанавливает отношения классов только на этапе интерпретации/компиляции. Композиция же, как мы видели в примерах, позволяет менять отношение сущностей на лету прямо в рантайме — иногда это очень важно, поэтому об этом нужно помнить при выборе отношений (если конечно нет желания использовать метапрограммирование).
Множественное наследование
Мы рассмотрели ситуацию, когда два класса унаследованы от общего потомка. Но в некоторых языках можно сделать и наоборот — унаследовать один класс от двух и более родителей, объединив их свойства и поведение. Возможность наследоваться от нескольких классов вместо одного — это множественное наследование.
Вообще, в кругах иллюминатов бытует мнение, что множественное наследование — это грех, оно несет за собой ромбовидную проблему и неразбериху с конструкторами. Кроме того, задачи, которые решаются множественным наследованием, можно решать другими механизмами, например, механизмом интерфейсов (о котором мы тоже поговорим). Но справедливости ради, следует отметить, что множественное наследование удобно использовать для реализации примесей.
Абстрактные классы
Кроме обычных классов в некоторых языках существуют абстрактные классы. От обычных классов они отличаются тем, что нельзя создать объект такого класса. Зачем же нужен такой класс, спросит читатель? Он нужен для того, чтобы от него могли наследоваться потомки — обычные классы, объекты которых уже можно создавать.
Абстрактный класс наряду с обычными методами содержит в себе абстрактные методы без имплементации (с сигнатурой, но без кода), которые обязан имплементировать программист, задумавший создать класс-потомок. Абстрактные классы не обязательны, но они помогают установить контракт, обязующий имплементировать определенный набор методов, дабы уберечь программиста с плохой памятью от ошибки имплементации.
Полиморфизм
Полиморфизм — свойство системы, позволяющее иметь множество реализаций одного интерфейса. Ничего непонятно. Обратимся к трансформерам.
Положим, у нас есть три трансформера: Оптимус, Мегатрон и Олег. Трансформеры боевые, стало быть обладают методом attack(). Игрок, нажимая у себя на джойстике кнопку «воевать», сообщает игре, чтобы та вызвала метод attack() у трансформера, за которого играет игрок. Но поскольку трансформеры разные, а игра интересная, каждый из них будет атаковать каким-то своим способом. Скажем, Оптимус — объект класса Автобот, а Автоботы снабжаются пушками с плутониевыми боеголовками (да не прогневаются фанаты трансформеров). Мегатрон — Десептикон, и стреляет из плазменной пушки. Олег — басист, и он обзывается. А в чем польза?
Польза полиморфизма в данном примере заключается в том, что код игры ничего не знает о реализации его просьбы, кто как должен атаковать, его задача просто вызвать метод attack(), сигнатура которого одинакова для всех классов персонажей. Это позволяет добавлять новые классы персонажей, или менять методы существующих, не меняя код игры. Это удобно.
Инкапсуляция
Инкапсуляция — это контроль доступа к полям и методам объекта. Под контролем доступа подразумевается не только можно/неможно, но и различные валидации, подгрузки, вычисления и прочее динамическое поведение.
Во многих языках частью инкапсуляции является сокрытие данных. Для этого существуют модификаторы доступа (опишем те, которые есть почти во всех ООП языках):
Как правильно выбрать модификатор доступа? В простейшем случае так: если метод должен быть доступен внешнему коду, выбираем public. В противном случае — private. Если есть наследование, то может потребоваться protected в случае, когда метод не должен вызываться снаружи, но должен вызываться потомками.
Аксессоры (геттеры и сеттеры)
Геттеры и сеттеры — это методы, задача которых контролировать доступ к полям. Геттер считывает и возвращают значение поля, а сеттер — наоборот, принимает в качестве аргумента значение и записывает в поле. Это дает возможность снабдить такие методы дополнительными обработками. Например, сеттер при записи значения в поле объекта, может проверить тип, или входит ли значение в диапазон допустимых (валидация). В геттер же можно добавить, ленивую инициализацию или кэширование, если актуальное значение на самом деле лежит в базе данных. Применений можно придумать множество.
В некоторых языках есть синтаксический сахар, позволяющий такие аксессоры маскировать под свойства, что делает доступ прозрачным для внешнего кода, который и не подозревает, что работает не с полем, а с методом, у которого под капотом выполняется SQL-запрос или чтение из файла. Так достигается абстракция и прозрачность.
Интерфейсы
Задача интерфейса — снизить уровень зависимости сущностей друг от друга, добавив больше абстракции.
Не во всех языках присутствует этот механизм, но в ООП языках со статической типизацией без них было бы совсем худо. Выше мы рассматривали абстрактные классы, затрагивая тему контрактов, обязующих имплементировать какие-то абстрактные методы. Так вот интерфейс очень смахивает на абстрактный класс, но является не классом, а просто пустышкой с перечислением абстрактных методов (без имплементации). Другими словами, интерфейс имеет декларативную природу, то есть, чистый контракт без капельки кода.
Обычно в языках, в которых есть интерфейсы, нет множественного наследования классов, но есть множественное наследование интерфейсов. Это позволяет классу перечислить интерфейсы, которые он обязуется имплементировать.
Классы с интерфейсами состоят в отношении «многие ко многим»: один класс может имплементировать множество интерфейсов, и каждый интерфейс, в свою очередь, может имплементироваться многими классами.
У интерфейса двустороннее применение:
Обращаю внимание, что получившаяся система слотов у трансформеров — это пример использования композиции. Если же оборудование в слотах будет сменным в ходе жизни трансформера, то тогда это уже агрегация. Для наглядности, мы будем называть интерфейсы, как принято в некоторых языках, добавляя заглавную «И» перед именем: IWeapon, IEnergyGenerator, IScanner.
Анимация №4
К сожалению, в картинку не влезла фабрика, но она все равно необязательна, трансформера можно собрать и во дворе.
Обозначенный на картинке слой абстракции в виде интерфейсов между слоем имплементации и слоем-потребителем дает возможность абстрагировать одних от других. Вы можете это наблюдать, посмотрев на каждый слой в отдельности: в слое имплементации (слева) нет ни слова про класс Transformer, а в слое-потребителе (справа) нет ни слова про конкретные имплементации (там нет слов Radar, RocketLauncher, NuclearReactor и т. д.)
В таком коде мы можем создавать новые комплектующие к трансформерам, не затрагивая чертежи самих трансформеров. В то же время и наоборот, мы можем создавать новых трансформеров, комбинируя уже существующие комплектующие, либо добавлять новые комплектующие, не меняя существующих.
Утиная типизация
Явление, которое мы наблюдаем в получившейся архитектуре, называется утиной типизацией: если что-то крякает как утка, плавает как утка, и выглядит как утка, то, скорее всего — это утка.
Переводя это на язык трансформеров, звучать будет так: если что-то стреляет как пушка, и перезаряжается как пушка, скорее всего, это пушка. Если устройство генерирует энергию, скорее всего, это генератор энергии.
(Interface Segregation Principle / Принцип разделения интерфейса / Четвертый принцип SOLID) призывает не создавать жирные универсальные интерфейсы. Вместо этого интерфейсы нужно разделять на более мелкие и специализированные, это поможет гибче их комбинировать в имплементирующих классах, не заставляя имплементировать лишние методы.
Абстракция
В ООП все крутится вокруг абстракции. Существуют фанатики, утверждающие, что абстракция должна быть частью ООП-троицы (инкапсуляция, полиморфизм, наследование). А мой инспектор по УДО говорил обратное: абстракция присуща для любого программирования, а не только для ООП, поэтому она должна стоять отдельно. С другой стороны, то же самое можно сказать и про остальные принципы, но из песни слов не выкинешь. Так или иначе, абстракция нужна, и особенно в ООП.
Уровень абстракции
Тут нельзя не процитировать одну известную шутку:
— любую архитектурную проблему можно решить добавлением дополнительного слоя абстракции, кроме проблемы большого количества абстракций.
В нашем примере с интерфейсами мы внедрили слой абстракции между трансформерами и комплектующими, сделав архитектуру более гибкой. Но какой ценой? Нам пришлось усложнить архитектуру. Мой психотерапевт говорил, что умение балансировать между простотой архитектуры и гибкостью приложения — это искусство. Выбирая золотую середину, следует опираться не только на собственный опыт и интуицию, но и на контекст текущего проекта. Поскольку будущее человек видеть пока не научился, нужно аналитически прикинуть, какой уровень абстракции и с какой долей вероятности может пригодиться в данном проекте, сколько времени потребуется на проработку гибкой архитектуры, и окупится ли затраченное время в будущем.
Неверный выбор уровня абстракции ведет к одной из двух проблем:
Еще важно понимать, что уровень абстракции определяется не для всего проекта в целом, а отдельно для разных компонентов. В каких-то местах системы абстракции может быть недостаточно, а где-то наоборот — перебор. Однако, неверный выбор уровня абстракции можно исправить своевременным рефакторингом. Ключевое слово — своевременным. Запоздалый рефакторинг провести проблематично, когда на данном уровне абстракции реализовано уже множество механизмов. Проводить обряд рефакторинга в запущенных системах может сопрягаться с острой болью в труднодоступных местах программиста. Это примерно как поменять фундамент в доме — дешевле построить рядом дом с нуля.
Давайте рассмотрим определение уровня абстракции из возможных вариантов на примере гипотетической игры «трансформеры-онлайн». Уровни абстракции в данном случае будут выступать как слои, каждый последующий рассматриваемый слой будет ложиться поверх предыдущего, забирая из него часть функционала в себя.
Первый слой. В игре есть один класс трансформера, все свойства и поведение описаны в нем. Это совсем деревянный уровень абстракции, подходит для казуальной игры, которая не предполагает никакой особой гибкости.
Второй уровень. В игре есть базовый трансформер с основными способностями и классы трансформеров со своей специализацией (типа разведчик, штурмовик, саппорт), которая описывается дополнительными методами. Тем самым игроку предоставляется возможность выбора, а разработчикам упрощается добавление новых классов.
Третий уровень. Помимо классификации трансформеров вводится агрегация с помощью системы слотов и компонентов (как в нашем примере с реакторами, пушками и радарами). Теперь часть поведения будет определяться тем, какой стаф игрок установил в своего трансформера. Это дает игроку еще больше возможностей для кастомизации игровой механики персонажа, а разработчикам дает возможность добавлять эти самые модули расширения, что в свою очередь упрощает работу гейм-дизайнерам по выпуску нового контента.
Четвертый уровень. В компоненты можно тоже включить собственную агрегацию, предоставляющую возможность выбора материалов и деталей, из которого собираются эти компоненты. Такой подход даст игроку возможность не только набивать трансформеров нужными комплектующими, но и самостоятельно производить эти комплектующие из различных деталек. Признаться, такой уровень абстракции я в играх никогда не встречал, и не без резона! Ведь это сопровождается значительным усложнением архитектуры, а регулировка баланса в таких играх превращается в ад. Но не исключаю, что такие игры существуют.
Как видим, каждый описанный слой, в принципе, имеет право на жизнь. Все зависит от того, какую именно гибкость мы хотим заложить в проект. Если в техническом задании ничего об этом не сказано, или автор проекта сам не знает, что может потребовать бизнес, можно посмотреть на похожие проекты в этой сфере и ориентироваться на них.
Паттерны проектирования
Десятилетия разработки привели к тому, что сформировался список наиболее часто применяемых архитектурных решений, которые со временем были классифицированы сообществом, и стали называться паттернами проектирования. Именно поэтому, когда я прочитал впервые про паттерны, я с удивлением обнаружил, что оказывается, многие из них я уже использую на практике, просто не знал, что у этих решений есть название.
Паттерны проектирования, как и абстракция, свойственны не только ООП разработке, но и другим парадигмам. Вообще, тема паттернов выходит за рамки данной статьи, но здесь хотелось бы предостеречь молодого разработчика, который только намерен познакомиться с паттернами. Это ловушка! Сейчас объясню, почему.
Предназначение паттернов — помощь в решении архитектурных проблем, которые либо уже обнаружились, либо, вероятнее всего, обнаружатся в ходе развития проекта. Так вот, у новичка, который прочитал про паттерны, может появиться непреодолимый соблазн использовать паттерны не для решения проблем, а для их порождения. А поскольку разработчик в своих желаниях необуздан, он может начать не решать задачу при помощи паттернов, а подстраивать любые задачи под решения с помощью паттернов.
Еще одна ценность от паттернов — формализации терминологии. Гораздо проще коллеге сказать, что в этом месте используется «цепочка обязанностей», чем полчаса рисовать поведение и отношения объектов на бумажке.
Заключение
В условиях современных требований наличие в вашем коде слова class не делает из вас ООП-программиста. Ибо если вы не используете описанные в статье механизмы (полиморфизм, композицию, наследование и т. д.), а вместо этого применяете классы лишь для группировки функций и данных, то это не ООП. То же самое можно решить какими-нибудь неймспейсами и структурами данных. Не путайте, иначе на собеседовании будет стыдно.
Хочется закончить свою песнь важными словами. Любые описанные механизмы, принципы и паттерны, как и ООП в целом не стоит применять там, где это бессмысленно или может навредить. Это ведет к появлению статей со странными заголовками типа «Наследование — причина преждевременного старения» или «Синглтон может приводить к онкологическим заболеваниям».
Я серьезно. Если рассмотреть случай с синглтоном, то его повсеместное применение без знания дела, стало причиной серьезных архитектурных проблем во многих проектах. И любители забивать гвозди микроскопом любезно его нарекли антипаттерном. Будьте благоразумны.
К сожалению, в проектировании не существует однозначных рецептов на все случаи жизни, где что применять уместно, а где неуместно. Это будет постепенно укладываться в голове с опытом.