16.2 – Композиция (агрегирование по значению)
Композиция объектов
В реальной жизни сложные объекты часто создаются из более мелких и простых объектов. Например, автомобиль построен с использованием металлического каркаса, двигателя, колес, коробки передач, рулевого колеса и большого количества других деталей. Персональный компьютер состоит из процессора, материнской платы, некоторого количества оперативной памяти и т.д. Даже вы построены из более мелких деталей: у вас есть голова, туловище, ноги, руки и т.д. Этот процесс построения сложных объектов из более простых называется композицией объектов.
Вообще говоря, композиция объектов моделирует связь «имеет/есть что-либо» между двумя объектами. У автомобиля «есть» коробка передач. У вашего компьютера «есть» процессор. У вас «есть» сердце. Сложный объект иногда называют целым или родительским. Более простой объект часто называют частью, дочерним элементом или компонентом.
В C++ вы уже видели, что структуры и классы могут иметь члены данных различных типов (например, базовых типов или других классов). Когда мы создаем классы с членами данных, мы, по сути, конструируем сложный объект из более простых частей, что и составляет композицию объекта. По этой причине структуры и классы иногда называют составными типами.
Композиция объектов полезна в контексте C++, потому что она позволяет нам создавать сложные классы, комбинируя более простые, более легко управляемые части. Это снижает сложность и позволяет нам писать код быстрее и с меньшим количеством ошибок, потому что мы можем повторно использовать код, который уже был написан, протестирован и проверен как работающий.
Типы композиции объектов
Существует два основных подтипа композиции объектов: композиция и агрегация. В этом уроке мы рассмотрим композицию, а в следующем – агрегацию.
Примечание по терминологии: термин «композиция» часто используется для обозначения как композиции, так и агрегации, а не только подтипа композиция. В этом руководстве мы будем использовать термин «композиция объектов», когда мы имеем в виду и то, и другое, и термин «композиция», когда мы говорим конкретно о подтипе композиция.
Композиция
Чтобы квалифицироваться как композиция, объект и компонент должны иметь следующие связи:
- компонент (член) является частью объекта (класса);
- компонент (член) может принадлежать только одному объекту (классу) одновременно;
- компонент (член) существует под управлением объекта (класса);
- компонент (член) не знает о существовании объекта (класса).
Хороший пример композиции из реальной жизни – это связь между телом человека и сердцем. Давайте рассмотрим его подробнее.
Связи композиции – это связи части-целого, в которых часть должна составлять часть целого объекта. Например, сердце – это часть тела человека. Часть в композиции в какой-либо момент может быть частью только одного объекта. Сердце, которое является частью тела одного человека, не может тот же момент быть частью тела другого человека.
В связях композиции объект отвечает за существование частей. Чаще всего это означает, что часть создается при создании объекта и уничтожается при уничтожении объекта. Но в более широком смысле это означает, что объект управляет временем жизни части таким образом, что пользователю объекта не нужно вмешиваться. Например, когда создается тело, создается и сердце. Когда тело человека разрушается, разрушается и его сердце. Из-за этого композицию иногда называют «смертельной связью».
И наконец, часть не знает о существовании целого. Ваше сердце работает, не осознавая, что является частью более крупной структуры. Мы называем это однонаправленной связью, потому что тело знает о сердце, но не наоборот.
Обратите внимание, что композиция ничего не говорит о переносимости частей. Сердце можно пересаживать из одного тела в другое. Однако даже после трансплантации оно по-прежнему соответствует требованиям композиции (сердце теперь принадлежит реципиенту и может быть частью только объекта-реципиента, если не будет снова передано).
Наш вездесущий класс Fraction
– отличный пример композиции:
class Fraction
{
private:
int m_numerator;
int m_denominator;
public:
Fraction(int numerator=0, int denominator=1):
m_numerator{ numerator }, m_denominator{ denominator }
{
// Мы помещаем reduce() в конструктор, чтобы убедиться,
// что любые дроби, которые мы создаем, сокращаются!
// Поскольку все перегруженные операторы создают новые дроби,
// мы можем гарантировать, что здесь это будет вызвано.
reduce();
}
};
Этот класс имеет два члена данных: числитель и знаменатель. Числитель и знаменатель являются частью Fraction
(содержатся в ней). Они не могут принадлежать более чем к одной дроби Fraction
одновременно. Числитель и знаменатель не знают, что они являются частью Fraction
, они просто содержат целые числа. Когда создается экземпляр Fraction
, создаются числитель и знаменатель. Когда экземпляр дроби уничтожается, числитель и знаменатель также уничтожаются.
В то время как композиция объектов моделирует тип связи «имеет/есть что-либо» (у тела есть сердце, у дроби есть знаменатель), мы можем быть более точными и сказать, что композиция моделирует связи «часть чего-либо» (сердце – это часть тела, числитель – это часть дроби). Композиция часто используется для моделирования физических связей, когда один объект физически содержится внутри другого.
Части композиции могут быть в единственном или во множественном числе – например, сердце – это часть тела в единственном числе, но тело содержит 10 пальцев рук (которые можно смоделировать как массив).
Реализация композиций
Композиции – один из самых простых типов связи для реализации в C++. Обычно они создаются как структуры или классы с обычными членами данных. Поскольку эти члены данных существуют непосредственно как часть структуры/класса, их время жизни привязано к времени жизни экземпляра самого класса.
Композиции, которые должны выполнять динамическое выделение или освобождение памяти, могут быть реализованы с использованием указателей-членов данных. В этом случае ответственность за выполнение всего необходимого управления памятью должен нести класс композиции (а не пользователь класса).
В общем, если вы можете спроектировать класс, используя композицию, то так и делайте. Классы, разработанные с использованием композиции, просты, гибки и надежны (в том, что они хорошо выполняют за собой очистку).
Еще примеры
Во многих играх и симуляторах есть существа или объекты, которые перемещаются по доске, карте или экрану. Все эти существа/объекты объединяет то, что все они имеют определенное местоположение. В этом примере мы собираемся создать класс существа, который использует класс точки для хранения местоположения существа.
Во-первых, давайте разработаем класс точки. Наше существо будет жить в 2D мире, поэтому наш класс точки будет иметь 2 измерения, X и Y. Мы будем предполагать, что мир состоит из дискретных квадратов, поэтому значения в этих измерениях всегда будут целыми числами.
Point2D.h:
#ifndef POINT2D_H
#define POINT2D_H
#include <iostream>
class Point2D
{
private:
int m_x;
int m_y;
public:
// Конструктор по умолчанию
Point2D()
: m_x{ 0 }, m_y{ 0 }
{
}
// Конструктор с конкретными значениями
Point2D(int x, int y)
: m_x{ x }, m_y{ y }
{
}
// Перегруженный оператор вывода
friend std::ostream& operator<<(std::ostream& out, const Point2D &point)
{
out << '(' << point.m_x << ", " << point.m_y << ')';
return out;
}
// Функции доступа
void setPoint(int x, int y)
{
m_x = x;
m_y = y;
}
};
#endif
Обратите внимание: поскольку мы реализовали все наши функции в заголовочном файле (для краткости примера), файл Point2D.cpp отсутствует.
Этот класс Point2d
является композицией из своих частей: значения местоположения x
и y
являются частями Point2D
, и их продолжительность жизни привязана к сроку жизни конкретного экземпляра Point2D
.
Теперь давайте спроектируем наш класс существа Creature
. Наше существо Creature
будет иметь несколько свойств: имя (будет представлено строкой) и местоположение (будет представлено нашим классом Point2D
).
Creature.h:
#ifndef CREATURE_H
#define CREATURE_H
#include <iostream>
#include <string>
#include "Point2D.h"
class Creature
{
private:
std::string m_name;
Point2D m_location;
public:
Creature(const std::string &name, const Point2D &location)
: m_name{ name }, m_location{ location }
{
}
friend std::ostream& operator<<(std::ostream& out, const Creature &creature)
{
out << creature.m_name << " is at " << creature.m_location;
return out;
}
void moveTo(int x, int y)
{
m_location.setPoint(x, y);
}
};
#endif
Это существо Creature
также является композицией своих частей. Имя и местоположение существа принадлежат одному родительскому объекту, и их продолжительность жизни привязана к жизни Creature
, частью которого они являются.
И, наконец, main.cpp:
#include <string>
#include <iostream>
#include "Creature.h"
#include "Point2D.h"
int main()
{
std::cout << "Enter a name for your creature: ";
std::string name;
std::cin >> name;
Creature creature{ name, { 4, 7 } };
while (true)
{
// напечатать имя и местоположение существа
std::cout << creature << '\n';
std::cout << "Enter new X location for creature (-1 to quit): ";
int x{ 0 };
std::cin >> x;
if (x == -1)
break;
std::cout << "Enter new Y location for creature (-1 to quit): ";
int y{ 0 };
std::cin >> y;
if (y == -1)
break;
creature.moveTo(x, y);
}
return 0;
}
Вот вывод, полученный при выполнении этого кода:
Enter a name for your creature: Marvin
Marvin is at (4, 7)
Enter new X location for creature (-1 to quit): 6
Enter new Y location for creature (-1 to quit): 12
Marvin is at (6, 12)
Enter new X location for creature (-1 to quit): 3
Enter new Y location for creature (-1 to quit): 2
Marvin is at (3, 2)
Enter new X location for creature (-1 to quit): -1
Вариации на тему композиций
Хотя большинство композиций напрямую создают свои части при создании композиции и напрямую разрушают свои части при разрушении композиции, существуют некоторые вариации композиций, которые немного изменяют эти правила.
Например:
- композиция может отложить создание некоторых частей до тех пор, пока они не понадобятся. Например, строковый класс может не создавать динамический массив символов, пока пользователь не присвоит строке какие-либо данные для хранения.
- композиция может предпочесть использовать часть, переданную ей в качестве входных данных, а не создавать эту часть самой;
- композиция может делегировать уничтожение своих частей какому-либо другому объекту (например, подпрограмме сбора мусора).
Ключевым моментом здесь является то, что композиция должна управлять своими частями без необходимости управлять чем-либо пользователю композиции.
Композиция и подклассы
Когда дело доходит до композиции объектов, начинающие программисты часто задают вопрос: «Когда мне следует использовать подкласс вместо прямой реализации функционала?». Например, вместо использования класса Point2D
для реализации местоположения существа, мы могли бы вместо этого просто добавить 2 числа int
в класс Creature
и написать код в классе Creature
для обработки позиционирования. Однако превращение Point2D
в собственный класс имеет ряд преимуществ:
- Каждый отдельный класс можно сделать относительно простым и понятным, сосредоточившись на выполнении одной задачи. Это упрощает написание этих классов и их понимание, поскольку они более сфокусированы. Например,
Point2D
заботится только о вещах, связанных с точками, что помогает сделать его проще. - Каждый подкласс может быть самодостаточным, что делает его пригодным для повторного использования. Например, мы могли бы повторно использовать наш класс
Point2D
в совершенно другом приложении. Или, если нашему существу когда-либо понадобится еще одна точка (например, пункт назначения, к которому оно пытается добраться), мы можем просто добавить еще одну переменную-членPoint2D
. - Родительский класс может содержать подклассы, выполняющие большую часть тяжелой работы, и он может сосредоточиться на координации потока данных между этими подклассами. Это помогает снизить общую сложность родительского объекта, поскольку он может делегировать задачи своим дочерним объектам, которые уже знают, как выполнять эти задачи. Например, когда мы перемещаем наше существо
Creature
, оно делегирует эту задачу классуPoint
, который уже понимает, как установить точку. Таким образом, классCreature
не должен беспокоиться о том, как такие вещи будут реализованы.
Хорошее практическое правило состоит в том, что каждый класс должен быть построен для выполнения одной задачи. Эта задача должна заключаться либо в хранении и обработке каких-либо данных (например, Point2D
, std::string
), либо в координации подклассов (например, Creature
). В идеале не обе этих задачи.
В случае нашего примера должно быть очевидно, что Creature
не должен беспокоиться о том, как реализуются точки или как сохраняется имя. Работа Creature
не в том, чтобы знать эти внутренние подробности. Работа Creature
состоит в том, чтобы беспокоиться о том, как координировать поток данных и гарантировать, что каждый из подклассов знает, что он должен делать. О том, как это сделать, должны беспокоиться отдельные подклассы.