<< Пред.           стр. 4 (из 121)           След. >>

Список литературы по разделу

  explicit IntArray (int sz = DefaultArraySize);
  IntArray (int *array, int array_size);
  IntArray (const IntArray &rhs);
 
  // деструктор
  ~IntArray() { delete[] ia; }
  // ...
 private:
  // ...
 };
 Теперь нам нужно определить операции доступа к элементам массива IntArray. Мы хотим, чтобы обращение к элементам IntArray выглядело точно так же, как к элементам массива встроенного типа, с использованием оператора взятия индекса:
 IntArray array;
 int last_pos = array.size()-1;
 
 int temp = array[0];
 array[0] = array[last_pos];
 array[last_pos] = temp;
 Для реализации доступа мы используем возможность перегрузки операций. Вот как выглядит функция, реализующая операцию взятия индекса:
 #include
 
 int& IntArray::operator[] (int index)
 {
  assert (index >= 0 && index < _size);
  return ia[index];
 }
 Обычно для проектируемого класса перегружают операции присваивания, операцию сравнения на равенство, возможно, операции сравнения по величине и операции ввода/вывода. Как и перегруженных функций, перегруженных операторов, отличающихся типами операндов, может быть несколько. К примеру, можно создать несколько операций присваивания объекту значения другого объекта того же самого или иного типа. Конечно, эти объекты должны быть более или менее “похожи”. (Подробно о перегрузке операций мы расскажем в главе 15, а в разделе 3.15 приведем еще несколько примеров.)
 Определения класса, различных относящихся к нему констант и, быть может, каких-то еще переменных и макросов по принятым соглашениям помещаются в заголовочный файл, имя которого совпадает с именем класса. Для класса IntArray мы должны создать заголовочный файл IntArray.h. Любая программа, в которой будет использоваться класс IntArray, должна включать этот заголовочный файл директивой препроцессора #include.
 По тому же самому соглашению функции-члены класса, определенные вне его описания, помещаются в файл с именем класса и расширением, обозначающим исходный текст С++ программы. Мы будем использовать расширение .С (напомним, что в разных системах вы можете встретиться с разными расширениями исходных текстов С++ программ) и назовем наш файл IntArray.C.
 Упражнение 2.5
 Ключевой особенностью класса С++ является разделение интерфейса и реализации. Интерфейс представляет собой набор операций (функций), выполняемых объектом; он определяет имя функции, возвращаемое значение и список параметров. Обычно пользователь не должен знать об объекте ничего, кроме его интерфейса. Реализация скрывает алгоритмы и данные, нужные объекту, и может меняться при развитии объекта, никак не затрагивая интерфейс. Попробуйте определить интерфейсы для одного из следующих классов (выберите любой):
 (a) матрица
 (b) булевское значение
 (c) паспортные данные человека
 (d) дата
 (e) указатель
 (f) точка
 Упражнение 2.6
 Попробуйте определить набор конструкторов, необходимых для класса, выбранного вами в предыдущем упражнении. Нужен ли деструктор для вашего класса? Помните, что на самом деле конструктор не создает объект: память под объект отводится до начала работы данной функции, и конструктор только производит определенные действия по инициализации объекта. Аналогично деструктор уничтожает не сам объект, а только те дополнительные ресурсы, которые могли быть выделены в результате работы конструктора или других функций-членов класса.
 Упражнение 2.7
 В предыдущих упражнениях вы практически полностью определили интерфейс выбранного вами класса. Попробуйте теперь написать программу, использующую ваш класс. Удобно ли пользоваться вашим интерфейсом? Не хочется ли Вам пересмотреть спецификацию? Сможете ли вы сделать это и одновременно сохранить совместимость со старой версией?
 2.4. Объектно-ориентированный подход
 Вспомним спецификацию нашего массива в предыдущем разделе. Мы говорили о том, что некоторым пользователям может понадобиться упорядоченный массив, в то время как большинство, скорее всего, удовлетворится и неупорядоченным. Если представить себе, что наш массив IntArray упорядочен, то реализация таких функций, как min(), max(), find(), должна отличаться от их реализации для массива неупорядоченного большей эффективностью. Вместе с тем, для поддержания массива в упорядоченном состоянии все прочие функции должны быть сильно усложнены.
 Мы выбрали наиболее общий случай – неупорядоченный массив. Но как же быть с теми немногочисленными пользователями, которым обязательно нужна функциональность массива упорядоченного? Мы должны специально для них создать другой вариант массива?
 А вот и еще одна категория недовольных пользователей: их не удовлетворяют накладные расходы на проверку правильности индекса. Мы исходили из того, что корректность работы нашего класса превыше всего, и старались обезопасить себя от ошибочных ситуаций. Но возьмем, к примеру, разработчиков систем виртуальной реальности. Трехмерные изображения должны строиться с максимально возможной скоростью, быть может, за счет точности.
 Да, мы можем удовлетворить и тех и других, создав для каждой группы пользователей свой, немного модернизированный, вариант IntArray. Более того, его даже не слишком трудно сделать, поскольку мы старались создать хорошую реализацию и необходимые изменения затронут совсем небольшие участки кода. Итак, копируем исходный текст, вносим необходимые изменения в нужные места и получаем три класса:
 // неупорядоченный массив без проверки границ индекса
 class IntArray { ... };
 
 // неупорядоченный массив с проверкой границ индекса
 class IntArrayRC { ... };
 
 // упорядоченный массив без проверки границ индекса
 class IntSortedArray { ... };
 Подобное решение имеет следующие недостатки:
 нам необходимо сопровождать три копии кода, различающиеся весьма незначительно. Хорошо бы выделить общие участки кода. Кроме упрощения сопровождения, это позволит использовать их впоследствии, если мы захотим создать еще один вариант массива, например упорядоченный с проверкой границ индекса;
 если понадобится какая-то общая функция для обработки всех наших массивов, то нам придется написать три копии, поскольку типы ее параметров будут различаться:
 void process_array (IntArray&);
 void process_array (IntArrayRC&);
 void process_array (IntSortedArray&);
 хотя реализация этих функций может быть совершенно идентичной. Было бы лучше написать единственную функцию, которая могла бы работать не только со всеми нашими массивами, но и с теми их вариациями, какие мы, возможно, реализуем впоследствии.
 Парадигма объектно-ориентированного программирования позволяет осуществить все эти пожелания. Механизм наследования обеспечивает пожелания из первого пункта. Если один класс является потомком другого (например, IntArrayRC потомок класса IntArray), то наследник имеет возможность пользоваться всеми данными и функциями-членами, определенными в классе-предке. То есть класс IntArrayRC может просто использовать всю основную функциональность, предоставляемую классом IntArray, и добавить только то, что нужно ему для обеспечения проверки границ индекса.
 В С++ класс, свойства которого наследуются, называют также базовым классом, а класс-наследник – производным классом, или подклассом базового. Класс и подкласс имеют общий интерфейс, предоставляемый базовым классом (т.к. подкласс имеет все функции-члены базового класса). Значит, программу, использующую только функции из этого общего интерфейса, не должен интересовать фактический тип объекта, с которым она работает, – базового ли типа этот объект или производного. В этом смысле общий интерфейс скрывает специфичные для подкласса детали. Отношения между классами и подклассами называются иерархией наследования классов. Вот как может выглядеть реализация функции swap(), которая меняет местами два указанных элемента массива. Первым параметром функции является ссылка на базовый класс IntArray:
 #include
 
 void swap (IntArray &ia, int i, int j)
 {
  int temp ia[i];
  ia[i] = ia[j];
  ia[j] = temp;
 }
 
 // ниже идут обращения к функции swap:
 IntArray ia;
 IntArrayRC iarc;
 IntSortedArray ias;
 // правильно - ia имеет тип IntArray
 swap (ia,0,10);
 
 // правильно - iarc является подклассом IntArray
 swap (iarc,0,10);
 
 // правильно - ias является подклассом IntArray
 swap (ias,0,10);
 
 // ошибка - string не является подклассом IntArray
 string str("Это не IntArray!");
 swap (str,0,10);
 Каждый из трех классов реализует операцию взятия индекса по-своему. Поэтому важно, чтобы внутри функции swap() вызывалась нужная операция взятия индекса. Так, если swap() вызвана для IntArrayRC:
 swap (iarc,0,10);
 то должна вызываться функция взятия индекса для объекта класса IntArrayRC, а для
 swap (ias,0,10);
 функция взятия индекса IntSortedArray. Именно это и обеспечивает механизм виртуальных функций С++.
 Давайте попробуем сделать наш класс IntArray базовым для иерархии подклассов. Что нужно изменить в его описании? Синтаксически – совсем немного. Возможно, придется открыть для производных классов доступ к скрытым членам класса. Кроме того, те функции, которые мы собираемся сделать виртуальными, необходимо явно пометить специальным ключевым словом virtual. Основная же трудность состоит в таком изменении реализации базового класса, которая позволит ей лучше отвечать своей новой цели – служить базой для целого семейства подклассов.
 При простом объектном подходе можно выделить двух разработчиков конечной программы – разработчик класса и пользователь класса (тот, кто использует данный класс в конечной программе), причем последний обращается только к открытому интерфейсу. Для такого случая достаточно двух уровней доступа к членам класса – открытого (public) и закрытого (private).
 Если используется наследование, то к этим двум группам разработчиков добавляется третья, промежуточная. Производный класс может проектировать совсем не тот человек, который проектировал базовый, и для того чтобы реализовать класс-наследник, совсем не обязательно иметь доступ к реализации базового. И хотя такой доступ может потребоваться при проектировании подкласса, от конечного пользователя обоих классов эта часть по-прежнему должна быть закрыта. К двум уровням доступа добавляется третий, в некотором смысле промежуточный, – защищенный (protected). Члены класса, объявленные как защищенные, могут использоваться классами-потомками, но никем больше. (Закрытые члены класса недоступны даже для его потомков.)
 Вот как выглядит модифицированное описание класса IntArray:
 class IntArray {
 public:
  // конструкторы
  explicit IntArray (int sz = DefaultArraySize);
  IntArray (int *array, int array_size);
  IntArray (const IntArray &rhs);
 
  // виртуальный деструктор
  virtual ~IntArray() { delete[] ia; }
 
  // операции сравнения:
  bool operator== (const IntArray&) const;
  bool operator!= (const IntArray&) const;
 
  // операция присваивания:
  IntArray& operator= (const IntArray&);
  int size() const { return _size; };
 
  // мы убрали проверку индекса...
  virtual int& operator[](int index)
  { return ia[index]; }
  virtual void sort();
 
  virtual int min() const;
  virtual int max() const;
  virtual int find (int value) const;
 
 protected:
  static const int DefaultArraySize = 12;
  void init (int sz; int *array);
 
  int _size;
  int *ia;
 }
 Открытые функции-члены по-прежнему определяют интерфейс класса, как и в реализации из предыдущего раздела. Но теперь это интерфейс не только базового, но и всех производных от него подклассов.
 Нужно решить, какие из членов, ранее объявленных как закрытые, сделать защищенными. Для нашего класса IntArray сделаем защищенными все оставшиеся члены.
 Теперь нам необходимо определить, реализация каких функций-членов базового класса может меняться в подклассах. Такие функции мы объявим виртуальными. Как уже отмечалось выше, реализация операции взятия индекса будет отличаться по крайней мере для подкласса IntArrayRC. Реализация операторов сравнения и функции size() одинакова для всех подклассов, следовательно, они не будут виртуальными.
 При вызове невиртуальной функции компилятор определяет все необходимое еще на этапе компиляции. Если же он встречает вызов виртуальной функции, то не пытается сделать этого. Выбор нужной из набора виртуальных функций (разрешение вызова) происходит во время выполнения программы и основывается на типе объекта, из которого она вызвана. Рассмотрим пример:
 void init (IntArray &ia)
 {
  for (int ix=0; ix   ia[ix] = ix;
 }
 Формальный параметр функции ia может быть ссылкой на IntArray, IntArrayRC или на IntSortedArray. Функция-член size() не является виртуальной и разрешается на этапе компиляции. А вот виртуальный оператор взятия индекса не может быть разрешен на данном этапе, поскольку реальный тип объекта, на который ссылается ia, в этот момент неизвестен.
 (В главе 17 мы будем говорить о виртуальных функциях более подробно. Там мы рассмотрим также и накладные расходы, которые влечет за собой их использование.)
 Вот как выглядит определение производного класса IntArrayRC:
 #ifndef IntArrayRC_H
 #define IntArrayRC_H
 
 #include "IntArray.h"
 
 class IntArrayRC : public IntArray {
 public:
  IntArrayRC( int sz = DefaultArraySize );
  IntArrayRC( const int *array, int array_size );
  IntArrayRC( const IntArrayRC &rhs );
 
  virtual int& operator[]( int ) const;
 
 private:
  void check_range( int ix );
 };
 #endif
 Этот текст мы поместим в заголовочный файл IntArrayRC.h. Обратите внимание на то, что в наш файл включен заголовочный файл IntArray.h.
 В классе IntArrayRC мы должны реализовать только те особенности, которые отличают его от IntArray: класс IntArrayRC должен иметь свою собственную реализацию операции взятия индекса; функцию для проверки индекса и собственный набор конструкторов.
 Все данные и функции-члены класса IntArray можно использовать в классе IntArrayRC так, как будто это его собственные члены. В этом и заключается смысл наследования. Синтаксически наследование выражается строкой
 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
 
 inline void IntArrayRC::check_range(int index)
 {
  assert (index>=0 && index < _size);
 }
 (Мы говорили о макросе assert() в разделе 1.3.)
 Почему проверка индекса вынесена в отдельную функцию, а не выполняется прямо в теле оператора взятия индекса? Потому что, если мы когда-нибудь потом захотим изменить что-то в реализации проверки, например написать свою обработку ошибок, а не использовать assert(), это будет сделать проще.
 В каком порядке активизируются конструкторы при создании производного класса? Первым вызывается конструктор базового класса, инициализирующий те члены, которые входят в базовый класс. Затем начинает работать конструктор производного класса, где мы должны проинициализировать только те члены, которые являются специфичными для подкласса, то есть отсутствуют в базовом классе.
 Однако заметим, что в нашем производном классе IntArrayRC нет новых членов, представляющих данные. Значит ли это, что нам не нужно реализовывать конструкторы для него? Ведь вся работа по инициализации членов данных уже проделана конструкторами базового класса.
 На самом деле конструкторы, как и деструкторы или операторы присваивания, не наследуются – это правило языка С++. Кроме того, конструктор производного класса обеспечивает механизм передачи параметров конструктору базового класса. Рассмотрим пример. Пусть мы хотим создать объект класса IntArrayRC следующим образом:
 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 )
  : IntArray( iar, sz ) {}
 (Мы будем подробно говорить о конструкторах в главах 14 и 17. Там же мы покажем, почему не нужно реализовывать конструктор копирования для IntArrayRC.)
 Часть определения, следующая за двоеточием, называется списком инициализации членов. Именно здесь, указав конструктор базового класса, мы можем передать ему параметры. Тела обоих конструкторов пусты, поскольку их работа состоит исключительно в передаче параметров конструктору базового класса. Нам не нужно реализовывать деструктор для IntArrayRC, так как ему просто нечего делать. Точно так же, как при создании объекта производного типа вызывается сначала конструктор базового типа, а затем производного, при уничтожении автоматически вызываются деструкторы – естественно, в обратном порядке: сначала деструктор производного, затем базового. Таким образом, деструктор базового класса будет вызван для объекта типа IntArrayRC, хотя тот и не имеет собственной аналогичной функции.
 Мы поместим все встроенные функции класса IntArrayRC в тот же заголовочный файл IntArrayRC.h. Поскольку у нас нет невстроенных функций, то создавать файл IntArrayRC.C не нужно.
 Вот пример простой программы, использующей классы IntArray и IntArrayRC:
 #include
 #include "IntArray.h"
 #include "IntArrayRC.h"
 
 void swap( IntArray &ia, int ix, int jx )
 {
  int tmp = ia[ ix ];
  ia[ ix ] = ia[ jx ];
  ia[ jx ] = tmp;
 }
 
 int main()
 {
  int array[ 4 ] = { 0, 1, 2, 3 };
  IntArray ia1( array, 4 );
  IntArrayRC ia2( array, 4 );
 
  // ошибка: должно быть size-1
  // не может быть выявлена объектом IntArray
  cout << "swap() with IntArray ia1" << endl;
  swap( ia1, 1, ia1.size() );
 
  // правильно: объект IntArrayRC "поймает" ошибку
  cout << "swap() with IntArrayRC ia2" << endl;
  swap( ia2, 1, ia2.size() );
  return 0;
 }
 При выполнении программа выдаст следующий результат:
 
  swap() with IntArray ia1
  swap() with IntArrayRC ia2
  Assertion failed: ix >= 0 && ix < _size,
  file IntArrayRC.h, line 19
 
 Упражнение 2.8
 Отношение наследования между типом и подтипом служит примером отношения является. Так, массив IntArrayRC является подвидом массива IntArray, книга является подвидом выдаваемых библиотекой предметов, аудиокнига является подвидом книги и т.д. Какие из следующих утверждений верны?
 (a) функция-член является подвидом функции
 (b) функция-член является подвидом класса
 (c) конструктор является подвидом функции-члена
 (d) самолет является подвидом транспортного средства
 (e) машина является подвидом грузовика
 (f) круг является подвидом геометрической фигуры
 (g) квадрат является подвидом треугольника
 (h) автомобиль является подвидом самолета
 (i) читатель является подвидом библиотеки
 Упражнение 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) Точка
 (b) Служащий
 (c) Фигура
 (d) Телефонный_номер
 (e) Счет_в_банке
 (f) Курс_продажи
 2.5. Использование шаблонов
 Наш класс IntArray служит хорошей альтернативой встроенному массиву целых чисел. Но в жизни могут потребоваться массивы для самых разных типов данных. Можно предположить, что единственным отличием массива элементов типа double от нашего является тип данных в объявлениях, весь остальной код совпадает буквально.
 Для решения данной проблемы в С++ введен механизм шаблонов. В объявлениях классов и функций допускается использование параметризованных типов. Типы-параметры заменяются в процессе компиляции настоящими типами, встроенными или определенными пользователем. Мы можем создать шаблон класса Array, заменив в классе IntArray тип элементов int на обобщенный тип-параметр. Позже мы конкретизируем типы-параметры, подставляя вместо них реальные типы int, double и string. В результате появится способ использовать эти конкретизации так, как будто мы на самом деле определили три разных класса для этих трех типов данных.
 Вот как может выглядеть шаблон класса Array:
 template
 class Array {
 public:
  explicit Array( int sz = DefaultArraySize );
  Array( const elemType *ar, int sz );
  Array( const Array &iA );
 
  virtual ~Array() { delete[] _ia; }
 
  Array& operator=( const Array & );
  int size() const { return _size; }
 
  virtual elemType& operator[]( int ix )
  { return _ia[ix]; }
 
  virtual void sort( int,int );
  virtual int find( const elemType& );
  virtual elemType min();
  virtual elemType max();
 protected:
  void init( const elemType*, int );
  void swap( int, int );
  static const int DefaultArraySize = 12;
  int _size;
  elemType *_ia;
 };
 Ключевое слово template говорит о том, что задается шаблон, параметры которого заключаются в угловые скобки (<>). В нашем случае имеется лишь один параметр elemType; ключевое слово class перед его именем сообщает, что этот параметр представляет собой тип.
 При конкретизации класса-шаблона Array параметр elemType заменяется на реальный тип при каждом использовании, как показано в примере:
 #include
 #include "Array.h"
 
 int main()
 {
  const int array_size = 4;
 

<< Пред.           стр. 4 (из 121)           След. >>

Список литературы по разделу