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В предыдущих упражнениях вы практически полностью определили интерфейс выбранного
вами класса. Попробуйте теперь написать программу, использующую ваш класс. Удобно
ли пользоваться вашим интерфейсом? Не хочется ли Вам пересмотреть спецификацию?
Сможете ли вы сделать это и одновременно сохранить совместимость со старой версией? Содержание |
Нет комментариев. Оставить комментарий: |