Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
C++ для начинающих.pdf
Скачиваний:
183
Добавлен:
01.05.2014
Размер:
3.97 Mб
Скачать

Что делает следующий фрагмент кода? В чем состоит логическая ошибка? (Отметим, что операция взятия индекса ([]) правильно применена к указателю pia. Объяснение этому

int *pi = new int(10); int *pia = new

int[10];

while ( *pi < 10 ) { pia[*pi] = *pi; *pi = *pi + 1;

}

delete pi;

факту можно найти в разделе 3.9.2.) delete[] pia;

2.3. Объектный подход

В этом разделе мы спроектируем и реализуем абстракцию массива, используя механизм классов С++. Первоначальный вариант будет поддерживать только массив элементов типа int. Впоследствии при помощи шаблонов мы расширим наш массив для поддержки любых типов данных.

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

1.обладать некоторыми знаниями о самом себе. Пусть для начала это будет знание собственного размера;

2.поддерживать операцию присваивания и операцию сравнения на равенство;

3.отвечать на некоторые вопросы, например: какова величина минимального и максимального элемента; содержит ли массив элемент с определенным значением; если да, то каков индекс первого встречающегося элемента, имеющего это значение;

4.сортировать сам себя. Пусть такая операция покажется излишней, все-таки реализуем

еев качестве дополнительного упражнения: ведь кому-то это может пригодиться.

5.Конечно, мы должны реализовать и базовые операции работы с массивом, а именно:Возможность задать размер массива при его создании. (Речь не идет о том, чтобы знать эту величину на этапе компиляции.)

6.Возможность проинициализировать массив некоторым набором значений.

7.Возможность обращаться к элементу массива по индексу. Пусть эта возможность реализуется с помощью стандартной операции взятия индекса.

8.Возможность обнаруживать обращения к несуществующим элементам массива и сигнализировать об ошибке. Не будем обращать внимание на тех потенциальных пользователей нашего класса, которые привыкли работать со встроенными массивами С и не считают данную возможность полезной – мы хотим создать такой массив, который был бы удобен в использовании даже самым неискушенным программистам на С++.

Кажется, мы перечислили достаточно потенциальных достоинств нашего будущего массива, чтобы загореться желанием немедленно приступить к его реализации. Как же это будет выглядеть на С++? В самом общем случае объявление класса выглядит

class classname { public:

//набор открытых операций private:

//закрытые функции, обеспечивающие реализацию

следующим образом:

};

class, public и private – это ключевые слова С++, а classname – имя, которое программист дал своему классу. Назовем наш проектируемый класс IntArray: на первом этапе этот массив будет содержать только целые числа. Когда мы научим его обращаться с данными любого типа, можно будет переименовать его в Array.

Определяя класс, мы создаем новый тип данных. На имя класса можно ссылаться точно так же, как на любой встроенный описатель типа. Можно создавать объекты этого нового

// статический объект типа IntArray

типа аналогично тому, как мы создаем объекты встроенных типов:

IntArray myArray;

// указатель на динамический объект типа IntArray IntArray *pArray = new IntArray;

Определение класса состоит из двух частей: заголовка (имя, предваренное ключевым словом class) и тела, заключенного в фигурные скобки. Заголовок без тела может

// объявление класса IntArray

служить объявлением класса.

// без определения его class IntArray;

Тело класса состоит из определений членов и спецификаторов доступа – ключевых слов public, private и protected. (Пока мы ничего не будем говорить об уровне доступа protected.) Членами класса могут являться функции, которые определяют набор действий, выполняемых классом, и переменные, содержащие некие внутренние данные, необходимые для реализации класса. Функции, принадлежащие классу, называют функциями-членами или, по-другому, методами класса. Вот набор методов класса

IntArray:

class IntArray { public:

// операции сравнения: #2b

bool operator== (const IntArray&) const; bool operator!= (const IntArray&) const;

// операция присваивания: #2a IntArray& operator= (const IntArray&);

int size() const; // #1

void sort();

// #4

int min() const; // #3a int max() const; // #3b

//функция find возвращает индекс первого

//найденного элемента массива

//или -1, если элементов не найдено

int find (int value) const; // #3c

private:

//дальше идут закрытые члены,

//обеспечивающие реализацию класса

...

}

