Подписи методов и необязательные аргументы
Как и при объявлении функций, язык C++ допускает определение в одном классе нескольких методов с одним и тем же именем, но разными типами и количеством аргументов. (Определение методов или атрибутов с одинаковыми именами в разных классах не вызывает проблем, поскольку пространства имен разных классов не пересекаются).
// определение класса комплексных чиселclass Complex{public: int real; // вещественная часть int imaginary; // мнимая часть // прибавить комплексное число Complex operator+(const Complex x) const; // прибавить целое число Complex operator+(long x) const;};В следующем примере вначале складываются два комплексных числа, и вызывается первая операция +. Затем к комплексному числу прибавляется целое число, и тогда выполняется вторая операция сложения.
Complex c1;Complex c2;long x;c1 + c2;c2 + x;Аналогично можно задавать значения аргументов методов по умолчанию. Более подробное описание можно найти в лекции 5.
Запись классов
Как уже отмечалось раньше, выбор имен - это не праздный вопрос. Существует множество систем именования классов. Опишем ту, которой мы придерживаемся в данной книге.
Имена классов, их методов и атрибутов составляются из английских слов, описывающих их смысл, при этом если слов несколько, они пишутся слитно. Имена классов начинаются с заглавной буквы; если название состоит из нескольких слов, каждое слово начинается с заглавной буквы, остальные буквы маленькие:
Complex, String, StudentLibrarianИмена методов классов также начинаются с большой буквы:
Add, ConcatИмена атрибутов класса начинаются с маленькой буквы, однако если имя состоит из нескольких слов, последующие слова начинаются с большой:
real, classElementПри записи определения класса мы придерживаемся той же системы расположения, что и при записи функций. Ключевое слово class и имя класса записываются в первой строке, открывающаяся фигурная скобка - на следующей строке, методы и атрибуты класса - на последующих строках с отступом.
Лекция 9 Производные типы данных
Массивы
Массив – это коллекция нескольких величин одного и того же типа. Простейшим примером массива может служить набор из двенадцати целых чисел, соответствующих числу дней в каждом календарном месяце:
int days[12]; days[0] = 31; // январьdays[1] = 28; // февральdays[2] = 31; // мартdays[3] = 30; // апрельdays[4] = 31; // майdays[5] = 30; // июньdays[6] = 31; // июльdays[7] = 31; // августdays[8] = 30; // сентябрьdays[9] = 31; // октябрьdays[10] = 30; // ноябрьdays[11] = 31; // декабрьВ первой строчке мы объявили массив из 12 элементов типа int и дали ему имя days. Остальные строки примера – присваивания значений элементам массива. Для того, чтобы обратиться к определенному элементу массива, используют операцию индексации []. Как видно из примера, первый элемент массива имеет индекс 0, соответственно, последний – 11.
При объявлении массива его размер должен быть известен в момент компиляции, поэтому в качестве размера можно указывать только целую константу. При обращении же к элементу массива в роли значения индекса может выступать любая переменная или выражение, которое вычисляется во время выполнения программы и преобразуется к целому значению.
Предположим, мы хотим распечатать все элементы массива days. Для этого удобно воспользоваться циклом for.
for (int i = 0; i < 12; i++) { cout << days[i];}Следует отметить, что при выполнении программы границы массива не контролируются. Если мы ошиблись и вместо 12 в приведенном выше цикле написали 13, то компилятор не выдаст ошибку. При выполнении программа попытается напечатать 13-е число. Что при этом случится, вообще говоря, не определено. Быть может, произойдет сбой программы. Более вероятно, что будет напечатано какое-то случайное 13-е число. Выход индексов за границы массива – довольно распространенная ошибка, которую иногда очень трудно обнаружить. В дальнейшем при изучении классов мы рассмотрим, как можно переопределить операцию [] и добавить контроль за индексами.
Отсутствие контроля индексов налагает на программиста большую ответственность. С другой стороны, индексация – настолько часто используемая операция, что наличие контроля, несомненно, повлияло бы на производительность программ.
Рассмотрим еще один пример. Предположим, что имеется массив из 100 целых чисел, и его необходимо отсортировать, т.е. расположить в порядке возрастания. Сортировка методом "пузырька" – наиболее простая и распространенная – будет выглядеть следующим образом:
int array[100];. . .for (int i = 0; i < 99; i++ ) { for (int j = i + 1; j < 100; j++) { if (array[j] < array[i] ) { int tmp = array[j]; array[j] = array[i]; array[i] = tmp; } }}
В приведенных примерах у массивов имеется только один индекс. Такие одномерные массивы часто называются векторами. Имеется возможность определить массивы с несколькими индексами или размерностями. Например, объявление
int m[10][5];представляет матрицу целых чисел размером 10 на 5. По-другому интерпретировать приведенное выше объявление можно как массив из 10 элементов, каждый из которых – вектор целых чисел длиной 5. Общее количество целых чисел в массиве m равно 50.
Обращение к элементам многомерных массивов аналогично обращению к элементам векторов: m[1][2] обращается к третьему элементу второй строки матрицы m.
Количество размерностей в массиве может быть произвольным. Как и в случае с вектором, при объявлении многомерного массива все его размеры должны быть заданы константами.
При объявлении массива можно присвоить начальные значения его элементам (инициализировать массив). Для вектора это будет выглядеть следующим образом:
int days[12] = { 31, 28, 31, 30, 31, 31, 30, 31, 30, 31 };При инициализации многомерных массивов каждая размерность должна быть заключена в фигурные скобки:
double temp[2][3] = { { 3.2, 3.3, 3.4 }, { 4.1, 3.9, 3.9 } };Интересной особенностью инициализации многомерных массивов является возможность не задавать размеры всех измерений массива, кроме самого последнего. Приведенный выше пример можно переписать так:
double temp[][3] = { { 3.2, 3.3, 3.4 }, { 4.1, 3.9, 3.9 } };// Вычислить размер пропущенной размерностиconst int size_first = sizeof (temp) / sizeof (double[3]);Структуры
Структуры – это не что иное, как классы, у которых разрешен доступ ко всем их элементам (доступ к определенным атрибутам класса может быть ограничен, о чем мы узнаем в лекции 11). Пример структуры:
struct Record { int number; char name[20];};Так же, как и для классов, операция "." обозначает обращение к элементу структуры.
В отличие от классов, можно определить переменную-структуру без определения отдельного типа:
struct { double x; double y;} coord;Обратиться к атрибутам переменной coord можно coord.x и coord.y.
Битовые поля
В структуре можно определить размеры атрибута с точностью до бита. Традиционно структуры используются в системном программировании для описания регистров аппаратуры. В них каждый бит имеет свое значение. Не менее важной является возможность экономии памяти – ведь минимальный тип атрибута структуры это байт (char), который занимает 8 битов. До сих пор, несмотря на мегабайты и даже гигабайты оперативной памяти, используемые в современных компьютерах, существует немало задач, где каждый бит на счету.
Если после описания атрибута структуры поставить двоеточие и затем целое число, то это число задает количество битов, выделенных под данный атрибут структуры. Такие атрибуты называют битовыми полями. Следующая структура хранит в компактной форме дату и время дня с точностью до секунды.
struct TimeAndDate{unsigned hours :5; // часы от 0 до 24 unsigned mins :6; // минуты unsigned secs :6; // секунды от 0 до 60unsigned weekDay :3; // день неделиunsigned monthDay :6; // день месяца от 1 до 31unsigned month :5; // месяц от 1 до 12unsigned year :8; // год от 0 до 100 };Одна структура TimeAndDate требует всего 39 битов, т.е. 5 байтов (один байт — 8 битов). Если бы мы использовали для каждого атрибута этой структуры тип char, нам бы потребовалось 7 байтов.
Объединения
Особым видом структур данных является объединение. Определение объединения напоминает определение структуры, только вместо ключевого слова struct используется union:
union number { short sx; long lx; double dx;};В отличие от структуры, все атрибуты объединения располагаются по одному адресу. Под объединение выделяется столько памяти, сколько нужно для хранения наибольшего атрибута объединения. Объединения применяются в тех случаях, когда в один момент времени используется только один атрибут объединения и, прежде всего, для экономии памяти. Предположим, нам нужно определить структуру, которая хранит "универсальное" число, т.е. число одного из предопределенных типов, и признак типа. Это можно сделать следующим образом:
struct Value { enum NumberType { ShortType, LongType, DoubleType }; NumberType type; short sx; // если type равен ShortType long lx; // если type равен LongType double dx; // если type равен DoubleType};Атрибут type содержит тип хранимого числа, а соответствующий атрибут структуры – значение числа.
Value shortVal;shortVal.type = Value::ShortType;shortVal.sx = 15;Хотя память выделяется под все три атрибута sx, ls и dx, реально используется только один из них. Сэкономить память можно, используя объединение:
struct Value { enum NumberType { ShortType, LongType, DoubleType }; NumberType type; union number { short sx; long lx; double dx; } val;}; // если type равен ShortType // если type равен LongType // если type равен DoubleTypeТеперь память выделена только для максимального из этих трех атрибутов (в данном случае dx). Однако и обращаться с объединением надо осторожно. Поскольку все три атрибута делят одну и ту же область памяти, изменение одного из них означает изменение всех остальных. На рисунке поясняется выделение памяти под объединение. В обоих случаях мы предполагаем, что структура расположена по адресу 1000. Объединение располагает все три своих атрибута по одному и тому же адресу.
Замечание. Объединения существовали в языке Си, откуда без изменений и перешли в C++. Использование наследования классов, описанное в следующей главе, позволяет во многих случаях добиться того же эффекта без использования объединений, причем программа будет более надежной.
Рис. 9.1. Использование памяти в объединениях.
Указатели
Указатель – это производный тип, который представляет собой адрес какого-либо значения. В языке C++ используется понятие адреса переменных. Работа с адресами досталась C++ в наследство от языка Си. Предположим, что в программе определена переменная типа int:
int x;Можно определить переменную типа "указатель" на целое число:
int* xptr;и присвоить переменной xptr адрес переменной x:
xptr = &x;Операция &, примененная к переменной, – это операция взятия адреса. Операция *, примененная к адресу, – это операция обращения по адресу. Таким образом, два оператора эквивалентны:
int y = x; // присвоить переменной y значение xint y = *xptr; // присвоить переменной y значение, // находящееся по адресу xptrС помощью операции обращения по адресу можно записывать значения:
*xptr = 10; // записать число 10 по адресу xptrПосле выполнения этого оператора значение переменной x станет равным 10, поскольку xptr указывает на переменную x.
Указатель – это не просто адрес, а адрес величины определенного типа. Указатель xptr – адрес целой величины. Определить адреса величин других типов можно следующим образом:
unsigned long* lPtr; // указатель на целое число без знака char* cp; // указатель на байт Complex* p; // указатель на объект класса ComplexЕсли указатель ссылается на объект некоторого класса, то операция обращения к атрибуту класса вместо точки обозначается "->", например p->real. Если вспомнить один из предыдущих примеров:
voidComplex::Add(Complex x){ this->real = this->real + x.real; this->imaginary = this->imaginary + x.imaginary;}то this – это указатель на текущий объект, т.е. объект, который выполняет метод Add. Запись this-> означает обращение к атрибуту текущего объекта.
Можно определить указатель на любой тип, в том числе на функцию или метод класса. Если имеется несколько функций одного и того же типа:
int foo(long x);int bar(long x);можно определить переменную типа указатель на функцию и вызывать эти функции не напрямую, а косвенно, через указатель:
int (*functptr)(long x);functptr = &foo;(*functptr)(2);functptr = &bar;(*functptr)(4);Для чего нужны указатели? Указатели появились, прежде всего, для нужд системного программирования. Поскольку язык Си предназначался для "низкоуровневого" программирования, на нем нужно было обращаться, например, к регистрам устройств. У этих регистров вполне определенные адреса, т.е. необходимо было прочитать или записать значение по определенному адресу. Благодаря механизму указателей, такие операции не требуют никаких дополнительных средств языка.
int* hardwareRegiste =0x80000;*hardwareRegiste =12;Однако использование указателей нуждами системного программирования не ограничивается. Указатели позволяют существенно упростить и ускорить ряд операций. Предположим, в программе имеется область памяти для хранения промежуточных результатов вычислений. Эту область памяти используют разные модули программы. Вместо того, чтобы каждый раз при обращении к модулю копировать эту область памяти, мы можем передавать указатель в качестве аргумента вызова функции, тем самым упрощая и ускоряя вычисления.
struct TempResults { double x1; double x2;} tempArea; // Функция calc возвращает истину, если // вычисления были успешны, и ложь – при // наличии ошибки. Вычисленные результаты // записываются на место аргументов по // адресу, переданному в указателе trPtrboolcalc(TempResults* trPtr){ // вычисления if (noerrors) { trPtr->x1 = res1; trPtr->x2 = res2; return true; } else { return false; }}voidfun1(void){ . . . TempResults tr; tr.x1 = 3.4; tr.x2 = 5.4; if (calc(&tr) == false) { // обработка ошибки } . . .}В приведенном примере проиллюстрированы сразу две возможности использования указателей: передача адреса общей памяти и возможность функции иметь более одного значения в качестве результата. Структура TempResults используется для хранения данных. Вместо того чтобы передавать эти данные по отдельности, в функцию calc передается указатель на структуру. Таким образом достигаются две цели: большая наглядность и большая эффективность (не надо копировать элементы структуры по одному). Функция calc возвращает булево значение – признак успешного завершения вычислений. Сами же результаты вычислений записываются в структуру, указатель на которую передан в качестве аргумента.
Упомянутые примеры использования указателей никак не связаны с объектно-ориентированным программированием. Казалось бы, объектно-ориентированное программирование должно уменьшить зависимость от низкоуровневых конструкций типа указателей. На самом деле программирование с классами нисколько не уменьшило потребность в указателях, и даже наоборот, нашло им дополнительное применение, о чем мы будем рассказывать по ходу изложения.
Адресная арифметика
С указателями можно выполнять не только операции присваивания и обращения по адресу, но и ряд арифметических операций. Прежде всего, указатели одного и того же типа можно сравнивать с помощью стандартных операций сравнения. При этом сравниваются значения указателей, а не значения величин, на которые данные указатели ссылаются. Так, в приведенном ниже примере результат первой операции сравнения будет ложным:
int x = 10;int y = 10;int* xptr = &x;int* yptr = &y;// сравниваем указателиif (xptr == yptr) { cout << "Указатели равны" << endl;} else { cout << "Указатели неравны" << endl;}// сравниваем значения, на которые указывают// указателиif (*xptr == *yptr) { cout << "Значения равны" << endl;} else { cout << "Значения неравны" << endl;}Однако результат второй операции сравнения будет истинным, поскольку переменные x и y имеют одно и то же значение.
Кроме того, над указателями можно выполнять ограниченный набор арифметических операций. К указателю можно прибавить целое число или вычесть из него целое число. Результатом прибавления к указателю единицы является адрес следующей величины типа, на который ссылается указатель, в памяти. Поясним это на рисунке. Пусть xPtr – указатель на целое число типа long, а cp – указатель на тип char. Начиная с адреса 1000, в памяти расположены два целых числа. Адрес второго — 1004 (в большинстве реализаций C++ под тип long выделяется четыре байта). Начиная с адреса 2000, в памяти расположены объекты типа char.
Рис. 9.2. Адресная арифметика.
Размер памяти, выделяемой для числа типа long и для char, различен. Поэтому адрес при увеличении xPtr и cp тоже изменяется по-разному. Однако и в том, и в другом случае увеличение указателя на единицу означает переход к следующей в памяти величине того же типа. Прибавление или вычитание любого целого числа работает по тому же принципу, что и увеличение на единицу. Указатель сдвигается вперед (при прибавлении положительного числа) или назад (при вычитании положительного числа) на соответствующее количество объектов того типа, на который показывает указатель. Вообще говоря, неважно, объекты какого типа на самом деле находятся в памяти — адрес просто увеличивается или уменьшается на необходимую величину. На самом деле значение указателя ptr всегда изменяется на число, равное sizeof(*ptr).
Указатели одного и того же типа можно друг из друга вычитать. Разность указателей показывает, сколько объектов соответствующего типа может поместиться между указанными адресами.