2. Краткий обзор С++Эту главу мы начнем с рассмотрения встроенного в язык С++ типа данных “массив”. Массив – это набор данных одного типа, например массив целых чисел или массив строк. Мы рассмотрим недостатки, присущие встроенному массиву, и напишем для его представления свой класс Array, где попытаемся избавиться от этих недостатков. Затем мы построим целую иерархию подклассов, основываясь на нашем базовом классе Array. В конце концов мы сравним наш класс Array с классом vector из стандартной библиотеки С++, реализующим аналогичную функциональность. В процессе создания этих классов мы коснемся таких свойств С++, как шаблоны, пространства имен и обработка ошибок. 2.1. Встроенный тип данных "массив"Как было показано в главе 1, С++ предоставляет встроенную поддержку для основных типов данных – целых и вещественных чисел, логических значений и символов: // объявление целого объекта ival // ival инициализируется значением 1024 К числовым типам данных могут применяться встроенные арифметические и логические операции: объекты числового типа можно складывать, вычитать, умножать, делить и т.д. int ival2 = ival1 + 4096; // сложение int ival3 = ival2 - ival; // вычитание В дополнение к встроенным типам стандартная библиотека С++ предоставляет поддержку
для расширенного набора типов, таких, как строка и комплексное число. (Мы отложим
рассмотрение класса vector из стандартной библиотеки до раздела 2.7.) 0 1 1 2 3 5 8 13 21 представляет собой первые 9 элементов последовательности Фибоначчи. (Выбрав
начальные два числа, вычисляем каждый из следующих элементов как сумму двух
предыдущих.) int fibon[9] = { 0, 1, 1, 2, 3, 5, 8, 13, 21 }; Здесь fibon – это имя массива. Элементы массива имеют тип int, размер (длина) массива равна 9. Значение первого элемента – 0, последнего – 21. Для работы с массивом мы индексируем (нумеруем) его элементы, а доступ к ним осуществляется с помощью операции взятия индекса. Казалось бы, для обращения к первому элементу массива естественно написать: int first_elem = fibon[1]; Однако это не совсем правильно: в С++ (как и в С) индексация массивов начинается с 0, поэтому элемент с индексом 1 на самом деле является вторым элементом массива, а индекс первого равен 0.Таким образом, чтобы обратиться к последнему элементу массива, мы должны вычесть единицу из размера массива: fibon[0]; // первый элемент fibon[1]; // второй элемент ... fibon[8]; // последний элемент fibon[9]; // ... ошибка Девять элементов массива fibon имеют индексы от 0 до 8. Употребление вместо
этого индексов 1-9 является одной из самых распространенных ошибок начинающих
программистов на С++. int main() { int ia[10]; int index; for (index=0; index<10; ++index) Оба цикла выполняются по 10 раз. Все управление циклом for осуществляется инструкциями в круглых скобках за ключевым словом for. Первая присваивает начальное значение переменной index. Это производится один раз перед началом цикла: index = 0; Вторая инструкция: index < 10; представляет собой условие окончания цикла. Оно проверяется в самом начале каждой итерации цикла. Если результатом этой инструкции является true, то выполнение цикла продолжается; если же результатом является false, цикл заканчивается. В нашем примере цикл продолжается до тех пор, пока значение переменной index меньше 10. На каждой итерации цикла выполняется некоторая инструкция или группа инструкций, составляющих тело цикла. В нашем случае это инструкция ia[index] = index; Третья управляющая инструкция цикла ++index выполняется в конце каждой итерации, по завершении тела цикла. В нашем примере это увеличение переменной index на единицу. Мы могли бы записать то же действие как index = index + 1 но С++ дает возможность использовать более короткую (и более наглядную) форму
записи. Этой инструкцией завершается итерация цикла. Описанные действия повторяются
до тех пор, пока условие цикла не станет ложным. int array0[10]; array1[10]; ... array0 = array1; // ошибка Вместо этого мы должны программировать такую операцию с помощью цикла: for (int index=0; index<10; ++index) array0[index] = array1[index]; Массив “не знает” собственный размер. Поэтому мы должны сами следить за тем, чтобы случайно не обратиться к несуществующему элементу массива. Это становится особенно утомительным в таких ситуациях, как передача массива функции в качестве параметра. Можно сказать, что этот встроенный тип достался языку С++ в наследство от С и процедурно-ориентированной парадигмы программирования. В оставшейся части главы мы исследуем разные возможности “улучшить” массив. Упражнение 2.1Как вы думаете, почему для встроенных массивов не поддерживается операция присваивания? Какая информация нужна для того, чтобы поддержать эту операцию? Упражнение 2.2Какие операции должен поддерживать “полноценный” массив? 2.2. Динамическое выделение памяти и указателиПрежде чем углубиться в объектно-ориентированную разработку, нам придется сделать
небольшое отступление о работе с памятью в программе на С++. Мы не сможем написать
сколько-нибудь сложную программу, не умея выделять память во время выполнения
и обращаться к ней. int ival = 1024; заставляет компилятор выделить в памяти область, достаточную для хранения переменной
типа int, связать с этой областью имя ival и поместить туда значение 1024. Все
это делается на этапе компиляции, до выполнения программы. int ival2 = ival + 1; то обращаемся к значению, содержащемуся в переменной ival: прибавляем к нему
1 и инициализируем переменную ival2 этим новым значением, 1025. Каким же образом
обратиться к адресу, по которому размещена переменная? int *pint; // указатель на объект типа int Существует также специальная операция взятия адреса, обозначаемая символом &. Ее результатом является адрес объекта. Следующий оператор присваивает указателю pint адрес переменной ival: int *pint; pint = &ival; // pint получает значение адреса ival Мы можем обратиться к тому объекту, адрес которого содержит pint (ival в нашем случае), используя операцию разыменования, называемую также косвенной адресацией. Эта операция обозначается символом *. Вот как можно косвенно прибавить единицу к ival, используя ее адрес: *pint = *pint + 1; // неявно увеличивает ival Это выражение производит в точности те же действия, что и ival = ival + 1; // явно увеличивает ival В этом примере нет никакого реального смысла: использование указателя для косвенной
манипуляции переменной ival менее эффективно и менее наглядно. Мы привели этот
пример только для того, чтобы дать самое начальное представление об указателях.
В реальности указатели используют чаще всего для манипуляций с динамически размещенными
объектами.
Оператор new имеет две формы. Первая форма выделяет память под единичный объект определенного типа: int *pint = new int(1024); Здесь оператор new выделяет память под безымянный объект типа int, инициализирует
его значением 1024 и возвращает адрес созданного объекта. Этот адрес используется
для инициализации указателя pint. Все действия над таким безымянным объектом
производятся путем разыменовывания данного указателя, т.к. явно манипулировать
динамическим объектом невозможно. int *pia = new int[4]; В этом примере память выделяется под массив из четырех элементов типа int.
К сожалению, данная форма оператора new не позволяет инициализировать элементы
массива. // освобождение единичного объекта delete pint; // освобождение массива delete[] pia; Что случится, если мы забудем освободить выделенную память? Память будет расходоваться
впустую, она окажется неиспользуемой, однако возвратить ее системе нельзя, поскольку
у нас нет указателя на нее. Такое явление получило специальное название утечка
памяти. В конце концов программа аварийно завершится из-за нехватки памяти
(если, конечно, она будет работать достаточно долго). Небольшая утечка трудно
поддается обнаружению, но существуют утилиты, помогающие это сделать. Упражнение 2.3Объясните разницу между четырьмя объектами: (a) int ival = 1024; (b) int *pi = &ival; (c) int *pi2 = new int(1024); (d) int *pi3 = new int[1024]; Упражнение 2.4Что делает следующий фрагмент кода? В чем состоит логическая ошибка? (Отметим, что операция взятия индекса ([]) правильно применена к указателю pia. Объяснение этому факту можно найти в разделе 3.9.2.) int *pi = new int(10); int *pia = new int[10]; 2.3. Объектный подходВ этом разделе мы спроектируем и реализуем абстракцию массива, используя механизм классов С++. Первоначальный вариант будет поддерживать только массив элементов типа int. Впоследствии при помощи шаблонов мы расширим наш массив для поддержки любых типов данных. Первый шаг состоит в том, чтобы определить, какие операции будет поддерживать наш массив. Конечно, было бы заманчиво реализовать все мыслимые и немыслимые операции, но невозможно сделать сразу все на свете. Поэтому для начала определим то, что должен уметь наш массив:
Кажется, мы перечислили достаточно потенциальных достоинств нашего будущего массива, чтобы загореться желанием немедленно приступить к его реализации. Как же это будет выглядеть на С++? В самом общем случае объявление класса выглядит следующим образом: class classname { public: // набор открытых операций private: // закрытые функции, обеспечивающие реализацию }; class, public и private – это ключевые слова С++, а classname – имя, которое
программист дал своему классу. Назовем наш проектируемый класс IntArray: на
первом этапе этот массив будет содержать только целые числа. Когда мы научим
его обращаться с данными любого типа, можно будет переименовать его в Array. // статический объект типа IntArray IntArray myArray; // указатель на динамический объект типа IntArray Определение класса состоит из двух частей: заголовка (имя, предваренное ключевым словом class) и тела, заключенного в фигурные скобки. Заголовок без тела может служить объявлением класса. // объявление класса IntArray // без определения его class IntArray; Тело класса состоит из определений членов и спецификаторов доступа – ключевых слов public, private и protected. (Пока мы ничего не будем говорить об уровне доступа protected.) Членами класса могут являться функции, которые определяют набор действий, выполняемых классом, и переменные, содержащие некие внутренние данные, необходимые для реализации класса. Функции, принадлежащие классу, называют функциями-членами или, по-другому, методами класса. Вот набор методов класса IntArray: class IntArray { public: // операции сравнения: #2b bool operator== (const IntArray&) const; bool operator!= (const IntArray&) const; Номера, указанные в комментариях при объявлениях методов, ссылаются на спецификацию
класса, которую мы составили в начале данного раздела. Сейчас мы не будем объяснять
смысл ключевого слова const, он не так уж важен для понимания того, что мы хотим
продемонстрировать на данном примере. Будем считать, что это ключевое слово
необходимо для правильной компиляции программы. // инициализация переменной min_val // минимальным элементом myArray int min_val = myArray.min(); Чтобы найти минимальный элемент в динамически созданном объекте типа IntArray, мы должны написать: int min_val = pArray->min(); (Да, мы еще ничего не сказали о том, как же проинициализировать наш объект
– задать его размер и наполнить элементами. Для этого служит специальная функция-член,
называемая конструктором. Мы поговорим об этом чуть ниже.) IntArray myАrray0, myArray1; Инструкции присваивания и сравнения с этими объектами выглядят совершенно обычным образом: // инструкция присваивания - // вызывает функцию-член myArray0.operator=(myArray1) myArray0 = myArray1; Спецификаторы доступа public и private определяют уровень доступа к членам
класса. К тем членам, которые перечислены после public, можно обращаться из
любого места программы, а к тем, которые объявлены после private, могут обращаться
только функции-члены данного класса. (Помимо функций-членов, существуют еще
функции-друзья класса, но мы не будем говорить о них вплоть до раздела
15.2.)
Какие же внутренние данные потребуются для реализации класса IntArray? Необходимо где-то сохранить размер массива и сами его элементы. Мы будем хранить их в массиве встроенного типа, память для которого выделяется динамически. Так что нам потребуется указатель на этот массив. Вот как будут выглядеть определения этих данных-членов: class IntArray { public: // ... int size() const { return _size; } private: // внутренние данные-члены int _size; int *ia; }; Поскольку мы поместили член _size в закрытую секцию, пользователь класса не
имеет возможности обратиться к нему напрямую. Чтобы позволить внешней программе
узнать размер массива, мы написали функцию-член size(), которая возвращает значение
члена _size. Нам пришлось добавить символ подчеркивания к имени нашего скрытого
члена _size, поскольку функция-член с именем size() уже определена. Члены класса
– функции и данные – не могут иметь одинаковые имена. IntArray array; int array_size = array.size(); array_size = array._size; Действительно, вызов функции гораздо менее эффективен, чем прямой доступ к
памяти, как во втором операторе. Так что же, принцип сокрытия информации заставляет
нас жертвовать эффективностью? for (int index=0; index<array.size(); ++index) // ... то функция size() не будет вызываться _size раз во время исполнения. Вместо вызова компилятор подставит ее текст, и результат компиляции предыдущего кода будет в точности таким же, как если бы мы написали: for (int index=0; index<array._size; ++index) // ... Если функция определена внутри тела класса (как в нашем случае), она автоматически
считается встроенной. Существует также ключевое слово inline, позволяющее объявить
встроенной любую функцию. // список перегруженных функций min() // каждая функция отличается от других списком параметров #include <string> Поведение перегруженных функций во время выполнения ничем не отличается от
поведения обычных. Компилятор определяет нужную функцию и помещает в объектный
код именно ее вызов. (В главе 9 подробно обсуждается
механизм перегрузки.) class IntArray { public: explicit IntArray (int sz = DefaultArraySize); IntArray (int *array, int array_size); IntArray (const IntArray &rhs); // ... private: static const int DefaultArraySize = 12; } Первый из перечисленных конструкторов IntArray (int sz = DefaultArraySize); называется конструктором по умолчанию, потому что он может быть вызван без параметров. (Пока не будем объяснять ключевое слово explicit.) Если при создании объекта ему задается параметр типа int, например IntArray array1(1024); то значение 1024 будет передано в конструктор. Если же размер не задан, допустим: IntArray::IntArray (int sz) { // инициализация членов данных _size = sz; ia = new int[_size]; Это определение содержит несколько упрощенный вариант реализации. Мы не позаботились
о том, чтобы попытаться избежать возможных ошибок во время выполнения. Какие
ошибки возможны? Во-первых, оператор new может потерпеть неудачу при выделении
нужной памяти: в реальной жизни память не бесконечна. (В разделе 2.6
мы увидим, как обрабатываются подобные ситуации.) А во-вторых, параметр sz из-за
небрежности программиста может иметь некорректное значение, например нуль или
отрицательное. IntArray::IntArray(int sz); Дело в том, что мы определяем нашу функцию-член (в данном случае конструктор)
вне тела класса. Для того чтобы показать, что эта функция на самом деле является
членом класса IntArray, мы должны явно предварить имя функции именем класса
и двойным двоеточием. (Подробно области видимости разбираются в главе
8; области видимости применительно к классам рассматриваются в разделе 13.9.) int ia[10] = {0,1,2,3,4,5,6,7,8,9}; IntArray iA3(ia,10); Реализация второго конструктора очень мало отличается от реализации конструктора по умолчанию. (Как и в первом случае, мы пока опустили обработку ошибочных ситуаций.) IntArray::IntArray (int *array, int sz) { // инициализация членов данных _size = sz; ia = new int[_size]; Третий конструктор называется копирующим конструктором. Он инициализирует один объект типа IntArray значением другого объекта IntArray. Такой конструктор вызывается автоматически при выполнении следующих инструкций: IntArray array; // следующие два объявления совершенно эквивалентны: Вот как выглядит реализация копирующего конструктора для IntArray, опять-таки без обработки ошибок: IntArray::IntArray (const IntArray &rhs ) { // инициализация членов данных _size = rhs._size; ia = new int[_size]; В этом примере мы видим еще один составной тип данных – ссылку на объект, которая
обозначается символом &. Ссылку можно рассматривать как разновидность указателя:
она также позволяет косвенно обращаться к объекту. Однако синтаксис их использования
различается: для доступа к члену объекта, на который у нас есть ссылка, следует
использовать точку, а не стрелку; следовательно, мы пишем rhs._size, а не rhs->_size.
(Ссылки рассматриваются в разделе 3.6.) class IntArray { public: explicit IntArray (int sz = DefaultArraySize); IntArray (int *array, int array_size); IntArray (const IntArray &rhs); // ... private: void init (int sz,int *array); // ... }; Имеется еще одна специальная функция-член – деструктор, который автоматически вызывается в тот момент, когда объект прекращает существование. Имя деструктора совпадает с именем класса, только в начале идет символ тильды (~). Основное назначение данной функции – освободить ресурсы, отведенные объекту во время его создания и использования. Применение деструкторов помогает бороться с трудно обнаруживаемыми ошибками, ведущими к утечке памяти и других ресурсов. В случае класса IntArray эта функция-член должна освободить память, выделенную в момент создания объекта. (Подробно конструкторы и деструкторы описаны в главе 14.) Вот как выглядит деструктор для IntArray: class IntArray { public: // конструкторы explicit IntArray (int sz = DefaultArraySize); IntArray (int *array, int array_size); IntArray (const IntArray &rhs); // деструктор Теперь нам нужно определить операции доступа к элементам массива IntArray. Мы хотим, чтобы обращение к элементам IntArray выглядело точно так же, как к элементам массива встроенного типа, с использованием оператора взятия индекса: IntArray array; int last_pos = array.size()-1; Для реализации доступа мы используем возможность перегрузки операций. Вот как выглядит функция, реализующая операцию взятия индекса: #include <cassert> int& IntArray::operator[] (int index) Обычно для проектируемого класса перегружают операции присваивания, операцию
сравнения на равенство, возможно, операции сравнения по величине и операции
ввода/вывода. Как и перегруженных функций, перегруженных операторов, отличающихся
типами операндов, может быть несколько. К примеру, можно создать несколько операций
присваивания объекту значения другого объекта того же самого или иного типа.
Конечно, эти объекты должны быть более или менее “похожи”. (Подробно о перегрузке
операций мы расскажем в главе 15, а в разделе 3.15 приведем
еще несколько примеров.) Упражнение 2.5Ключевой особенностью класса С++ является разделение интерфейса и реализации.
Интерфейс представляет собой набор операций (функций), выполняемых объектом;
он определяет имя функции, возвращаемое значение и список параметров. Обычно
пользователь не должен знать об объекте ничего, кроме его интерфейса. Реализация
скрывает алгоритмы и данные, нужные объекту, и может меняться при развитии объекта,
никак не затрагивая интерфейс. Попробуйте определить интерфейсы для одного из
следующих классов (выберите любой): Упражнение 2.6Попробуйте определить набор конструкторов, необходимых для класса, выбранного вами в предыдущем упражнении. Нужен ли деструктор для вашего класса? Помните, что на самом деле конструктор не создает объект: память под объект отводится до начала работы данной функции, и конструктор только производит определенные действия по инициализации объекта. Аналогично деструктор уничтожает не сам объект, а только те дополнительные ресурсы, которые могли быть выделены в результате работы конструктора или других функций-членов класса. Упражнение 2.7В предыдущих упражнениях вы практически полностью определили интерфейс выбранного
вами класса. Попробуйте теперь написать программу, использующую ваш класс. Удобно
ли пользоваться вашим интерфейсом? Не хочется ли Вам пересмотреть спецификацию?
Сможете ли вы сделать это и одновременно сохранить совместимость со старой версией? 2.4. Объектно-ориентированный подходВспомним спецификацию нашего массива в предыдущем разделе. Мы говорили о том,
что некоторым пользователям может понадобиться упорядоченный массив, в то время
как большинство, скорее всего, удовлетворится и неупорядоченным. Если представить
себе, что наш массив IntArray упорядочен, то реализация таких функций, как min(),
max(), find(), должна отличаться от их реализации для массива неупорядоченного
большей эффективностью. Вместе с тем, для поддержания массива в упорядоченном
состоянии все прочие функции должны быть сильно усложнены. // неупорядоченный массив без проверки границ индекса class IntArray { ... }; Подобное решение имеет следующие недостатки:
Парадигма объектно-ориентированного программирования позволяет осуществить
все эти пожелания. Механизм наследования обеспечивает пожелания из первого
пункта. Если один класс является потомком другого (например, IntArrayRC потомок
класса IntArray), то наследник имеет возможность пользоваться всеми данными
и функциями-членами, определенными в классе-предке. То есть класс IntArrayRC
может просто использовать всю основную функциональность, предоставляемую классом
IntArray, и добавить только то, что нужно ему для обеспечения проверки границ
индекса. #include <IntArray.h> Каждый из трех классов реализует операцию взятия индекса по-своему. Поэтому важно, чтобы внутри функции swap() вызывалась нужная операция взятия индекса. Так, если swap() вызвана для IntArrayRC: swap (iarc,0,10); то должна вызываться функция взятия индекса для объекта класса IntArrayRC, а для swap (ias,0,10); функция взятия индекса IntSortedArray. Именно это и обеспечивает механизм виртуальных
функций С++. class IntArray { public: // конструкторы explicit IntArray (int sz = DefaultArraySize); IntArray (int *array, int array_size); IntArray (const IntArray &rhs); Открытые функции-члены по-прежнему определяют интерфейс класса, как и в реализации
из предыдущего раздела. Но теперь это интерфейс не только базового, но и всех
производных от него подклассов. void init (IntArray &ia) { for (int ix=0; ix<ia.size(); ++ix) ia[ix] = ix; } Формальный параметр функции ia может быть ссылкой на IntArray, IntArrayRC или
на IntSortedArray. Функция-член size() не является виртуальной и разрешается
на этапе компиляции. А вот виртуальный оператор взятия индекса не может быть
разрешен на данном этапе, поскольку реальный тип объекта, на который ссылается
ia, в этот момент неизвестен. #ifndef IntArrayRC_H #define IntArrayRC_H #include "IntArray.h" Этот текст мы поместим в заголовочный файл IntArrayRC.h. Обратите внимание
на то, что в наш файл включен заголовочный файл IntArray.h. class IntArrayRC : public IntArray Эта строка показывает, что класс IntArrayRC произведен от класса IntArray,
другими словами, наследует ему. Ключевое слово public в данном контексте говорит
о том, что производный класс сохраняет открытый интерфейс базового класса, то
есть что все открытые функции базового класса остаются открытыми и в производном.
Объект типа IntArrayRC может использоваться вместо объекта типа IntArray, как,
например, в приведенном выше примере с функцией swap(). Таким образом, подкласс
IntArrayRC – это расширенная версия класса IntArray. IntArrayRC::operator[]( int index ) { check_range( index ); return _ia[ index ]; } А вот реализация встроенной функции check_range(): #include <cassert> inline void IntArrayRC::check_range(int index) { (Мы говорили о макросе assert() в разделе 1.3.) int ia[] = {0,1,1,2,3,5,8,13}; IntArrayRC iarc(ia,8); Нам нужно передать параметры ia и 8 конструктору базового класса IntArray. Для этого служит специальная синтаксическая конструкция. Вот как выглядят реализации двух конструкторов IntArrayRC: inline IntArrayRC::IntArrayRC( int sz ) : IntArray( sz ) {} inline IntArrayRC::IntArrayRC( const int *iar, int sz ) (Мы будем подробно говорить о конструкторах в главах 14
и 17. Там же мы покажем, почему не нужно реализовывать
конструктор копирования для IntArrayRC.) #include <iostream> #include "IntArray.h" #include "IntArrayRC.h" При выполнении программа выдаст следующий результат: swap() with IntArray ia1 swap() with IntArrayRC ia2 Assertion failed: ix >= 0 && ix < _size, file IntArrayRC.h, line 19 Упражнение 2.8Отношение наследования между типом и подтипом служит примером отношения.
Так, массив IntArrayRC является подвидом массива IntArray, книга является подвидом
выдаваемых библиотекой предметов, аудиокнига является подвидом книги и т.д.
Какие из следующих утверждений верны? Упражнение 2.9Определите, какие из следующих функций могут различаться в реализации для производных классов и, таким образом, выступают кандидатами в виртуальные функции: (a) rotate(); (b) print(); (c) size(); (d) DateBorrowed(); // дата выдачи книги (e) rewind(); (f) borrower(); // читатель (g) is_late(); // книга просрочена (h) is_on_loan(); // книга выдана Упражнение 2.10Ходят споры о том, не нарушает ли принципа инкапсуляции введение защищенного уровня доступа. Есть мнение, что для соблюдения этого принципа следует отказаться от использования такого уровня и работать только с закрытыми членами. Противоположная точка зрения гласит, что без защищенных членов производные классы невозможно реализовывать достаточно эффективно и в конце концов пришлось бы везде задействовать открытый уровень доступа. А каково ваше мнение по этому поводу? Упражнение 2.11Еще одним спорным аспектом является необходимость явно указывать виртуальность функций в базовом классе. Есть мнение, что все функции должны быть виртуальными по умолчанию, тогда ошибка в разработке базового класса не повлечет таких серьезных последствий в разработке производного, когда из-за невозможности изменить реализацию функции, ошибочно не определенной в базовом классе как виртуальная, приходится сильно усложнять реализацию. С другой стороны, виртуальные функции невозможно объявить как встроенные, и использование только таких функций сильно снизит эффективность. Каково ваше мнение? Упражнение 2.12Каждая из приведенных ниже абстракций определяет целое семейство подвидов, как, например, абстракция “транспортное средство” может определять “самолет”, “автомобиль”, “велосипед”. Выберите одно из семейств и составьте для него иерархию подвидов. Приведите пример открытого интерфейса для этой иерархии, включая конструкторы. Определите виртуальные функции. Напишите псевдокод маленькой программы, использующей данный интерфейс. (a) Точка 2.5. Использование шаблоновНаш класс IntArray служит хорошей альтернативой встроенному массиву целых чисел.
Но в жизни могут потребоваться массивы для самых разных типов данных. Можно
предположить, что единственным отличием массива элементов типа double от нашего
является тип данных в объявлениях, весь остальной код совпадает буквально. template <class elemType> class Array { public: explicit Array( int sz = DefaultArraySize ); Array( const elemType *ar, int sz ); Array( const Array &iA ); Ключевое слово template говорит о том, что задается шаблон, параметры которого
заключаются в угловые скобки (<>). В нашем случае имеется лишь один параметр
elemType; ключевое слово class перед его именем сообщает, что этот параметр
представляет собой тип. #include <iostream> #include "Array.h" Здесь определены три экземпляра класса Array: Array<int> ia(array_size); Array<double> da(array_size); Array<char> ca(array_size); Что делает компилятор, встретив такое объявление? Подставляет текст шаблона Array, заменяя параметр elemType на тот тип, который указан в каждом конкретном случае. Следовательно, объявления членов приобретают в первом случае такой вид: // Array<int> ia(array_size); int _size; int *_ia; Заметим, что это в точности соответствует определению массива IntArray. // Array<double> da(array_size); int _size; double *_ia; Что происходит с функциями-членами? В них тоже тип-параметр elemType заменяется
на реальный тип, однако компилятор не конкретизирует те функции, которые не
вызываются в каком-либо месте программы. (Подробнее об этом в разделе 16.8.) [ 0 ] ia: 0 ca: a da: 0 [ 1 ] ia: 1 ca: b da: 1.75 [ 2 ] ia: 2 ca: c da: 3.5 [ 3 ] ia: 3 ca: d da: 5.25 Механизм шаблонов можно использовать и в наследуемых классах. Вот как выглядит определение шаблона класса ArrayRC: #include <cassert> #include "Array.h" Подстановка реальных параметров вместо типа-параметра elemType происходит как в базовом, так и в производном классах. Определение ArrayRC<int> ia_rc(10); ведет себя точно так же, как определение IntArrayRC из предыдущего раздела.
Изменим пример использования из предыдущего раздела. Прежде всего, чтобы оператор swap( ia1, 1, ia1.size() ); был допустимым, нам потребуется представить функцию swap() в виде шаблона. #include "Array.h" template <class elemType> inline void swap( Array<elemType> &array, int i, int j ) { При каждом вызове swap() генерируется подходящая конкретизация, которая зависит от типа массива. Вот как выглядит программа, использующая шаблоны Array и ArrayRC: #include <iostream> #include "Array.h" #include "ArrayRC.h" template <class elemType> Упражнение 2.13Пусть мы имеем следующие объявления типов: template<class elemType> class Array; enum Status { ... }; typedef string *Pstring; Есть ли ошибки в приведенных ниже описаниях объектов? (a) Array< int*& > pri(1024); (b) Array< Array<int> > aai(1024); (c) Array< complex< double > > acd(1024); (d) Array< Status > as(1024); (e) Array< Pstring > aps(1024); Упражнение 2.14Перепишите следующее определение, сделав из него шаблон класса: class example1 { public: example1 (double min, double max); example1 (const double *array, int size); Упражнение 2.15Имеется следующий шаблон класса: template <class elemType> class Example2 { public: explicit Example2 (elemType val=0) : _val(val) {}; bool min(elemType value) { return _val < value; } Какие действия вызывают следующие инструкции? (a) Example2<Array<int>*> ex1; (b) ex1.min (&ex1); (c) Example2<int> sa(1024),sb; (d) sa = sb; (e) Example2<string> exs("Walden"); (f) cout << "exs: " << exs << endl; Упражнение 2.16Пример из предыдущего упражнения накладывает определенные ограничения на типы данных, которые могут быть подставлены вместо elemType. Так, параметр конструктора имеет по умолчанию значение 0: explicit Example2 (elemType val=0) : _val(val) {}; Однако не все типы могут быть инициализированы нулем (например, тип string), поэтому определение объекта Example2<string> exs("Walden"); является правильным, а Example2<string> exs2; приведет к синтаксической ошибке . Также ошибочным будет вызов функции min(), если для данного типа не определена операция меньше. С++ не позволяет задать ограничения для типов, подставляемых в шаблоны. Как вы думаете, было бы полезным иметь такую возможность? Если да, попробуйте придумать синтаксис задания ограничений и перепишите в нем определение класса Example2. Если нет, поясните почему. Упражнение 2.17Как было показано в предыдущем упражнении, попытка использовать шаблон Example2 с типом, для которого не определена операция меньше, приведет к синтаксической ошибке. Однако ошибка проявится только тогда, когда в тексте компилируемой программы действительно встретится вызов функции min(), в противном случае компиляция пройдет успешно. Как вы считаете, оправдано ли такое поведение? Не лучше ли предупредить об ошибке сразу, при обработке описания шаблона? Поясните свое мнение. 2.6. Использование исключенийИсключениями называют аномальные ситуации, возникающие во время исполнения
программы: невозможность открыть нужный файл или получить необходимое количество
памяти, использование выходящего за границы индекса для какого-либо массива.
Обработка такого рода исключений, как правило, плохо интегрируется в основной
алгоритм программы, и программисты вынуждены изобретать разные способы корректной
обработки исключения, стараясь в то же время не слишком усложнить программу
добавлением всевозможных проверок и дополнительных ветвей алгоритма. if ( !infile ) { string errMsg("Невозможно открыть файл: "); errMsg += fileName; throw errMsg; } Место программы, в котором исключение обрабатывается. При возбуждении исключения нормальное выполнение программы приостанавливается и управление передается обработчику исключения. Поиск нужного обработчика часто включает в себя раскрутку так называемого стека вызовов программы. После обработки исключения выполнение программы возобновляется, но не с того места, где произошло исключение, а с точки, следующей за обработчиком. Для определения обработчика исключения в С++ используется ключевое слово catch. Вот как может выглядеть обработчик для примера из предыдущего абзаца: catch (string exceptionMsg) { log_message (exceptionMsg); return false; } Каждый catch-обработчик ассоциирован с исключениями, возникающими в блоке операторов, который непосредственно предшествует обработчику и помечен ключевым словом try. Одному try-блоку могут соответствовать несколько catch-предложений, каждое из которых относится к определенному виду исключений. Приведем пример: int* stats (const int *ia, int size) { int *pstats = new int [4]; try { pstats[0] = sum_it (ia,size); pstats[1] = min_val (ia,size); pstats[2] = max_val (ia,size); } catch (string exceptionMsg) { // код обработчика } catch (const statsException &statsExcp) { // код обработчика } В данном примере в теле функции stats() три оператора заключены в try-блок, а четыре – нет. Из этих четырех операторов два способны возбудить исключения. 1) int *pstats = new int [4]; Выполнение оператора new может окончиться неудачей. Стандартная библиотека С++ предусматривает возбуждение исключения bad_alloc в случае невозможности выделить нужное количество памяти. Поскольку в примере не предусмотрен обработчик исключения bad_alloc, при его возбуждении выполнение программы закончится аварийно. 2) do_something (pstats); Мы не знаем реализации функции do_something(). Любая инструкция этой функции,
или функции, вызванной из этой функции, или функции, вызванной из функции, вызванной
этой функцией, и так далее, потенциально может возбудить исключение. Если в
реализации функции do_something и вызываемых из нее предусмотрен обработчик
такого исключения, то выполнение stats() продолжится обычным образом. Если же
такого обработчика нет, выполнение программы аварийно завершится. pstats [3] = pstats[0] / size; может привести к делению на ноль, в стандартной библиотеке не предусмотрен
такой тип исключения. throw string ("Ошибка: adump27832"); Выполнение функции sum_it() прервется, операторы, следующие в try-блоке за вызовом этой функции, также не будут выполнены, и pstats[0] не будет инициализирована. Вместо этого возбуждается исключительное состояние и исследуются два catch-обработчика. В нашем случае выполняется catch с параметром типа string: catch (string exceptionMsg) { // код обработчика } После выполнения управление будет передано инструкции, следующей за последним catch-обработчиком, относящимся к данному try-блоку. В нашем случае это pstats [3] = pstats[0] / size; (Конечно, обработчик сам может возбуждать исключения, в том числе – того же
типа. В такой ситуации будет продолжено выполнение catch-предложений, определенных
в программе, вызвавшей функцию stats().) catch (string exceptionMsg) { // код обработчика cerr << "stats(): исключение: " << exceptionMsg << endl; delete [] pstats; return 0; } В таком случае выполнение вернется в функцию, вызвавшую stats(). Будем считать,
что разработчик программы предусмотрел проверку возвращаемого функцией stats()
значения и корректную реакцию на нулевое значение. catch (...) { // обрабатывает любое исключение, // однако ему недоступен объект, переданный // в обработчик в инструкции throw } (Детально обработка исключительных ситуаций рассматривается в главах 11 и 19.) Упражнение 2.18Какие ошибочные ситуации могут возникнуть во время выполнения следующей функции: int *alloc_and_init (string file_name) { ifstream infile (file_name) int elem_cnt; infile >> elem_cnt; int *pi = allocate_array(elem_cnt); int elem; int index=0; Упражнение 2.19В предыдущем примере вызываемые функции allocate_array(), sort_array() и register_data() могут возбуждать исключения типов noMem, int и string соответственно. Перепишите функцию alloc_and_init(), вставив соответствующие блоки try и catch для обработки этих исключений. Пусть обработчики просто выводят в cerr сообщение об ошибке. Упражнение 2.20Усовершенствуйте функцию alloc_and_init() так, чтобы она сама возбуждала исключение в случае возникновения всех возможных ошибок (это могут быть исключения, относящиеся к вызываемым функциям allocate_array(), sort_array() и register_data() и какими-то еще операторами внутри функции alloc_and_init()). Пусть это исключение имеет тип string и строка, передаваемая обработчику, содержит описание ошибки. 2.7. Использование пространства именПредположим, что мы хотим предоставить в общее пользование наш класс Array, разработанный в предыдущих примерах. Однако не мы одни занимались этой проблемой; возможно, кем-то где-то, скажем, в одном из подразделений компании Intel был создан одноименный класс. Из-за того что имена этих классов совпадают, потенциальные пользователи не могут задействовать оба класса одновременно, они должны выбрать один из них. Эта проблема решается добавлением к имени класса некоторой строки, идентифицирующей его разработчиков, скажем, class Cplusplus_Primer_Third_Edition_Array { ... }; Конечно, это тоже не гарантирует уникальность имени, но с большой вероятностью
избавит пользователя от данной проблемы. Как, однако, неудобно пользоваться
столь длинными именами! namespace Cplusplus_Primer_3E { template <class elemType> class Array { ... }; } Ключевое слово namespace задает пространство имен, определяющее видимость нашего класса и названное в данном случае Cplusplus_Primer_3E. Предположим, что у нас есть классы от других разработчиков, помещенные в другие пространства имен: namespace IBM_Canada_Laboratory { template <class elemType> class Array { ... }; По умолчанию в программе видны объекты, объявленные без явного указания пространства имен; они относятся к глобальному пространству имен. Для того чтобы обратиться к объекту из другого пространства, нужно использовать его квалифицированное имя, которое состоит из идентификатора пространства имен и идентификатора объекта, разделенных оператором разрешения области видимости (::). Вот как выглядят обращения к объектам приведенных выше примеров: Cplusplus_Primer_3E::Array<string> text; IBM_Canada_Laboratory::Matrix mat; Disney_Feature_Animation::Point origin(5000,5000); Для удобства использования можно назначать псевдонимы пространствам имен. Псевдоним выбирают коротким и легким для запоминания. Например: // псевдонимы namespace LIB = IBM_Canada_Laboratory; namespace DFA = Disney_Feature_Animation; Псевдонимы употребляются и для того, чтобы скрыть использование пространств имен. Заменив псевдоним, мы можем сменить набор задействованных функций и классов, причем во всем остальном код программы останется таким же. Исправив только одну строчку в приведенном выше примере, мы получим определение уже совсем другого массива: namespace LIB = Cplusplus_Primer_3E; int main() { LIB::Array<int> ia(1024); } Конечно, чтобы это стало возможным, необходимо точное совпадение интерфейсов классов и функций, объявленных в этих пространствах имен. Представим, что класс Array из Disney_Feature_Animation не имеет конструктора с одним параметром – размером. Тогда следующий код вызовет ошибку: namespace LIB = Disney_Feature_Animation; Еще более удобным является способ использования простого, неквалифицированного
имени для обращения к объектам, определенным в некотором пространстве имен.
Для этого существует директива using: using namespace IBM_Canada_Laboratory; Пространство имен IBM_Canada_Laboratory становится видимым в программе. Можно сделать видимым не все пространство, а отдельные имена внутри него (селективная директива using): #include "IBM_Canada_Laboratory.h" using namespace IBM_Canada_Laboratory::Matrix; Как мы уже упоминали, все компоненты стандартной библиотеки С++ объявлены внутри пространства имен std. Поэтому простого включения заголовочного файла недостаточно, чтобы напрямую пользоваться стандартными функциями и классами: #include <string> // ошибка: string невидим string current_chapter = "Обзор С++"; Необходимо использовать директиву using: #include <string> using namespace std; // Ok: видим string Заметим, однако, что таким образом мы возвращаемся к проблеме “засорения” глобального пространства имен, ради решения которой и был создан механизм именованных пространств. Поэтому лучше использовать либо квалифицированное имя: #include <string> // правильно: квалифицированное имя std::string current_chapter = "Обзор С++"; либо селективную директиву using: #include <string> using namespace std::string; // Ok: string видим Мы рекомендуем пользоваться последним способом. Упражнение 2.21Дано пространство имен namespace Exercize { template <class elemType> class Array { ... }; и текст программы: int main() { const int size = 1024; Array<String> as (size); List<int> il (size); Программа не компилируется, поскольку объявления используемых классов заключены
в пространство имен Exercise. Модифицируйте код программы, используя 2.8. Стандартный массив - это векторХотя встроенный массив формально и обеспечивает механизм контейнера, он, как
мы видели выше, не поддерживает семантику абстракции контейнера. До принятия
стандарта C++ для программирования на таком уровне мы должны были либо приобрести
нужный класс, либо реализовать его самостоятельно. Теперь же класс массива является
частью стандартной библиотеки C++. Только называется он не массив, а вектор. vector<int> ivec(10); vector<string> svec(10); Есть два существенных отличия нашей реализации шаблона класса Array от реализации
шаблона класса vector. Первое отличие состоит в том, что вектор поддерживает
как присваивание значений существующим элементам, так и вставку дополнительных
элементов, то есть динамически растет во время выполнения, если программист
решил воспользоваться этой его возможностью. Второе отличие более радикально
и отражает существенное изменение парадигмы проектирования. Вместо того чтобы
поддержать большой набор операций-членов, применимых к вектору, таких, как sort(),
min(), max(), find()и так далее, класс vector предоставляет минимальный набор:
операции сравнения на равенство и на меньше, size() и empty(). Более общие операции,
перечисленные выше, определены как независимые обобщенные алгоритмы. #include <vector> // разные способы создания объектов типа vector Так же, как наш класс Array, класс vector поддерживает операцию доступа по индексу. Вот пример перебора всех элементов вектора: #include <vector> extern int getSize(); Для такого перебора можно также использовать итераторную пару. Итератор – это объект класса, поддерживающего абстракцию указательного типа. В шаблоне класса vector определены две функции-члена – begin() и end(), устанавливающие итератор соответственно на первый элемент вектора и на элемент, который следует за последним. Вместе эти две функции задают диапазон элементов вектора. Используя итератор, предыдущий пример можно переписать таким образом: #include <vector> extern int getSize(); Определение переменной iter vector<int>::iterator iter = vec.begin(); инициализирует ее адресом первого элемента вектора vec. iterator определен с помощью typedef в шаблоне класса vector, содержащего элементы типа int. Операция инкремента ++iter перемещает итератор на следующий элемент вектора. Чтобы получить сам элемент, нужно применить операцию разыменования: *iter В стандартной библиотеке С++ имеется поразительно много функций, работающих
с классом vector, но определенных не как функции-члены класса, а как набор обобщенных
алгоритмов. Вот их неполный перечень: sort ( ivec.begin(), ivec.end() ); Чтобы применить алгоритм sort() только к первой половине вектора, мы напишем: sort ( ivec.begin(), ivec.begin() + ivec.size()/2 ); Роль итераторной пары может играть и пара указателей на элементы встроенного массива. Пусть, например, нам дан массив: int ia[7] = { 10, 7, 9, 5, 3, 7, 1 }; Упорядочить весь массив можно вызовом алгоритма sort(): sort ( ia, ia+7 ); Так можно упорядочить первые четыре элемента: sort ( ia, ia+4 ); Для использования алгоритмов в программу необходимо включить заголовочный файл #include <algorithm> Ниже приведен пример программы, использующей разнообразные алгоритмы в применении к объекту типа vector: #include <vector> #include <algorithm> #include <iostream> Стандартная библиотека С++ поддерживает и ассоциативные массивы. Ассоциативный массив – это массив, элементы которого можно индексировать не только целыми числами, но и значениями любого типа. В терминологии стандартной библиотеки ассоциативный массив называется отображением (map). Например, телефонный справочник может быть представлен в виде ассоциативного массива, где индексами служат фамилии абонентов, а значениями элементов – телефонные номера: #include <map> #include <string> #include "TelephoneNumber.h" map<string, telephoneNum> telephone_directory; (Классы векторов, отображений и других контейнеров в подробностях описываются
в главе 6. Мы попробуем реализовать систему текстового
поиска, используя эти классы. В главе 12 рассмотрены
обобщенные алгоритмы, а в Приложении приводятся примеры их использования.) Упражнение 2.22Поясните результаты каждого из следующих определений вектора: string pals[] = { "pooh", "tiger", "piglet", "eeyore", "kanga" }; (a) vector<string> svec1(pals,pals+5); (b) vector<int> ivec1(10); (c) vector<int> ivec2(10,10); (d) vector<string> svec2(svec1); (e) vector<double> dvec; Упражнение 2.23Напишите две реализации функции min(), объявление которой приведено ниже. Функция
должна возвращать минимальный элемент массива. Используйте цикл for и перебор
элементов с помощью Содержание |
2011-10-14 19:42:59 Crazy_penguin Глава 2 - Рассматривается ООП, конструкторы и деструкторы. Глава 3 - Изучаем, что такое переменные и цикл for. Кто-то кого-то где-то ... 2012-05-30 18:46:11 "... private: static const int DefaultArraySize = 12; " - на это билдер ругался до тех пор, пока я не поместил обьявление класса в .h ----------------------------- Не " else ix[ix] = array[ix]; " , а "...iа[ix]". ----------------------------- " int& IntArray::operator[] (int index) " ругается на непричасность к классу IntArray ----------------------------- Словил ошибку линкёра после переноса реализации класса в IntArray.cpp (в IntArray.h есть его обьявление, директивы include тоже, эта пара - один unit. 2012-06-27 18:30:04 FeelUs "Упражнение 2.8 Отношение наследования между типом и подтипом служит примером отношения является." - как-то не по русски Оставить комментарий: |