Номера, указанные в комментариях при объявлениях методов, ссылаются на спецификацию класса, которую мы составили в начале данного раздела. Сейчас мы не будем объяснять смысл ключевого слова const, он не так уж важен для понимания того, что мы хотим продемонстрировать на данном примере. Будем считать, что это ключевое слово необходимо для правильной компиляции программы.

Именованная функция-член (например, min()) может быть вызвана с использованием одной из двух операций доступа к члену класса. Первая операция доступа, обозначаемая точкой (.), применяется к объектам класса, вторая – стрелка (->) – к указателям на объекты. Так, чтобы найти минимальный элемент в объекте, имеющем тип IntArray, мы

// инициализация переменной min_val

должны написать:

int min_val = myArray.min();

// минимальным элементом myArray

Чтобы найти минимальный элемент в динамически созданном объекте типа IntArray, мы должны написать:

int min_val = pArray->min();

(Да, мы еще ничего не сказали о том, как же проинициализировать наш объект – задать его размер и наполнить элементами. Для этого служит специальная функция-член, называемая конструктором. Мы поговорим об этом чуть ниже.)

Операции применяются к объектам класса точно так же, как и к встроенным типам данных. Пусть мы имеем два объекта типа IntArray:

IntArray myАrray0, myArray1;

Инструкции присваивания и сравнения с этими объектами выглядят совершенно

//инструкция присваивания -

//вызывает функцию-член myArray0.operator=(myArray1)

myArray0 = myArray1;

//инструкция сравнения -

//вызывает функцию-член

myArray0.operator==(myArray1) if (myArray0 == myArray1)

обычным образом:

cout << "Ура! Оператор присваивания сработал!\n";

Спецификаторы доступа public и private определяют уровень доступа к членам класса. К тем членам, которые перечислены после public, можно обращаться из любого места программы, а к тем, которые объявлены после private, могут обращаться только функции-члены данного класса. (Помимо функций-членов, существуют еще функциидрузья класса, но мы не будем говорить о них вплоть до раздела 15.2.)

В общем случае открытые члены класса составляют его открытый интерфейс, то есть набор операций, которые определяют поведение класса. Закрытые члены класса обеспечивают его скрытую реализацию.

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

если мы меняем или расширяем реализацию класса, то изменения можно выполнить так, что большинство пользовательских программ, использующих наш класс, их “не заметят”: модификации коснутся лишь скрытых членов (мы поговорим об этом в разделе 6.18);

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

Какие же внутренние данные потребуются для реализации класса IntArray? Необходимо где-то сохранить размер массива и сами его элементы. Мы будем хранить их в массиве встроенного типа, память для которого выделяется динамически. Так что нам потребуется указатель на этот массив. Вот как будут выглядеть определения этих данных-членов:

class IntArray { public:

// ...

int size() const { return _size; }

private:

// внутренние данные-члены

int _size; int *ia;

};

Поскольку мы поместили член _size в закрытую секцию, пользователь класса не имеет возможности обратиться к нему напрямую. Чтобы позволить внешней программе узнать размер массива, мы написали функцию-член size(), которая возвращает значение члена _size. Нам пришлось добавить символ подчеркивания к имени нашего скрытого члена _size, поскольку функция-член с именем size() уже определена. Члены класса – функции и данные – не могут иметь одинаковые имена.

Может показаться, что реализуя подобным образом доступ к скрытым данным класса, мы очень сильно проигрываем в эффективности. Сравним два выражения (предположим,

IntArray

arr ay;

что мы изменили спецификатор доступа члена _size на public):

int array_size = array.size(); array_size = array._size;

Действительно, вызов функции гораздо менее эффективен, чем прямой доступ к памяти, как во втором операторе. Так что же, принцип сокрытия информации заставляет нас жертвовать эффективностью?

На самом деле, нет. С++ имеет механизм встроенных (inline) функций. Текст встроенной функции подставляется компилятором в то место, где записано обращение к ней. (Это напоминает механизм макросов, реализованный во многих языках, в том числе и в С++. Однако есть определенные отличия, о которых мы сейчас говорить не будем.)

