Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
ООП_ Лекция №10 - Более сложные формы композиции.docx
Скачиваний:
0
Добавлен:
01.07.2025
Размер:
68.53 Кб
Скачать

48

Ооп: Лекция 10. Более сложные формы композиции.

Версия 4.0 27 августа 2016г.

(С) 2013-2016, Зайченко Сергей Александрович, к.т.н, ХНУРЭ, доцент кафедры АПВТ

Типичные ошибки при реализации родительских классов

Использование реализации векторов и других структур данных из стандартной библиотеки является массовым каждодневным явлением в работе С++ программиста. Стандартная реализация std::vector берет на себя задачи эффективного управления памятью, может взаимодействовать с любым копируемым или перемещаемым типом данных, указываемым при помощи угловых скобок <>, предоставляет набор удобных методов для просмотра и модификации хранимых элементов. Программисту, решающему конкретную задачу, нужно лишь применить эту готовую реализацию в своем родительском классе в соответствии с приведенными выше образцами. Остаются только 2 вопросительных момента, требующих принятия программистом какого-либо решения, в частности:

  • хранится ли набор дочерних объектов по значению или по указателю? (выбираем первое для классов-значений и второе для классов-сущностей);

  • если дочерние объекты хранятся по указателю, отвечает ли родительский объект за их уничтожение, когда приходит пора уничтожать его самого? (зависит от требований задачи).

Казалось бы, при таком подходе негде ошибиться. Тем не менее, начинающие С++ программисты, не до конца понимая смысл написанного ими кода и вообще не понимая принципы работы кода стандартной библиотеки, часто допускают грубые ошибки при реализации класса-родителя.

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

Класс Chapter является простейшей абстракцией главы, содержит лишь данные о ее названии и количестве страниц, а также конструктор и методы доступа - здесь действительно негде ошибиться:

chapter.hpp

#ifndef _CHAPTER_HPP_

#define _CHAPTER_HPP_

/*****************************************************************************/

#include <string>

/*****************************************************************************/

class Chapter

{

/*-----------------------------------------------------------------*/

public:

/*-----------------------------------------------------------------*/

// Конструктор

Chapter ( std::string const & _title, int _nPages )

: m_title( _title ), m_nPages( _nPages )

{}

// Метод доступа к названию главы

std::string const & getTitle () const { return m_title; }

// Метод доступа к количеству страниц

int getPagesCount () const { return m_nPages; }

/*-----------------------------------------------------------------*/

private:

/*-----------------------------------------------------------------*/

// Название главы

const std::string m_title;

// Количество страниц

const int m_nPages;

/*-----------------------------------------------------------------*/

};

#endif // _CHAPTER_HPP_

Класс Book немного сложнее. Объект-книга содержит название и набор глав в виде вектора указателей на объекты Chapter. Стоит отметить, что приведенный ниже код ужасен по концентрации нерациональности, и в таком духе ПИСАТЬ НИ В КОЕМ СЛУЧАЕ НЕ СЛЕДУЕТ! Но, к сожалению, подобные “трюки” весьма популярны.

Проблемные участки в коде реализации класса-книги выделены красным цветом:

book_bad.hpp

#ifndef _BOOK_BAD_HPP_

#define _BOOK_BAD_HPP_

/*****************************************************************************/

#include "chapter.hpp"

#include <vector>

/*****************************************************************************/

class Book

{

/*-----------------------------------------------------------------*/

public:

/*-----------------------------------------------------------------*/

// Конструктор. Передаем название и вектор глав по значению.

Book ( std::string _title, std::vector< Chapter * > _chapters )

: m_title( _title ), m_chapters( _chapters )

{}

// Запрещенные конструктор копий и оператор присвоения

Book ( const Book & ) = delete;

Book & operator = ( const Book & ) = delete;

// Деструктор - уничтожает дочерние объекты-главы

~ Book ()

{

for ( Chapter * pChapter : m_chapters )

delete pChapter;

}

// Метод доступа к названию

std::string getTitle () const { return m_title; }

// Метод доступа к вектору глав

std::vector< Chapter * > getChapters () const { return m_chapters; }

/*-----------------------------------------------------------------*/

private:

/*-----------------------------------------------------------------*/

// Набор глав в виде вектора указателей на объекты Chapter

std::vector< Chapter * > m_chapters;

// Название книги

std::string m_title;

/*-----------------------------------------------------------------*/

};

