Наследование, композиция, агрегация
Нередко случается, что решив разобраться с какой-то новой темой, понятием, инструментом программирования, я читаю одну за другой статьи на различных сайтах в интернете. И, если тема сложная, то эти статьи могут не на шаг не приблизить меня к понимаю. И вдруг встречается статья, которая моментально дает озарение и все паззлы складываются воедино. Трудно определить, что отличает такую статью от других. Правильно подобранные слова, оптимальная логика изложения или же просто более релевантный пример. Я не претендую на то, что моя статься окажется новым словом в C# или же лучшей обучающей статьей. Но, возможно для кого-то она станет именно той, которая позволит разобраться, запомнить и начать правильно применять те понятия, о которых пойдет речь.
В объектно-ориентированных языках программирования существует три способа организации взаимодействия между классами. Наследование — это когда класс-наследник имеет все поля и методы родительского класса, и, как правило, добавляет какой-то новый функционал или/и поля. Наследование описывается словом «является». Легковой автомобиль является автомобилем. Вполне естественно, если он будет его наследником.
Ассоциация – это когда один класс включает в себя другой класс в качестве одного из полей. Ассоциация описывается словом «имеет». Автомобиль имеет двигатель. Вполне естественно, что он не будет являться наследником двигателя (хотя такая архитектура тоже возможна в некоторых ситуациях).
Выделяют два частных случая ассоциации: композицию и агрегацию.
Композиция – это когда двигатель не существует отдельно от автомобиля. Он создается при создании автомобиля и полностью управляется автомобилем. В типичном примере, экземпляр двигателя будет создаваться в конструкторе автомобиля.
Агрегация – это когда экземпляр двигателя создается где-то в другом месте кода, и передается в конструктор автомобиля в качестве параметра.
Хотя ведутся дискуссии о преимуществах того или иного способа организации взаимодействия между классами, какого-либо абстрактного правила не существует. Разработчик выбирает тот или иной путь основываясь на элементарной логике (“является” или “имеет”), но также принимает во внимание возможности и ограничения, которые дают и накладывают эти способы. Для того, чтобы увидеть эти возможности и ограничения, я попытался написать пример. Достаточно простой, чтобы код оставался компактным, но и достаточно развитый, чтобы в рамках одной программы можно было применить все три способа. И, главное, я попытался сделать этот пример как можно менее абстрактным – все объекты и экземпляры понятны и осязаемы.
Напишем простенькую игру – танковый бой. Играют два танка. Они поочередно стреляют и проигрывает тот, здоровье которого упало до нуля. В игре будут различные типы снарядов и брони. Для того, чтобы нанести урон необходимо во-первых, попасть по танку противника, во-вторых, пробить его броню. Если броня не пробита, урон не наносится. Логика игры построена на принципе «камень-ножницы-бумага»: то есть броня одного типа хорошо противостоит снарядам определенного типа, но плохо держит другие снаряды. Кроме того, снаряды, которые хорошо пробивают броню, наносят малый «заброневой» урон, и, напротив, наиболее «летальные» снаряды имеют меньше шансов пробить броню.
Создадим простенький класс для пушки. Он будет иметь два приватных поля: калибр и длину ствола. От калибра зависит урон, и, частично, способность к пробитию брони. От длины ствола – точность стрельбы.
Сделаем также конструктор для пушки:
Сделаем метод для получения калибра из других классов:
Помните, что для поражения цели должно произойти две вещи: попадание в цель и пробитие брони? Так вот, пушка будет отвечать за первую из них: попадание. Поэтому делаем булевый метод IsOnTarget, который принимает случайную величину (dice) и возвращает результат: попали или нет:
Целиком класс пушки выглядит следующим образом:
Теперь сделаем снаряды – это наиболее очевидный случай для применения наследования, но и агрегацию в нем тоже применим. Любой снаряд имеет свои особенности. Просто неких гипотетических снарядов не бывает. Поэтому класс делаем абстрактным. Делаем ему строковое поле «тип».
Снаряды делают для пушек. Для определенных пушек. Снаряд одного калибра не выстрелит из пушки другого калибра. Поэтому добавляем снаряду поле-ссылку на экземпляр пушки. Делаем конструктор.
Здесь мы применили агрегацию. Где-то будет создана пушка. Потом к этой пушке будут создаваться снаряды, которые имеют указатель на пушку.
Конкретные типы снарядов будут наследниками абстрактного снаряда. Наследники могут просто наследовать методы родителя, но могут и быть переопределены, то есть работать не так, как родительский метод. Но мы точно знаем, что любой снаряд должен иметь ряд методов. Любой снаряд должен наносить урон. Метод GetDamage просто возвращает калибр, умноженный на три. В общем случае, урон снаряда зависит от калибра. Но этот метод будет переопределяться в дочерних классах (помним, что снаряды, которые хорошо пробивают броню, как правило наносят меньший «заброневой» урон. Чтобы иметь возможность переопределить метод в дочернем классе, используем слово virtual.
Любой снаряд должен пробивать (или по крайней мере пытаться пробить) броню. В общем случае способность пробивать броню также зависит от калибра (ну, и еще от многого – начальной скорости, например, но мы не будем усложнять). Поэтому, метод возвращает калибр. То есть, грубо говоря, снаряд может пробить броню, равную по толщине своему калибру. Этот метод не будет переопределяться в дочерних классах.
Кроме того, для удобной отладки и организации консольного вывода, имеет смысл добавить метод ToString, который просто позволит нам увидеть, что это за снаряд и какого калибра:
Теперь сделаем разные типы снарядов, которые будут наследовать абстрактный снаряд: фугасный, кумулятивный, подкалиберный. Фугасный наносит самый большой урон, кумулятивный – меньше, подкалиберный – еще меньше. Дочерние классы не имеют полей и вызывают конструктор базового снаряда, передавая ему пушку, и строковый тип. В дочернем классе переопределяется метод GetDamage() – вносятся коэффициенты, которые увеличат или уменьшат урон по сравнению с дефолтным.
Фугасный (дефолтный урон):
Кумулятивный (дефолтный урон х 0.6):
Подкалиберный (дефолтный урон х 0.3):
Обратите внимание, что в переопределенном методе GetDamage вызывается и метод базового класса. То есть, переопределив метод, мы также сохраняем возможность обратиться к дефолтному методу, использовав ключевое слово base).
Итак, для снарядов мы применили и агрегацию (пушка в базовом классе), и наследование.
Создадим теперь броню для танка. Здесь применим только наследование. Любая броня имеет толщину. Поэтому абстрактный класс брони будет иметь поле thickness, и строковое поле type, которое будет определятся при создании дочерних классов.
Броня будет в нашей игре определять пробита они или нет. Поэтому, у нее будет лишь один метод, который будет переопределяться в дочерних, в зависимости от типа брони.
А пробита они или нет – зависит от того, какой прилетел снаряд: в дефолтном случае какого калибра. Поэтому метод принимает экземпляр снаряда и возвращает булевый результат: пробита или нет. Создадим несколько типов брони – наследников абстрактной брони. Приведу код лишь одного типа – логика примерно такая же, как и в снарядах. Гомогенная броня хорошо держит фугасный снаряд, но плохо – подкалиберный. Поэтому, если прилетел подкалиберный снаряд, который имеет высокую бронепробиваемость, то в вычислениях наша броня как-бы становится тоньше. И так далее: каждый вид брони имеет свой набор коэфициентов устойчивости к тому или иному снаряду.
Здесь мы используем одно из чудес, которые дает полиморфизм. Метод принимает любой снаряд. В сигнатуре указан базовый класс, а не дочерние. Но внутри метода, мы можем увидеть, что за снаряд прилетел – какого типа. И в зависимости от этого, реализуем ту или иную логику. Если бы мы не применили наследование для снарядов, а сделали просто три уникальных класса типов снарядов, то проверку пробития брони пришлось бы организовывать иначе. Нам пришлось бы писать столько перегруженных методов, сколько типов снарядов у нас в игре, и вызывать один из них в зависимости от того, какой снаряд прилетел. Это тоже было бы довольно изящно, но не относится к теме данной статьи.
Теперь у нас все готово для создания танка. В танке не будет наследования, но будет композиция и агрегация. Разумеется, у танка будет название. У танка будет пушка (агрегация). Для нашей игры сделаем допущение, что танк может «переодевать» броню перед каждым ходом – выбрать тот или иной тип брони. Для этого, у танка будет список типов брони. У танка будет боеукладка – список снарядов, который будет наполнен снарядами, созданными в конструкторе танка (композиция!). У танка будет здоровье (уменьшается при попадании в него), и, у танка будет текущая выбранная броня и текущий выбранный снаряд.
Для того, чтобы конструктор танка остался более-менее компактным, сделаем два вспомогательных приватных метода, которые добавляют три типа брони соответствующей толщины, и наполняют боеукладку 10 снарядами каждого из трех типов:
Теперь конструктор танка выглядит вот таким образом:
Обратите внимание, что здесь мы снова используем возможности полиморфизма. Наша боекладка вмещает снаряды любого типа, так как список имеет тип данных Ammo – родительский снаряд. Если бы мы не наследовались, а создавали уникальные типы снарядов, пришлось бы делать отдельный список под каждый тип снаряда.
Пользовательский интерфейс танка состоит из трех методов: выбрать броню, зарядить пушку, выстрелить.
Как я упомянул в начале, в этом примере я старался максимально уйти от абстрактных понятий, которые нужно все время держать в голове. Поэтому каждый экземпляр снаряда у нас равен физическому снаряду, который положили в боеукладку перед боем. Следовательно, снаряды могут закончится в самый неподходящий момент!
Здесь – поподробнее. Во-первых, есть проверка заряжена ли пушка. Во-вторых, снаряд, который вылетел из ствола, уже не существует для данного танка, его уже нет ни в пушке, ни в боеукладке. Но физически он еще существует – летит по направлению к цели. И если попадет, будет участвовать в вычислении пробития брони и урона цели. Поэтому, мы сохраняем этот снаряд в новой переменной: Ammo firedAmmo. Поскольку на следующей же строке данный снаряд перестанет существовать для данного танка, придется использовать интерфейс IClonable для базового класса снаряда:
Этот интерфейс требует реализации метода Clone(). Вот она:
Теперь все супер реалистично: при выстреле генерируется dice, пушка рассчитывает попадание своим методом IsOnTarget, и, если попадание есть, то метод Shoot вернет экземпляр снаряда, а если промах – то вернет null.
Последний метод танка – его поведение при попадании вражеского снаряда:
Снова полиморфизм во всей красе. К нам прилетает снаряд. Любой. Исходя из выбранной брони и типа снаряда, вычисляется пробита броня или нет. Если пробита, то вызывается метод конкретного типа снаряда GetDamage().
Все готово. Остается только написать консольный (или неконсольный) вывод, в котором будет обеспечен пользовательский интерфейс и в цикле реализованы поочередные ходы игроков.
Подведем итоги. Мы написали программу, в которой использовали наследование, композицию и агрегацию, надеюсь, поняли и запомнили различия. Активно задействовали возможности полиморфизма, во-первых, когда любые экземпляры дочерних классов можно сложить в список, имеющий тип данных родительского, а во-вторых, создавая методы, которые принимают в качестве параметра родительский экземпляр, но внутри которых вызываются методы дочернего. По ходу текста я упоминал возможные альтернативные реализации – замену наследования на агрегацию, и, универсального рецепта тут нет. В нашей реализации наследование дало нам легкость добавления новых деталей в игру. Например, чтобы добавить новый тип снаряда нам нужно лишь:
- собственно, скопировать один из существующих типов, заменив название и строковое поле, передаваемое в конструктор;
- добавить еще один if в дочерние классы брони;
- добавить дополнительный пункт в меню выбора снаряда в пользовательском интерфейсе.
Ниже – приведена диаграмма наших классов.
В финальном коде игры все «магические числа», которые использовались в тексте, вынесены в отдельный статический класс Config. К публичным полям статического класса мы можем обратиться из любого фрагмента нашего кода и его экземпляр не нужно (и невозможно) создавать. Вот так он выглядит:
И благодаря этому классу мы можем производить дальнейшую настройку, меняя параметры лишь здесь, без дальнейшего углубления в классы и методы. Если, например, мы пришли к выводу, что подкалиберный снаряд получился слишком сильным, то мы меняем одну циферку в Config.
Весь код игры можно увидеть вот здесь.
Композиция программного обеспечения: Вступление
Композиция — это составление целого из частей.
На моём первом уроке программирования в старших классах мне рассказывали, что программирование — это разделение сложной задачи на несколько небольших, и объединение простых результатов для получения конечного решения сложной проблемы.
Одним из моих главных сожалений в жизни является то, что я не смог рано понять значение того урока. Я узнал суть проектирования программного обеспечения слишком поздно.
Я проводил собеседования с сотнями разработчиков. Из этих встреч я узнал, что я не одинок. Очень немногие разработчики имели хорошее понимание сути разработки программ. Большинство не знали о самых важных инструментах, которые имеются в наличии, или как их использовать. 100% пытались ответить на один или на оба самых важных вопроса в области разработки программного обеспечения:
- Что такое композиция функций?
- Что такое композиция объектов?
Проблема заключается в том, что вы не можете избежать композиции, только потому что не знаете о ней. Вы все-равно делаете это — но делаете плохо. Вы пишете код с большим количеством ошибок и делаете его сложным для понимания другим разработчикам и это большая проблема. Мы тратим больше времени на поддержку программного обеспечения, чем на создание его с нуля, и наши ошибки влияют на миллиарды людей по всему миру.
Во всем мире используют различное программное обеспечение. Каждый автомобиль это мини-суперкомпьютер на колесах, и проблемы с разработкой его программного обеспечения может создают реальные проблемы и стоят человеческих жизней. В 2013 году комитет признал команду разработчиков программного обеспечения Toyota виновной в грубом нарушении после того, как расследование аварии выявило спагетти-код с 10000 глобальными переменными.
Хакеры и правительственные агенты собирают ошибки, чтобы шпионить за людьми, красть кредитные карты, использовать вычислительные ресурсы для запуска распределенных атак типа “отказ в обслуживании” (DDoS), взламывать пароли и даже манипулировать выборами.
Мы должны добиваться лучшего.
Вы составляете программы каждый день
Если вы разработчик, вы составляете функции и структуры данных каждый день, знаете вы это или нет. Вы можете делать это сознательно (что лучше), или случайно, используя клейкую ленту и суперклей.
Процесс разработки это разделение больших проблем на более мелкие, создание компонентов, которые решают эти маленькие проблемы, и затем составление всех этих частей вместе, чтобы получить конечное приложение.
Компонуем функции
Композиция функций — это процесс применения одной функции к результату другой. Например, в математике даны две функции f и g , в результате будет будет (f ∘ g)(x) = f(g(x)) , где кругляшок является оператором композиции. Часто говорят "применение к" или "после". Вы можете произнести вслух "применение f к g равно результату f от g от x " или " f применяется после g от x ". Мы говорим f после g , потому что g выполняется первой, а результат её выполнения является аргументом для f .
Всякий раз когда вы пишите подобный код, вы компонуете функции:
Всякий раз когда вы пишите цепочку промисов, вы компонуете функции:
Точно так же, каждый раз, когда вы делаете цепочку вызовов методов массива, методов lodash, observables (RxJS, и т.д.), вы компонуете функции. Если вы используете цепочки вызовов, вы компонуете функции. Если вы передаете возвращаемые значения в другие функции, вы компонуете функции. Если вы последовательно вызываете два метода вы компонуете их, используя this в качестве входных данных.
Если вы используете цепочки вызовов, вы компонуете функции.
Когда вы используете композицию функций намеренно, то делаете это лучше.
Специально скомпоновав функции, мы можем улучшить наш doStuff() до одной строчки.
Главное замечание к такой форме написания заключается в том, что её сложнее отлаживать. Например как бы вы написали следующий код, используя композицию функций?
Во-первых, давайте вынесем логирование “after f”, “after g” в отдельную вспомогательну функцию с именем trace() :
Теперь мы можем использовать её:
Популярные библиотеки функционального программирования, такие как Lodash и Ramda, уже включают в себя утилиты для упрощения композиции функций. Вы можете переписать функцию выше, следующим образом:
Если хотите попробовать этот код без импорта чего-либо, то можете определить функцию pipe таким образом:
Не беспокойтесь, если не смогли еще уследить, как это работает. Позже мы рассмотрим функциональную композицию более подробно. На самом деле это настолько важно, потому вы увидите ее определение и демонстрацию множество раз в этом тексте. Цель в том, чтобы помочь вам понять настолько, чтобы сделать использование этого автоматическим. Будьте едины с композицией.
pipe() создает конвейер из функций, где результат одной функции является аргументом для следующей. Когда вы используете pipe() (или его аналог compose() ), то не используете промежуточные переменные. Описание функций без указания аргументов называется бесточечной нотацией. Для этого вы вызываете функцию, которая возвращает новую функцию, без явного её объявления. Это означает, что вам не нужно использовать ключевое слово function или стрелочный синтаксис ( => ).
Бесточечную нотацию можно использовать гораздо дальше, и это хорошо, потому то промежуточные переменные создают ненужную сложность вашим функциям.
Несколько преимуществ уменьшения сложности:
Работа памяти
Средний человеческий мозг имеет только несколько общих ресурсов для дискретных квантов в рабочей памяти, и каждая переменная потенциально потребляет один из этих квантов. Когда вы добавляете больше переменных, то наша способность точно вспомнить значение каждой переменной уменьшается. Модель рабочей памяти включают 4–7 дискретных квантов, превысив эти значения коэффициент ошибок резко возрастают.
Используя pipe , мы исключили 3 переменных, освободив тем самым почти половину нашей доступного запаса памяти для других вещей. Это значительно снижает умственную нагрузку. Разработчики, как правило, лучше разбивают информацию в памяти, чем средний человек, но не настолько, чтобы забыть о её сохранении.
Отношение сигнал/шум
Короткий код увеличивает отношение сигнал/шум в вашем коде. Это как слушать радио — когда оно не настроено должным образом, вы будете получать множество помех, из-за чего труднее услышать музыку. Но стоит настроить на правильную станцию, как шум уходит и вы получаете отличный музыкальный сигнал.
Писать код — это тоже самое, короткое выражение ведет к лучшему пониманию. Какой-то код несет полезную информацию, а какой-то просто занимает место. Если вы сможете уменьшить объем кода, не уменьшая его значение, которое передается, то облегчите анализ и понимание кода для других людей, которым нужно его прочитать.
Место для ошибок
Взгляните на функции до и после. Похоже, что эта функция пошла на диету и похудела. Это важно, потому что дополнительный код создает дополнительное место для ошибок, что означает больше ошибок будут скрываться в нем.
Меньше кода = меньше места для ошибок = меньше ошибок
Компонуем объекты
“Предпочитайте композицию наследованию класса” — «Банда Четырех», «Приёмы объектно-ориентированного проектирования. Паттерны проектирования» (прим. пер. the Gang of Four, “Design Patterns: Elements of Reusable Object Oriented Software”)
“В информатике составные или сложные типы данных — это тип, который может быть получен в программе, используя примитивные типы языка программирования и другие составные типы. […] Построение составного типа является композицией.”
А это составной тип
Кроме того, все массивы, Sets, Maps, WeakMaps, TypedArrays и т. д. являются составными типами данных. Всякий раз когда вы создаете любую структуру данных, не являющейся примитивом, вы производите композицию в каком-то роде.
Обратите внимание, что “Банда Четырех” определяет паттерн, называемый “Компоновщик”, который является особым типом рекурсивной композиции объектов, что позволяет одинаково обрабатывать отдельные компоненты и агрегированные составные типы. Некоторые разработчики путаются, думая, что паттерн компоновщик единственное определение композиции объектов. Не дайте себя запутать, существует множество различных типов композиции объектов.
“Банда Четырех” продолжает, — “вы увидите как композиция объектов используется снова и снова в паттернах проектирования”, и затем они показывают три вида связей между скомпонованным объектами, в которые включены делегирование (используется в паттернах состояние, стратегия и посетитель), осведомленность (когда объекту известно о другом объекте посредством ссылки, обычно переданной как параметр: Uses-A отношение, например в обработчик запроса можно передать ссылку на логгер, который будет выводить запрос — запрос использует логгер), и агрегирование (когда дочерние объекты являются частью родительского: Has-a отношение, например дочерние DOM элементы являются составными частями DOM-узла — у DOM-узла имеются дети).
Наследование классов может использоваться для создания составных объектов, но это ограниченный и хрупкий способ делать это. Когда Банда Четырех говорит “преподчитайте композицию объектов наследованию”, они советуют вам использовать гибкие подходы для построения составных объектов, а не жесткий, тесно связанный подход наследования классов.
Мы будем использовать более общее определение композиции объектов из книги “Categorical Methods in Computer Science: With Aspects from Topology” (1989):
Составные объекты формируются путем объединения объектов таким образом, что каждый из них является “частью” первого.
Еще одна хорошая отсылка — “Reliable Software Through Composite Design”, Glenford J Myers, 1975. Обе книги давно вышли из печати, но вы все еще можете найти продавцов на Amazon или eBay, если вы хотите глубже изучить тему композиции объектов с технической точки зрения.
Наследование классов — это только один из видов построения составного объекта. Все классы дают в результате составные объекты, но не все сложные объекты созданы классами или наследованием классов. “Предпочитайте композицию объектов наследованию” означает, что следует создавать составные объекты из мелких частей, а не наследовать все свойства от предка в иерархии классов. Последнее вызывает большое разнообразие известных проблем в объектно-ориентированном проектировании:
- Проблема тесной связи: поскольку дочерние классы зависят от реализации родительского класса, то наследование является самой тесной связью, доступной в объектно-ориентированном дизайне.
- Проблема хрупкого базового класса: из-за тесной связи изменения в базовом классе могут нарушить работу большого числа дочерних классов, и вероятно в коде, управляемом третьими сторонами. Автор может нарушить код, о котором он не знает.
- Проблема негибкой иерархии: с таксономией одного предка и учетом достаточного времени и эволюции, все таксономии классов в конечном итоге неверны для новых случаев их применения.
- Проблема дублирования по необходимости: из-за негибкой иерархии новые классы часто реализуются путем дублирования, а не c помощью расширения, что приводит к появлению подобных классов, которые неожиданно различаются. Как только происходит дублирование, становится неочевидно, из какого класса должны происходить новые классы или почему.
- Проблема гориллы с бананом: “…проблема в объектно-ориентированных языках заключается в том, что у них существует неявная среда, которую они с собой несут. Вы хотели всего лишь банан, но в результате получаете гориллу, держащую этот банан, и все джунгли в придачу.”
Наиболее распространенная форма объектной композиции в JavaScript известна как объединение объектов (или примесь). Это работает как мороженное, вы начинаете с объекта (допустим ванильное мороженное), и затем подмешиваете дополнительные функции, которые хотите. Добавьте орехи, карамель, шоколад, и в итоге получите орехово-карамельно-шоколадное мороженое.
Создание составных объектов через наследование классов:
Создание составных объектов через примеси:
Позже мы рассмотрим другие виды композиции объектов более подробно. На данный момент вы можете понять, что:
- Существует несколько способов, чтобы сделать композицию объектов.
- Какие-то способы лучше, чем другие.
- Вы хотите выбрать самое простое и гибкое решение для поставленной задачи.
Заключение
Эта статья не о функциональном программировании (ФП) против объектно-ориентированного (ООП), или один язык против другого. Компонентами могут быть функции, структуры данных, классы и т.д. Различные языки программирования, как правило, предоставляют разные базовые элементы для компонентов — Java предлагает классы, Haskell предлагает функции и т.д. Но независимо от того, какой язык и какую парадигму вы предпочитаете, вам никуда не деться от составления функций и структур данных. В конце концов, это то, к чему все сводится.
Мы будем много говорить о функциональном программировании, потому что функции в JavaScript очень просто компонуются, и сообщество функционального программирования вложило много времени и усилий для формирования техник композиции функций.
Чего мы не будем делать, так это говорить, что функциональное программирование лучше объектно-ориентированного программирования, или что вы должны выбрать одно вместо другого. ООП против ФП — неправильное противопоставление. Каждое реальное приложение на Javascript, которое я видел в последние годы, активно смешивает ФП и ООП.
Мы будем использовать композицию объектов, чтобы создавать типы данных для функционального программирования, а функциональное программирование — чтобы создавать объекты для ООП.
Независимо от того как вы пишите программное обеспечение, вы должно хорошо составить его.
Суть разработки программного обеспечения — это композиция.
Разработчик, который не понимает композицию, похож на строителя дома, который не знает о болтах или гвоздях. Построение программы без знания композиции похоже на устаноку стен с помощью клейкой ленты и клея.
Пришло время упростить и лучший для этого способ — добраться до сути. Проблема в том, что почти никто в отрасли не имеет хорошего знания сути. Мы, как отрасль, подвели вас, разработчиков. Наша обязанность как отрасли — это лучше обучать разработчиков. Мы должны совершенствоваться, должны взять на себя ответственность. На программном обеспечении работает все в мире, от экономики до медицинского оборудования. На этой планете нет буквально ни одного уголка, где обитает человек, на который бы не повлияло бы качество наших программ. Нам нужно осознавать, что мы делаем.
Теперь время изучить как составлять программы.
Узнайте больше на EricElliottJS.com
Видеоуроки по композиции функций и объектов доступны для пользоваетелей EricElliottJS.com. Если вы не еще не являетесь им, регистрируйтесь сегодня
Eric Elliott автор книг “Programming JavaScript Applications” (O’Reilly), и “Learn JavaScript with Eric Elliott”. Он внес свой вклад в опыт разработки программного обеспечения для Adobe Systems, Zumba Fitness, The Wall Street Journal, ESPN, BBC, и ведущих артистов, в том числе Usher, Frank Ocean, Metallica, и множество других.
Он работает удаленно из любой точки мира с самой красивой женщиной в мире.
Урок №147. Композиция объектов
В реальной жизни сложные объекты часто состоят из меньших, более простых объектов. Например, автомобиль состоит из металлической рамы, двигателя, четырех колес, коробки передач, руля и большого количества других деталей. Персональный компьютер состоит из центрального процессора, материнской платы, памяти и пр. Даже вы состоите из небольших частей: у вас есть голова, ноги, руки и т.д. Процесс построения сложных объектов из более простых называется композицией объекта.
Типы композиции объектов
В композиции между двумя объектами представлен тип отношения «имеет». Автомобиль «имеет» коробку передач. Ваш компьютер «имеет» центральный процессор. Вы «имеете» сердце. Сложный объект иногда называют целым (или «родителем»). Более простой объект часто называют частью (или «дочерним элементом», «компонентом»).
Ранее мы рассматривали, что структуры и классы могут иметь члены разных типов данных (например, фундаментальных или вообще других классов). Когда мы создаем классы с членами, то мы, по сути, создаем сложный объект из более простых частей, что и является композицией объекта. По этой причине структуры и классы еще называют составными типами данных.
Композиция объектов полезна в контексте языка C++, поскольку позволяет создавать сложные классы, объединяя более простые и легко управляемые части. Это уменьшает сложность и позволяет писать код быстрее и с меньшим количеством ошибок, так как мы можем повторно использовать код, который уже был написан, протестирован, и является рабочим.
Существует два основных подтипа композиции объекта: композиция и агрегация. На этом уроке мы рассмотрим композицию, а на следующем — агрегацию.
Примечание по терминологии: Термин «композиция» часто используется для обозначения композиции и агрегации, как единого целого, а не только подтипа композиция. На этом уроке мы будем использовать термин «композиция объекта», когда будем иметь в виду целое (и композицию, и агрегацию), а термин «композиция», когда речь будет идти конкретно о подтипе композиция.
Композиция
Для реализации композиции объект и часть должны иметь следующие отношения:
Часть (член) является частью объекта (класса).
Часть (член) может принадлежать только одному объекту (классу) в моменте.
Часть (член) существует, управляемая объектом (классом).
Часть (член) не знает о существовании объекта (класса).
Хорошим примером композиции в жизни является взаимосвязь между телом человека и его сердцем. Рассмотрим это детально.
Отношения в композиции — это отношения части-целого. Например, сердце является частью тела человека. Часть в композиции может быть частью только одного объекта в моменте. Сердце, которое является частью тела одного человека, не может быть одновременно частью тела еще одного человека.
В отношениях внутри композиции объект несет ответственность за существование частей. Чаще всего это означает, что часть создается при создании объекта и уничтожается при его уничтожении. Но в более широком смысле это означает, что объект управляет временем жизни части таким образом, что пользователь, который использует объект, не должен участвовать в этом. Например, при создании тела создается и сердце. Когда тело человека уничтожается, то и его сердце уничтожается тоже.
И, наконец, часть не знает о существовании целого. Ваше сердце работает круглосуточно, не зная, что оно является частью более крупной организации. Это называется однонаправленным отношением, поскольку тело знает о сердце, а сердце о теле — нет.
Обратите внимание, в композиции ничего не говорится о переносимости частей. Сердце можно пересадить из тела одного человека в тело другому человеку. Однако даже после пересадки оно по-прежнему будет соответствовать требованиям композиции (сердце принадлежит другому человеку и может быть частью только этого другого человека и никого больше до тех пор, пока сердце не пересадят снова).
Наш уже любимый класс Drob является отличным примером композиции:
Этот класс имеет два члена: m_numerator (числитель) и m_denominator (знаменатель). Числитель и знаменатель являются частью Drob, они находятся в этом классе. Они не могут принадлежать еще одному классу одновременно. m_numerator и m_denominator не знают, что они являются частью Drob, они просто хранят целые числа. При создании объекта класса Drob, создаются и m_numerator , и m_denominator . Когда объект класса Drob уничтожается, то и эти члены уничтожаются тоже.
Так как типом отношений в композиции объектов является «имеет» (тело «имеет» сердце, Drob «имеет» m_denominator ), то мы можем сказать, что композиция имеет и тип отношения «часть чего-то» (сердце является «частью» тела, m_numerator является «частью» Drob). Композиция часто используется для моделирования физических отношений, где один объект физически находится внутри другого объекта.
Части в композиции могут быть как сингулярными (единственными в своем роде), так и мультипликативными (таких частей может быть несколько). Например, в теле человека есть только одно сердце (сердце является сингулярным), но также 20 пальцев (пальцы являются мультипликативными и могут быть реализованы в виде массива).
Реализация композиций
Композиции являются одними из самых простых типов отношений для реализации на языке C++. Это обычные структуры или классы с обычными членами. Поскольку члены существуют непосредственно как части структур/классов, то их продолжительность жизни напрямую зависит от продолжительности жизни объектов этих структур/классов.
Композиции, в которых выполняется динамическое выделение или освобождение памяти, могут быть реализованы с использованием указателей в виде членов этих структур или классов. В этом случае управление памятью полностью накладывается на композицию.
В общем, если вы можете создать класс, используя композицию, то вы должны создать класс, используя композицию. Классы с реализованной композицией являются простыми, гибкими и надежными.
Еще один пример
Во многих играх есть существа или объекты, которые перемещаются по карте или вокруг каких-то объектов. Все эти существа/объекты имеют одну общую вещь — локацию. В следующем примере мы создадим класс Creature, который использует класс Point2D для хранения местоположения (локации) существа.
Сначала создадим класс Point2D. Наше существо будет находиться в 2D-измерении, поэтому в нашем классе будет 2 члена: x и y .
Обратите внимание, поскольку мы реализовали все наши функции в заголовочном файле (ради сохранения краткости примера), у нас нет Point2D.cpp.
Класс Point2D является целым, которое состоит из частей: x и y , продолжительность жизни которых напрямую зависит от продолжительности жизни объектов класса Point2D.
Теперь создадим класс Creature. У нашего существа будет 2 свойства: имя (строка) и местоположение (объект класса Point2D).
Класс Creature также является целым, которое состоит из частей: m_name и m_location , продолжительность жизни которых также зависит от продолжительности жизни объектов класса Creature.
И, наконец, main.cpp:
Результат выполнения программы:
Enter a name for your creature: Anton
Anton is at (5, 6)
Enter new X location for creature (-1 to quit): 7
Enter new Y location for creature (-1 to quit): 11
Anton is at (7, 11)
Enter new X location for creature (-1 to quit): 2
Enter new Y location for creature (-1 to quit): 4
Anton is at (2, 4)
Enter new X location for creature (-1 to quit): -1
Вариации композиции
Хотя в большинстве композиций создание/удаление частей происходит непосредственно при создании/удалении самой композиции, есть вариации композиции, где правила несколько видоизменены, например:
Композиция может отложить создание некоторых из своих частей до тех пор, пока они не понадобятся. Например, строковый класс может не создавать динамический массив символов до тех пор, пока пользователь не предоставит данные, которые эта строка могла бы хранить.
Композиция может предпочесть использовать часть, которая была предоставлена ей в качестве входных данных, а не создавать эту часть самостоятельно.
Композиция может делегировать уничтожение своих частей другому объекту (например, процедуре сбора мусора).
Ключевым моментом является то, что композиция должна управлять своими частями самостоятельно, без вмешательства пользователя композиции.
Композиция и подклассы
Одним из самых частых вопросов, которые задают новички, когда дело доходит до композиции объекта, является: «Когда я должен использовать подкласс вместо непосредственной реализации?». Например, вместо использования класса Point2D для реализации местоположения Creature, мы могли бы просто добавить в класс Creature еще два члена ( m_x и m_y ) и записать весь код реализации местоположения в классе Creature. Тем не менее, в создании Point2D есть ряд преимуществ:
Преимущество №1: Каждый отдельный класс можно сохранить относительно простым/понятным и сфокусировать на выполнение одной конкретной задачи. Таким образом, писать классы легче и понимать их проще. Например, в Point2D всё вертится только вокруг местоположения и это позволяет сохранить общую картину программы более простой.
Преимущество №2: Каждый подкласс может быть автономным, что делает его многоразовым. Например, мы можем повторно использовать наш класс Point2D в совершенно другой программе. Или, если нашему Creature когда-либо понадобится еще один пункт в определении локации (например, место куда ему нужно будет добраться), мы можем просто добавить еще одну переменную-член в Point2D.
Преимущество №3: Родительский класс может оставить выполнение большей части сложной работы на подклассы, а сам сосредоточиться на координации потока данных между подклассами. Это поможет снизить общую сложность родительского объекта, поскольку родительский объект делегирует выполнение работы своим дочерним элементам, которые уже знают, как выполнять эти задания. Например, при перемещении нашего Creature, сам Creature делегирует выполнение перемещения классу Point2D, который уже понимает, как работать с местоположением. Таким образом, класс Creature не должен беспокоиться о том, как такие вещи реализовать.
Хорошим правилом является то, что один класс должен выполнять одну конкретную задачу (как в примере с функциями). Этой задачей может быть хранение, манипулирование данными или координация подклассов.
В нашем случае есть смысл в том, чтобы Creature не беспокоился о реализации местоположения. Задача Creature состоит не в том, чтобы знать все подробности, а в том, чтобы координировать поток данных и гарантировать, что каждый из подклассов знает, что он должен делать. То, как следует выполнять конкретные задания — зависит уже от каждого подкласса отдельно.
16.2 – Композиция (агрегирование по значению)
В реальной жизни сложные объекты часто создаются из более мелких и простых объектов. Например, автомобиль построен с использованием металлического каркаса, двигателя, колес, коробки передач, рулевого колеса и большого количества других деталей. Персональный компьютер состоит из процессора, материнской платы, некоторого количества оперативной памяти и т.д. Даже вы построены из более мелких деталей: у вас есть голова, туловище, ноги, руки и т.д. Этот процесс построения сложных объектов из более простых называется композицией объектов.
Вообще говоря, композиция объектов моделирует связь «имеет/есть что-либо» между двумя объектами. У автомобиля «есть» коробка передач. У вашего компьютера «есть» процессор. У вас «есть» сердце. Сложный объект иногда называют целым или родительским. Более простой объект часто называют частью, дочерним элементом или компонентом.
В C++ вы уже видели, что структуры и классы могут иметь члены данных различных типов (например, базовых типов или других классов). Когда мы создаем классы с членами данных, мы, по сути, конструируем сложный объект из более простых частей, что и составляет композицию объекта. По этой причине структуры и классы иногда называют составными типами.
Композиция объектов полезна в контексте C++, потому что она позволяет нам создавать сложные классы, комбинируя более простые, более легко управляемые части. Это снижает сложность и позволяет нам писать код быстрее и с меньшим количеством ошибок, потому что мы можем повторно использовать код, который уже был написан, протестирован и проверен как работающий.
Типы композиции объектов
Существует два основных подтипа композиции объектов: композиция и агрегация. В этом уроке мы рассмотрим композицию, а в следующем – агрегацию.
Примечание по терминологии: термин «композиция» часто используется для обозначения как композиции, так и агрегации, а не только подтипа композиция. В этом руководстве мы будем использовать термин «композиция объектов», когда мы имеем в виду и то, и другое, и термин «композиция», когда мы говорим конкретно о подтипе композиция.
Композиция
Чтобы квалифицироваться как композиция, объект и компонент должны иметь следующие связи:
- компонент (член) является частью объекта (класса);
- компонент (член) может принадлежать только одному объекту (классу) одновременно;
- компонент (член) существует под управлением объекта (класса);
- компонент (член) не знает о существовании объекта (класса).
Хороший пример композиции из реальной жизни – это связь между телом человека и сердцем. Давайте рассмотрим его подробнее.
Связи композиции – это связи части-целого, в которых часть должна составлять часть целого объекта. Например, сердце – это часть тела человека. Часть в композиции в какой-либо момент может быть частью только одного объекта. Сердце, которое является частью тела одного человека, не может тот же момент быть частью тела другого человека.
В связях композиции объект отвечает за существование частей. Чаще всего это означает, что часть создается при создании объекта и уничтожается при уничтожении объекта. Но в более широком смысле это означает, что объект управляет временем жизни части таким образом, что пользователю объекта не нужно вмешиваться. Например, когда создается тело, создается и сердце. Когда тело человека разрушается, разрушается и его сердце. Из-за этого композицию иногда называют «смертельной связью».
И наконец, часть не знает о существовании целого. Ваше сердце работает, не осознавая, что является частью более крупной структуры. Мы называем это однонаправленной связью, потому что тело знает о сердце, но не наоборот.
Обратите внимание, что композиция ничего не говорит о переносимости частей. Сердце можно пересаживать из одного тела в другое. Однако даже после трансплантации оно по-прежнему соответствует требованиям композиции (сердце теперь принадлежит реципиенту и может быть частью только объекта-реципиента, если не будет снова передано).
Наш вездесущий класс Fraction – отличный пример композиции:
Этот класс имеет два члена данных: числитель и знаменатель. Числитель и знаменатель являются частью Fraction (содержатся в ней). Они не могут принадлежать более чем к одной дроби Fraction одновременно. Числитель и знаменатель не знают, что они являются частью Fraction , они просто содержат целые числа. Когда создается экземпляр Fraction , создаются числитель и знаменатель. Когда экземпляр дроби уничтожается, числитель и знаменатель также уничтожаются.
В то время как композиция объектов моделирует тип связи «имеет/есть что-либо» (у тела есть сердце, у дроби есть знаменатель), мы можем быть более точными и сказать, что композиция моделирует связи «часть чего-либо» (сердце – это часть тела, числитель – это часть дроби). Композиция часто используется для моделирования физических связей, когда один объект физически содержится внутри другого.
Части композиции могут быть в единственном или во множественном числе – например, сердце – это часть тела в единственном числе, но тело содержит 10 пальцев рук (которые можно смоделировать как массив).
Реализация композиций
Композиции – один из самых простых типов связи для реализации в C++. Обычно они создаются как структуры или классы с обычными членами данных. Поскольку эти члены данных существуют непосредственно как часть структуры/класса, их время жизни привязано к времени жизни экземпляра самого класса.
Композиции, которые должны выполнять динамическое выделение или освобождение памяти, могут быть реализованы с использованием указателей-членов данных. В этом случае ответственность за выполнение всего необходимого управления памятью должен нести класс композиции (а не пользователь класса).
В общем, если вы можете спроектировать класс, используя композицию, то так и делайте. Классы, разработанные с использованием композиции, просты, гибки и надежны (в том, что они хорошо выполняют за собой очистку).
Еще примеры
Во многих играх и симуляторах есть существа или объекты, которые перемещаются по доске, карте или экрану. Все эти существа/объекты объединяет то, что все они имеют определенное местоположение. В этом примере мы собираемся создать класс существа, который использует класс точки для хранения местоположения существа.
Во-первых, давайте разработаем класс точки. Наше существо будет жить в 2D мире, поэтому наш класс точки будет иметь 2 измерения, X и Y. Мы будем предполагать, что мир состоит из дискретных квадратов, поэтому значения в этих измерениях всегда будут целыми числами.
Обратите внимание: поскольку мы реализовали все наши функции в заголовочном файле (для краткости примера), файл Point2D.cpp отсутствует.
Этот класс Point2d является композицией из своих частей: значения местоположения x и y являются частями Point2D , и их продолжительность жизни привязана к сроку жизни конкретного экземпляра Point2D .
Теперь давайте спроектируем наш класс существа Creature . Наше существо Creature будет иметь несколько свойств: имя (будет представлено строкой) и местоположение (будет представлено нашим классом Point2D ).
Это существо Creature также является композицией своих частей. Имя и местоположение существа принадлежат одному родительскому объекту, и их продолжительность жизни привязана к жизни Creature , частью которого они являются.
И, наконец, main.cpp :
Вот вывод, полученный при выполнении этого кода:
Вариации на тему композиций
Хотя большинство композиций напрямую создают свои части при создании композиции и напрямую разрушают свои части при разрушении композиции, существуют некоторые вариации композиций, которые немного изменяют эти правила.
- композиция может отложить создание некоторых частей до тех пор, пока они не понадобятся. Например, строковый класс может не создавать динамический массив символов, пока пользователь не присвоит строке какие-либо данные для хранения.
- композиция может предпочесть использовать часть, переданную ей в качестве входных данных, а не создавать эту часть самой;
- композиция может делегировать уничтожение своих частей какому-либо другому объекту (например, подпрограмме сбора мусора).
Ключевым моментом здесь является то, что композиция должна управлять своими частями без необходимости управлять чем-либо пользователю композиции.
Композиция и подклассы
Когда дело доходит до композиции объектов, начинающие программисты часто задают вопрос: «Когда мне следует использовать подкласс вместо прямой реализации функционала?». Например, вместо использования класса Point2D для реализации местоположения существа, мы могли бы вместо этого просто добавить 2 числа int в класс Creature и написать код в классе Creature для обработки позиционирования. Однако превращение Point2D в собственный класс имеет ряд преимуществ:
- Каждый отдельный класс можно сделать относительно простым и понятным, сосредоточившись на выполнении одной задачи. Это упрощает написание этих классов и их понимание, поскольку они более сфокусированы. Например, Point2D заботится только о вещах, связанных с точками, что помогает сделать его проще.
- Каждый подкласс может быть самодостаточным, что делает его пригодным для повторного использования. Например, мы могли бы повторно использовать наш класс Point2D в совершенно другом приложении. Или, если нашему существу когда-либо понадобится еще одна точка (например, пункт назначения, к которому оно пытается добраться), мы можем просто добавить еще одну переменную-член Point2D .
- Родительский класс может содержать подклассы, выполняющие большую часть тяжелой работы, и он может сосредоточиться на координации потока данных между этими подклассами. Это помогает снизить общую сложность родительского объекта, поскольку он может делегировать задачи своим дочерним объектам, которые уже знают, как выполнять эти задачи. Например, когда мы перемещаем наше существо Creature , оно делегирует эту задачу классу Point , который уже понимает, как установить точку. Таким образом, класс Creature не должен беспокоиться о том, как такие вещи будут реализованы.
Хорошее практическое правило состоит в том, что каждый класс должен быть построен для выполнения одной задачи. Эта задача должна заключаться либо в хранении и обработке каких-либо данных (например, Point2D , std::string ), либо в координации подклассов (например, Creature ). В идеале не обе этих задачи.
В случае нашего примера должно быть очевидно, что Creature не должен беспокоиться о том, как реализуются точки или как сохраняется имя. Работа Creature не в том, чтобы знать эти внутренние подробности. Работа Creature состоит в том, чтобы беспокоиться о том, как координировать поток данных и гарантировать, что каждый из подклассов знает, что он должен делать. О том, как это сделать, должны беспокоиться отдельные подклассы.