for (int index=0; index<array.size(); + +index)

Вот пример. Если у нас есть следующий фрагмент кода:

// ...

то функция size() не будет вызываться _size раз во время исполнения. Вместо вызова компилятор подставит ее текст, и результат компиляции предыдущего кода будет в точности таким же, как если бы мы написали:

for (int index=0; index<array._size; ++index)

// ...

Если функция определена внутри тела класса (как в нашем случае), она автоматически считается встроенной. Существует также ключевое слово inline, позволяющее объявить встроенной любую функцию3.

Мы до сих пор ничего не сказали о том, как будем инициализировать наш массив.

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

Конструктор – это специальная функция-член, которая вызывается автоматически при создании объекта типа класса. Конструктор пишется разработчиком класса, причем у одного класса может быть несколько конструкторов.

Функция-член класса, носящее то же имя, что и сам класс, считается конструктором. (Нет никаких специальных ключевых слов, позволяющих определить конструктор как-то по-другому.) Мы уже сказали, что конструкторов может быть несколько. Как же так: разные функции с одинаковыми именами?

В С++ это возможно. Разные функции могут иметь одно и то же имя, если у этих функций различны количество и/или типы параметров. Это называется перегрузкой функции. Обрабатывая вызов перегруженной функции, компилятор смотрит не только на ее имя, но и на список параметров. По количеству и типам передаваемых параметров компилятор может определить, какую же из одноименных функций нужно вызывать в данном случае. Рассмотрим пример. Мы можем определить следующий набор перегруженных функций min(). (Перегружаться могут как обычные функции, так и

//список перегруженных функций min()

//каждая функция отличается от других списком

параметров #include <string>

int min (const int *pia,int size); int min (int, int);

int min (const char *str); char min (string);

функции-члены.)

string min (string,string);

Поведение перегруженных функций во время выполнения ничем не отличается от поведения обычных. Компилятор определяет нужную функцию и помещает в объектный код именно ее вызов. (В главе 9 подробно обсуждается механизм перегрузки.)

Итак, вернемся к нашему классу IntArray. Давайте определим для него три конструктора:

3 Объявление функции inline – это всего лишь подсказка компилятору. Однако компилятор не всегда может сделать функцию встроенной, существуют некоторые ограничения. Подробнее об этом сказано в разделе 7.6.

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 array2;

то в качестве значения отсутствующего параметра конструктор принимает величину DefaultArraySize. (Не будем пока обсуждать использование ключевого слова static в определении члена DefaultArraySize: об этом говорится в разделе 13.5. Скажем лишь, что такой член данных существует в единственном экземпляре и принадлежит одновременно всем объектам данного класса.)

IntArray::IntArray (int sz)

{

// инициализация членов данных _size = sz;

ia = new int[_size];

// инициализация элементов массива

for (int ix=0; ix<_size; ++ix) ia[ix] = 0;

Вот как может выглядеть определение нашего конструктора по умолчанию:

}

Это определение содержит несколько упрощенный вариант реализации. Мы не позаботились о том, чтобы попытаться избежать возможных ошибок во время выполнения. Какие ошибки возможны? Во-первых, оператор new может потерпеть неудачу при выделении нужной памяти: в реальной жизни память не бесконечна. (В разделе 2.6 мы увидим, как обрабатываются подобные ситуации.) А во-вторых, параметр sz из-за небрежности программиста может иметь некорректное значение, например нуль или отрицательное.

Что необычного мы видим в таком определении конструктора? Сразу бросается в глаза первая строчка, в которой использована операция разрешения области видимости (::):

IntArray::IntArray(int sz);

Дело в том, что мы определяем нашу функцию-член (в данном случае конструктор) вне тела класса. Для того чтобы показать, что эта функция на самом деле является членом класса IntArray, мы должны явно предварить имя функции именем класса и двойным двоеточием. (Подробно области видимости разбираются в главе 8; области видимости применительно к классам рассматриваются в разделе 13.9.)