/*****************************************************************************/

#endif // _BOOK_BAD_HPP_

Соответственно, использовать такой класс предполагается примерно следующим образом:

test_books_bad.cpp

#include "book_bad.hpp"

#include <iostream>

// Функция, создающая тестовую книгу с 3-мя главами

Book * createTestBook ()

{

// Делаем временный вектор и наполняем его объектами-главами

std::vector< Chapter * > chapters;

chapters.push_back( new Chapter( "AAA", 12 ) );

chapters.push_back( new Chapter( "BBB", 15 ) );

chapters.push_back( new Chapter( “CCC”, 10 ) );

// Создаем объект-книгу, передаем название и временный вектор

return new Book( "Some Title", chapters );

}

int main ()

{

// Создаем тестовую книгу

Book * pBook = createTestBook();

// Извлекаем вектор глав из книги и печатаем на экране названия каждой из глав

std::vector< Chapter * > chapters = pBook->getChapters();

for ( Chapter const * pChapter : chapters )

std::cout << pChapter->getTitle() << std::endl;

// Уничтожаем книгу

delete pBook;

}

Ну и что же “криминального” в этом, на первый взгляд, обычном, более менее чистом решении?

Проблема №1 лежит на поверхности, и заключается в расточительном копировании. Обратим внимание на список аргументов и список инициализации конструктора:

Book ( std::string _title, std::vector< Chapter * > _chapters )

: m_title( _title ), m_chapters( _chapters )

{}

И строка с названием книги (_title), и вектор указателей на главы (_chapters) передаются в конструктор книги по значению. В зависимости от способа вызова этого кноструктора, может произойти копирование. Тестовая программа использует конструктор следующим образом:

return new Book( "Some Title", chapters );

Название передается в виде строкового литерала “Some Title”, однако конструктор предполагает передачу объекта std::string. По логике вещей, здесь должно происходить неявное создание временного объекта std::string из литерала, поскольку std::string содержит такой конструктор, не помеченный ключевым словом explicit. Затем должно происходить его копирование при передаче в функцию. Однако современные компиляторы генерируют более оптимальный код для такого случая, в частности, конструируют аргумент вызываемой функции сразу в нужном месте на стеке, без создания временного объекта и копирования. Кстати, такая полезная оптимизация не возможна в том случае, когда фактический аргумент не является временным объектом - его приходится копировать при передаче, оригинальный объект на вызывающей стороне не должен быть затронут:

std::string bookTitle = "Some Title";

return new Book( bookTitle, chapters );

// ^

// копирование объекта bookTitle при передаче в конструктор

Именно такая ситуация наблюдается со вторым аргументом chapters - с вектором указателей на главы. Фактический объект-вектор был создан и заполнен заранее, а теперь передается в конструктор книги по значению, что приводит к его копированию. Если хранение небольших строк (до 16 символов) хорошо оптимизировано в классе std::string и не приводит к выделению блока динамической памяти для хранения символов, то непустой вектор точно выделяет хотя бы небольшой блок памяти для хранения элементов, в том числе, с учетом возможного будущего роста. И такое выделение будет происходить для каждой копии вектора.

Помимо нерациональной техники при передаче аргументов, в списке инициализации для обоих полей активизируется конструктор копий. Оба класса - std::string и std::vector, очевидно, определяют собственные конструкторы копий, выделяют блоки динамической памяти, копируют данные - символы и указатели на главы соответственно.

: m_title( _title ), m_chapters( _chapters )

