ОСНОВЫ ОБОБЩЕННОГО И ОБЪЕКТНО...
TRANSCRIPT
МИНИСТЕРСТВО ОБРАЗОВАНИЯ И НАУКИ РОССИЙСКОЙ ФЕДЕРАЦИИУРАЛЬСКИЙ ФЕДЕРАЛЬНЫЙ УНИВЕРСИТЕТ
ИМЕНИ ПЕРВОГО ПРЕЗИДЕНТА РОССИИ Б. Н. ЕЛЬЦИНА
Д. Р. Кувшинов, С. И. Осипов
ОСНОВЫ ОБОБЩЕННОГО И ОБЪЕКТНО-ОРИЕНТИРОВАННОГО
ПРОГРАММИРОВАНИЯ
Стандартная библиотека шаблонов
Рекомендовано методическим советом УрФУ в качестве учебного пособия для студентов, обучающихся по программе бакалавриата по направлению подготовки
010800 «Механика и математическое моделирование»
Екатеринбург Издательство Уральского университета
2013
УДК 004.42(075.8) К885
Р е ц е н з е н т ы : кафедра высшей математики Российского государственного
профессионально-педагогического университета (заведующий кафедрой кандидат физико-математических наук,
доцент Е. А. П ерм ин ов);С. Г. Ф р о л о в , кандидат технических наук, доцент
(Уральский государственный горный университет)
Кувшинов, Д. Р.К885 Основы обобщенного и объектно-ориентированного
программирования : Стандартная библиотека шаблонов : [учеб. пособие] / Д. Р. Кувшинов, С. И. Осипов ; М-во образования и науки Рос. Федерации, Урал, федер. ун-т. — Екатеринбург : Изд-во Урал, ун-та, 2013. — 116 с.
ISBN 978-5-7996-1014-2В издании рассматриваются основные понятия и некоторые
приемы объектно-ориентированного и обобщенного программирования с примерами на языке C++. Отдельная глава посвящена Стандартной библиотеке шаблонов и смежным компонентам Стандартной библиотеки C++. Для закрепления материала предлагаются упражнения. Пособие рассчитано на студентов, освоивших основы структурного программирования.
УДК 004.42(075.8)
© Уральский федеральный университет, 2013 ISBN 978-5-7996-1014-2 © Кувшинов Д. Р., Осипов С. И., 2013
Оглавление
Предисловие 5
Введение 7
Глава 1. От процедур к шаблонам 111.1. Структурное программирование............................. 111.2. Калькулятор на основе постфиксной записи . . . 121.3. Модульное программирование................................ 151.4. От модулей к классам ................................................ 161.5. Конструкторы и д еструкторы ................................ 191.6. Обобщенная р е а л и за ц и я .......................................... 271.7. Расширение классов................................................... 321.8. Стратегии...................................................................... 36L9. Метапрограммирование............................................. 38
Глава 2. Объектно-ориентированное программирование 42
2.1. Значения и объекты ................................................... 422.2. АТД «Множество» и класс «Множество» 472.3. Основные понятия и п р и н ц и п ы ............................ 49
2.3.1. И нкапсуляция................................................ 502.3.2. Наследование 512.3.3. П олим орф изм ................................................ 512.3.4. Оформление классов и интерфейсов в UML 552.3.5. Виды отношений между классами 56
2.3.6. Геттеры и сеттеры ........................................ 572.3.7. SO LID ............................................................... 59
2.4. Паттерны ООП ........................................................ 632.4.1. Паттерны со зд ан и я ..................................... 642.4.2. Паттерны стр у кту р ы .................................. 652.4.3. Паттерны поведения 662.4.4. М Ѵ С и М Ѵ Р .................................................. 71
2.5. Множественное наследование.............................. 72
Глава 3. Элементы Стандартной библиотеки 763.1. Контейнеры и итераторы ........................................ 78
3.1.1. И тер ато р ы ..................................................... 783.1.2. Стандартные контейнеры 803.1.3. Линейные кон тей н ер ы ............................... 823.1.4. Ассоциативные контей неры ..................... 853.1.5. И тераторы-адаптеры.................................. 89
3.2. Алгоритмы и ф у н к т о р ы ........................................ 913.2.1. Идиома удаления элементов из контейнера 983.2.2. Средства конструирования функторов . . 99
3.3. Ѵ аіаггау........................................................................ 1023.4. Статическая диспетчеризация................................ 1093.5. Умные у к а з а т е л и ....................................................... 110
Список рекомендуемой литературы 114
Предисловие
Цель данного пособия — формирование у студентов базовых знаний и навыков в области современного программирования на языке C++. Для того чтобы освоить представленный материал, требуется знание основных конструкций общего подмножества языков С и C++, а также наличие навыков разработки программ, опирающихся на принципы структурного программирования.
Пособие содержит ряд сравнительно крупных по объему работы упражнений, которые помимо закрепления пройденного материала предполагают самостоятельное изучение студентами дополнительных источников. Упражнения отмечены значком [у].
Материал пособия разбит на три главы. В первой главе «От процедур к шаблонам» в качестве точки отсчета взято структурное программирование и постепенно вводятся элементы обобщенного программирования, опирающегося в языке C++ на механизм применяемых во время компиляции шаблонов. Во второй главе «Объектно-ориентированное программирование» дано описание объектно-ориентированного программирования с нуля, при этом основная часть представленных понятий, принципов и паттернов (моделей программных компонент) применима в большинстве актуальных современных языков программирования. Третья глава «Элементы Стандартной библиотеки» в основном посвящена Стандартной библио
теке шаблонов, но освещает и некоторые другие важные части Стандартной библиотеки языка программирования C++.
Во введении представлены исторический аспект развития C++ в последние годы и авторский взгляд на место, занимаемое этим языком среди прочих языков программирования, а также его ценность в качестве изучаемого языка.
Пособие ориентировано на студентов, изучающих компьютерные науки в рамках учебного плана направления 010800 «Механика и математическое моделирование».
Введение
С принятием в 2011 г. нового Международного стандарта языка программирования C++1 (ISO С++11), добавившего в язык и Стандартную библиотеку много новых элементов, произошло оживление интереса к C++ как к основному языку разработки программного обеспечения. В отличие от таких языков, как Java, G-jf и JavaScript, которые задействуют виртуальные машины и компиляцию в машинный код на компьютере пользователя2, использование C++ подразумевает компиляцию в машинный код до распространения продукта, поэтому наметившийся рост популярности C++ в англоязычном Интернете окрестили native renaissance, что можно примерно перевести как «возрождение распространения программного обеспечения, заранее скомпилированного в машинный код»3.
Существует множество языков программирования, предполагающих компиляцию в машинный код, не опирающийся на виртуальную машину, однако языки С и C++ занимают среди них особое положение, связанное с их широким применением в инфраструктурном программном обеспечении (ПО): операционные системы, драйверы устройств и системы управления
*См.: International Standard ISO/IEC-14882: Programming Languages - C++/ ISO/IEC. N. Y., 2011.
2JIT-компиляция, от англ. just in time «точно в срок» или «только в тот момент, когда потребуется». ЛТ-комгіиляция является оптимизацией и может выполняться не для всего кода или не выполняться вовсе.
3Здесь от англ. native code — машинный код.
оборудованием, браузеры, виртуальные машины и трансляторы, «движки» и библиотеки средств управления базами данных, серверного ПО, игровых приложений, систем трехмерного моделирования, систем для выполнения инженерных и научных вычислений. Кроме того, они оказали значительное влияние на ряд более поздних языков, в первую очередь на широко применяемые в настоящее время Java и С # , а также языки, предназначенные для программирования графических процессоров (например, GLSL и OpenCL).
На волне native renaissance получили известность три других сравнительно новых языка, отчасти претендующих на место C++: D, Go и Rust. Среди них D наиболее развитый и наиболее похож на C++. Важно отметить, что во многом данные языки в своем развитии отталкиваются от решений, принятых в C++, поэтому их изучение удобно строить на сопоставлении с C++, и люди, хорошо его освоившие, сразу видят идеологию большинства конструкций и причины их появления в данных языках.
Другая тенденция последних лет состоит в росте популярности функционального программирования (в том числе компилируемых в машинный код языков Haskell и Common Lisp), сопровождающемся широким внедрением конструкций, позаимствованных из функциональных языков4, в традиционные («императивные») языки. C++ один из ранних примеров такого проникновения: механизм шаблонов основан на А-исчислении и предоставляет возможность описывать вычисляемые компилятором чистые функции («шаблоны»), пользуясь сопоставлением по образцу, встроенным в систему типов C++. ISO С++11 пополнился некоторыми другими элементами, свойственными функциональным языкам.
C++ продолжает активно развиваться. Планируется принятие новых стандартов в 2014 и 2017 гг. Первый из них будет включать необходимые уточнения и дополнения ISO С++11.
4Англ. fimperative programming -- от соединения слов «функциональное» и «императивное».
Следующая версия стандарта предполагает существенное обновление языка. Таким образом, несмотря на почтенный по компьютерным меркам возраст, язык C++ продолжает сохранять актуальность и даже становится источником нововведений для других языков программирования.
Язык С тоже развивался, и в 2011 г. был принят новый Международный стандарт языка программирования С5. Тем не менее наиболее распространена поддержка старого стандарта 1990 г. (можно считать, что компиляторы есть для всех актуальных программно-аппаратных платформ). Кроме того, основная его часть входит в качестве подмножества в язык C++ (в дальнейшем планируется сближение стандартов и улучшение совместимости C++ с С более новых версий, хотя некоторые их элементы уже включены в ISO C++11). Поэтому изучение C++ неизменно влечет изучение С, и в данном пособии предполагается, что читатель знаком с основными элементами ISO С90 и структурного программирования.
Поддержка стандартного C++ на различных платформах уступает таковой ISO С90, однако все равно находится на высоком уровне относительно прочих языков программирования, что делает C++ подходящим средством для создания кросс- платформенных приложений. Особенно важно наличие богатого выбора готовых качественных библиотек программных компонент, в том числе кросс-платформенных (от смартфонов до суперкомпьютеров).
В мире открытого ПО существуют два основных компилятора, поддерживающих широкий круг систем: GNU g-f-h и Clang. Их последние версии поддерживают ISO C++11 полностью. В мире Windows основным компилятором является Microsoft Visual C++ (сокращенно называемый MSVC). Основным приоритетом в развитии MSVC является поддержка грядущего стандарта 2014 г. К сожалению, на данный момент MSVC не может похвастать полной поддержкой ISO С++11.
5См.: International Standard ISO/IEC-9899: Programming Langur ges - С / ISO/IEC. N. Y., 2011.
Содержание данного пособия ориентируется на возможности Visual C++ 2012.
С точки зрения создания программных продуктов следует отметить следующее. Язык С задает стандарт связывания программных компонент в машинном коде (называемый АВІЬ), благодаря поддержке которого прочими языками программирования можно обеспечить минимальный уровень совместимости между компонентами, созданными на разных языках. Что же касается С-+ +, то, несмотря на полную обратную совместимость с С АВІ, C++ ABI не является переносимым и зависит от конкретного компилятора. Сюда относятся конструкции, отвечающие объектно-ориентированному и обобщенному программированию.
Ценность C++ в качестве языка программирования, изучаемого в учреждениях профессионального образования, заключается в двух основных (взаимосвязанных) аспектах: богатстве поддерживаемых методологий программирования и практической применимости для широкого спектра задач. Первое опирается на «поддержку» структурного, объектно-ориентированного и обобщенного программирования, включая некоторые элементы, характерные для функционального программирования. Второе, помимо того что перечислено выше, также опирается на широкий охват «уровней» программирования — от ассемблерных вставок и прямого управления памятью до высокоуровневых классов и метапрограммирования. Именно прямое управление выделением памяти и временем жизни объектов нередко считается основной чертой, отличающей C++ от языков, использующих виртуальную машину и скрытый от программиста механизм освобождения ресурсов («сборщик мусора»). Средства, предоставляемые C++ и Стандартной библиотекой, позволяют во многом преодолеть недостатки такого подхода, сохранив его преимущества.
6От англ. application binary interface — двоичный интерфейс приложений.
Глава 1
От процедур к шаблонам
1.1. Структурное программирование
Процесс решения некоторой задачи можно представить состоящим из двух основных действий - анализа и синтеза.
Анализ состоит в изучении задачи и разложении ее на составляющие элементы (понятия, объекты, правила и подзадачи). В частности, разбиение решения задачи в последовательность решений подзадач, которые можно представить в виде отдельных действий, позволяет сформулировать нужный алгоритм и называется процедурной (функциональной) декомпозицией. Таким образом, с точки зрения проектирования программного продукта задача подпрограммы (процедуры, функции1) — решать некоторую подзадачу комплекса, отвечающего определенному кругу задач в рамках заданной предметной области (домена).
Синтез состоит в подборе подходящих элементов из набора готовых компонент и составлении из них нужных конструкций. Набор готовых компонент (будь то фрагменты кода, литературные обороты, решения классов математических задач или что-то еще) может пополняться за счет повторения действий анализа и синтеза.
*В языках С и C++ эти термины являются синонимами.
В рамках процедурного программирования алгоритмы задаются в виде явных последовательностей инструкций, а состояния объектов задачи — в виде наборов переменных. Алгоритм следует разбивать на элементы до тех пор, пока не появится возможность выразить их все, используя имеющиеся конструкции языка программирования.
Структурное2 программирование и проектирование ввело набор стандартных базовых конструкций построения алгоритмов и принцип разработки «сверху-вниз»3: сначала ставится общая задача, алгоритм решения которой записывается с использованием еще не реализованных подпрограмм, после чего реализация алгоритма проверяется. Чтобы иметь возможность выполнить тестирование, вместо еще не готовых рабочих реализаций подпрограмм подставляются «заглушки», которые выводят текстовое сообщение. После того как разработчик убедится в корректности реализации алгоритма, он переходит к реализации подпрограмм.
Другим элементом, свойственным структурному проектированию, является выделение кортежей характеристик предметов, с описаниями которых нужно работать программе, объявление их самостоятельными типами данных и снабжение этих типов данных наборами подпрограмм, выполняющих над их значениями базовые действия. Иными словами, это определение алгебры («абстрактного типа данных») — пары (множество значений, множество операций над этими значениями).
1.2. Калькулятор на основе постфиксной записи
В качестве примера простой программы в структурном стиле рассмотрим «Стековый калькулятор». Программа позволя
2Англ. structured programming можно также перевести как «упорядоченное программирование».
3См.: Дал У., Дейкстра Э., Хоор К. Структурное программирование. М., 1975.
ет пользователю вычислять значения арифметических выражений, вводя с клавиатуры числа и знаки арифметических операций. Чтобы упростить реализацию, ограничимся постфиксной записью выражений, при которой знаки операций следуют после операндов. Например,
45 3 2 1 + * / = 45 3 (2 + 1) * / = 45 (3 * (2 + 1)) / = = (45/(3 *(2 + 1))).
Все поступающие числа будем сохранять в стеке. При поступлении знака арифметической операции надо извлечь из стека операнды и поместить туда результат операции:
45 3 2 1 + * / => 45 3 3 * / =» 45 9 / =► 5.Опишем алгоритм более формально.
• Создать стек чисел.
• Считывать лексемы из потока ввода, пока это возможно,
если следующая лексема:
число — поместить число в стек; знак «равно» — вывести вершину стека; знак операции —
проверить наличие операндов в стеке; извлечь операнды из стека; выполнить операцию; поместить результат в стек.
Иначе сообщить об ошибке.
• Удалить стек.
Опишем стек чисел на См-, следуя идеологии структурного программирования.
/ / представление стека const size t STACK SIZE = 500; s tru c t Stack {
double elem [STACK_SIZE]: s iz e _ t top :
};/ / операции над стекомvoid in i t ( Stack&); / / разметить пустой стек bool isEnipt}'( const S tack&); / / пуст ли стек? bool isF u Il (const Stack&); / / полон ли стек? void push ( Stack&, doub le ); / / затолкнуть значение double pop(Stack& ); / / извлечь значение из стека double top (const S tack&): / / посмотреть вершину
Операцию считывания лексем удобно реализовать в виде отдельной функции, работающей со значениями типа «Лексема»./ / представление лексемыs t r u c t Token { / / число или знак операции
union { double number; char o p e ra tio n ; }; enum { Number, O peration } type;
};/ / считывание лексемы, возвращает успех bool readToken ( std :: is tream &from , Token&);
Теперь не составит труда записать алгоритм на языке С-н-Характерными чертами этой реализации являются:
• непосредственная доступность представлений используемых типов. Каждый может видеть, что Stack построен поверх статического массива, a Token использует объединение с тегом. Из любого места программы с этим представлением можно работать непосредственно;
• функции, реализующие базис операций над типами, существуют отдельно и могут быть объявлены и определены где угодно. Изучение структуры Stack не дает нам знание о том, какие действия с ней можно выполнять;
• процедурная декомпозиция, построенная на основе явно выписанного алгоритма. Программа предстает в виде набора вызывающих друг друга функций, обрабатывающих данные.
1.3. Модульное программирование
Предположим, калькулятор представляет собой не отдельную программу, а компонент более крупного приложения. Хотелось бы скрыть подробности реализации (в частности, внутреннюю структуру используемых типов данных), чтобы избежать возникновения неожиданных зависимостей компонентов, использующих калькулятор, от деталей его реализации.
Например, можно было бы заменить массив в реализации стека на связный список. Однако если чей-то код уже обращается к массиву напрямую по индексам (что выходит за рамки функционала стека), то после замены массива на список этот код придется переделывать.
Идея модульного программирования состоит в том, чтобы разделить исходный код на не слишком большие логически связанные компоненты — модули, каждый из которых предоставляет другим модулям (экспортирует) интерфейс, в то время как реализация интерфейса скрыта. Интерфейс состоит из типов данных и объявлений функций, видимых из других модулей, явно импортирующих этот модуль.
Таким образом, каждый модуль может быть достаточно хорошо отделен от других модулей, чтобы уменьшить вероятность возникновения ошибок (и упростить их локализацию) в зависимых модулях при изменении реализации, если гарантируется сохранение интерфейса. Но даже в случае изменения интерфейса код остается управляемым благодаря явно описанным зависимостям между модулями.
Отдельные отлаженные модули удобно задействовать повторно в других проектах. Таким образом, модульное программирование повышает производительность работы программистов относительно структурного программирования без использования модулей.
В языке С (и, по наследству, в C++) модулем может считаться связка заголовочного файла (.h), содержащего интерфейс, с единицей трансляции (.с/'.срр файл), содержащей реализацию.
Язык позволяет скрыть представление типов, дав в .h файле только объявление типа (определение — в .с файле). К значениям таких типов извне определившего их модуля можно будет обращаться только через ссылку или указатель. Вынесем Stack в пару файлов stack.h и stack.cpp.// s t a c k . h ^pragm a once / / объявление типа s t r u c t S tack;/ / операции над стекомStack* newStack ( void ); / / создать пустой стек void d e le teS tack ( Stack *); / / удалить стек bool isE m pty(const S tack * ); / / пуст ли стек? bool is F u ll (co n st S tack* ); / / полон ли стек? void push (S tack * , d o u b le ); / / втолкнуть значение double pop (S tack * ); / / извлечь значение из стека double top (co n st S tack * ); / / посмотреть вершину
// s t a c k . cpp # in c lu d e " stack . h ”/ / представление стека const size t STACK_S1ZE = 500; s t r u c t Stack {
double elem [STACK_SIZE]; s iz e _ t top :
};
Stack* newStack () {Stack *S = new S tack ; / / выделить память S->top — 0; / / стек пустой r e tu rn S;
}/ / далее — определения остальных функций
1.4. От модулей к классамРазумно размещать объявления типов и операций над ни
ми в одном заголовочном файле. Следующий т а г — связать
их синтаксически. В C++ в качестве членов структуры можно объявлять не только поля данных, но и функции (функции- члены).
/ / s ta c k .h ^pragma once s t r u c t Stack {
const size t STACKSIZE = 500; double elem [STACK SIZE]; s iz e _ t top_;/ / операции над стеком void i n i t ( ) ; / / разметить пустой стек bool isEm ptyO c o n s t; / / пуст ли стек? bool isF u ll () co n s t; / / полон ли стек? void push (d o u b le ); / / втолкнуть значение double рор(): / / извлечь значение из стека double top () c o n s t: / / посмотреть вершину
};
/ / s tack, срр ^ in c lu d e " stack . h" void S ta c k :: i n i t (){ t°P_ — 0; /* понимается как th i s —>top_ * / }/ / далее реализации других функций
Заметим, что функции, став членами структуры, потеряли первый аргумент — указатель на Stack, так как компилятор передает указатель на значение, для которого вызвана функция-член, в виде неявного дополнительного параметра. Теперь вместо, например, push (festack, х + у) следует писать s tack .p u sh (х + у).
Внутри функции-члена этот неявно переданный указатель на значение, для которого она вызвана (стоящее слева от точки), можно получить, используя ключевое слово th i s . Далее будем называть такие значения объектами (подробнее в главе 2). При обращении к членам структуры th is -> можно не писать, если нет конфликта имен. Запись вида
bool isEm ptyO c o n s t;
говорит о том, что th i s для этой функции является указателем на константу, так что компилятор будет препятствовать явному изменению полей структуры внутри этой функции и вызову из нее функций-членов без const (так как они, в свою очередь, могут изменять поля).
Впрочем, с точки зрения модульного программирования новый код — это шаг назад. Реализация видна и доступна. Все, что мы на данный момент получили, это более явное связывание данных с обрабатывающими их процедурами и новый синтаксис вызова функции для объекта «через точку». Поэтому немного изменим код Stack.
c la s s Stack { p u b l i c :
Stack () ; / / разметить пустой стек bool isEm ptyO c o n s t; / / пуст ли стек? bool is F u ll () c o n s t; / / полон ли стек? void push (double ); / / втолкнуть значение double pop () ; / / извлечь значение double top () c o n s t; / / посмотреть вершину
p r iv a te :s t a t i c co n st s iz e _ t STACK SIZE = 500; double elem [STACK_SIZE] ; s iz e _ t top_ ;
};
Рассмотрим внесенные изменения.
• Ключевое слово pub lic открывает секцию, содержащую общедоступные зависимые имена.
• Ключевое слово p r iv a te открывает секцию, содержащую зависимые имена, доступ к которым открыт только функциям-членам самого класса.
• Слово s t r u c t поменяли на c lass . В языке C++ как структуры традиционно оформляются простые типы с открытой реализацией. В случае же если реализация скрыта,
тип описывается как класс. Отличие класса C++ от структуры C++ только в том, что в классе доступ по умолчанию private, а в структуре — public.
• Вместо функции i n i t объявлено что-то, имеющее вид функции с именем класса, не возвращающей значения. В C++ так объявляется конструктор — специальная функция, вызываемая в момент создания объекта (после выделения памяти на его представление). Конструктор выполняет инициализацию объекта. Конструктор, не имеющий параметров (как Stack О ), называется конструктором по умолчанию и вызывается автоматически при создании объекта, как показано ниже:
Stack s tack ; / / неявный вызов S t a c k :: S t a c k () Stack *ps = new S tack ; / / аналогично
• К определению константы STACK_ SIZE добавлено слово s ta t ic . Предыдущий вариант предполагал, что каждая копия стека содержит свое поле STACK_SIZE (пусть и константное). Слово s t a t i c приводит к тому, что член класса считается «общим» для всех его объектов и не размещается внутри какого-либо из них. Другими словами, теперь Stack — эго пространство имен для STACK_SIZE (но с контролем доступа — p r iv a te остается в силе).
[у] (1.1) Допишите Stack и реализуйте функцию readToken. Создайте консольное приложение «Постфиксный калькулятор» и проверьте его работоспособность.
1.5. Конструкторы и деструкторы
Наш стек был построен поверх массива строго заданного размера STACK SIZE. При этом пользователь стека не имеет никакого способа выбрать этот размер и даже (в случае private- константы) не может получить его из программы. С другой
19
стороны, 500 элементов кому-то может быть мало, а кому-то и много — пустой расход памяти. Поэтому разумно позволить при создании объекта стека указывать его размер.
Будем хранить в объекте Stack указатель на динамический массив. У пользователя будет возможность указать требуемый размер стека в момент создания объекта.
c la s s Stack { p u b l i c :
/ / создать новый стек размера size S t a c k ( s i z e _ t size = 500);"Stack () ; / / удалить стек / / . . . старый набор функций
p r i v a t e :double *elem ; s ize_ t size_ . top_ ;
};
Мы заменили конструктор, не принимающий параметров, конструктором, принимающим размер массива. Этот конструктор может быть использован и как конструктор по умолчанию, так как мы предоставили значения по умолчанию для всех его параметров (в данном случае — одного параметра size, те самые 500 элементов). Возможная реализация приведена ниже.
Stack :: S ta c k ( s i z e _ t sz) { elem = n u l l p t r ; elem — new double [ sz ];s ize_ — sz ; top — 0;
}
При удалении объекта Stack (это равным образом относится к автоматической, статической и динамической памяти) происходит вызов деструктора — специальной функции (имя которой "ИмяКласса, например, "Stack), отвечающей за корректное освобождение ресурсов, управляемых удаляемым объектом. В нашем случае деструктор должен удалить массив, выделенный в куче конструктором.
Stack :: ~ Stack (){ de l e te [J elem; }
Теперь посмотрим на такой код.Stack S = 10; / / вызов конструктора?Stack Р — S; / / вызов конструктора?S = 20; / / ???
Этот код успешно скомпилируется. Так как при создании объекта обязательно вызывается конструктор, то для S будет вызван тот самый конструктор, принимающий размер массива, т. е. под стек будет выделен массив из 10 элементов. Запись Р = S подразумевает копирование объекта S в новый объект Р. Когда копирование выполняется при инициализации, компилятор вызывает конструктор копирования, принимающий ссылку на объект своего класса. Если конструктор копирования не определен, то компилятор создаст его автоматически, и этот конструктор будет тривиальным: он будет копировать поля класса в порядке перечисления в определении класса, вызывая конструкторы копирования полей. Конструкторы копирования примитивных типов выполняют копирование двоичного представления.
Конструктор по умолчанию также может быть тривиальным и создается компилятором, если класс не объявляет пи одного конструктора. Поэтому, если, например, определить конструктор копирования и только его, то объекты класса нельзя будет создать «просто так», для этого потребуется определить конструктор по умолчанию явно. Тривиальный конструктор по умолчанию вызывает конструкторы по умолчанию (где они определены) для полей в порядке их перечисления. C++ гарантирует вызов конструктора при создании объекта, поэтому любой наш конструктор также будет неявно вызывать для полей (не POD) их конструкторы по умолчанию, если не описаны явные их вызовы с помощью списка инициализации (см. ниже).
Упрощая, можно сказать, что в C++ под POD (англ. plain old data) понимают типы, которые определены в рамках подмножества языка С. Поля, имеющие такой тип, не инициали
зируются, поэтому elem нужно явно присваивать нулевой указатель, иначе там может оказаться произвольное значение. Деструктор должен быть способен корректно отработать, даже если во время работы конструктора возникла ошибка, из-за которой объект не был полностью создан. В нашем случае ошибка возможна при вызове new, если блок переданного размера невозможно выделить. Вызов d e le te для нулевого указателя не является ошибкой.
Деструктор автоматически вызывает деструкторы для нолей объекта в порядке, обратном порядку их перечисления в определении класса. Деструкторы POD-типов не выполняют никаких действий, поэтому ресурсы, вроде блоков динамической памяти, управляемых через указатель, необходимо зудалять явно.
Итак, к чему же приведет Р = S? Содержимое S будет скопировано в Р «как есть». В частности, P.elem станет равно S . elem, что может привести к двойному удалению этого блока памяти вызовом деструктора и для S, и для Р. Это серьезная ошибка. Следовательно, нужно определить конструктор копирования явно.
А что же делает присваивание S = 20? Компилятор находит конструктор S tack(s ize_ t ) , которому передает значение 20, и выполняет оператор присваивания для S и нового объекта стека (временного объекта, который после выполнения присваивания будет автоматически удален!). Автоматически определяемый оператор присваивания поступает аналогично тривиальному конструктор}' копирования: присваивает полям объекта слева значения соответствующих полей объекта справа в порядке их перечисления. В нашем случае это просто копирование значений «как есть», что приведет к ошибке (блок памяти, исходно привязанный к полю elem объекта справа, будет сразу удален).
Итак, во-первых, следует запретить неявно вызывать конструктор, для чего к его объявлению надо добавить ключевое слово e x p lic i t . Во-вторых, следует явно определить оператор
присваивания, который будет выполнять корректное копирование. Правило трех: если требуется определить конструктор копирования, деструктор или копирующий оператор присваивания, то, скорее всего, надо определить все три.
c lass Stack { p u b l i c :
e x p l i c i t S t a c k ( s i z e _ t sz = 500);Stack (const Stack&); / / 1) скопировать “ Stack (); / / 2) удалить стекStack& op e ra to r= (co n s t Stack&); / / 3) присвоить / / . . . остальное как раньше
};
Конструктор может явно передавать параметры конструкторам полей с помощью списка инициализации, что даже необходимо, если поле не имеет конструктора по умолчанию.
Stack :: Stack ( s ize_ t sz) / / от и до ’{ ’ — список: s i z e _ ( s z ) , t o p _ (0 ) . elem ( n u l l p t r ) / / инициализации { elem — new double [ s z ] ; }
Список инициализации «отрабатывает» строго до старта тела конструктора и не влияет на порядок вызова конструкторов полей (важно помнить об этом: в каком бы порядке вы не указывали конструкторы в списке инициализации, вызываться они все равно будут в порядке определения нолей класса).
Обеспечив корректность кода, можно позволить себе поработать над оптимизацией. Предположим, где-то понадобилось обменять значения двух стеков. Простая реализация через временную переменную (объект стека) весьма неэффективна: это три копирования возможно больших массивов с выделением временного массива в куче. Очевидно, эффективно выполняемый обмен легко реализовать как функцию-член S tack : :swap (Stack &other), обменивающий значения нолей elem, size_ и top_.
Чтобы компилятор мог использовать наш обмен вместо std:: swap, мы можем определить собственную свободную функцию swap, делегирующую вызов функции-члену swap (ее необходимо разместить в том же пространстве имен, что и Stack).void swap (Stack , Stack &b) { a. swap (b); }
Функция foo обратится к нашей «быстрой» функции swap для обмена стеков вместо «медленной» стандартной:void foo () {
using s t d : : swap; / / no умолчанию s td: :swapStack a (10) , b (20);in t с — 10, d = 20;swap(c, d) ; / / ѳызоѳ s t d :: swapswap (a , b ) ; / / вызов нашей функции swap
}Рассмотрим еще одну ситуацию. Возможно, есть функция,
которая заполняет стек определенным образом, например помещает в него числа от 1 до п. Каким образом эта функция должна возвращать заполненный стек? Например, она может сформировать локальный объект и вернуть его по значению.Stack f ro in l to n ( in t n) {
Stack S(n) ;for ( i n t i — 1; i <— n; -H-i) S .p u s h ( i ) ; r e t u r n S :
}Однако в данный момент это неэффективно, так как вызов
Stack ns = fromlton(100); потенциально приведет к двум копированиям: вызовам конструктора копирования локального объекта S инструкцией re tu rn во временный безымянный объект на стеке вызовов и конструктора копирования этого временного объекта в ns. Иногда компилятор способен удалить лишние копирования, выполнив неявную передачу ссылки на переменную, которой присваивается возвращаемое значение. Не полагаясь на оптимизацию, выполняемую компилятором, можно передавать объект по ссылке явно и вызывать функцию для уже готового объекта.
bool from lton (Stack &S, in t n) { for ( in t i = 1; i <= n: -f+ i) {
i f ( S . i s F u l lQ ) r e tu rn f a ls e ;S . push( i );
}re tu rn t ru e ;
}
Stack ns(100); from lton (ns , 100);
Иногда этот способ действительно удобнее, особенно если один и тот же объект используется неоднократно. Однако нередко желательно возвращать объект непосредственно и при этом не выполнять ненужные дорогостоящие копирования. В ISO C++11 для этого предусмотрен механизм rvalue reference — ссылок на временные объекты, позволяющих явно определить семантику перемещения объектив. Тип «ссылка на временный объект типа Т» обозначается как Тkk, стандартная функция std: :move(T&) преобразует «обычную» ссылку (lvalue reference) в ссылку на временный объект (в обратную сторону приводится автоматически).
Чтобы при инициализации или присваивании действительно происходило перемещение, следует определить перемещающий конструктор (аналог конструктора копирования, но принимающий ссылку на временный объект) и перемещающий оператор присваивание (аналог копирующего оператора присваивания). Действие этих функций сводится к передаче управляемых ресурсов из временного объекта в новый объект. Итак, теперь класс Stack выглядит следующим образом:
c la ss Stack { p u b l ic :
e x p l ic i t S ta c k (s iz e _ t sz - 500);Stack (co n st Stack& ); / / скопировать Stack ( Stack&&); / / переместить ~ S tack (); / / удалить стекStack& o p e ra to r= (c o n s t S tack &); / / присвоитьStack& o p e ra to r = (Stack&:&:); / / переместить
void swap( S tack&); / / обменять / / . . . остальное как раньше
};Покажем определение перемещающего конструктора и при
сваивания. После совершения операции временный объект уда- ляется автоматически с вызовом деструктора, поэтому его поле elem должно иметь корректное значение.
Stack :: Stack ( Stack Met ): elem ( t . e lem) , s ize_ ( t . size_ ) , t o p _ ( t . t o p _ )
{ t . elem = n u l l p t r ;/* теперь t может быть удален * / }
Stack& Stack :: ope ra to r= (S tack kkX ) {swap ( t ); / / обменяем, а старое содержимоеr e t u r n * t h i s : / / будет удалено автоматически
}Теперь первый вариант from 1 ton не будет требовать копиро
вания для возвращения значения из функции: упрощая, можно сказать, что локальная переменная, возвращаемая re tu rn , считается временным значением.
Механизм перемещения можно использовать и для передачи параметров функции в тех случаях, когда при передаче, например, по ссылке на константу все равно потребовалось бы выполнить копирование.
s t r u c t Person { s t r i n g name;
/ / Person(const s t r ing &n) : name(n) {}Person ( s t r i n g n) : name(move(n )) {}
};
Внимание! Если используется параметр Т&&, где Т — тип, выводимый по параметрам шаблона функции (о шаблонах рассказывается далее), то он может разрешаться и как rvalue reference, и как lvalue reference (универсальная ссылка).
te m p la te e c l a s s Т> void foo (const T&){ cout « " co n s twl v a l u e wref ” ; } tem pla te e c l a s s T> void foo (T&&)
{ cout « M lv a l u e wo r wrv a lu e wr e f "; } Stack<int> S; / / S ниж е— это l v a l ue ! foo(S); / / > lvalue or rvalue re f
1.6. Обобщенная реализация
Итак, Stack выделен в качестве отдельного программного компонента. Очевидно, он может пригодиться не только в составе калькулятора. В других программах может понадобиться стек, который содержит не числа с плавающей точкой, а например, целые числа, символы или указатели. Конечно, если в программе достаточно иметь стек только одного типа, то можно было бы приписать строчку
ty p e d e f StackElement double ;
и заменить везде в stack.h/.cpp double на StackElement. Проблема возникнет, если в одной программе нужны стеки разных типов. Стандартное решение этой проблемы в языке С состоит в написании макроса, принимающего нужный тип элементов и генерирующего описание соответствующего типа стека. В C++ вместо этого класс следует определить как шаблон, принимающий типы в качестве параметров, подставляемых во время компиляции.
tem p la te Cclass ElementType> c la ss Stack { public :
e x p l i c i t S t a c k ( s i z e _ t sz — 500);Stack (const Stack<ElcmentType>&); / / копировать Stack (Stack<ElementType>&&); / / переместить “ Stack () ; / / удалить стекStack& o p e ra to r= (c o n s t Stack<ElementType>&);Stack& operator=(Stack<ElementType>&&:);void swap(Stack<ElementType>&;);bool isEmptyO c ons t ;bool i s F u l l Q cons t ;void push ( EleinentType );
ElementType pop ();ElementType to p () c o n s t;
p r i v a t e :ElementType *clcm; s iz e _ t size_ . to p _ ;
};
Теперь, написав Stack<double>, мы скажем компилятору, что по шаблону Stack<ElementType> надо сгенерировать новый тип, подставив вместо ElementType тип double. Имя нового типа будет Stack<double>. Если же понадобятся стеки целых чисел или символов, то они будут называться Stack<int> и Stack<char>. Следует помнить, что это разные типы.
Для того чтобы компилятор смог создать определение нового типа по шаблону (инстанцировать шаблон) в некоторой единице трансляции, он должен иметь полный доступ к определению этого шаблона. Поэтому определения функций-членов шаблона класса обычно помещаются в тот же заголовочный файл, что и определение самого шаблона класса.
В основе обобщенного программирования лежит абстракция от конкретных типов и алгоритмов и параметризация типов и алгоритмов (определение шаблонов классов и функций) типами или значениями. В C++ подстановка параметров шаблонов происходит во время компиляции, предоставляя, таким образом, инструмент программирования процесса порождения кода (метапрограммирование).
В Стандартной библиотеке языка C++ имеется реализация шаблона стека. Продемонстрируем его интерфейс.
# in c lu d e < stack> / / здесь определен стек # in c lu d e < iostream >/ / читать символы, пока не встретится конец файла,/ / затем вывести их в обратном порядке in t main () {
using namespace std ; s tack< char> S; / / стек символов fo r ( ; ; ) {
const char ch = c in .g e t ( ) ;
i f ( c i n . e o f Q ) break;S . push ( ch ): / / втолкнуть символ в стек
}w hile (! S . empty ()) { / / стек еще не пуст
cout « S . top ();S . pop (); / / удалить символ
/ / std :: stack <T> ::pop () ничего не возвращает}r e tu r n 0;
}Функциональность калькулятора также можно обернуть в
класс. Заложим в него возможность использования чисел разных типов.
tem p la te <c la s s NumberType> c lass S tackCalcu la to r { p u b l i c :
enum Error{ Success, NoLastResult , NotEnoughOperands };/ / инициализацияStackCalcu la to r () : l a s t E r r o r S t a t e ( Success ) {}/ / получить результат последней операции NumberType l a s t R e s u l t Q cons t ;/ / получить информацию о последней ошибке bool hasError ( ) const { r e tu r n l a s t E r r o r Q != Success; }Error l a s t E r r o r Q const { r e tu r n l a s t E r r o r S t a t e ; }/ / положить в стек следующее число void pushNumber(NumberType operand){ ope rands . push(operand ); }/ / операцииvoid add (); / / сложить void sub (); / / ' вычесть void mul (); / / умножить void d iv ( ) ; / / разделить
p r i v a t e :std :: stack<NumberType> operands ;Error l a s t E r r o r S t a t e ;
};
Функции-члены можно определять прямо в месте объявления в классе или структуре (при этом внутри функции доступно полное определение класса, как если бы определение функции находилось после определения класса). Обычно так делается, если они достаточно просты, как показано в примере.
Класс StackCalculator<NT> ничего «не знает» о способах взаимодействия с пользователем. Может показаться разумным добавить к нему в качестве члена функцию, реализующую алгоритм постфиксного калькулятора, но лучше избегать смешивания кода, отвечающего за программную модель, и кода, отвечающего за пользовательский интерфейс. Поэтому напишем эту функцию отдельно как шаблон функции с параметром NumberType.
tem p la te Cclass NumberType>StackCalcu la to r <NuniberType>:: Error runC a lcu la to r ( S tackCalcula to r <NuniberType> &calc ,
s t d : : i s t r e a m &in , std :: is tream &out) { Token<NumberType> to k e n ; w hile ( token . read ( in )) {
i f ( t o k e n . t a g ( ) = Token :: Number) { calc . pushNumber( token . number ( ) ) ;
} e lse i f ( t o k e n . opera t ion () = ’= ’) {const NumberType r = calc . l a s tR e s u l t (); i f ( calc . hasError ()) b reak; out « r « s t d : ; e n d l ;
} e l se {switch (token . operat ion ()) { case ca lc , add (); break;case : c a l c . s u b (); break; case c a l c .m u l ( ) ; break;case ’ / ’ : c a l c . d i v ( ) ; break:}i f ( calc . hasError ()) break;
}}r e t u r n calc . l a s t E r r o r () ;
}
Ранее упоминавшиеся стандартные функции swap и move являются функциями-шаблонами. Например, «общий» вариант реализации функции обмена мог бы выглядеть так:
tem p la te < c la s s Т> void swap(T &а, Т &b){ Т tmp ( а ); а = b ; b = tm p; }
Определять более эффективные реализации — дело авторов конкретных классов. Для класса-шаблона Stack оптимизированный вариант обмена определяется аналогично, но, поскольку требуется передать параметр шаблона, используется функция-шаблон.
te m p la te < c la s s ЕТ>void swap(Stack<ET> &а, Stack<ET> &b){ a . s w a p ( b ) ; }
[y] (1.2) Измените приведенный выше код общего варианта swap так, чтобы он использовал семантику перемещения. Перемещение представлений, используемое стандартной функцией swap, делает ненужным определение собственных вариантов swap для классов, определяющих перемещающие конструкторы и операторы присваивания.
Среди нескольких доступных перегрузок (как шаблонов, так и не шаблонов) функции компилятор пытается выбрать наиболее подходящую (наименее «общую»). Поэтому для Stack будет выбрана наша специальная функция, а не стандартная «общая». Если однозначный выбор невозможен, то происходит ошибка компиляции.
Объявленная внутри некоторого пространства имен функция полностью закрывает одноименную функцию из внешнего пространства имен. Поэтому при вызове функции, могущей иметь определения в разных пространствах имен, обычно следует использовать using-директивы (как в случае std::swap) для предоставления варианта но умолчанию. Если же есть особое определение одноименной функции (шаблон общего вида, например та же std::swap, не будет выбран) в том же пространстве имен, что и определения типов ее аргументов, то компи
лятор найдет его самостоятельно (эта технология называется ADL — англ. argument dependent-lookup).|~у] (1.3) Перепишите Token в виде шаблона класса4. Предполагать, что операция operator>> (istream&, NumberTypefe) определена. Перепишите «Постфиксный калькулятор», используя представленные выше шаблоны классов и Token. Следует корректно обрабатывать ситуацию «неизвестная операция» (сохранять стек нетронутым, выводить сообщение об ошибке).
1.7. Расширение классов
Предположим, кто-то написал класс StackCalculator<NT> и передал его нам. Впоследствии выяснилось, что четырех арифметических действий недостаточно, нужна еще операция возведения в степень.
Все операции извлекают операнды из стека, поэтому при непосредственной реализации все они обращаются непосредственно к объекту стека, являющемуся private-полем класса калькулятора. Они также отвечают за выставление ошибки в случае невозможности извлечь операнд. В итоге имеем дублирование кода. Можно выделить действие «извлечь операнд из стека или выставить ошибку» в качестве отдельной функции- члена. Эта функция могла бы иметь вид
tem p la te Cclass NumberType> bool StackCalculator<NumbcrType>::
popNumber(NumberType fcoperand) { i f ( operands . empty ()) {
l a s t E r r o r S t a t e = NotEnoughOperands; r e tu r n f a l s e ;
}operand — operands . top () ; operands . pop(); r e tu r n t r u e ;
}
4Не рекомендуется использовать union.
Теперь о стеке, поверх которого построен калькулятор, достаточно «знать» только функциям-членам pushNumber и рор- Number. Итак, интерфейс класса StackCalculator<NT> распадается на две части — «стек» и «арифметические действия».
Определим на основе интерфейса «стека» отдельный класс.
templa te < c la s s ElemType> c la ss CheckedStack { p u b l i c :
/ / попытаться просмотреть вершину стека bool pcekNumber (ElemTypc&;) cons t ;/ / положить ѳ стек следующий элемент void pushNumber (ElemType);/ / попытаться извлечь вершину стека bool popNumber(ElemType&);
p r i v a t e :std :: stack<ElemType> elems ; / / содержимое
Язык C++ позволяет расширять определения классов путем включения в них объектов других классов в виде неявных полей (подобъектов). Допустим, мы хотим построить калькулятор с четырьмя арифметическими действиями поверх базовой функциональности класса CheckedStack<ET>. Это можно описать следующим образом:
tem p la te e c l a s s NumberType>c la ss S tackC a lcu la to r CheckedStack<NumberType> { p u b l i c :
eniun Error{ Success, NoLastResult , NotEnoughOperands }; S tackC a lcu la to r () : l a s t E r r o r S t a t e ( Success ) {}/ / получить результат последней операции NuinberType l a s t R e s u l t () cons t ;/ / получить информацию о последней ошибке Error l a s t E r r o r ( ) cons t ;/ / положить в стек следующее число void pushNumber(NumberType op){ CheckedStacke'NuinberType >:: pushNumber (op ); }/ / операции
void add (); / / сложитьvoid sub (); / / вычестьvoid mul (); / / умножитьvoid div (); / / разделить
p r iv a t e :Error la s tE r ro rS ta te ;
};Обратите внимание, что расширяющий класс StackCalcula-
tor <NT> получает непосредственный доступ к открытым членам расширяемого класса CheckedStack<NT> (базового класса). Однако по умолчанию эти члены не видны извне расширяющего класса (являются закрытыми). Закрытые же члены базового класса недоступны расширяющему классу: они скрыты для разделения ответственности и предупреждения неожиданных зависимостей.
В качестве примера приведем реализации last Result и add.
tem p la te < с la ss NumberType>NuiiiberType
StackCalculator<N um berType>:: la s tR e s u lt () const { NumbcrType re s u l t (0 ); / / по умолчанию нуль la s tE r ro rS ta te —
peekNumber( r e s u l t )? Success: N oL astR esu lt; r e tu rn r e s u l t ;
};
tem p la te e c la s s NumberType>void S tack C alcu la to r <N umber Type >:: add () {
NumbcrType x , у ;i f (popNumber(y) && popNumber(x)) {
la s tE r ro rS ta te — Success; pushNumber(x + y );
} e lsela s tE r r o r S ta te = NotEnoughOperands;
}
Теперь мы можем расширить калькулятор, добавив дополнительные действия. Кроме того, поскольку новому калькулятору требуется предоставлять полный интерфейс базового
калькулятора, следует явно указать, что public-члены базового класса должны быть доступны как public-члены расширяющего класса. Для этого поставим ключевое слово pub lic перед именем базового класса. Такое «public-расширение» называют наследованием. Класс не только получает содержимое другого класса, но и его интерфейс (подробнее в главе 2).
tem p la te < c la s s NumberType> c lass S tackCalcu la torEx :
pub l ic StackCalculator<NumberType> { p u b l i c :
void pow(); / / возвести в степень};
Проблема возникнет, когда мы попробуем реализовать pow: на уровне StackCalculator<NT> открытые члены CheckedStack <NT> уже были закрытыми, и public-расширение здесь не поможет — закрытые члены базового класса недоступны для расширяющего класса.
В языке Cf + можно предоставить наследникам особый расширенный интерфейс, разместив его в секции p ro tec ted («защищенное») вместо p u b lic или p r iv a te . Защищенные члены классов доступны только из самих этих классов и их наследников (сколь угодно отдаленных, так что, например, если бы CheckedStack<NT> имел protected-члены, то они были бы доступны и в StackCalculatorEx<NT>).
Помимо public-наследования («просто» наследования) возможно protected-наследование, при использовании которого public- и protected-члены базового класса на уровне наследника получают уровень доступа protected и не доступны никому, кроме самого класса и его наследников.
Изменим заголовок определения классагшаблона StackCal- culator<NT> с целью позволить реализовать StackCalculatorEx <NT>::pow аналогично тому, как это сделано в определениях арифметических действий в StackCalculat.or<NT>.
tem p la te c c la s s NumberType> c la ss S tac k C a lcu la to r
: p ro te c te d CheckedStack<NumberType> {/ / дальше все как раньше . . .
};В качестве общей рекомендации можно дать принцип ми
нимизации открытого и защищенного (protected) интерфейсов. Если классу не обязательно быть настоящим наследником, то следует использовать private-расширение. Если не требуется предоставлять классам-наследникам доступ к некоторым членам, то следует поместить их в private-секцию. Чем меньше будет открытый интерфейс, тем проще управлять его реализацией, проще распределять работу в команде разработчиков, проще развивать и сопровождать выпущенный код.
В случае с классом StackCalculatorEx использование public- расширения закладывает в нем «мину замедленного действия», что можно проиллюстрировать следующим кодом.S ta c k C a lc u la to r< in t> * scalc ~
new S tackC alcu lato rE x e in t >; d e le te s c a le ;
Произойдет вызов деструктора StackCalculator < int >, а ne StackCalculatorEx<int>. Конечно, в данном случае оба класса имеют одинаковое представление, и их деструкторы действуют одинаково. Но подобный код потенциально приводит к серьезным и труднообнаружимым ошибкам. Исправить его можно по-разному. Например, можно отказаться от public- расширения в пользу private-расширения, экспортируя унаследованный интерфейс с помощью using-объявлений в расширяющем классе. Другой способ состоит в объявлении деструктора базового класса виртуальным (см. главу 2).
1.8. СтратегииДальнейшее увеличение гибкости классов-шаблонов дости
жимо через «подмешивание» (используя расширение) к ним
требуемого функционала, реализуемого сторонними компонентами, которые можно передавать как параметры шаблона.
В литературе для обозначения таких компонентов используется термин стратегия. Стратегии можно считать частным случаем политик. Политикой (policy) называют класс, определяющий, как именно выполнять какие-либо действия, на основе которых построен зависящий от политики функционал. Политики вводятся для того, чтобы пользователь класса-шаблона мог выбирать из набора различных конкретных реализаций этих действий, просто передавая их в качестве параметра шаблона5. В общем случае политики не обязательно опираются на механизм расширения классов.
Наш шаблон CheckedStack<ET> использует стандартный стек, «зашитый» в качестве private-поля. Этот объект — подходящий выбор для делегирования его функционала стратегии.
tem p la te < c la ss ЕіешТуре,c la ss UncheckedStack — std : : s ta c k <Elem Type»
c la ss ChcckedStack : UnchcckedStack { p u b lic :
/ / попытаться просмотреть вершину стека bool peekNumber(ЕІешТуре &el) c o n s t;/ / положить в стек следующий элемент void pushNumber (ElemType );/ / попытаться извлечь вершину стека bool popNumber (Е1ешТуре&);
};Использование стратегий предполагает предоставление
«стратегий по умолчанию» с помощью значений но умолчанию для параметров шаблонов. В нашем случае стратегия по умолчанию состоит в том, чтобы использовать стандартный стек. Поэтому, если пользователь пе имеет ничего против std::stack, то он может не указывать второй параметр шаблона, достаточно указать только тип элементов.
5Термин «политика» используется в рамках разных парадигм программирования с примерно одинаковым смыслом. Здесь он применяется в рамках обобщенного программирования.
Реализация с использованием стратегии позволяет напрямую обращаться к public-членам класса-стратегии из функций- членов определяемого класса как к собственным функциям.
В языке C++ механизмы объектно-ориентированного программирования (классы и наследование) и обобщенного программирования (шаблоны) максимально ортогональны (незаг висимы друг от друга). Стратегии представляют собой интересный пример синтеза этих механизмов.[7] (1.4) Перепишите «Постфиксный калькулятор» на основе шаблона StackCalculatorEx<NT, US>, где US — стратегия «стек», «спускаемая» вниз вплоть до CheckedStack<NT, US>.
1.9. Метапрограммирование
Программирование процесса порождения программы называют метапрограммированием. Встроенными средствами ме- гапрограммирования в C++ являются препроцессор и механизм шаблонов. Последний является полным по Тьюрингу подъязыком в составе C++, позволяющим записывать метапрограммы в виде рекурсивных функций, параметрами которых являются типы, целые значения или указатели и ссылки. Вычисление этих функций происходит во время компиляции. Рассмотрим пример вычисления целочисленного двоичного логарифма. Рекурсивное определение можно описать следующим образом:
[log2nj = 1 + log2 |n /2 j, log2 1 = 0.
Ha C++ это определение обретает следующую форму:
tem pla te cunsigned N> s t r u c t Log2 {
s t a t i c _ a s s e r t (N != 0);enum { value = 1 + Log2<N/2 >:: value };
};te m pla te о / / частная специализация шаблона s t r u c t Log2<l> { enum { value = 0 };};
[~у] (1.5) Определить шаблон If<bool С, in t Т, in t F>, член value которого разрешается как Т, если С есть tru e , и как F — в противном случае. Используя эту метафункцию, определите Мах<in t A, in t В>.
Значение х п для целого п можно вычислить за 0 (log |n |) время, используя рекурсивное определение:
На C++ это определение выглядит так:
tem p la te < in t X, unsigned Y> s t r u c t Pow { enum { value —
If<Y&l, X, 1>:: value * Pow<X*X, Y/2 >:: value };} ; tem p la te < in t X>s t r u c t Pow<X, 0> { enum { value = 1 };};
py] (1.6) Обобщить определение Pow на показатели степени произвольного знака ( in t Y).
Пусть Ор — бинарная ассоциативная операция над целыми числами. Тогда вышеприведенный алгоритм возведения в степень можно обобщить следующим образом:
te m p la te < tem pla te< in t A, i n t В> c la s s Ор> s t r u c t Unit {}; / / единица операции Ор t em pla te < tem pla te< in t A, i n t В> c l a s s Op,
s t r u c t Pow : / / расширение сокращает запись Op<If<Y&l, X, Unit<Op>:: value >:: value ,
Pow<Op, Op<X, X>::value , Y /2> : :va lue> {}; tem pla te < tem pla te< in t A, i n t B> c l a s s Op, i n t X> s t r u c t Pow<Op, X, 0>{ enum { value — Unit<Op>:: value };} ;
[V] (1.7) Напишите шаблон MuKint , int>, выполняющий произведение своих параметров. Примените вышеприведенный Pow к Mul (не забудьте определить Unit<Mul>). Введите новый
mod 2, mod 2,
in t X, unsigned Y>
шаблон IsAssociative<Op>, поле value которого установлено в true, если оператор Ор ассоциативен, и в f a l s e — в противном случае. Дополните определение Pow автоматическим выбором алгоритма (линейный или логарифмический) в зависимости от значения IsAssociative<Op>: .-value.
На практике не всегда удобно использовать шаблоны, которые принимают в качестве параметров константы. Чтобы свести все к единой форме, можно определить вспомогательные шаблоны Int и Bool.
te m p la te c in t I> s t r u c t In t { enum {value = I} ;} ; tem plate< boo l B> s t r u c t Bool { enum {value = B};};
Вычисление метафункции, оперирующей типами, традиционно производится обращением к вложенному типу type.
tem p la te C c lass Cond, c la ss T, c la ss F> s t r u c t If { /* ошибка * / }; tem p la te c c la s s T, c la ss F>s t r u c t If cB oo lcfa lse >, T, F> { ty p ed ef F type ; }; tem p la te C class T, c la ss F>s t r u c t Ifc B o o lc tru e > , T, F> { ty p ed ef T type; };
Передача метафункций в качестве параметров также более удобна, если метафункция преподносится не как шаблон, а как замкнутый тип. Тогда оперирующие ею метафункции вроде If могут «не знать», с чем они имеют дело. Для этого вычисление метафункции можно переложить на вложенный шаблонный класс apply. Если переписать Pow с учетом вышенаписан- ного, получим следующее (обратите внимание на использование ключевых слов tem plate и typename):
tem p la te C class Op, c la ss X, unsigned Y> s t r u c t Pow : tem p la te Op:: apply
cIfcB oolcY & l>, X, UnitcOp>>,PowcOp, tem p la te O p::applycX , X>, Y /2 » {}:
tem p la te c c la s s Op, c la s s X> s t r u c t PowcOp, X, 0>{ ty p e d e f typename U nitcO p>:: type type: } ;
[у] (1.8) Основная польза метафункций состоит в возможности отвлеченно манипулировать типами в обобщенных библиотечных классах и функциях. Ряд стандартных метафункций определен в заголовочном файле < type_traits> . Изучите его содержимое.
Более сложные метафункции позволяют формировать и обрабатывать коллекции типов. Наиболее известной библиотекой на языке C++, предоставляющей для этого средства, является Boost.MPL6.
6Boost C++ Libraries [Электронный ресурс]. URL: http: / / www.boost.org/
Глава 2
Объектно- ориентированное программирование
2.1. Значения и объекты
Введем определения1, которые помогают продемонстриро- вать различие в расстановке акцентов между объектно-ориентированным подходом и процедурным или функциональным подходами.
В первом случае основную понятийную роль играют конкретные сущности, которые действительно могут существовать (но не обязательно реально существуют) в некотором времени и пространстве. Во втором случае формулировки опираются на абстрактные сущности, являющиеся умозрительными конструкциями. Вопрос о том, включать абстрактные сущности в состав объективной реальности или нет, оставим философам. Заметим лишь, что абстрактные сущности не имеют места во времени и пространстве. Примеры конкретных сущно-
!См.: Степанов А., Мак-Джоунс П. Начала программирования. М., 2011. Гл. 1. Вводные определения. С. 15-29.
стей: Правительство России; книга, которую вы читаете; файл notepad.exe. Примеры абстрактных сущностей: число 7г; прямой угол; точка плоскости с координатами (—1,2).
Математика — это язык описания абстрактных сущностей. Математическая модель — конструкция, приближенно отражающая часть реального мира, опираясь на абстрактные сущности. Занимаясь программированием, разработчики вынуждены строить математические модели. Для описания конкретных сущностей вводятся атрибуты — соответствия с абстрактными сущностями, позволяющие «закодировать» качества конкретных сущностей и встроить их в математическую модель (что и составляет суть абстракции, так как «за бортом» такого описания остается множество малозначимых или вовсе неизвестных деталей). Примерами атрибутов могут служить физические величины, цвета, количества составляющих частей.
Атрибуты конкретных сущностей могут изменяться с течением времени. Однако, несмотря на изменения, конкретная сущность сохраняет идентичность, то есть, с нашей точки зрения, это один и тот же объект, пусть и неодинаковый вчера и завтра.
В математике абстрактные сущности организованы в абстрактные виды. Абстрактные виды могут быть определены путем ввода наборов аксиом. Примеры абстрактных видов: двоичное дерево, абелева группа, натуральное число, цвет (в рамках некоторой цветовой модели). Наборы атрибутов позволяют выделить конкретные виды. Конкретные сущности, принадлежащие одному конкретному виду, играют определенную роль в рамках модели и являются взаимозаменяемыми. Примеры: двигатель внутреннего сгорания, чайник со свистком, служащий банка.
Абстрагируясь от видов, можно вводить роды сходных видов. Примеры абстрактных родов: линейное пространство, число, множество, граф. Примеры конкретных родов: участник дорожного движения, неподвижный объект, предмет посуды. Сущность принадлежит одному виду, но может быть отнесена
к нескольким родам. При этом не существует единственно правильного членения сущностей на виды и роды, все зависит от конкретики строимой модели и решаемых задач.
В процедурном и функциональном программировании мы имеем значения, которые представляют абстрактные сущности. Можно считать, что значения как таковые неизменны (число 3 всегда и везде одно и то же). В разное время одно и то же имя просто «привязано» к тому или иному значению, либо (в процедурном программировании) значение может храниться в переменной (которая играет роль места размещения представления значения).
Типы значений отвечают абстрактным видам (в языках с автоматическим выводом типов явно указанные «типы» могут на деле отвечать абстрактным родам). Как правило, типы описываются как абстрактные типы данных. Пример: целые числа и арифметические операции. Множество значений может быть формально бесконечным и не существовать физически.
В рамках обобщенного программирования абстрактные роды (и иногда виды, если им не отвечают имеющиеся в распоряжении типы) называют концепциями (англ. concept). Типы, вкладывающиеся в некоторую концепцию, называются моделями этой концепции.
В объектно-ориентированном программировании объекты отвечают конкретным сущностям. Множества объектов всегда конечны и разбиваются на классы, определяющие полный (в рамках нашей модели) набор атрибутов некоторого конкретного вида. Класс можно считать «чертежом», по которому можно создать объект нужного вида и по которому же ненужный более объект можно корректно удалить. В свою очередь, абстрагируясь от конкретики создания, существования (физического представления) и удаления объектов, только на основе ограниченного набора атрибутов можно определить интерфейс — отражение конкретного рода в объектно-ориентированном проектировании. Класс может служить реализацией набора интерфейсов. Итак, под объектно-ориентированным программи-
рованисм (ООП) понимается программирование, оперирующее конкретными сущностями (объектами) и конкретными видами и родами (классами, абстрактными классами, интерфейсами).
Математически интерфейсы можно представлять как наборы функций, отображающих объект и необязательный кортеж параметров в некоторое множество результатов. Эти функции называют методами.
Можно считать, что значения кодируются с помощью конечных последовательностей бит, которые называются представлениями значений. Из равенства представлений следует равенство значений (хотя, строго говоря, не всегда это означает равенство исходных абстрактных сущностей, потому что представления иногда могут быть неоднозначными2). Из равенства значений следует равенство представлений, если каждой абстрактной сущности этого вида (типа) соответствует единственное представление. Таким образом, копирование представления возвращает нам значение, отвечающее той же самой абстрактной сущности, что и исходное.
В свою очередь, объекты также кодируются конечными последовательностями бит. Свойство идентичности объектов выполняется благодаря уникальным адресам, по которым их представления расположены в компьютерной памяти. Поэтому, даже если есть два одинаковых представления, но они расположены по разным адресам, они отвечают двум разным объектам (представляют две разные конкретные сущности). Копирование объекта создает новый объект. Более того, исходный объект и копия могут иметь различные представления, так как объект может состоять из других объектов, которые могут находиться в отдельной части памяти и представляться своими адресами. У объекта-копии будут копии этих объектов-частей, занимающие другие адреса.
Вследствие свойства идентичности даже объекты, лишенные состояния (например, объекты типа s t r u c t А {> не име
2Вспомните «проблему 2000-го года».
ют состояния), в C++ занимают отдельную память (проверьте sizeof(A) и sizeo f (В), где s t ru c t В { А а , Ъ; }).
Понятие равенства разных объектов друг другу приравнивается к отношению «являться копией». Равные объекты находятся в одинаковом состоянии и имеют равные атрибуты. Отклонение от этого принципа обычно влечет ошибки.
Формирование специального представления объекта, позволяющего создать эквивалентный объект уже в другом адресном пространстве (другой памяти), называется сериализацией. Воссоздание объекта по сериализованному представлению называется десериализацией. Сериализация требуется, когда необходимо передать объект по сети или сохранить на носителе данных.
Необходимо отметить, что компьютер является реальным физическим устройством, поэтому представления значений и объектов в памяти реального компьютера можно считать конкретными сущностями, возможно, моделирующими сущности абстрактные. Соответственно типы значений в языках программирования могут быть более ограничены, чем предполагается для соответствующих абстрактных видов. Например, в языке C++ нет встроенного типа «целое число», зато есть тип int , множество значений которого конечно. Ограниченность реальных представлений ведет к тому, что концепции в C++ могут отвечать как абстрактным родам, так и видам, например, может быть введена концепция «целое число», моделью которой является любой тип, предоставляющий необходимый набор действий над своими значениями и предназначенный для представления (подмножества) целых чисел, в то время как, строго говоря, абстрактным родом вида «целое число» является, например, «кольцо».
Система взаимодействующих объектов, отражающая некоторые аспекты рассматриваемой предметной области или программного решения, называется объектной моделью. На основе математических и объектных моделей может быть создана компьютерная модель, позволяющая изучать моделируемую
(предметную) область, в том числе эмпирическими методами, что получило название вычислительный эксперимент. Исследование статистических особенностей построенной модели на основе серий вычислительных экспериментов, например в целях оптимизации производственных процессов или систем массового обслуживания, называют имитационным моделированием.
2.2. АТД «Множество» и класс «Множество»
В качестве иллюстрации различия абстрактных типов данных и классов приведем следующий пример3. Опишем «множество целых чисел» в виде АТД.
IntSet type С 2Z тип «множество»empty IntSet = 0 пустое множествоisEmpty IntSet {0,1} проверка на пустотуinsert IntSet X Z IntSet вставить элементcontains IntSet X Z {0,1} проверить наличиеjoin IntSet X IntSet —V IntSet объединениеintersect IntSet X IntSet IntSet пересечение
Это описание отвечает следующему описанию на языке С (интерфейс модуля со скрытой реализацией). Не будем вдаваться в подробности управления памятью./ / объявление типа "множество" ty p e d e f s t r u c t In tS e tlm p l * In tS e t ;/ / сообщить, что это значение более не нужно void d isp o se ( I n tS e t );/ / получить пустое множество In tS e t em pty ln tSet (void );/ / проверить j является ли множество пустым bool isEmpty ( I n tS e t );/ / построить объединение множества и {п}
3См.: Cook W. R. On understanding data abstraction, revisited / / Proc. of the 24th ACM SIGPLAN conf. on OOPSLA. N. Y., 2009. Vol. 44, iss. 10. P. 557-572.
In tS e t i n s e r t ( In tS e t , in t n );/ / проверить, принадлежит ли элемент множеству bool con ta in s ( In tS e t , in t n );/ / построить объединение In tS e t jo in ( I n tS e t , I n tS e t) ;/ / построить пересечение In tS e t i n t e r s e c t ( In tS e t , I n tS e t ) ;
Также можно дать формальное описание функционального интерфейса объектов, которые могут представлять собой множества целых чисел. Можно считать, что объект реализует интерфейс, если этот объект предоставляет нужный набор функций. Таким образом, интерфейс как тип данных является аналогом индикаторной функции (предикатом принадлежности объекта некоторому множеству объектов).
IntSet interface A интерфейс объектаisEmpty 0 {0,1} проверка на пустотуinsert Z —> IntSet вставить элементcontains Z -> {0,1} проверить наличиеjoin IntSet —► IntSet объединениеintersect IntSet -+ IntSet пересечение
На языке C++ это описание можно приблизить следующим определением (о v i r tu a l и = 0 см. ниже).
s t r u c t In tS e t {v i r tu a l “ In tS e t () •••- 0; v i r tu a l bool isEm ptyO const — 0; v i r tu a l In tS e t* in s e r t ( in t n) const = 0; v i r tu a l bool co n ta in s ( in t n) const — 0; v i r tu a l In tS e t* j o i n ( I n tS e t*) const = 0; v i r tu a l In tS e t* i n t e r s e c t ( I n tS e t*) const = 0;
};
Теперь мы легко можем построить классы IntSet Join («объединение») и IntSetlntersect («пересечение»), опирающиеся на функциональные описания методов интерфейса «множества».
IntSetJoin : class =si, s2 : IntSetisEmpty = si.isEmpty A s2.isEmptyinsert(n) — new IntSetJoin(sl — this, s2 — {n})contains(n) = sl.contains(n) V s2.contains(n)join(s) = new IntSetJoin(sl — this, s2 = s)intersect (s) = new IntSetlntersect (si = this, s2 = s)
IntSetlntersect class =si, s2 IntSetisEmpty = si.isEmpty V s2.isEmptyinsert(n) = new IntSetJoin(sl = this, s2 = {n})contains (n) = sl.contains(n) A s2.contains(n)join(s) = new IntSetJoin(sl = this, s2 = s)intersect(s) = new IntSetIntersect(sl = this, s2 = s)
Для произвольного АТД можно ввести функциональный интерфейс. И наоборот, для объектов, реализующих функциональный интерфейс, можно ввести АТД. Однако функциональный интерфейс, с одной стороны, является естественным способом охарактеризовать некоторый набор объектов, с другой сггороны, заведомо «прячет» реализацию этих объектов (у разных объектов она может быть разной), что, в свою очередь, обеспечивает высокую гибкость. У медали есть и обратная сторона: вводимая косвенность обращений к представлениям объектов и необходимость разрешения вызова функций во время исполнения программы приводят к увеличению затрат времени процессора и объема требуемой памяти.
2.3. Основные понятия и принципы
Если используемые объекты являются отражением объектов предметной области решаемой задачи (домена), то такие объекты называют доменными. Объекты, роль которых заключается в объединении и передаче родственных данных, называют объектами передачи данных (от англ. data transfer object, DTO).
Аналогично инвариантам циклов в структурном программировании в ООП используется понятие инварианта класса — свойства, необходимо выполняющегося для объектов некоторого класса, находящихся в корректном состоянии. Например, инвариантом объекта класса «населенный пункт» может быть условие «население неотрицательно».
Методология ООП стремится увеличить подобие программы и домена. С точки зрения собственно программирования ООП можно считать развитием структурного и модульного (понимая классы как развитие модулей) программирования. В процессе разработки накапливается набор программных компонент, которые иногда могут быть использованы повторно в других проектах, что может упростить их разработку, поэтому одной из задач в процессе разработки становится облегчение повторного использования. Принципы ООП направлены в том числе и на это. Впрочем, на практике важнее оказывается возможность развития программного проекта большого размера, разработкой которого занимаегся много людей. Для написания программы в тысячу строк, как правило, нет никакого смысла использовать полный «заряд» методологии ООП и заниматься проектированием (хотя всегда может быть полезно сделать схематический набросок решения на бумаге). Это затрудняет демонстрацию ООП на примерах, так как эти примеры должны быть достаточно объемными.
2.3.1. Инкапсуляция
Состояние объекта, выраженное как набор значений атрибутов (полей данных), следует по мере возможности скрывать от доступа извне, используя private-секцию класса. Изменение состояния производится в соответствии с ролью объекта путем вызова его функций-членов (методов). Таким образом, мы предоставляем пользователям объектов и классов функциональный интерфейс, но оставляем за собой право распоряжаться реализацией, выполняя контракт, заданный функциональным интерфейсом.
2.3.2. Наследование
Новые классы могут расширять ранее определенные классы, выделяя некоторые важные возможные подмножества объектов (как правило, обладающие дополнительными атрибутами). В C++ наследующий класс фактически является надстройкой над классом-базой, объект классагнаследника неявно включает в себя объект класса-базы.
Классы-наследники в полной мере предоставляют пользователям интерфейс класса-базы (выполняют его контракт). Дополнительный интерфейс для классов-наследииков (возможно, более низкоуровневый) размещается в protected-секции.
2.3.3. ПолиморфизмПри вызове метода выбор конкретной функции, его реа
лизующей, выполняется не во время компиляции программы (раннее связывание), а во время ее исполнения (позднее связывание). Позднее связывание выполняется как переход по хранящемуся в памяти указателю, вместо перехода по «зашитому» в код адресу (как это происходит в случае раннего связывания).
Конкретика работы функций определяется каждым объектом независимо от других в соответствии с его реальным внутренним устройством и текущим состоянием, однако пользователь объекта обычно может позволить себе не знать детали. В частности, объекты класса-наследника могут использоваться там же, где и объекты класса-базы.
Принцип подстановки4: пусть свойство q(x) является свойством, верным относительно объектов х некоторого класса X , тогда q(y) также должно быть верным для объектов у класса Y , если Y — класс-наследник класса X.
В C+-I ключевое слово v i r tu a l позволяет указать функции- члены, подлежащие позднему связыванию (виртуальные функции). Позднее связывание позволяет выполнять код, соответ-
4См.: Liskov В. Data abstraction and hierarchy / / Add. to the proc. on OOPSLA. N. Y., 1987. Vol. 23, iss. 5. P. 17-34.
ствующий реальному классу объекта, а не типу указателя или ссылки на объект в месте вызова, известному компилятору. Нередко в C++ только виртуальные функции называют собственно «методами». Компилятор реализует позднее связывание для виртуальных функций автоматически. Для этого в объект добавляется скрытое поле — указатель на таблицу виртуальных функций, которая состоит из указателей на функции. объявленные виртуальными в классе этого объекта. В С аналогичный эффект может быть достигнут включением указателей на функции в качестве полей структуры и инициализации их «вручную» при создании объекта того или иного типа. Указатель на таблицу виртуальных функций может играть роль поля типа — специального значения, позволяющего определять реальный тип объекта во время исполнения.
Наследник может давать свои определения методам интерфейса базы, при этом принцип подстановки влечет
• контравариантность классов параметров методов — методы наследника не могут сужать множества значений принимаемых параметров;
• ковариантность классов значений, возвращаемых методами, — методы наследника не могут расширять множества значений возвращаемых значений.
Рассмотрим пример, иллюстрирующий контравариантность и ковариантность.c l a s s ComnioiiMaterial { . . . );c l a s s Spec lMater ia l : pub lic CommonMaterial { . . . }:c l a s s RarcMaterial : pub l ic SpeclMateria l { . . . };c l a s s CommonProduct { . . . };c l a s s SpeclProduct : pub l ic CommonProduct { . . . }; c l a s s RareProduct : pub li c SpeclProduct { . . . }; c l a s s Factory { pub l i c :
v i r t u a l SpeclProduct* produce (Spec lM ate r ia l* ) ;};
c la ss R areFactory : p u b lic Factory { p ub lic :
/ / контраѳариантный и ковариантный метод RareProduct * produce ( CommonMaterial *);
/ / SpeclProduct и SpeclMater ial тоже подошли бы };c la ss FlawedFactory : p u b lic Factory { p u b lic :
/ / нарушение контравариантности SpeclProduct* produce( R arcM aterial *);/ / нарушение ковариантности CommonProduct* produce ( S p eclM ateria l *):
};
Термин «ковариантность» отражает тот факт, что направления отношений наследования классов, содержащих метод, и классов возвращаемых объектов совпадают, «Ксштравариант- ность» соответствует обратному направлению наследования.
«Ко»: RareFactory —> FactoryRareProduct -» SpeclProduct.
«Контра»: RareFactory —> FactoryCommonMaterial <— SpeclMaterial.
Классическим примером нарушения принципа подстановки является проблема взаимоотношения между классами Круг и Эллипс. Формально круг является частным случаем эллипса, что соответствует наследованию крута от эллипса. Но с точки зрения реализации эллипс описывается большим объемом информации, чем круг: две полуоси (и, возможно, угол поворота) против радиуса. Любое направление наследования нарушает принцип подстановки, поэтому при возникновении подобной ситуации следует перестроить отношеішя между классами.
В языке C++ не проводится различия между понятиями «интерфейс» и «класс», равно как и между понятиями «реализация интерфейса» и «наследование класса». Полезно различать все эти понятия если не на уровне программирования, то на уровне проектирования. Аналогом интерфейса в C++ может служить абстрактный класс.
Лбст.рактный класс является отражением некоторого достаточно общего понятия (существование объектов собственно этого класса не имеет смысла) и должен использоваться только как базовый для классов, на основе которых уже могут быть созданы объекты. Абстрактный класс отвечает конкретному роду, в то время как «конкретный» класс отвечает конкретному виду.
В C++ абстрактным классом считается класс хотя бы с одной чисто виртуальной функцией (их также называют аб- страктными методами). Класс-наследник должен дать определения для унаследованных чисто виртуальных функций.
Объявления чисто виртуальных функций оформляются с помощью суффикса = 0:
v i r t u a l void childC lassD efinesM e () = 0;
До тех пор пока не даны определения для всех унаследованных чисто виртуальных функщій, объекты класса создавать будет нельзя.
Класс, не содержащий полей данных, а также функций- членов, не являющихся чисто виртуальными, часто называют «интерфейсом», так как он содержит лишь функциональный интерфейс, реализуемый уже классами-наследниками.
Если класс предназначен быть базой для наследования других классов, то, как правило, он должен явно определять виртуальный деструктор (пусть даже и пустой). В противном случае корректное удаление объекта по указателю на базовый класс невозможно. Если же класс хотя и предполагается использовать в качестве базового, но мы не хотим вводить в его состав виртуальные функции и не предполагаем удалять объекты классов-наследников через указатели на этот класс, то следует поместить невиртуальный деструктор в protected- секцию. В этом случае попытка удалить объект базового класса вызовет ошибку компиляции.
2.3.4. О ф орм ление классов и интерф ейсов в U M L
Unified Modeling Language (UML) представляет собой визуальный язык объектно-ориентированного проектирования5.
Диаграмма классов UML (пример на рис. 2.1) предназначена для описания отношений между классами (и интерфейсами) проекта, а также их атрибутов и методов, и является одним из основых видов диаграмм UML. Ниже показаны код и соответствующие элементы диаграммы классов.
c la ss MyClass { p r iv a t e :
in t m yP riva te ; void onlySelfCanDo ();
p ro te c te d :bool m yP ro tected ; bool onlyChildCanDo ():
p u b l ic :s tr in g myPublic ;void d o lt (const s tr in g &what):
};s t r u c t IMovable {
v i r tu a l void move( f lo a t tim e) = 0;};s t r u c t IT ranspo rt {
v i r tu a l void load ( o b jec t *) = 0; v i r tu a l ob jec t* un load() — 0; v i r tu a l bool em pty() const = 0:
};c la ss Vehicle : p u b lic IM ovable. p u b lic IT ra n sp o rt{ A ••• * / };
UML-диаграммы могут не содержать некоторых деталей или вовсе опускать значительные части проекта. Их назначение — представить вид на него под некоторым углом зрения, и таких «видов» может быть много. Кроме того, UML не привязан к конкретному языку программирования, поэтому дета-
5См.: Буч Г., Рамбо ДжЯкобсон И. Язык UML : рук. пользователя. 2-е изд. М.. 2006.
IMovable+move(time)
IГ
ITransport+load(object) +unload(): object +empty(): bool
Vehicle
_________MyClass-myPrivate: int #myProtected: bool+myPublic: string______-onlySelfCanDoO #onlyChildCanDo(): bool +dolt(what: string)
Рис. 2.1. Оформление классов в UML
ли языка могут быть не отражены (например, отсутствие конструкции «интерфейс» и даже различие между виртуальными и невиртуальными функциями в C++).
2.3.5. Виды отнош ений м еж ду классами
На рис. 2.2 показано, каким образом отображаются отношения между классами в UML.
• Наследование (отношение is-а): класс А является наследником класса В.
• Агрегация (отношение has-a): объект класса А может содержать объекты класса В (также «агрегация по ссылке»).
• Композиция (усиленный вариант агрегации): объект класса А включает объекты класса В как свою необходимую часть, управляя временем их жизни («агрегация по значению»).
• Использование (направленная ассоциация): методы класса А используют объекты класса В (В может «не знать» об А).
наследование использование
Vehicle ----------- ч> ЮгіѵаЫе Саг ownership Person
реализация интерфейса ассоциация
Car <С>— ”— Passenger Vehicle ♦ — - Engine1 0..п 1 1
агрегация композиция
Рис. 2.2. Оформление отношений между классами в UML
• Ассоциация (взаимное использование): классы А и В «знают» друг о друге, используют объекты друг друга.
Продемонстрируем эти отношения на примере класса «автомобиль», возможного в некоторой системе моделирования дорожного движения. Автомобиль является транспортным средством (наследование), у автомобиля обязательно есть двигатель и колеса (композиция), автомобиль может везти пассажиров (агрегация), автомобиль имеет владельца (использование или ассоциация), автомобиль взаимодействует с объектами на дороге (ассоциация).[у] (2.1) Допустим, требуется построить систему, связанную с моделированием дорожного движения. Выделите набор классов доменных объектов. Хорошим начальным приближением служит список существительных (словарь предметной области), используемых для описания различных ситуаций на дороге. Составьте схему отношений между ними, определите виды отношений.
2.3.6. Геттеры и сеттеры
Если поле данных скрыто, то метод, позволяющий получить его значение, называется геттером. Аналогично метод,
позволяющий установить новое значение поля данных, называют сеттером. Когда некоторые данные получают интерфейс в виде пары геттер-сеттер, то эти данные называют свойством (хотя нередко «свойством» называют любое поле данных объекта).
В целом методы, не изменяющие состояние объекта, называют аксессорами или селекторами, и в C++ они помечаются модификатором const, например:
v i r t u a l in t g e t ld ( ) const = 0;
Методы, изменяющие состояние, называют мутаторами или модификаторами, например:
v i r t u a l void nextStep (double timeStamp) — 0;
Геттер является частным случаем метода-аксессора, а сеттер — частным случаем метода-мутатора. В тривиальном случае пара «геттер-сеттер» может казаться (или действительно быть) избыточной:
c la s s Person { p u b l ic :
const s tring& getNameQ const{ r e tu rn name; }void setName( s tr in g value){ name = move (v a lu e ) ; }
p r i v a t e :s tr in g name;
};
Однако благодаря инкапсуляции можно расширить поведение геттеров и сеттеров (сохраняя интерфейс неизменным). Например, сеттер может проверять корректность предлагаемого значения:
void Person :: setName( s tr in g value) {i f ( value . empty ()) throw "emptywnameM ; name -- m ove(value):
}
Более того, свойство может быть реализовано неявно (не храниться в виде значения поля). Например, извлекаться из базы данных.
c la ss Person { p u b l ic :
s tr in g getNameQ const{ re tu rn d b . g e tF ie ld (" P e rso n s" , "Name", k ey ); } void setName( s tr in g value) {
i f ( value . empty ()) throw "emptywname" ; db. s e tF ie ld ("P e rso n s” , "Name", key, v a lu e ) ;
}p r iv a t e :
DataBaseKey key ;};
Однако не следует злоупотреблять сеттерами и геттерами, так как это ведет к привязке интерфейса к реализации и усложнению внесения изменений в будущем. Набор методов класса должен в первую очередь определяться поведением объектов, а не тем, из чего они состоят.
2-3.7. SOLID
В отличие от процедурной декомпозиции, применяемой в классическом процедурном программировании, в ООП основную роль играет объектная декомпозиция: модель решения задачи представляется в виде сети взаимодействующих объектов, каждый из которых может хранить некоторое свое состояние и предоставляет другим объектам набор методов, формализуемый через интерфейсы. На практике один вид декомпозиции не отменяет другого, а, скорее, дополняет его.
Нередко оказывается сложно выбрать между альтернативными вариантами реализации того или иного решения. Иерархию классов, отвечающих заданной предметной области, можно строить по-разному, и вряд ли существует некий идеальный единственно верный способ. Чтобы было легче выбирать между вариантами, следует ввести некоторые критерии качества
решения и выбрать вариант, наилучшим образом отвечающий этим критериям качества.
Одним из базовых критериев качества является принцип разумного минимализма и максимально возможной простоты решения («как можно проще, но не проще»). Новые элементы следует добавлять только тогда, когда они действительно понадобились, а не наперед.
К сожалению, как правило, получается, что «просто» не значит «легко». В качестве критериев качества ООП решения могут выступать предложенные Р. Мартином принципы SOLID.
Принцип одной ответственности (Single responsibility principle, SRP): каждый класс должен иметь строго определенную зону ответственности и не иметь лишних методов, не отвечающих этой зоне ответственности.
Это позволяет изменять классы в соответствии с изменениями требований к программному решению, независимо друг от друга. Таким образом, «ответственность» — это причина для изменения. Рассмотрим пример нарушения SRP. Пусть есть класс Rectangle.
c la s s R ectangle {double area () c o n s t; / / площадь void draw() c o n s t; / / нарисовать / / ...
};
Недостаток этого класса в том, что он отвечает сразу за две зоны ответственности: геометрию (area) и графику (draw). Поэтому в соответствии с SRP его следует разделить на чисто «геометрический» класс и класс, который будет отвечать за рисование, опираясь на представление прямоугольника, заданное «геометрическим» классом.
Принцип открытой закрытости (Open/closed principle, ОСР): классы должны быть открыты для расширения, но закрыты для модификации.
Функционал можно дополнить или видоизменить, используя тем или иным образом готовый код, не изменяя его. В частности, выделение базовой, стабильной (изменение которой потребуется разве что для исправления ошибок), но в некотором смысле неполной функциональности в абстрактные классы может расцениваться как следование этому принципу.
С другой стороны, использование полей типа в связке со sw itch-case вместо виртуальных функций является примером нарушения ОСР. Если класс объявляет хотя бы одну виртуальную функцию, то его объекты содержат неявное поле — указатель на таблицу виртуальных функций, определяемую классом объекта, который может использоваться в качестве поля типа, однако такое его использование также может приводить к нарушению ОСР. В C++ приведение типов указателей и ссылок внутри иерархии наследования может производиться с помощью ключевого слова dynamic_cast. При невозможности приведения вариант dynamic__cast с указателем возвращает n u llp tr , вариант со ссылкой бросает исключение.
Base b o b j;Derived dobj ;Base *p = fcbobj , *q = &dobj ;Derived *ql — dynam ic_cast< D erived*> (q); / / успешноDerived *pl = dynam ic_cast< D erived*> (p); / / n u llp trDerived &p2 = dynam ic_cast<D erived& >(*p); / / ошибка
Нарушением ОСР является использование dynamic_cast в каскадном if-e lse : такой код придется изменить при добавлении новых классов-наследников, иначе возникнет «мина замедленного действия»:
i f (au to р = dynamic__cast<DerivedA*> (o b j)) {/ / obj является указателем на объект DerivedA ,/ / и с ним можно работать через р как с DerivedA
} e lse i f (au to p = dynam ic_cast<D erivcdB*>(o b j) ) {/* работаем с DerivedB *p — obj * /
} e lse if (au to p = dynam ic_cast<D erivedC*> (o b j)) {/* работаем с DerivedC *p = obj * /
} e lse { /* неизвестно * / }
Уменьшить «хрупкость» базовых классов может использование невиртуального открытого (клиентского) интерфейса. При этом действия, которые могут быть переопределены наследниками, размещаются в защищенных (если необходимо разрешить наследникам их вызывать) или даже закрытых (что предпочтительнее, так как наследники не будут тогда зависеть от их реализации в базовом классе) виртуальных функциях.
Принцип подстановки Б. Дисков (Liskov substitution principle, LSP): см. выше в подразделе «Полиморфизм».
Принцип разделения интерфейсов (Interface segregation principle, ISP): каждый интерфейс должен отвечать одной роли, пользователи классов не должны зависеть от интерфейсов, которыми они не пользуются. Этот принцші можно считать SUP, примененным к интерфейсам. Пример нарушения ISP:
s t r u c t DataConnection {v i r tu a l void connect (co n st string& ) = 0; v i r tu a l void d isconnect () = 0;v i r tu a l bool send (ch ar) — 0; / / отправить байт v i r tu a l bool recv(char& ) = 0; / / принять байт
};
Недостаток этого интерфейса в том, что он претендует на две роли: управление соединением и передачу данных по соединению. В соответствии с принципом ISP интерфейса должно быть два, а реализация может реализовать оба, если это требуется.
s t r u c t Connection { / / соединениеv ir tu a l void connect (co n st string& ) = 0; v i r tu a l void d isconnect () - 0;
};s t r u c t DataChannel { / / канал передачи данных
v ir tu a l bool send (ch ar) — 0; / / отправить байт v i r tu a l bool recv(char& ) = 0; / / принять байт
};c la ss RemoteFile
: pub lic Connection , p u b lic DataChannel{ ••• };
Принцип обращения зависимостей (Dependency inversion principle, DIP): высокоуровневые компоненты должны опираться на абстракции низкоуровневых компонент, а не использовать их непосредственно. При этом абстракции моделируются не на основе поведения низкоуровневых компонент, а на основе нужд высокоуровневых компонент и размещаются вместе с высокоуровневыми компонентами.
Сходством с DIP обладает принцип отделенного интерфейса (separated interface principle, SIP), на котором основана библиотека iostreams (часть Стандартной библиотеки Cf+). SIP предполагает независимость отделенного интерфейса как от низкоуровневых, так и от высокоуровневых компонент, что повышает пригодность первых для повторного использования.
2.4. Паттерны ООП
Паттерном (от англ. pattern — образец) называют некоторый характерный способ организации элементов программы, часто используемый для достижения определенного эффекта. Паттерны подсказывают разумный подход к решению типичных задач проектирования и имеют общепринятые названия, используемые как термины. Характерный набор паттернов зависит от конкретного языка программирования: если определенный механизм поддержан непосредственно на уровне языка, то он не считается паттерном. Например, С не предоставляет средств поддержки наследования и полиморфизма, их имитация может считаться паттерном, в то время как в C++ они есть и паттернами уже не считаются.
Паттерны ООП определяют способ организации структуры и взаимодействия классов. Их принято разделять на три категории: паттерны создания (creational), структуры (structural) и поведения (behaviorial).
2.4.1. П аттерны создания
Иногда возникает необходимость в создании глобальных объектов, которые существуют в единственном экземпляре (например, объект Приложение или Система), соответствующий паттерн называется одиночка (синглтон — от англ. singleton). Для создания таких объектов в C++ следует: а) запретить произвольное создание объектов, скрыв конструкторы (для этого можно поместить их в секцию p riv a te ); б) обеспечить создание единственного экземпляра. Последнего можно добиться с помощью статической функции-члена (этот подход называют «синглтон Мейерса», возможны и другие подходы).
c la ss A pp lication {A pplication () ; / / скрыт A pp lication (co n st A p p lic a tio n ^ );
p u b l ic :s t a t i c A pplication& g e tO b jec tQ {
s t a t i c A pp lica tion ap p ; re tu rn app ;
/ / при первом вызове getO bject ()/ / будет вызван A p p lica tio n :: A pp lica tio n ()
}};
В Gh- невозможно определить виртуальный конструктор, однако можно реализовать подобное поведение. Например, можно создать интерфейс «фабрика», объекты реализаций которого будут создавать объекты некоторого продукта. Такой паттерн называется абстрактная фабрика (abstract factory), пример представлен выше в разделе «Полиморфизм» в качестве иллюстрации ко- и контравариантности. Метод, порождающий объекты-продукты, называют фабричным методом (factory method) или даже виртуальным конструктором.
Особую роль играет конструктор копирования. Паттерн на основе фабричного метода, реализующего копирование, имеет собственное название: прототип (prototype).
s t r u c t IC loneable {v i r tu a l “ IC loneab le () {}v i r tu a l IC loneable* clone () const — 0:
>;s t r u c t Something : IC loneab le {
Something* clone () co n st { re tu rn new Som ething(* th i s ); }
};
В метод прототипа можно «примешивать» к классу, применяя паттерн CRTP (curiously recurring template pattern). CRTP предполагает наследование от класса-шаблона, которому в качестве параметра передается сам класс-наследник.
te m p la te < c la ss Derived> s t r u c t Cloneable : IC loneab le {
Cloneable<D erived>* clone () const { r e tu rn new Derived
(* s ta tic_ _ ca st< co n s t D eriv ed * > (th is ) ) ; }};s t r u c t Something : C loneable<Som ething>{ . . . }: / / clone уже определять не надо
[у] (2.2) Используя CRTP, реализовать класс-шаблон Sing- leton<T>, делающий своего наследника, класс Т, «одиночкой».
2.4.2. П аттерны структуры
Адаптером (adapter) или оберткой (wrapper) называют объект, «склеивающий» два разных интерфейса. Особым случаем адаптера можно считать паттерн pimpl (характерен для Сь+).
Pimpl (private implementation). Данный паттерн позволяет максимально разделить клиентский интерфейс класса и его реализацию, которая выносится в отдельный, скрытый от клиента класс. В .h файле публикуется класс-обертка, управляющий объектом-реализацией через указатель и содержащий внешний интерфейс к этому объекту. Собственно реализация «спрятана» в отдельный .срр файл. Такой подход также является удобным для создания объектно-ориентированных оберток поверх
необъектных библиотек (например, написанных на С), реализация которых в исходном коде даже может быть недоступна.
c la ss Window {WindowDesc *wnd; / / указатель на pim pl—объект
pu b lic :Window (); / / реализация в . срр Window (co n st Window &); v i r tu a l ~Window (); void show (bool v i s ib l e = t r u e ) ; void s e t T i t l e (co n st s t r i ng&) ;/ / •••
};Если применить паттерн адаптер «сам к себе», создав класс-
наследник некоторого базового класса, содержащего ссылку на объект этого же класса, то получим паттерн декоратор (decorator). Декоратор предоставляет способ расширения функциональности классов в ситуации, когда использовать наследование не представляется разумным (из-за комбинаторного взрыва вариантов наследников и загромождения иерархии классов). На рис. 2.3 и 2.4 приведен пример декоратора6.
2.4.3. П аттерны поведения
Итератор (iterator) — объект, позволяющий перебирать элементы из некоторого набора (коллекции). Объект, хранящий коллекцию объектов, по запросу создает объект итератора, установленный на первый объект в коллекции. Клиент последовательно вызывает метод next, перебирая объекты коллекции по одному. Итератор может сообщать об окончании коллекции с помощью специального метода empty или попросту возвращая нулевой указатель при очередном вызове next.
Итератор позволяет скрыть реализацию хранилища объектов. Более того, при необходимости объекты могут создаваться итератором. Дальнейшее применение паттерна декоратор
6См.: Mössenböck H. Object-Oriented Programming in Oberon-2. В., 1995.
Рис. 2.3. Ситуация комбинаторного взрыва
Frame
+draw()< -
«decorates»
TextFrame GraphicFrame Decorator
+draw() +draw() #component+draw()
ScrolledPecorator
+draw()
BorderedDecorator
+draw()
draw border; ^ component.draw();
Рис. 2.4. Результат применения декоратора
к итератору позволяет организовать фильтры и обработчики коллекций.|~у] (2.3) Напишите реализацию динамического массива, которая позволяет перебрать содержимое массива с помощью объекта-итератора.
При использовании итератора клиент, как правило, перебирает все оставшиеся элементы коллекции с помощью простого цикла. Вместо того чтобы отделять действие «выбрать следующий элемент» в метод next, можно отделить этот цикл целиком: коллекция лучше «знает», каким способом перебирать свои элементы. В этом случае коллекция может предоставить метод accept ( IV is ito r &ѵ), который передает каждый элемент коллекции объекту ѵ, вызывая его метод v i s i t . Данный паттерн (рис. 2.5) называется посетитель (visitor). Например, описать обход дерева с помоіцью посетителя можно намного проще и естественней, чем с помощью итератора.
Посетитель может реализовать интерфейс для обработки объектов разных типов. В свою очередь, класс можно считать коллекцией своих полей, тогда класс вызывает методы посетителя для своих полей. Так можно описывать объекты заг ранее неизвестных типов. Обрабатываемая структура может быть не линейной коллекцией, а сложной иерархией объектов: сами элементы могут содержать вложенные элементы и вызывать посетителя для них, корректно представляя древовидную структуру. Посетитель может, например, сохранять состояния объектов на диск (выполнять сериализацию), при этом разные посетители могут реализовывать разные форматы файлов.|~У~| (2.4) Реализуйте изображенную иерархию классов. Json- Formatter должен формировать текстовое представление в формате JSON. Можно воспользоваться следующей (упрощенной) грамматикой, где число является текстовым представлением double, а строка оформляется аналогично строковому литералу С (в двойных кавычках с escape-последовательностями). Значение null может соответствовать n u llp tr . Объекты предстают в виде словарей с произвольными типами значений.
lElement+accept (IVisitor)
Ellipseelems: lElementn+accept (IVisitor)
Rectangleelems: lElementn♦accept (IVisitor)
Text♦accept (IVisitor)
Linetext: Text♦accept (IVisitor)
—i
_ J
IVisitor
+visit (Line)+visit (Rectangle) +visit (Ellipse)4-l/IC f# / T o v f l* V lUli I I S/Al J
JsonFormatter♦visit (Line)♦visit (Rectangle) ♦visit (Ellipse) ♦visit (Text)
Рис. 2.5. Паттерн «посетитель»
JS O N —> значение
значение —> null | булево \ число | строка | массив | объект
null —> «null»
булево -> «true» | « false»
массив «[» [значение {«,» значение}] «]»
объект —> «{» [пара {«,» пара}] «}»
п ар а -* имя «:» значение
и м я —у строка
Предположим, что взаимодействие объектов в системе производится путем передачи сообщений. Есть объекты — источники и объекты — получатели сообщений. При этом один источник может передать сообщение группе получателей. Наблюдателем (observer) будем называть интерфейс получателя сообщений. Передача сообщений популярна в библиотеках грат фического интерфейса пользователя. Клик мышкой, нажатие клавиши, изменение размеров окна — все это примеры событий графического интерфейса, уведомление о которых может производиться посредством механизма передачи сообщений. Источники сообщений хранят списки наблюдателей, которые будут получать сообщения.
tem p la te c c l a s s Message> s t r u c t IObservcr {
v i r t u a l ~IObserver () {}v i r t u a l void update ( cons t Messaged) = 0;
};tem p la te <c l as s Message> c l ass McssagcSource {
std :: set<IObserver*> observer s ; publ i c :
void a t t ach ( IObserver &obs){ o b s e r v e r s . i n s e r t (&obs); }
void detach ( IObserver fcobs){ o b s e r ve r s . erase(&obs); } void not i fy (const Message &msg) {
fo r (auto o b s : observers ) obs->update (msg) ;
}};
Тип сообщения вынесен в параметр шаблона. Используется стандартный контейнер set (см. главу 3). В программе применяется форма цикла for , введенная в ISO С++11, позволяющая пройти по всем элементам контейнера, если для него определены функции begin и end. Случай, когда update непосредственно вызывает notify, может привести к ситуации неограниченной рекурсии с последующим переполнением стека. Тогда новые сообщения можно предварительно помещать в очередь, затем, в определенный момент, notify вызывается для извлекаемых из очереди сообщений в «цикле обработки событий».
2.4.4. МѴС и М Ѵ Р
При проектировании приложений полезно максимально разделять пользовательский интерфейс, выполняющий визуализацию данных и объектов приложения и собственно эти данные (доменные объекты). Совокупность классов, реализующая элементы представления данных для пользователя, называют видом (view). Классы, описывающие модель мира, данные, которыми оперирует приложение, составляют модель (model).
В случае простого приложения данного разделения может быть достаточно. В ином случае важно обеспечить взаимодействие между ними, обеспечив низкий уровень сцепления. Наиболее известный (классический) способ состоит в добавлении ответственного за управление взаимодействием компонента — контроллера (controller, в целом паттерн (рис. 2.6) называется model-view-controller, МѴС). Как правило, за контроллером скрывается обработчик пользовательского ввода, «переводящий» его в вызовы методов модели и вида и реализующий,
Рис. 2.6. Паттерн МѴС
таким образом, логику приложения. Уведомление реализуется с помощью паттерна наблюдатель.
В некоторых случаях МѴС не является удобным способом организации взаимодействия модели и вида. Другой популярный способ (рис. 2.7) состоит в «линеаризации» взаимодействия и полном расцеплении модели и вида с помощью промежуточного компонента — представителя (presenter, соответ- ственно паттерн называют МѴР). Например, при организации веб-службы вид (клиентская часть) реализуется в браузере, модель — в базе данных на сервере, собственно серверная компонента веб-службы, к которой обращается браузер, является « представителем ».
2.5. Множественное наследование
Множественным наследованием называют использование более одного класса в качестве непосредственных базовых классов. Ряд языков (например, Java и С #) допускают лишь одиночное наследование для «обычных» классов (имеющих состояние) и множественное наследование (реализацию) для интерфейсов, ближайшим аналогом которых в C++ являются чисто абстрактные классы. C++ допускает множественное наследование классов с соблюдением следующих условий:72
Модель/ \ ответ1Азапрос VПредставитель/ \ ответ1
запрос VВид
Рис. 2.7. Паттерн MVP
• класс не должен непосредственно наследовать от одного и того же класса более одного раза (нельзя: class В: А,А);
• класс ни в какой форме не должен наследовать самого себя.
Можно считать, что каждому классу-базе отвечает подобъ- ект в составе объекта класса-наследника. В случае если имена членов разных базовых классов совпадают, эти члены будут доступны только при явном указании имени базового класса.
s t r u c t А { i n t х; };s t r u c t В { f l oa t х; };s t r u c t С : А, В {
/ / унаследовано два разных х , необходимо/ / явно указывать подобъект, содержащий х С() { А: :х = 42; В : : х = -1 .5 ; }
};
Повторным наследованием называют ситуацию, когда в результате множественного наследования среди предков некоторого класса один и тот же класс появляется более одного раза. По умолчанию каждый соответствующий подобъект повторно унаследованного класса является независимым от остальных и
73
доступен только при указании корректного «адреса» — пути в иерархии наследования, по которому он расположен. Например, ниже в объекте класса D есть два подобъекта повторно унаследованного класса А: подобъект В::А и подобъект С::А:s t r u c t А { i n t х; }; s t r u c t В : А {}; s t r u c t С : А {}; s t r u c t D : В, С {
/ / два разных подобъекта А — два разных х D() { В :: А :: х — 0; С : : А : : х = 1; }
};Приведение указателя на D к указателю на А возможно через промежуточный указатель на В или на С. Обратное приведение также требует получения промежуточного указателя на В или С.
Виртуальным наследованием называют объединение под- объектов повторно унаследованных классов в один подобъект. Для этого перед именем базового класса требуется поставить ключевое слово v ir tu a l . Рассмотрим тот же пример, что и выше, но уже с использованием виртуального наследования.s t r u c t А { i n t х; };s t r u c t В : v i r t u a l А {};s t r u c t С : v i r t u a l А {};s t r u c t D : В, С {
/ / один подобъект А — один х D() { х = 0; }
};На рис. 2.8 показана схема наследования, иллюстрирующая
виртуальное наследование на примере интерфейса ICloneable. Благодаря характерному виду схемы такая модель наследования также называется ромбовидным наследованием.
При наличии нетривиальных конструкторов у виртуального базового класса за инициализацию разделяемого подобъекта отвечает «нижний» класс (в примере выше — D). В C++ виртуальное наследование обычно применяется при использовании абстрактных классов, предоставляющих некоторую базо-
ICIoneable
.V у:;Receiver Transmitter
Tranceiver
Рис. 2.8. Ромбовидное наследование
вую функциональность своим наследникам или оішсывающих общие интерфейсы, которые легко могут быть объединены в листовых наследниках.
Для того чтобы вычислять смещение при преобразовании типов указателей во время исполнения, компилятор помещает в объекты виртуальных наследников дополнительные неявные поля, которые могут привести к увеличению размера объекта. Впрочем, если объект виртуального базового класса имеет большой размер, то, наоборот, виртуальное наследование может привести к экономии места по сравнению с обычным повторным наследованием. Кроме того, вызов виртуальных функций виртуального базового класса обходится дороже.
Глава 3
Элементы Стандартной библиотеки
Стандартная библиотека С+-+ включает:
• вспомогательные компоненты (language support), используемые в других частях библиотеки;
• обработку ошибок (diagnostics), включая стандартные классы исключений (< exception>, <stdexcept>);
• функционал общего назначения (general utilities), включая распределители памяти (allocators), кортежи (<tuple>), обертки функций (std:function), инструменты для работы с датой и временем (<chrono>), массивы бит (<bitset>);
• строки (<string>);
• средства локализации (localization);
• контейнеры и итераторы;
• обобщенные алгоритмы (< algorithm>, <numeric>);
• средства для поддержки вычислений (numerics), включая комплексные числа (<complex>), векторную арифметику (<valarray>) и генераторы псевдослучайных чисел (<random>);
• средства ввода-вывода, включая iostrearns;
• регулярные выражения (<regex>);
• атомарные операции в параллельных алгоритмах;
• нити исполнения (threads) и механизмы синхронизации;
• Стандартную библиотеку С.
Под Стандартной библиотекой шаблонов (Standard Template Library, STL) понимаются контейнеры + итераторы + алгоритмы + некоторые вспомогательные компоненты, используемые в связке с контейнерами и алгоритмами. Строки и ѵаіаггау близки контейнерам, а алгоритмы из < numeric> используют итераторы, поэтому их часто рассматривают вместе с STL, хотя формально и не относят к ней.
В рамках данной книги состав Стандартной библиотеки C++ невозможно охватить даже поверхностно. Ее подробное описание можно найти в источниках, указанных в дополнительной литературе. Здесь же мы рассмотрим некоторые элементы Стандартной библиотеки, демонстрирующие применение подходов обобщенного и объектно-ориентированного программирования .[у] (3.1) Изучите и проанализируйте зависимости между стандартными классами исключений, определенными в <exception> и <stdexcept>.|~у] (3.2) Изучите и проанализируйте зависимости между стандартными классами-шаблонами (<ios>) ios_base, basic_ios, (<ostream>) basic__ostream, (<istream>) basic _is- t ream, basic _ iost ream, (< ss tream >) basic _ ist ringst ream,basic__ostringstream, basic_stringstream. Какие реализации
77
абстрактных классов istream, ostream, iostream помимо определенных в < sstream > предлагает Стандартная библиотека?
3.1. Контейнеры и итераторыКонтейнер — это программный компонент, способный хра
нить набор значений одного типа. Контейнер предоставляет средства доступа к своему содержимому. В Стандартной библиотеке C++ эти средства доступа строятся на обобщении понятия «указатель на элемент массива», которое носит название итератор. В зависимости от внутреннего устройства контейнера не все характерные для указателей операции могут быть выполнены эффективно на итераторах. Например, при доступе к связному списку обращение по числовому индексу может потребовать значительного числа операций. Итераторы могут не поддерживать неэффективные операции. Чтобы выделить характерные виды итераторов, в Стандарте C++ определены категории итераторов. Как правило, итератор нельзя использовать для модификации структуры контейнера (кроме специальных итераторов-адаптеров) без вызова функций самого контейнера.
3.1.1. Итераторы
Итератор ввода предназначен только для однократного чтения (ввода) последовательности, основная конструкция выглядит так:
Value value - * i t ++ ; / / i t — итератор
Итератор можно передвигать на одну позицию вперед (инкремент) и разыменовывать (операции * и ->), получая доступ к текущему значению. Итераторы можно сравнивать между собой на равенство и неравенство.
Итератор вывода предназначен только для однократной записи (вывода) последовательности. В остальном аналогичен итератору ввода. Основная конструкция выглядит так:78
* it-f-f = value ;
Однонаправленный итератор является расширением концепций «итератор ввода» и «итератор вывода». Итератор допускает многократное чтение и запись линейной последовательности, по которой можно двигаться только в одну сторону (как по односвязному списку).
Двунаправленный итератор является расширением концепции «однонаправленный итератор». Итератор допускает движение в двух направлениях: вперед (++) и назад (--).
Итератор произвольного доступа является расширением концепции «двунаправленный итератор». Итератор допускает адресацию по индексу (оператор []). сдвиг в обе стороны на некоторое количество позиций (добавление и вычитание целого числа), вычисление расстояния с помощью вычитания и сравнение на «меньше» и «больше» (согласованное с расстоянием, которое имеет знак). Итератор произвольного доступа наиболее близок концепции указателя. Указатель на элемент массива является итератором произвольного доступа.
Для упрощения работы с итераторами Стандартная библиотека предоставляет ряд средств (заголовочный файл < iterator >). Перечислим их.
Класс характеристик iterator_traits<T>. Классом характеристик называют класс-шаблон, предоставляющий для своего параметра набор некоторых базовых определений, как правило типов и констант. В случае iterator_traits это набор типов: valuetype — тип значения, на которое указывает итератор; reference — тип ссылки, возвращаемой при разыменовании итератора; pointer — тип указателя, возвращаемого при обращении к объекту итератора через operato r-> ; differencetype — целочисленный тип, представляющий значения смещений итераторов относительно друг друга, и, наконец, самый главный тип — iterator_category, являющийся синонимом одного из предопределенных теговых классов: in p u tite ra to rta g , output_jterator_ tag, forward_iterator_tag, bidirectional_iterator_tag и random_ac- cess_iterator_tag. Под теговыми классами понимаются пустые
структуры, все назначение которых сосредоточено в их имени и формальных отношениях наследования. Таким образом, с помощью iterator_traits можно определить вид итератора, что используется при выборе подходящих алгоритмов во время компиляции. Подробнее об этом см. подраздел 3.4. Имеются частные специализации шаблона iterator_traits для указателей.
Класс-шаблон iterator<Category, Т, Distance = ptrdiff t, Pointer = T *. Reference = T & > . Здесь Category — один из тегов, перечисленных выше, а Т — тип значения, на которое указывает итератор. Данный класс используется в качестве базового при создании других классов итераторов: он добавляет к определению вложенные типы, доступные затем через iterator_ traits.
Вспомогательные функции: advance(p, n), distance(pf q), next (р) и ргеѵ(р). Функция distance вычисляет расстояние между парой переданных ей итераторов (количество применений оператора инкремента к первому итератору до достижения им второго либо обычная разность для итераторов произвольного доступа). Функция advance сдвигает итератор (принимает по ссылке) на заданное число шагов (сдвиг назад определен для двунаправленных итераторов). Функции next и ргеѵ возвращают итератор, сдвинутый соответственно вперед или назад на одну позицию. Также есть перегруженные варианты, принимающие число шагов.
Итераторы-адаптеры будут рассмотрены после стандартных контейнеров.
3.1.2. Стандартные контейнеры
Стандартные контейнеры можно разбить на две большие группы: линейные и ассоциативные. В свою очередь, линейные контейнеры можно разделить на связные списки (forward_ list и list) и контейнеры произвольного доступа (deque, vector и array). Ассоциативные контейнеры представлены восемью контейнерами, являющимися комбинациями следующих вариантов: множество (*set) или словарь (*іпар), допускающие повто-
рение элементов (*multi*) или не допускающие, упорядоченные или неупорядоченные (unordered*).
Все контейнеры содержат вложенные типы iterator и const_ iterator, определяющие итераторы чтения-записи и только чтения соответственно. Диапазон итераторов, охватывающий содержимое контейнера, можно получить с помощью функций begin и end, а также cbegin и cend (только const__iterator). Все контейнеры можно проверять на пустоту функцией empty. Контейнер cont пуст, если c o n t . begin () == c o n t . end (), end () возвращает итератор, указывающий на условный элемент, находящийся за последним элементом контейнера. Количество элементов можно получить с помощью функции size (за исключением forward list). Контейнер можно очистить от содержимого вызовом функции clear (кроме array).
В <iterator> также определены свободные шаблоны функций begin, end, cbegin, cend, rbegin, rend, crbegin и crend1, no умолчанию переадресующие вызов одноименным функциям- членам, кроме того, даны определения этих функций для статических массивов и std::valarray. Именно на них (с использованием механизма ADL) опирается новая форма цикла fo r . Пусть сг — некоторый «контейнер» (в том числе, возможно, статический массив), тогда запись
for (Т X : сг) work(x) ;
семантически эквивалентна записи
{ using std :: begin ; us ing s t d : : e n d ; const auto e - e n d ( c r ) ; for ( auto p = begin ( c r ) ; p != e; ++p){ T X -- *p; work(x); }}
Линейные контейнеры (кроме array) можно заполнить значениями из заданного диапазона вызовом функции assign (старое содержимое будет уничтожено) и изменить их размер функцией resize (при уменьшении размера лишние элементы удаля
1 Последние две пары введены в черновом варианте ISO С++14.81
ются с конца, при увеличении размера новые элементы добавляются в конец).
Прямой доступ к первому элементу контейнера (кроме неупорядоченных ассоциативных контейнеров) можно получить функцией front. Все контейнеры, итераторы которых являются по крайней мере двунаправленными, предоставляют функцию back для доступа к последнему элементу, а также противоположно направленные итераторы reverse iterator и const_reverse_ iterator, соответствующие диапазоны можно по- лзчить с помощью функций rbegin, rend и crbegin, crend.
Все контейнеры можно сравнивать на равенство и неравенство, а также обменивать их содержимое с помощью функции swap. Все контейнеры, кроме неупорядоченных ассоциативных, можно сравнивать лексикографически оператором «меньше».
Для управления динамической памятью стандартные контейнеры используют специальные классы, называемые аллокаторы. Аллокатор привязан к типу элемента, который определяет минимальную единицу управления памятью и предоставляет ряд вспомогательных определений. Работа осуществляется с помощью четырех основных функций: allocate для выделения памяти под заданное количество элементов, deallocate для освобождения памяти, construct для вызова конструктора и destroy для вызова деструктора. Аллокатор должен предоставлять метафункцию rebind, позволяющую получить «аналогичный» аллокатор для элементов другого типа:
t y pe de f typename Alloc :: rebind<U>:: other AllocForU ;
Стандартная библиотека предоставляет allocator<T>, являющийся оберткой операторов new/new [] и d e le te /d e le te [] - Его можно использовать в качестве модели при написании своих аллокаторов.
3.1.3. Л инейны е контейнеры
Односвязный список forward list<Tr А = allocator<T>> предоставляет доступ к элементам типа Т через однонаправлен-
ный итератор. Для создания узлов список использует аллокатор, полученный через A::rebind. Особенность односвязного списка состоит в том, что элементы можно добавлять и удалять только после заданной позиции: insert after — вставить элемент; emplace_after создать новый элемент, вызвав конструктор для переданных параметров; erase_after — удалить элемент. Имеется дополнительная фиктивная позиция «перед первым элементом», возвращаемая функциями before begin и cbefore_begin (const__iterator). Элементы можно вставлять в начало: вызов f l.p u s h _ fro n t(ite m ) эквивалентен
f l . i n s e r t _ a f t e r ( f l . before _ begin () , i tem)
аналогично f l .e m p la c e _ fro n t( . . .) эквивалентен
fl . emplacc_af ter ( fl . before_begin () , . . . )
Функция pop front удаляет первый элемент списка. Особенностью списков STL также является поддержка более высокоуровневых операций, что проистекает из невозможности эффективного использования одних только итераторов для реализации этих операций: merge сливает два отсортированных списка в один, элементы не копируются, а передаются из правого в левый; splice after — вставляет переданный список целиком после указанного элемента; remove — удаляет все элементы, значение которых равно заданному; remove_ if — удаляет все элементы в соответствии с предикатом; reverse — обращает порядок элементов; unique — удаляет все подряд идущие дубликаты; sort — сортирует список на месте. В целом данные функции аналогичны соответствующим стандартным алгоритмам (см. ниже).
Двусвязиый список listcT, А = allocator<T>> предоставляет доступ к элементам через двунаправленный итератор. В отличие от односвязного списка элементы вставляются перед заданной позицией (функции insert, emplace, splice), доступна вставка и удаление с конца (push_back, emplace_back, pop back), удаление элемента, на который указывает итератор (erase). Функции вида *_after отсутствуют.
Дек deque<T, А = allocator<T>> предоставляет доступ к элементам через итератор произвольного доступа. Так же, как и list, позволяет эффективно добавлять и удалять элементы с обоих концов. Для доступа по индексу предназначены две функции: оператор [] и аѣ(индекс). В отличие от первой вторая проверяет индекс и в случае недопустимого значения бросает исключение out_of_range. Контейнер deque допускает выполнение вставки и удаления элементов в произвольной позиции аналогично list, однако в случае deque эти операции затратны: могут требовать времени линейного по размеру контейнера. Кроме того, необходимо помнить, что вставка и удаление элементов может «испортить» ранее сохраненные итераторы или указатели из-за потенциального перемещения хранимых элементов в памяти (в то время как итераторы списков сохраняются, если соответствующие элементы не были удалены).
Динамический массив vector<T, А = allocator<T>> предоставляет доступ к элементам через итератор произвольного доступа. В отличие от deque не позволяет вставлять и удалять элементы в начале. Динамический массив гарантирует расположение хранимых элементов подряд в непрерывном участке памяти, адрес которого возвращает функция data. При добавлении элементов и исчерпании заранее выделенного хранилища может быть выделен новый динамический массив большего размера, куда будут перенесены элементы. Старое хранилище при этом удаляется, все сохраненные итераторы «пропадают» и становятся эквивалентны указателям на удаленные объекты. Массив позволяет заранее подготовить хранилище достаточного размера с помощью функции reserve (может вызвать перемещение элементов). Узнать размер хранилища можно спомощью функции capacity. Функция shrink to fit выделяетхранилище размера, равного size, и переносит туда элементы, освобождая незанятую память.
Статический массив arraycT, N> предоставляет доступ к элементам через итератор произвольного доступа и является оберткой над статическим массивом T[N], адрес которого мож
но получить функцией data. Адресовать элементы по индексу можно так же, как в случае deque и vector. Изменять количество элементов нельзя, поэтому никаких функций для вставки и удаления элементов array не предоставляет. Функция fill заполняет массив копиями переданного значения.
3.1.4. Ассоциативны е контейнеры
Все ассоциативные контейнеры поддерживают следующие операции: count возвращает количество элементов, эквивалентных заданному; find возвращает итератор, указывающий на некоторый хранимый элемент, эквивалентный заданному, либо end(), если таковых нет; equal_range возвращает пару итераторов, задающих полуоткрытый диапазон всех хранимых элементов, эквивалентных заданному.
Упорядоченные контейнеры построены па сбалансированном двоичном дереве и опираются на операцию «меньше». Два элемента считаются эквивалентными, если ни один из них не меньше другого. Все упорядоченные контейнеры предоставляют доступ к элементам через двунаправленные итераторы и позволяют найти позицию первого элемента, не меньшего искомого с помощью функции lower_bound, и первого элемента, большего искомого, с помощью функции upper_bound, поэтому для контейнера ас вызов ac.equal_range() по смыслу эквивалентен вызову
make_pair ( ас . lower_bound () , ас . upper_bound ())
Упорядоченное множество уникальных элементов set<Kf С = less<K>, А = allocator<K>>. Данный контейнер не позволяет изменять хранимые значения «на месте», set<K>:-.iterator и set.<K> :: const _ iterator функционально эквивалентны и возвращают const К& при разыменовании. Для того чтобы изменить хранимый в set объект, его нужно сначала удалить, а затем вставить новый вариант. Вставка элементов производится функцией insert, имеющей несколько вариантов: вставка значений из диапазона принимает пару итераторов ввода,
вставка одного значения с указанием возможного места вставки (оптимизация, которую библиотека может игнорировать) и, наконец, основной вариант — вставка заданного значения. Последний вариант insert возвращает пару (итератор, булевское значение), первый элемент которой указывает место вставленного или найденного значения, второй же позволяет узнать, было значение вставлено (true) или уже находилось во множестве на момент вставки (false). Для двух последних видов insert существуют аналоги emplace hint и emplace, принимающие параметры конструктора и создающие значения «на месте». Удаление элементов выполняется функцией erase, которая принимает итератор или диапазон итераторов, задающие элементы множества, или значение. Первые два варианта возвращают итератор, указывающий на элемент, следующий за удаленными. Последний вариант возвращает количество удаленных элементов (0 или 1 в случае set).
/ / пример использования erase в цикле: удалить из / / множества все строки с заданной подстрокой void e rase_subs
( s e t < s t r i n g > &s , co n st s t r i ng &subs) { au to p -= s . begin ( ) , pc — s . c nd ( ) ; w hile (p != pe) {
i f (p—>find ( s ubs ) != s t r i ng :: npos) p — s . e r a s e ( p ) ;
e l se+-tp;
}}
Упорядоченное мультимножество multiset. В отличие от set вставка одного значения функцией insert всегда возвращает итератор, указывающий на вставленное значение.
/ / сортировка деревом с помощью m u ltise t te m p la te e c l a s s Fwdlt>void tree__sort (Fwdlt begin, Fwdlt end) {
ty p e d e f typenamei t e r a t o r _ t r a i t s <FwdIt > :: value_ type VT;
mul t i set <VT> t ree ( make _ move iterator (begin ) , make_move_i terator ( end )) ;
copy( t r e e . begin () , t r e e . end () , b e g i n );}
Упорядоченный словарь с уникальными ключами mapcK, Т, С = less<K>, А = a!locator<pair<const К, Т > > > хранит значения типа std::pair<const К, Т > , где К играет роль ключа, по которому осуществляется выборка, а Т — хранимое значение. Поэтому при разыменовании итератора поле first позволяет прочитать ключ, а поле second предоставляет доступ к хранимому значению. Основная операция т а р — индексирование с помощью o pera to r [], которому передается значение ключа. Данный оператор возвращает ссылку на значение, отвечающее переданному ключу. Если в словаре не было значения с таким ключом, то будет создано новое значение (конструктором по умолчанию), ссылка на которое и будет возвращена. Таким образом, opera to r [] не позволяет узнать, было значение найдено или создано. Так как индексирование может изменять структуру контейнера (создавать новые узлы), оно неприменимо к const map. Пусть map<K, Т> m, тогда запись m[k] = t по смыслу эквивалентнаm. i ns e r t (make_pai r (k , Т ( ) ) ) . f i r s t —>second = t
Действительная реализация может быть эффективнее и не создавать лишний раз объект Т. Если поведение operato r [] представляется неудобным, то ему есть по крайней мере две альтернативы. Во-первых, можно использовать find, чтобы по ключу получить итератор, указывающий на соответствующую пару (ключ, значение), либо итератор end О , если ключа в словаре нет. Во-вторых, можно использовать функцию at, принимающую ключ и возвращающую ссылку на значение. В случае отсутствия искомого ключа в словаре at бросает исключение.
Упорядоченный словарь с неуникальными ключами multimap не определяет o p era to r [] и, по сути, напоминает multiset пар, поиск среди которых ведется только по первому полю (ключу). В качестве примера использования multimap рассмот
рим задачу об обращении словаря, хранимого в текстовом файле. Для простоты положим, что словарь состоит из пар слов, упорядоченных по первому слову, слова могут повторяться. В примере используется функция сору из <algorithm> (см. раздел, посвященный стандартным алгоритмам).
/ / пара слое — элемент словаря ty p e d e f pa i r<s t r i ng , s t r i n g > Entry ;/ / словарьty p e d e f niul t imap<string , s t r i ng> Dic t ionary;/ / ввод—вывод namespace std {istream& o p e r a t o r » ( i s t r e a m &is , Entry &en){ r e t u r n is » en. f i r s t » en. second; } ostreamfc o p e r a t o r « ( o s t r e a m &os , const Entry &en){ r e t u r n os « en. f i r s t « ’w’ « e n . second ; } istreamfc o p e r a t o r » ( i s t r e a m &is , Dict ionary &d) {
i s t r e a m_i t e r a t o r < En t r y> begin ( i s ) , end; d = Dict ionary ( begin , end); r e t u r n is ;
}ostrcam& o p e r a t o r «
(ostream &os , const Dict ionary &d) { os t r eam_ i t erator <Entry> out (os, " \ n ,f); c opy ( d . beg i n () , d . e n d ( ) , out ) ; r e t u r n o s ;
}}/ / операция обращения словаря Dict ionary reverse (cons t Dict ionary &d) {
Dict ionary r e s u l t ;for (cons t auto &el : d)
r e s u l t . emplace ( el . second , cl . f i r s t ); r e t u r n r e s u l t ;
}/ / чтение—обращение—записьvoid Dict ionaryReversc ( i s t rcam &from , ostream &to) {
Dict ionary read; from » read ; to « r e v e r s e ( read ):
}
[У] (3.3) Как переделать пример с обращением словаря, чтобы не пришлось хранить одни и те же слова дважды (предполагая, что прочитанный словарь нам больше не требуется и его можно уничтожить в процессе обращения)?
Неупорядоченные контейнеры построены на хэш-таблице (обычно это хэш-таблица списков — «закрытая адресация») и опираются на некоторую хэш-функцию (по умолчанию std::hash < Т > из <functional>) и операцию «равно». Два элемента считаются эквивалентными, если их хэши равны и операция «равно» возвращает истину. Неупорядоченные контейнеры предоставляют доступ к элементам через однонаправленные итераторы.
К стандартным неупорядоченным ассоциативным контейнерам относятся классы-шаблоны unordered_set<Kf Н = hash < К > , Е = equal_to<K>, А = a!locator<K>>, unordered_map<K, Т, Н = hash < К > , Е = equal_to<K>, А = allocator< pair<const К, Т > > > , unordered_multiset, unordered_multimap. Они предоставляют похожую на аналогичные упорядоченные ассоциативные контейнеры функциональность.|~у~1 (3.4) Изучите отличия интерфейсов неупорядоченных ассоциативных контейнеров от интерфейсов упорядоченных контейнеров.
3.1.5. И тераторы -адаптеры
Класс «обратный итератор» reverse_iterator<lterator> оборачивает объект двунаправленного итератора Iterator, обращая порядок обхода последовательности, т. е. инкремент обратного итератора приводит к декременту базового итератора. Извлечь его можно с помощью функции-члена base. Стандартные контейнеры используют reverse_iterator для реализации rbegin и rend. Чтобы обеспечить корректность диапазона [rbegin, rend), базовый итератор сдвинут на одну позицию вперед. Таким образом, если г — обратный итератор, то истинно выражение & *r == & *p re v (r .b aseO ).
Класс move_iterator<lterator> является оберткой, подменяющей копирующее присваивание перемещением. Создать объект данного класса на месте можно с помощью функции make_ move _ iterator( iter).
Класс back_insert_iterator<Container> является итератором вывода, реализующим операцию записи через вызов функции- члена push_back для контейнера, указатель на который хранится в объекте итератора. Создать такой итератор на месте можно с помощью функции back_inserter(container), что бывает удобно при сохранении последовательности, размер которой заранее неизвестен (пример см. ниже).
Класс front_insert_iterator<Container> аналогичен предыдущему, но вызывает функцию push_front. Создать объект на месте можно с помощью front inserter(container).
Класс insert_iterator<Container> похож на два предыдущих, но предназначен для вставки элементов в произвольной позиции внутри контейнера, поэтому помимо указателя на контейнер хранит итератор, задающий позицию вставки в этом контейнере. При записи вызывает insert и обновляет позицию. Создать объект insert_iterator на месте можно с помощью функции inserter(container, iterator).
Класс istream_iterator<T, CharT = char, Traits = char_traits <CharT>, Dist = ptrdiff t> является итератором ввода, предназначенным для чтения из basic_istream<CharT, Traits>. Типы CharT и Traits обеспечивают возможность использования пользовательских типов символов. Второй из них — класс хаг рактеристик, содержащий ряд базовых типов и операций над символами и передаваемыми по указателю строками. Для стандартных символьных типов определены соответствующие версии стандартного шаблона char_traits.
Объект ist ream _ iterator, привязанный в момент создания к потоку, сбрасывается при невозможности прочитать следующее значение и становится равным объекту, созданному конструктором по умолчанию.
Вместе с back_inserter istream _iterator можно использовать для организации считывания последовательности чисел произвольной длины с потока ein в контейнер xs:
сору ( i s t ream _ i t e r a t o r e i n t >(cin ) ,is t re am _ i t e r a to r <int >( ) , b a ck_ i ns e r t e r ( xs ) ) ;
Класс ostream_iterator<T, CharT = char, Traits = char_traits <C harT>> является итератором вывода, предназначенным для записи э объект basic_ostream<CharT, Traits>. Кроме указателя ца поток вывода итератор хранит указатель CharT* на строку-разделитель, которая выводится после каждой записи (если указатель ненулевой). Выведем последовательность чисел xs, разделенную запятыми в поток cout.
copy ( xs . b eg i n () , x s . e n d ( ) .os t ream _ i t e ra tor <i n t > (cout ,
Заметим, что запятая будет поставлена и после последнего выведенного числа, что может быть нежелательным. В этом случае последний элемент следует выводить отдельным вызовом. [у] (3.5) Помимо char_traits к классам характеристик можно отнести определенный в <limits> шаблон numeric_limits<T>, где Т — числовой тип. Целью этого класса является обобщение и замена макросов С, описывающих особенности числовых типов (например, из <float.h>). Изучите этот класс. Сопоставьте его возможности с соответствующими возможностями Стандартной библиотеки С.
3.2. Алгоритмы и функторы
Стандартная библиотека C++ содержит несколько десятков функций, называемых алгоритмами, оперирующих на наборах элементов, заданных диапазонами итераторов. Таким образом, итераторы выступают в качестве «клея», соединяющего алгоритмы и контейнеры. Однако функционал алгоритмов был бы весьма ограничен, если бы не возможность задавать произвольные операции с помощью функторов.
Функтором в C++ называют класс, объекты которого можно использовать в качестве функций. Технически это оформляется с помощью перегрузки opera to r О . Данный «оператор» является единственным оператором C++, допускающим перегрузку с произвольной сигнатурой, поэтому объекты функтора могут имитировать произвольные функции. Соответственно о функторах часто говорят как о функциях: «функтор вызывается», «функтор принимает и возвращает значения» и т. п. Кроме того, обычные функции, передаваемые по указателю, могут считаться частным случаем функторов, поскольку могут быть использованы в тех же контекстах.
Генератором называют функтор, который не принимает аргументов и возвращает некоторую (генерируемую) последовательность значений. В качестве примера можно привести генератор псевдослучайных чисел.
Предикатом называют функтор, возвращающий булевское значение и используемый, например, при фильтрации последовательностей. Обычно предикаты являются одноместными (унарными), т. е. принимают один параметр. Двуместные (бинарные) предикаты, принимающие два параметра и отвечающие некоторому отношению между ними, называют компараторами. Компараторы используются, например, в упорядоченных ассоциативных контейнерах и в стандартном алгоритме sort для определения отношения «меньше».
Неупорядоченные ассоциативные контейнеры используют два функтора: компаратор, задающий отношение «равно», и хэш, задающий способ вычисления хэш-функции для элементов контейнера, возвращающий целое число.
Большая часть стандартных алгоритмов определена в заголовочном файле <algorithm>. Далее приведен список некоторых стандартных алгоритмов с краткими описаниями.
copy(from, from_end, to) — копирует диапазон [from, from_end) в диапазон [to, to_end), в процессе получая и возвращая итератор to_end, что позволяет конкатенировать последовательности цепным вызовом сору; например, чтобы по
лучить конкатенацию [fl, el) и [f2, е2), начинающуюся в d, вызовем copy (f 2, е2, copy( f l , e l , d)) ;
copy if(fr°m , from_end, to, pred) - отличается от copy тем,что копирует только те элементы, для которых pred возвращает истину («фильтр»);
move(from, from_end, to) — отличается от сору тем, что вместо копирования выполняет перемещение, поэтому элементы исходной последовательности [from, from_end) могут потерять исходные значения;
find(begin, end, value) — ищет среди [begin, end) первое вхождение value, возвращает итератор, указывающий на найденное вхождение, либо end, если value не найдено;
find if(first, end, pred) — отличается от find тем, что вместопоиска конкретного значения, выполняет поиск первого элемента, для которого выполняется предикат pred;
count(first, end, value) — считает, сколько элементов из [begin, end) равны value;
count if(first, end, pred) — считает, для скольких элементовиз [begin, end) выполняется предикат pred;
adjacent find(begin, end, bipred) — пытается найти первыйэлемент из [begin, end) такой, что для него и следующего за ним элемента выполняется двуместный предикат bipred, возвращает позицию найденного элемента или end. если ничего не найдено; существует также вариант adjacent_find(begin, end), использующий в качестве bipred оператор равенства;
fill(to, to_end, value) — записывает value в каждый элемент диапазона [to, to_end), функция ничего не возвращает;
fill n(to? n, value) — записывает value в [to, to + n), возвращает (to + n), полученный в процессе; значения n < 1 игнорируются;
generate(to, to_end, gen) заполняет [to, to_end) значениями, сгенерированными генератором gen; например, заполнить вектор V псевдослучайными числами можно так:
generate (ѵ . begin () , v . e n d ( ) , r and) :
generate_n(to, n, gen) — соответствует generate так же, как fill_n соответствует fill, например, вывести в cout семь псевдослучайных чисел можно так:o s t r e a m _ i t e r a t o r < i n t > o u t _ i t ( c o u t , ,fwH); generate_n ( ou t _ i t , 7, r and) ;
reverse(begin, end) — обращает последовательность [begin, end), выполняя серию обменов (по умолчанию с помощью std::swap);
rotate(begin, new_begin, end) — выполняет циклический сдвиг («вращение») элементов последовательности [begin, end) таким образом, что new_ begin становится новым началом, а элементы [begin, new_begin) попадают в конец; возвращает (begin + (end — new_begin));
swap__ranges(a, a_end, b) — последовательно обменивает содержимое элементов [a, a_end) и элементов [b, b_end), в процессе находя b_end, возвращает b end;
for_each(begin, end, fun) — применяет fun к каждому элементу из [begin, end), возвращает fun;
transform(from, from end, to, fun) — применяет одноместный функтор к [from, from_end), записывая возвращенные им значения в [to, to_end), возвращает to_end;
transform(a, a e n d , b, to, fun) — применяет двуместный функтор к парам элементов из [a, a_end), [b, b_end), записывая возвращенные им значения в [to, to_end), возвращает to_end;
remove(begin, end, value) - перемещает содержимое [begin, end) от конца к началу, затирая все вхождения value, возвращает конец новой последовательности (не содержащей value), для реального удаления «хвоста» из контейнера требуется вызвать функцию-член erase;
remove_if(begin, end, pred) — действует аналогично remove, но затирает те элементы, для которых pred возвращает истину;
unique(begin, end, eq) — действует аналогично remove if, но используег двуместный предикат eq, применяемый к парам подряд идущих элементов; существует также вариант unique (begin, end), использующий в качестве eq оператор равенства,
позволяющий удалять из последовательности идущие подряд дубликаты (откуда и происходит название функции);
replace(begin, end, old val, new_val) — пробегает [begin, end)и заменяет каждое вхождение old_val на new_val;
replace if(begin, end, pred, new_val) — заменяет в [begin, end)на new_val все элементы, для которых истинен предикат pred;
max_element(begin, end, comp) — возвращает первое вхождение максимального элемента из [begin, end), comp задает компаратор; существует вариант max_element(begin, end), использующий в качестве comp оператор меньше;
min_element(begin, end, comp) — аналогичен max_element, но возвращает позицию первого минимального элемента;
minmax_element(begm, end, comp) — за один проход находит и минимальный, и максимальный элементы, возвращает пару итераторов, семантически эквивалентен
make_pai r (min_element( begin , end, comp), max_element ( begin , end, comp));
partition(begin, end, pred) — разделяет [begin, end) на два участка: [begin, par), для элементов которого предикат pred выполняется, и [par, end), для элементов которого pred не выполняется; возвращает точку разбиения par;
sort(begin, end, comp) — сортирует [begin, end) на месте за линейно-логарифмическое время в соответствии с компаратором comp (неустойчивая сортировка; как правило, это гибридный алгоритм на основе быстрой, пирамидальной сортировок и сортировки вставками); алгоритмы сортировки в STL требуют итераторы произвольного доступа; алгоритмы поиска и сортировки, принимающие компаратор, также существуют в вариантах, где comp не передается, а в качестве компаратора используется оператор «меньше», например, sort(begin, end) сортирует по возрастанию;
stable_sort(begin, end, comp) — отличается от sort тем, что гарантирует устойчивость (сохраняет исходный порядок эквивалентных элементов), однако работает несколько медленнее;
partial sort(begin, mid, end, comp) — выполняет частичнуюсортировку [begin, end) так, что участок [begin, mid) представ- ляет собой начало отсортированной последовательности, а порядок элементов «хвоста» [mid, end) не определен;
nth_element(begin, nth, end, comp) — выполняет частичную сортировку [begin, end), пока в [begin, nth) не окажутся элементы, которые не больше любых элементов из [nth, end);
lower_bound(begin, end, val, comp) — двоичным поиском на отсортированной в соответствии с компаратором comp последовательности [begin, end) находит первый элемент, который «не меньше» в смысле comp, чем ѵаі; возвращает соответствующий итератор, или end, если таковой не был найден;
upper_bound(begin, end, val, comp) — похож на lower_bound, но возвращает позицию первого элемента, который «больше» в смысле comp, чем val, либо end;
equaІ_ гаnge(begin, end, val, comp) — находит диапазон элементов, эквивалентных val в смысле comp, по семантике то же, что и
make__pair(lower_bound( begin , end, val , comp) , upper_bound ( begin , end, val , comp));
merge(a, a_end, b, b_end, to, comp) — сливает две заранее отсортированные последовательности [a, a_end) и [b, b__end), копируя элементы в [to, to_end), возвращает to_end;
includes(a, a e n d , b, b_end, comp) — предполагая, что последовательности [a, a end) и [b, b_end) отсортированы, проверяет, что все элементы второй содержатся в первой;
set_difference(a, a end, b, b_end, to, comp) — предполагая, что последовательности [a, a_end) и [b, b_end) отсортированы, строит теоретико-множественную разность [a, a_end) \ [b, b_end), записывая результат в [to, to_end), возвращает to_end;
set_intersection(a, a_end, b, b_end, to, comp) — действует аналогично set_difference, но строит теоретико-множественное пересечение;
set_union(a, a e n d , b, b_end, to, comp) — действует аналогично set_difference, но строит теоретико-множественное объединение, в отличие от merge не сохраняет дубликаты;
set_symmetric_difference(a, a_end, b, b_end, to, comp) — действует аналогично set_ difference, но строит симметрическую разность;
equal(a, a_end, b, b_end, eq) — проверяет равенство последовательностей [a, a_end) и [b, b_end), оператор равенства задается предикатом eq; как и в других подобных случаях, есть вариант данного алгоритма без предиката, который использует оператор «равно»;
lexicographical_compare(a, a end, b, b end, comp) — проверяет, меньше ли [a, a_end), чем [b, b_end), лексикографически, comp — реализация оператора «меньше» для элементов;
search(a, a__end, s, s_end, eq) — ищет начало первой подпоследовательности [a, a_end), совпадающей с [s, s_end), используя отношение «равно», заданное двуместным предикатом eq;
search_n(a, a end, n, val, eq) — ищет начало первой подпоследовательности [a, a_end), состоящей из п идущих подряд элементов, равных ѵаі (в смысле предиката eq);
mismatch(a, a_end, b, b_end, eq) — возвращает пару итераторов, указывающих на первые элементы [a, a_end) и [Ь, b_end), для которых компаратор eq возвращает ложь.
Ряд «вычислительных» стандартных алгоритмов определен в заголовочном файле <numeric>. Перечислим их.
iota(begin, end, val) — заполняет [begin, end) последовательно инкрементируемыми значениями val; например, присвоить элементам контейнера ѵ значения, равные их индексам, можно вызовом i o t a (v . beg i n ( ) , v . endO, 0);
accumulate(begin, end, sO, adder) — накапливает «сумму» элементов [begin, end), начиная с sO и «добавляя» элементы к наг копленной сумме, по умолчанию в качестве adder используется оператор «плюс»; так, посчитать сумму элементов контейнера
V можно вызовом accumulate (v. beginO , v . endO, 0), а если мы хотим произведение, то запишемaccumulate (ѵ. begin () , v . en d ( ) , 1.0,
mul t ip l i es <double > ());
inner_product(a, a_end, b, sO, adder, mult) — вычисляет внутреннее (скалярное) произведение двух последовательностей [а, a_end), [b, b_end) равной длины, которое определяется как сумма (задается adder, по умолчанию сложение) sO и попарных произведений элементов последовательностей (задается mult, по умолчанию умножение); внутреннее произведение можно использовать нетривиально: например, пусть многоугольник задан контейнером с, содержащим точки, для которых определена операция d is t , возвращающая расстояние, тогда периметр многоугольника можно посчитать вызовомinner_ p r oduc t ( n e x t ( с . begin ()) , с . end ( ) , с . begin ( ) ,
di s t (с . f ront () , с . back ()) , p luscdouble >( ) , d i s t ) ;
adjacent_difference(begin, end, to, diff) — записывает в [to, to_end) разности (задается diff, по умолчанию разность) соседних элементов из [begin, end), при этом вначале выполняет ♦to = *begin, поэтому to_end отстоит от to на столько же шагов, насколько end от begin: возвращает to_end;
partial_sum(begin, end, to, adder) — записывает в [to, to_end) частичные суммы, накопленные при суммировании элементов [begin, end), т. е. (условно) to [0] = beg in [0] ; t o [ i ] = adder ( t o [ i - l ] , begin [ i ] ) .
3.2.1. И диом а удаления элементов из контейнера
При удалении элементов из контейнера с помощью алгоритмов remove, remove_if и unique требуется результат вызова алгоритма передать в функцию контейнера erase для удаления «хвоста». На деле среди стандартных контейнеров эту схему эффективно можно применить лишь к deque и vector.
Следующий код считывает последовательность слов с потока ввода, сортирует слова, удаляет дубликаты и выводит по
лученную последовательность. Обратите внимание, что в нем не встречается ни одного ключевого слова Сі-+.
ve c t o r <s t r i n g > words;copy ( i s t r ea m_ i t e r a to r < s t r i n g > ( c i n ) ,
i s t r e a m _ i t e r a t o r <s t r i ng >( ) , back inser ter ( words ) ) ; ein . clear ();sort (words . begin () , words . end ( ) ) ; words . erase ( unique ( words . begin () . words . end ()) ,
words . end ( ) ) ; copy (words . begin () , words . end () ,
os t ream__i te ra tor<st r ing >(cout , ,fw" )) ;
|~y] (3.6) Изучите документацию, описывающую содержимое <algorithm> и <numeric>.
3.2.2. С редства конструирования ф ункторов
Заголовочный файл <fuiictionai> содержит ряд классов- шаблонов, являющихся функторами-обертками соответствующих операций: компараторы less, greater, less_equal, greater_ equal, equal_to. not_equal_to, двуместные операции plus, minus, multiplies и др. Например, отсортировать vec to r< in t> v по убыванию можно так:
s o r t ( v . b e g i n ( ) , v . end () , g r e a t e r < i n t > 0 ) ;
Кроме того, < functional> предоставляет средство для оборачивания функций-членов mem_fn, средство связывания параметров функтора bind и контейнер произвольного функтора function.
Рассмотрим пример с использованием mem_fn. Допустим, требуется убрать из вектора strings все пустые строки. Строка string имеет функцию-член empty, которая выступает в роли предиката, но не может быть вызвана как свободная функция, принимающая const stringfc. Обертка позволяет решить эту проблему: новый функтор будет принимать в качестве первого параметра ссылку или указатель на объект.
s t r i n g s . erase ( remove_i f
( s t r i n g s . be g i n () , s t r i ngs . end () , mem_fh(&;string:: empty) ) , s t r i n gs , end ( ) ) ;
Функция bind принимает базовый функтор и набор параметров. ему передаваемых при вызове обертки. При передаче значения его копия сохраняется в обертке и передается при каждом вызове. Чтобы сохранить параметр по ссылке, следует использовать функции ref и cref (const-ссылка). Чтобы связать параметр, передаваемый в базовый функтор с параметром, принимаемым оберткой, нужно указать местодержа- тель, имеющий вид placeholders::_п, где п — число (обязательно поддерживаются значения 1 и 2), задающее номер параметра обертки (отсчитывается от единицы). Например, получить удвоенную последовательность ѵ2 из ѵі (считая, что они одина^ ковой длины), содержащей f lo a t , можно следующим образом:
t ransform (v l . begin () , v l . e n d ( ) , v2. begin ( ) ,bind ( mul t ip l i es <f l oa t >( ) , 2 . f , p l aceholders : : 1));
Другой пример: добавим в конец каждой строки из диапазона [begin, end) другую строку suffix, которую передадим обертке по ссылке, воспользовавшись функцией cref.
for _each ( begin , end, bind (mem_fn(&st r ing :: append) , p l aceholders : : 1, c r e f ( s u f f i x ) ) ) ;
Тем не менее возможности bind довольно ограничены. Наиболее сильным средством, появившимся в ISO С++П, являются замыканѵя — объекты анонимных функторов, созданные на месте использования с помощью лямбда-выражения. Структура лямбда-выражения слагается из следующих элементов:
« [ перечисление захваченных замыканием переменных ]», если функтор не нуждается ни в каких внешних переменных, то следует поставить []; указание [=] предписывает компилятору помещать в замыкание копию каждой используемой внешней переменной; указание [&] предписывает компилятору помещать в замыкание не копии, а ссылки на переменные, а, например, указание [=, &s] предписывает помещать копии на все переменные, кроме s, которая будет захвачена по ссылке;
«( параметры функт,ора )» — список параметров, оформляется так же, как у обычной функции; можно не указывать, если параметров нет;
«mutable» — указывается, чтобы разрешить замыканию модификацию захваченных по значению переменных; необязательный элемент: если он отсутствует, то все захваченные замыканием копии переменных будут считаться константами;
«-> возвращаемый тип» — описание возвращаемого замыканием типа; указывать необязательно, если возвращаемый тип void, либо если тело функтора состоит лишь из одной инструкции re tu rn (тогда тип выводится автоматически);
«{ т.ело функции }» — собственно код.Например, с помощью лямбда-выражений предыдущие два
примера можно переписать так:
t ransform ( v l . begin ( ) , v l . e n d ( ) , v 2 . begin ( ) ,[ { ( f l o a t x) { r e t u r n 2 . f * x; });
for_each ( begin , end, [&suf f ix ]( s t r i n g &s){ s . append( suffiX ); });
Замыкания можно сохранять в переменные. При этом ключевое слово auto решает проблему неизвестного типа.
Еще одним важным элементом < functional > является класс- шаблон function<T(Args...)>. В качестве параметра шаблона передается функциональный тип, указывающий возвращаемый тип и сигнатуру. Данный класс позволяет разместить в своем объекте произвольный функтор с подходящей сигнатурой (в том числе замыкание), а также указатель на функцию. Таким образом, за счет увеличения накладных расходов по памяти (может быть выделен блок динамической памяти) и процессорному времени при вызове (дополнительные косвенные обращения) достигается максимально возможная гибкость. Данный класс-шаблон может быть использован при реализации, например, паттерна наблюдатель для привязки обработчиков сообщений, заданных в произвольной форме. Узнать, что лежит внутри объекта function, можно с помоіцью функций ta rg e tcT > и target type. Первая возвращает указатель на хранимый объ
ект Т, если он имеет такой тип, либо nullptr в противном случае. Вторая возвращает type_info, описывающий тип хранимого объекта. Объект function может быть «пуст», если к нему не привязали функтор или функцию. Проверить это можно, приведением к bool или с помощью операции «!».
3.3. Ѵаіаггау
Класс-шаблон valarray<T>, определенный в заголовочном файле <valarray>, является динамическим массивом объектов Т, предоставляющим ряд дополнительных возможностей2, предназначенных для вычислений. Набор встроенных «обычных» функций-членов данного класса не велик: swap, size, resize не должны вызвать вопросов. Кроме того, имеются sum, min и max, позволяющие посчитать сумму, минимум и максимум соответственно, элементов массива. Функция apply(fun) создает новый объект ѵа1аггау<Т> из результатов применения функции fun к каждому элементу массива.
Функции-члены shift(n) и cshift(n) выполняют линейный и циклический сдвиги элементов в массиве (похоже на сдвиг бит в целом числе: г-й элемент становится на место і + п, длина сохраняется) и возвращают новый объект valarray<T>, новые значения создаются конструктором Т().
Кроме того, для ѵа1аггау<Т> определен полный набор арифметико-логических операций, которые применяются для каждого элемента или каждой пары элементов с равными индексами (поведение не определено, если массивы имеют разные размеры). Также определены перегрузки стандартных математических функций abs, sqrt, exp, log, loglO, pow и тригонометрических, вычисляемые поэлементно.
2Вероятно, именно этот класс следовало назвать vector, но история распорядилась иначе: к моменту введения в Стандартную библиотеку ѵаіаггау название vector уже закрепилось за динамическим массивом.
Библиотека и компиляторы могут предлагать различные оптимизации операций с ѵаіаггау. Поэтому, например, для ѵа- la rray < flo a t> х, у код
au to z — X * X — у * у ;
может оказаться быстрее, чем его «наивный» аналог
v a l a r r a y < f l o a t > z ( x . s i z e ( ) ) ;for ( s i ze_ t k -• 0, sz - x . s i z e ( ) ; k < s z ; -H-k)
z[k) — XIkJ * X[k ] - у [kI * у [k ];
С valarray связан еще один паттерн, используемый и в объектно-ориентированном, и в обобщенном программировании. В разных источниках он носит разные названия: представитель, прокси, вид. Суть его состоит в том, что создается прокси- объект, который связан с некоторым базовым объектом и имитирует (как правило, частично) его интерфейс, что роднит его с «декоратором». Например, для удобства работы с объектами некоторого класса «матрица» можно предоставить прокси- объекты «строка», «столбец», «прямоугольная область», позволяющие адресовать элементы выбранной части матрицы удобным образом.
В случае valarray доступны четыре прокси. Их возможности ограничены присваиванием и операторами вида «*=». Простейшим из прокси является slice_array<T>, получаемый с помощью valarray<T >:: o p e ra to r [] ( s l i c e ) , где slice (срез) есть тройка (start, size, stride), характеризующая выборку элементов из исходного массива, индексы которых начинаются со start и идут с шагом stride, всего size элементов.
Обобщенным срезом gslice называется тройка (start, size, stride), где size и stride — объекты valarray< size_ t> . Соответствующим прокси является gslice_array<T>. С помощью срезов можно организовать работу с уложенным в valarray многомерным массивом. Размерность равна размеру size и stride; размеры измерений задаются size; шаги между элементами в исходном массиве с зафиксированными индексами по всем измерениям, кроме заданного индексом, задаются stride.
Оставшиеся два прокси представлены классами mask_array<T> и indirect_array<T>. Первый порождается valarray<T>: : o p e ra to r[ ] (valarray<bool> mask) и включает только те элементы исходного массива, для которых в соответствующих позициях mask стоит истина. Маска должна иметь тот же размер, что и исходный массив. Второй порождается valarray<T>: : operato r П (valarray<size_t> in d ices) и позволяет сделать выборку элементов по заданным в indices индексам. Const-версии оператора [] вместо прокси-объектов возвращают объекты ѵаіаітау, содержащие копии соответствующих элементов.
Операции сравнения двух объектов ѵа1аггау<Т> также выполняются попарно и порождают объекты valarray<bool>, которые затем можно использовать в качестве масок. Подобная схема является типичной при векторизации ветвлений на массово-параллельных архитектурах с векторными арифметико- логическими устройствами.
Рассмотрим пример реализации матричных операций поверх ѵаіаггау.tem p la te <c l a s s Т>c l ass Matrix : valarray<T> {
s i z e _ t rs , c s ; / / размеры: строки, колонки p u b l i c :
t y pe de f valarray<T> Data; ty p e d e f slice array<T> View;Matr ix() : r s ( 0 ) , cs(0) {}Matrix ( s i ze_ t rows, s i ze_ t cols)
: r s ( r ows ) , c s ( c o l s ) , Data(rows * cols) {} s i ze_ t rows() const { r e t u r n r s ; } s i ze_ t cols () const { r e t u r n c s ; }
Удобно уметь обращаться и к нижележащему массиву, для этого введем функцию data, а также экспортируем оператор [] из ѵаіаггау, позволяющий не писать лишний раз .data( ) для получения срезов. Оператор () предназначен для адресации элементов матрицы по номерам строки и столбца.
Data& data () { r e t u r n * t h i s ; }
const Data& dat a( ) con st { r e t u r n * t h i s ; } using Data :: ope r a t o r [ ];/ / получить элемент no индексам T& ope r a t o r () ( s i z e _ t i , s i ze_ t j ){ r e t u r n ( * t h i s ) [ i * cs 4 j ]; }const T& opera t o r () ( s i ze_ t i , s i z e_ t j ) const{ r e t u r n ( * t h i s ) [ i * cs -f j ]; }
Функции row и col позволяют получить вид (slice_array) или копию (valarray) соответственно строки и столбца с заданным индексом.
/ / адресовать строчку View row(size__t i){ r e t u r n ( * t h i s ) [ s l i c e ( i * cs , cs , 1)]; }Data row(s i ze_t i) const{ r e t u r n (* t h i s )[ s l i c e ( i * cs , cs , 1)]; }/ / адресовать столбец View col ( s ize_ t j ){ r e t u r n (* t h i s )[ s l i c e (j . rs , c s ) ] ; }Data c o l ( s i z e _ t j ) const { r e t u r n (* t h i s )[ s l i c e (j , rs , c s ) ] ; }
Операция «+=» выполняет поэлементное суммирование матриц средствами valarray.
Matrix<T>&; o p e ra to r+ = (c o n st Matrix<T> Mother) { a s s e r t ( r s = o t h e r . rs && cs = o t h e r . c s ) : da t a ( ) += other . da t a (); r e t u r n * t h i s ;
}T t r ace () const { / / след матрицы
a s s e r t ( rs = cs );r e t u r n data ()[ s l i c e (0 , cs , cs + 1) ]. sum ();
}};Для того чтобы можно было перебирать элементы матрицы в цикле for . определим соответствующие варианты begin и end (на основе уже существующих стандартных функций, определенных для valarray). Синтаксис заголовка функции, введенный в ISO С++11 вкупе с новым ключевым словом decltype,
позволяет вывести возвращаемый тип средствами компилятора, опираясь на известные к тому времени параметры функции (выводимый тип есть тип значения, переданного decltype выражения. при этом значение этого выражения не вычисляется). Два аналогичных варианта begin и end, принимающие не-const ссылки, не приводятся для краткости.
tem p la te c c l a s s Т>i n l i n e auto begin (const Matrix<T> &m)
-> d ec lty p e ( begin (m. data () ) ){ r e t u r n begin (m. data ( ) ) ; }te m p la te Cclass T>i n l i n e au to end (const Matrix<T> &m)
-> dec l type (end (m. data ()) ){ r e t u r n end(m. data ( ) ) ; }
Имея в наличии конструктор копирования и операции вида «+=», легко определить соответствующие неприсваивающие формы операций как свободные функции.
te m p la te c c l a s s Т> i n l i n e Matrix<T> operator4-
( cons t MatrixcT> &a, const MatrixcT> &b){ r e t u r n Matrix<T>(a) += b; }
Операции сравнения, определенные для valarray, выполняют покомпонентные сравнения элементов и возвращают результат в виде объекта valarray<bool>. Поэтому сравнение матриц на равенство придется выполнять «вручную».
te m p la te c c l a s s Т> i n l i n e bool o p e r a to r =
(cons t MatrixcT> &a, const MatrixcT> &b) { r e t u r n a . rows( ) = b . r o w s ( ) &&
a . c o l s ( ) = b . c o l s ( ) &&; e q u a l ( b e g i n ( a ) . e n d ( a ) , b eg i n (b ));
}Имея операцию ==, легко определить операцию !=. Похожим образом на основе операции < строятся <=, > и >=.te m p la te c c l a s s Т>
i n l i ne bool operator ! =(const Matrix<T> &a, co n st Matrix<T> &b)
{ r e t u r n ! ( a b ); }
Самым сложным элементом данного примера является операция умножения. По определению произведение N x K матрицы и К X М матрицы есть N х М матрица скалярных произведений строк первой матрицы со столбцами второй. Это несложно выразить имеющимися средствами.
tem pla te <c l a s s Т>Matrix<T> opera tor*
(const Matr ix<T>&a, con st M atrix</T> &b) { const au to N = a . rows () , M - b . cols (); a s se r t (a. cols () = - b . r o w s Q ) ;Matrix<T> c(N, M) ; / / нули for ( s i z e _ t i 0; i < N; H-i )
for ( s i ze_ t j = 0; j < M; -H-j)с ( i , j ) = ( a . r o w ( i ) * b . col ( j ) ) . sum ();
r e t u r n с ;}
Очевидно, данный алгоритм требует O ( N M K ) операций. Можно заметить, что приведенная реализация весьма неэффективна с точки зрения работы с памятью: даже если для каждого i j -элемента не создается новый объект ѵаіаггау размера К из произведений (продуманная реализация ѵаіаггау способна избежать создания лишних объектов), то все равно остаются промахи кэшей процессора при последовательном обращении к элементам столбца из-за их удаленности друг от друга. Можно изменить порядок обхода элементов так, чтобы не обращаться к столбцам.
const auto N = a. rows () , К — a. cols (); a s se r t (К = b . rows ( ) ) ;Matrix<T> c(N, b. cols ( ) ) ; / / пули for ( s i z e _ t i = 0; i < N; -H-i)
fo r (size__t k = 0; k < K; -H-k) c . r o w( i ) +— a( i . k) * b . row(k) ;
r e t u r n с ;
Впрочем, если реализация ѵаіаггау примитивна, то данный вариант все равно хуже аналогичного кода, написанного «вручную». Для сравнения приведем и его.
const auto N = a. rows () . М — b. cols () ,К = а . с о 1 s ():
a s se r t (К -== b . r o w s ( ) ) ;Matrix<T> c(N, М) ; / / пулиТ *г — &с. d a t a ( ) [ОJ ;const Т *р = &а. data () [0] , *q = &b. data () [0];for ( s i z e _ t i = 0; i < N; -H-i , г += М. p += K) {
const T *qk = q;fo r ( s i z e _ t k — 0; k < K; ++k, qk -f= M) {
co n st T aik = p [ k ]; for ( size t. j -= 0; j < M; -f+j )
г [ j ] += aik * qk [ j ];}
}r e t u r n с ;
В случае Microsoft Visual С-н-2012 второй вариант оказался намного быстрее первого, а третий — намного быстрее второго.
рУ~| (3.7) Сравните быстродействие всех трех вариантов на матрицах разных размеров. Существуют и другие, более сложные и асимптотически эффективные алгоритмы умножения матриц. Реализуйте алгоритм умножения матриц Штрассена3, каково его быстродействие?
На практике valarray не снискал большой популярности, хотя во многих случаях он способен выступить более эффективной заменой vector (если не требуется вставлять элементы). Вычислительные задачи, связанные, например, с матрицами, обычно решают, привлекая сторонние библиотеки.
3См.: Алгоритмы: построение и анализ. / Т. Кормен, Ч. Лейзер- сон, Р. Ривест, К. Штайн. 2-е изд. М., 2005. С. 833-839.
3.4. Статическая диспетчеризация
Предположим, дана пара итераторов и требуется вычислить расстояние между ними. Операция «—» доступна только для итераторов произвольного доступа, в остальных случаях придется пройтись от первого итератора до второго и посчитать число шагов. Определим функцию distance, которая будет делать это. Поле iterator_category класса iterator__traits позволяет осуществлять статическую диспетчеризацию вызова — выбор реализации этой функции во время компиляции.tem p la te c c l a s s I t>typename i t e r a t o r _ t r a i t s <It >:: d i f f e r e nce_ t ype distance impl
( I t from, It t o , f o r w a r d _ i t e r a t o r _ t a g ) { typename i t e r a t o r _ t r a i t s <I t >:: d i f f e rence_type d = 0; whi le (from != to) -H-from, +-hd; r e t u r n d:
}
tem p la te <c l as s I t >typename i t e r a t o r _ t r a i t s <I t > :: d i f f e r e nc e _ t ype dis tance_impl
( I t from, It t o , r andom_a cces s _ i t e r a t o r _ t a g ){ r e t u r n to - f rom; } tem p la te Cclass I t>typename i t e r a t o r _ t r a i t s <I t >:: d i f f e r e nc e _ t ype di s t ance ( I t from, It to) {
r e t u r n di s t ance_impl (from , t o , typenamei t e r a t o r _ t r a i t s <I t > : : i t e r a t o r _ c a t e g o r y ( ) ) ;
}Благодаря выстроенной в Стандартной библиотеке иерар
хии наследования теговых классов, отражающих категории итераторов, вариант реализации функции для forward_itera- to r_ tag будет выбран автоматически для итератора с категорией bidirectional_iterator_tag. А вот попытка вызвать функцию distance для итератора с категорией inpu t_ iterato r_ tag приведет к ошибке компиляции.
Далее, предположим, мы выполняем конкатенацию наборов последовательностей в динамический массив (назовем эту функцию append). В случае если последовательность приходит из потока чтения, ее длину заранее определить нельзя, поэтому придется добавлять в массив по одному элементу. В других случаях можно применить функцию distance и заранее подготовить массив нужного размера. Здесь также пригодится статическая диспетчеризация.
tem p la te Cclass It , c l a s s Vector> void append_impl ( Vector &cv, It begin, It end,
i nput _ i t era tor _ t a g ){ copy(begin, end, ba c k_ i ns e r t e r ( v )) ; } tem p la te Cclass It , c l a s s Vector> void append_irnpl(Vector &v, It begin, It end,
fo rward_ i t e r a t o r _ t a g ) { const auto o ld_s izc = v . s i z e ( ) ;V. res i ze ( o ld_s ize + d i s t ance ( begin , end) ) ; copy( begin . end, v. begin () + o l d_s i ze ) ;
}tem p la te c c l a s s It , c l a s s Vector> void append (Vector &v, I t begin, I t end) {
append_impl (v , begin , end , typename i t e r a t o r _ t r a i t s c I t > : : i t e r a t o r _ c a t e g o r y ( ) ) ;
}
3.5. Умные указатели
Логично предположить, что внедрение семантики перемещения заменяет упомянутое в главе 1 правило трех правилом пят и , дополняя конструктор копирования, деструктор и оператор присваивания, формирующих класс, управляющий ресурсом (принцип R A II от англ. resource acquisition is initialization — «выделение ресурса есть инициализация»), перемещающими конструктором и оператором присваивания.
Однако Стандартная библиотека СМ-+ нередко позволяет свести правило пяти к «правилу нуля», так как необходимые
элементы уже реализованы в классах Стандартной библиотеки.
Наиболее ярким примером этого служат классы-шаблоны «умных указателей» (от англ. smart pointers) unique_ptr и sha- red_ptr, определенные в заголовочном файле <memory>. Помимо использования одиночных объектов этих классов возможно эффективное размещение их в стандартных контейнерах (в то время как размещение в контейнерах обычных указателей может легко повлечь ошибки управления памятью).
Класс unique_ptr<T, Deleter = default_delete<T>> берет управление динамически созданным объектом на себя. Объекты этого класса можно перемещать, но нельзя копировать. Второй параметр шаблона определяет политику удаления управляемых объектов (по умолчанию вызов обычного d e le te или d e le te []). Конструктор по умолчанию инициализирует такой указатель нулем (состояние «нет управляемого ресурса»). Получить хранимый адрес можно с помощью функции-члена get, «забрать» управляемый ресурс — с помощью функции-члена release, которая, возвращая указатель, сбрасывает хранимый адрес в n u llp tr . Можно заменить (уничтожив) старый ресурс на новый с помощью функции-члена reset(pointer).
/ / автоматически удаляемый динамический массив unique p t r < i n t [ ] > histogram (new in t [ blocks ]);
Класс shared_ptr<T> хранит указатель на разделяемый ресурс со счетчиком ссылок. Копирование объекта shared_ptr увеличивает счетчик. Уничтожение объекта shared p tг уменьшает счетчик. Если счетчик достиг нуля, ресурс освобождается. Кроме функций reset и get, похожих по функционалу (с поправкой на наличие счетчика ссылок) на аналоги, определяемые unique_ptr, класс shared_ptr позволяет узнать текущее значение счетчика (use_count) или проверить его на равенство единице (unique). При создании нового объекта shared_ptr можно передать непосредственно указатель на ресурс и, если потребуется, функтор, ответственный за освобождение этого ресурса. При этом в динамической памяти будет создан блок
управления, хранящий счетчик ссылок и, возможно, переданный функтор. Более эффективное выделение памяти (одним блоком) позволяет ускорить выполнение операций объектами через shared_ptr. Для этого предназначена стандартная функция make_shared<T, Args...>(args), которой необходимо указать тип управляемого объекта Т и передать параметры args желаемого конструктора Т.
Потенциальной проблемой при активном использовании sha- red_ptr является возможность возникновения циклических ссылок, т. е., например, ресурс А хранит shared_ptr, указывающий на Б, а ресурс Б, в свою очередь, хранит shared_ptr, указывающий на А (в этой цепочке зависимостей может быть произвольное число звеньев). Такие ресурсы не будут удалены, даже если будут уничтожены все внешние указывающие на них объекты shared_ptr. Имеем ситуацию утечки памяти.
Проблема циклических ссылок возникает при формировании структуры объектов с «предками» и «потомками», в которой и предки имеют указатели на потомков, и потомкам желаг тельно иметь указатели на предков. В данной ситуации Стандартная библиотека предлагает воспользоваться объектом класса weak_ptr<T>, хранящим ссылку на управляемый sh a r ed p t r ресурс, но не участвующим в изменении числа ссылок.
«Слабый указатель» можно сбросить вызовом reset (не принимает параметров). Можно узнать количество ссылок с помощью use_count либо удостовериться в уже произошедшем освобождении ресурса с помощью функции expired. Внимательный читатель может задаться вопросом, каким образом организована работа этой функции. Дело в том, что и shared_ptr, и w eak_ptr увеличивают и уменьшают второй неявный счетчик ссылок — не на ресурс, а на блок управления. Пусть ресурс уничтожен, но блок управления со счетчиками будет существовать, пока к нему привязан хотя бы один объект weak_ptr.
Чтобы получить возможность работать с объектом, на который указывает weak_ptr, следует «защитить» его от возможно-
го удаления, получив ссылающийся на него объект shared_ptr. Это делается с помощью функции-члена lock.
s t r u c t Child {weak_ptr<Parent> parent ; void no t i f y_pa r en t () {
i f ( auto p — p a r e n t . lock ())p—> n o t i f y ( ) : / / p — это shared_ptr
} };Стандартный предикат owner_less<T> задает строгое упо
рядочение на объединении shared_ptr< T > и w eak_ptr<T>, предлагая замену сравнению «меньше» в контекстах, где требуется упорядочивать смешанные коллекции указателей.
Для приведения типов «умных указателей» в <memory> определены функции-шаблоны static_pointer_cast<U>, dyna- mic_pointer_cast<U> и const_pointer_cast<U>, создающие из объектов *_p t r <T> объекты *_pt r<U>, привязанные к тем же ресурсам. Действие функций определяется семантикой приведения типов указателей на Т к указателям на U, определенной для соответствующих операторов приведения типа.
Если требуется создать класс Т, объекты которого могут управляться через shared_ptr< T > , то следует рассмотреть возможность наследования его от enable_shared_from_this<T> (паттерн CRTP), примешивающего к Т закрытый weak__ptr и открытую функцию-член shared_from_this, позволяющие получать корректные объекты shared_ptr для объекта данного класса, которые будут разделять блок управления с ранее созданными shared_ptr, пусть даже о них ничего не известно в локальном контексте. В противном случае невозможно создать shared_ptr, имея адрес некоторого объекта, временем жизни которого мы не распоряжаемся.[У] (3.8) Создайте класс Node : e n a b le .sh a re d .fro m .th is <Node>, имеющий поля weak.ptr<Node> paren t и vector<sha- red _ p tr <Node», позволяющий описать произвольное дерево. Проверьте корректность автоматического удаления объектов данного класса вместе с принадлежащими им поддеревьями.
Список рекомендуемой литературыАлександреску А. Современное проектирование на Си- / А. Алск- сандреску. — М. : Вильямс, 2002. *— 336 с.Буч Г. Объектно-ориентированный анализ и проектирование /Г. Буч. — 3-е изд. — М. : Вильямс, 2008. — 720 с.Приемы объектно-ориентированного проектирования. Паттерны проектирования / Э. Гамма [и др.]. — СПб. : Питер, 2012. — 368 с.Макконнелл С. Совершенный код / С. Макконнелл. — СПб. : Питер, 2005. - 896 с.Мартин Р. Быстрая разработка программ. Принципы, примеры, практика / Р. Мартии, Дж. Ныокирк, Р. Косс. — М. : Вильямс, 2004. — 752 с.Мартин Р. Чистый код: создание, анализ и рефакторинг / Р. Мартин. — СПб. : Питер, 2010. — 464 с.Мейерс С. Эффективное использование STL / С. Мейерс. — СПб. : Питер, 2003. — 224 с.Мэйерс С. Эффективное использование C++ / С. Мэйерс. — 3-е изд. — М. : ДМК Пресс, 2006. — 300 с.Прата С. Язык программирования C++ : лекции и упражнения: учеб. / С. Прата. — 5-е изд. — М. : Вильямс, 2007. — 1171 с.Саттер Г. Решение сложных задач на C++ / Г. Саттер. — М. : Вильямс, 2008. —* 400 с.Степанов А. Начала программирования / А. Степанов, П. Мак- Джоунс. — М. : Вильямс, 2011. — 272 с.Страуструп Б. Программирование. Принципы и практика использования С+̂ - / Б. Страуструп. — М. : Вильямс, 2011. — 1248 с.Страуструп Б . Язык программирования C++ : спец. изд. / Б. Страуструп. — М. : Бином-Пресс, 2011. — 1136 с.
Учебное издание
Кувшинов Дмитрий Рустамович Осипов Сергей Иванович
ОСНОВЫ ОБОБЩЕННОГО И ОБЪЕКТНО-ОРИЕНТИРОВАННОГО
ПРОГРАММИРОВАНИЯСтандартная библиотека шаблонов
Учебное пособие
Заведующий редакцией М. А. Овечкина Редактор Т. А. ФедороваКорректор Т. А. ФедороваОригинал-макет Д. Р. Кувшинов
План выпуска 2013 г. Подписано в печать 14.11.2013. Формат 60х841/іб- Бумага офсетная. Гарнитура Times.
Уч.-изд. л. 6,2. Уел. печ. л. 6,7. Тираж 70 экз. Заказ 2487. Издательство Уральского университета 620000, г. Екатеринбург, пр. Ленина, 51.
Отпечатано в Издательско-полиграфическом центре УрФУ 620000, Екатеринбург, ул. Тургенева, 4.
Тел.: + (343) 350-56-64, 350-90-13 Факс -1-7 (343) 358-93-06
E-mail: [email protected]