Второй конструктор класса IntArray инициализирует объект IntArray значениями элементов массива встроенного типа. Он требует двух параметров: массива встроенного типа со значениями для инициализации и размера этого массива. Вот как может

int ia[10] = {0,1,2,3,4,5,6,7,8,9};

выглядеть создание объекта IntArray с использованием данного конструктора:

IntArray iA3(ia,10);

Реализация второго конструктора очень мало отличается от реализации конструктора по

IntArray::IntArray (int *array, int sz)

{

// инициализация членов данных _size = sz;

ia = new int[_size];

// инициализация элементов массива for (int ix=0; ix<_size; ++ix)

ia[ix] = array[ix];

умолчанию. (Как и в первом случае, мы пока опустили обработку ошибочных ситуаций.)

}

Третий конструктор называется копирующим конструктором. Он инициализирует один объект типа IntArray значением другого объекта IntArray. Такой конструктор вызывается автоматически при выполнении следующих инструкций:

IntArray array;

// следующие два объявления совершенно эквивалентны: IntArray ia1 = array;

IntArray ia2 (array);

Вот как выглядит реализация копирующего конструктора для IntArray, опять-таки без

IntArray::IntArray (const IntArray

&rhs )

обработки ошибок:

{

//инициализация членов данных _size = rhs._size;

ia = new int[_size];

//инициализация элементов массива for (int ix=0; ix<_size; ++ix)

ia[ix] = rhs.ia[ix];

}

В этом примере мы видим еще один составной тип данных – ссылку на объект, которая обозначается символом &. Ссылку можно рассматривать как разновидность указателя: она также позволяет косвенно обращаться к объекту. Однако синтаксис их использования различается: для доступа к члену объекта, на который у нас есть ссылка, следует использовать точку, а не стрелку; следовательно, мы пишем 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); // ...

};

// функция, используемая всеми конструкторами void IntArray::init (int sz,int *array)

{

_size = sz;

ia = new int[_size];

for (int ix=0; ix<_size; ++ix) if ( !array )

ia[ix] = 0; else

ix[ix] = array[ix];

}

// модифицированные конструкторы IntArray::IntArray (int sz) { init(sz,0); } IntArray::IntArray (int *array, int array_size)

{ init (array_size,array); } IntArray::IntArray (const IntArray &rhs)

функцию init():

{ init (rhs._size,rhs.ia); }

Имеется еще одна специальная функция-член – деструктор, который автоматически вызывается в тот момент, когда объект прекращает существование. Имя деструктора совпадает с именем класса, только в начале идет символ тильды (~). Основное назначение данной функции – освободить ресурсы, отведенные объекту во время его создания и использования. Применение деструкторов помогает бороться с трудно

обнаруживаемыми ошибками, ведущими к утечке памяти и других ресурсов. В случае класса IntArray эта функция-член должна освободить память, выделенную в момент создания объекта. (Подробно конструкторы и деструкторы описаны в главе 14.) Вот как

class IntArray

{

выглядит деструктор для IntArray:

public:

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

explicit IntArray (int sz = DefaultArraySize); IntArray (int *array, int array_size); IntArray (const IntArray &rhs);

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

~IntArray() { delete[] ia; }

//...

private:

//...

};

Теперь нам нужно определить операции доступа к элементам массива IntArray. Мы хотим, чтобы обращение к элементам IntArray выглядело точно так же, как к элементам

IntArray

arr ay;

массива встроенного типа, с использованием оператора взятия индекса: int last_pos = array.size()-1;

int temp = array[0]; array[0] = array[last_pos]; array[last_pos] = temp;

Для реализации доступа мы используем возможность перегрузки операций. Вот как

#include <cassert>

int& IntArray::operator[] (int index)

{

assert (index >= 0 && index < _size);

return ia[index];

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

}

Обычно для проектируемого класса перегружают операции присваивания, операцию сравнения на равенство, возможно, операции сравнения по величине и операции ввода/вывода. Как и перегруженных функций, перегруженных операторов, отличающихся типами операндов, может быть несколько. К примеру, можно создать несколько операций присваивания объекту значения другого объекта того же самого или иного типа. Конечно, эти объекты должны быть более или менее “похожи”. (Подробно о