Таким образом, такой конструктор и конкретный его вызов в тестовой программе приводят к:

  • созданию 2 объектов std::string и 2-х кратному копированию символов (при конструировании аргумента и в списке инициализации);

  • созданию 3 объектов std::vector и 3-х кратному копированию указателей:

    • вектор в функции createTestBook размещает указатели внутри себя;

    • второй вектор образуется при передаче аргумента по значению, копируется из первого;

    • третий вектор находится в объекте Book, копируется из второго в списке инициализаци.

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

std::string getTitle () const { return m_title; }

std::vector< Chapter * > getChapters () const { return m_chapters; }

Как очевидно из приведенного анализа, столь поверхностный подход к программированию на С++ неприемлем. Язык и стандартная библиотека буквально “пропитаны” средствами для оптимизации решений, а такой код на этом фоне напоминает поедание черной икры столовыми ложками!

Как минимум, это решение следует улучшить передачей данных по ссылке, тем самым хотя бы убирая очевидно избыточные копирования:

Book ( std::string const & _title, std::vector< Chapter * > const &_chapters )

: m_title( _title ), m_chapters( _chapters )

{}

std::string const & getTitle () const { return m_title; }

std::vector< Chapter * > const & getChapters () const { return m_chapters; }

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

std::vector< Chapter * > const & chapters = pBook->getChapters();

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

Если задуматься, явно создаваемый временный вектор при создании книги так же не имеет особого смысла, и можно передать набор указателей через список инициализаторов std::initializer_list:

Book * createTestBook ()

{

// Создаем главы

Chapter * pChAAA = new Chapter( "AAA", 12 );

Chapter * pChBBB = new Chapter( "BBB", 15 );

Chapter * pChCCC = new Chapter( “CCC”, 10 );

// Создаем книгу

return new Book( "Some Title", { pChAAA, pChBBB, pChCCC } );

// ^........................^

// std::initializer_list< Chapter * >,

// из которого неявно формируется вектор

}

Учитывая, что указатели на главы также не используются здесь никак, кроме как для передачи адресов глав в книгу, функцию можно записать еще более компактно:

Book * createTestBook ()

{

// Создаем книгу с главами

return new Book(

"Some Title",

{ // это по-прежнему

new Chapter( "AAA", 12 ), // std::initializer_list< Chapter * >,

new Chapter( "BBB", 15 ), // по которому будет сформирован вектор

new Chapter( “CCC”, 10 ) // - аргумент конструктора Book

}

);

}

Проблема №2 носит более глубокий характер и состоит в недопонимании принципа инкапсуляции. Сколь бы ни был универсален и удачен стандартный класс std::vector, он представляет собой конкретную выбранную программистом для решения данной текущей задачи структуру данных, которая реализует абстрактное понятие набора (списка) объектов. В тот момент, когда std::vector начинает явно использоваться в списке аргументов открытого конструктора и как возвращаемое значение открытого метода доступа, создается нежелательная зависимость. Она заключается в том, что весь код, использующий класс Book, будет вынужден знать и использовать именно std::vector для передачи глав, как только понадобится создать книгу или перебрать ее главы.

Создание такого рода зависимостей от конкретного способа реализации набора данных осложняет последующую модификацию кода. Чем именно? Допустим, программист в результате тестирования и замеров производительности придет к выводу, что вместо вектора в данной конкретной задаче лучше использовать другую структуру данных, например, связные списки std::list или множества std::set. Чтобы внести такое изменение, потребуется не только исправить реализацию в классе Book, но и обновить весь код, который уже использует методы на основе вектора. Если таких место много, это трудоемкий процесс. А если речь идет о проектировании библиотеки, которой будут пользоваться многие сторонние программисты, процесс обновления будет не только трудоемким, но и очень болезненным - внешний код внезапно перестанет компилироваться после обновления библиотеки!

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

Чтобы достичь желаемого повышения уровня абстракции, имеет смысл поставить под сомнение необходимость передавать полный набор объектов-глав при создании книги. Скорее всего, более пригодный вариант состоит в создании пустой книги по одному лишь названию, а затем в постепенном добавлении в нее глав. Соответственно, в классе Book необходим конструктор, который принимает одно лишь название. Для добавления глав понадобится специальный метод addChapter. С технической точки зрения это изменение незначительно - метод addChapter всего лишь будет добавлять очередной объект-главу в вектор указателей при помощи метода push_back самостоятельно вместо использующего объект-книгу клиентского кода.

Если конструктор в прежней форме, когда все главы формируются заранее до создания книги, будет все же востребован, то следует передавать нечто не привязанное к конкретной структуре данных. Хороший кандидат на исполнение такого пожелания - объект std::initializer_list< Chapter * >, который позволит формировать набор глав без явной декларации массива или вектора. Такой вариант дополнительно привлекателен тем, что никакого временного вектора создаваться не будет. std::initializer_list является легковесной оберткой, реализуемой при помощи двух указателей, а сам std::vector содержит конструктор, принимающий такой список инициализаторов. Соответственно, помимо повышения уровня абстракции, улучшится и быстродействие, т.к. понадобится лишь 1 объект-вектор, который ни разу не подвергнется копированию на протяжении всей программы.

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

Ниже приведен набросок такого улучшенного решения:

class Book

{

/*-----------------------------------------------------------------*/

public:

/*-----------------------------------------------------------------*/

// Конструктор

Book ( std::string const & _title )

: m_title( _title )

{}

// Конструктор со списком глав

Book ( std::string const & _title, std::initializer_list< Chapter * > _chapters )

: m_title( _title ), m_chapters( _chapters )

{}

// Запрещенные конструктор копий и оператор присвоения

Book ( const Book & ) = delete;

Book & operator = ( const Book & ) = delete;

// Деструктор

~Book ()

{

for ( Chapter * pChapter : m_chapters )

delete pChapter;

}

// Метод доступа к названию главы

std::string const & getTitle () const { return m_title; }

// Метод, возвращающий количество глав

int getChaptersCount () const { return m_chapters.size(); }

// Метод, возвращающий главу по порядковому номеру

Chapter & getChapter ( int _index ) const

{

return * m_chapters.at( _index );

}

// Метод, добавляющий главу в конец книги

void addChapter ( Chapter * _pChapter )

{

m_chapters.push_back( _pChapter );

}

/*-----------------------------------------------------------------*/

private:

/*-----------------------------------------------------------------*/

// Набор глав

std::vector< Chapter * > m_chapters;

// Название книги

const std::string m_title;

/*-----------------------------------------------------------------*/

};

Тестовая программа преобразится следующим образом:

test_books.cpp

#include "book.hpp"

#include "chapter.hpp"

#include <iostream>

// Функция, создающая тестовую книгу с 3-мя главами без передачи глав в конструктор

Book * createTestBook ()

{

// Создаем объект-книгу, передаем только название

Book * pBook = new Book( "Some Title" );

// Добавляем к уже созданной книге объекты-главы

pBook->addChapter( new Chapter( "AAA", 12 ) );

pBook->addChapter( new Chapter( "BBB", 15 ) );

pBook->addChapter( new Chapter( “CCC”, 10 ) );

// Возвращаем созданную книгу

return pBook;

}

// Функция, создающая тестовую книгу с 3-мя главами с передачей глав в конструктор

Book * createAnotherTestBook ()

{

// Создаем и сразу возвращаем объект-книгу,

// передаем название и список инициализаторов с главами

return new Book(

"Another Title",

{

new Chapter( "AAA", 12 ),

new Chapter( "BBB", 15 ),

new Chapter( “CCC”, 10 )

}

);

}

int main ()

{

// Создаем две тестовые книги двумя способами

Book * pBook1 = createTestBook();

Book * pBook2 = createAnotherTestBook();

// Перебираем главы первой книги без прямого обращения к вектору.

// Работа со второй книгой аналогично

int nChapters = pBook1->getChaptersCount();

for ( int i = 0; i < nChapters; i++ )

std::cout << pBook1->getChapter( i ).getTitle() << std::endl;

// Уничтожаем книги

delete pBook1;

delete pBook2;

}

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