Взаимосвязь массивов и указателей
Если мы имеем определение массива:
int ia[] = { 0, 1, 1, 2, 3, 5, 8, 13, 21 };
то что означает простое указание его имени в программе?
ia;
Использование идентификатора массива в программе эквивалентно указанию адреса его первого элемента:
&ia[0]
Аналогично обратиться к значению первого элемента массива можно двумя способами:
// оба выражения возвращают первый элемент
*ia;
| ia[0];
Чтобы взять адрес второго элемента массива, мы должны написать:
&ia[1];
Как мы уже упоминали раньше, выражение
ia+1;
также дает адрес второго элемента массива. Соответственно, его значение дают нам следующие два способа:
ia[1];
Отметим разницу в выражениях:
*ia+1
и
*(ia+1);
Операция разыменования имеет более высокий приоритет, чем операция сложения (о приоритетах операций говорится в разделе 4.13). Поэтому первое выражение сначала разыменовывает переменную ia и получает первый элемент массива, а затем прибавляет к нему 1. Второе же выражение доставляет значение второго элемента.
Проход по массиву можно осуществлять с помощью индекса, как мы делали это в предыдущем разделе, или с помощью указателей. Например:
#include <iostream>
int main()
{
int ia[9] = { 0, 1, 1, 2, 3, 5, 8, 13, 21 };
int *pbegin = ia;
| int *pend = ia + 9;
while ( pbegin != pend ) {
cout << *pbegin <<;
++pbegin;
| }
Указатель pbegin инициализируется адресом первого элемента массива. Каждый проход по циклу увеличивает этот указатель на 1, что означает смещение его на следующий элемент. Как понять, где остановиться? В нашем примере мы определили второй указатель pend и инициализировали его адресом, следующим за последним элементом массива ia. Как только значение pbegin станет равным pend, мы узнаем, что массив кончился.
Перепишем эту программу так, чтобы начало и конец массива передавались параметрами в некую обобщенную функцию, которая умеет печатать массив любого размера:
#inc1ude <iostream>
void ia_print( int *pbegin, int *pend )
{
while ( pbegin != pend ) {
cout << *pbegin<< ' ';
++pbegin;
}
}
int main()
{
int ia[9] = { 0, 1, 1, 2, 3, 5, 8, 13, 21 };
ia_print( ia, ia + 9 );
| }
Наша функция стала более универсальной, однако, она умеет работать только с массивами типа int. Есть способ снять и это ограничение: преобразовать данную функцию в шаблон (шаблоны были вкратце представлены в разделе 2.5):
#inc1ude <iostream>
template <c1ass e1emType>
void print( elemType *pbegin, elemType *pend )
{
while ( pbegin != pend ) {
cout << *pbegin<< ' ';
++pbegin;
}
| }
Теперь мы можем вызывать нашу функцию print() для печати массивов любого типа:
int main()
{
int ia[9] = { 0, 1, 1, 2, 3, 5, 8, 13, 21 };
double da[4] = { 3.14, 6.28, 12.56, 25.12 };
string sa[3] = { "piglet", "eeyore", "pooh" };
print( ia, ia+9 );
print( da, da+4 );
print( sa, sa+3 );
| }
Мы написали обобщенную функцию. Стандартная библиотека предоставляет набор обобщенных алгоритмов (мы уже упоминали об этом в разделе 3.4), реализованных подобным образом. Параметрами таких функций являются указатели на начало и конец массива, с которым они производят определенные действия. Вот, например, как выглядят вызовы обобщенного алгоритма сортировки:
#include <a1gorithm>
int main()
{
int ia[6] = { 107, 28, 3, 47, 104, 76 };
string sa[3] = { "piglet", "eeyore", "pooh" };
sort( ia, ia+6 );
sort( sa, sa+3 );
| };
(Мы подробно остановимся на обобщенных алгоритмах в главе 12; в Приложении будут приведены примеры их использования.)
В стандартной библиотеке С++ содержится набор классов, которые инкапсулируют использование контейнеров и указателей. (Об этом говорилось в разделе 2.8.) В следующем разделе мы займемся стандартным контейнерным типом vector, являющимся объектно-ориентированной реализацией массива.
Класс vector
Использование класса vector (см. раздел 2.8) является альтернативой применению встроенных массивов. Этот класс предоставляет гораздо больше возможностей, поэтому его использование предпочтительней. Однако встречаются ситуации, когда не обойтись без массивов встроенного типа. Одна из таких ситуаций – обработка передаваемых программе параметров командной строки, о чем мы будем говорить в разделе 7.8. Класс vector, как и класс string, является частью стандартной библиотеки С++.
Для использования вектора необходимо включить заголовочный файл:
#include <vector>
Существуют два абсолютно разных подхода к использованию вектора, назовем их идиомой массива и идиомой STL. В первом случае объект класса vector используется точно так же, как массив встроенного типа. Определяется вектор заданной размерности:
vector< int > ivec( 10 );
что аналогично определению массива встроенного типа:
int ia[ 10 ];
Для доступа к отдельным элементам вектора применяется операция взятия индекса:
void simp1e_examp1e()
{
const int e1em_size = 10;
vector< int > ivec( e1em_size );
int ia[ e1em_size ];
for ( int ix = 0; ix < e1em_size; ++ix )
ia[ ix ] = ivec[ ix ];
// ...
| }
Мы можем узнать размерность вектора, используя функцию size(), и проверить, пуст ли вектор, с помощью функции empty(). Например:
void print_vector( vector<int> ivec )
{
if ( ivec.empty() )
return;
for ( int ix=0; ix< ivec.size(); ++ix )
cout << ivec[ ix ] << ' ';
| }
Элементы вектора инициализируются значениями по умолчанию. Для числовых типов и указателей таким значением является 0. Если в качестве элементов выступают объекты класса, то инициатор для них задается конструктором по умолчанию (см. раздел 2.3). Однако инициатор можно задать и явно, используя форму:
vector< int > ivec( 10, -1 );
Все десять элементов вектора будут равны -1.
Массив встроенного типа можно явно инициализировать списком:
int ia[ 6 ] = { -2, -1, О, 1, 2, 1024 };
Для объекта класса vector аналогичное действие невозможно. Однако такой объект может быть инициализирован с помощью массива встроенного типа:
// 6 элементов ia копируются в ivec
| vector< int > ivec( ia, ia+6 );
Конструктору вектора ivec передаются два указателя – указатель на начало массива ia и на элемент, следующий за последним. В качестве списка начальных значений допустимо указать не весь массив, а некоторый его диапазон:
// копируются 3 элемента: ia[2], ia[3], ia[4]
| vector< int > ivec( &ia[ 2 ], &ia[ 5 ] );
Еще одним отличием вектора от массива встроенного типа является возможность инициализации одного объекта типа vector другим и использования операции присваивания для копирования объектов. Например:
vector< string > svec;
void init_and_assign()
{
// один вектор инициализируется другим
vector< string > user_names( svec );
// ...
// один вектор копируется в другой
svec = user_names;
| }
Говоря об идиоме STL[6], мы подразумеваем совсем другой подход к использованию вектора. Вместо того чтобы сразу задать нужный размер, мы определяем пустой вектор:
vector< string > text;
Затем добавляем к нему элементы при помощи различных функций. Например, функция push_back()вставляет элемент в конец вектора. Вот фрагмент кода, считывающего последовательность строк из стандартного ввода и добавляющего их в вектор:
string word;
while ( cin >> word ) {
text.push_back( word );
// ...
| }
Хотя мы можем использовать операцию взятия индекса для перебора элементов вектора:
cout << "считаны слова: \n";
for ( int ix =0; ix < text.size(); ++ix )
cout << text[ ix ] << ' ';
| cout << endl;
более типичным в рамках данной идиомы будет использование итераторов:
cout << "считаны слова: \n";
for ( vector<string>::iterator it = text.begin();
it != text.end(); ++it )
cout << *it << ' ';
| cout << endl;
Итератор – это класс стандартной библиотеки, фактически являющийся указателем на элемент массива.
Выражение
*it;
разыменовывает итератор и дает сам элемент вектора. Инструкция
++it;
сдвигает указатель на следующий элемент. Не нужно смешивать эти два подхода. Если следовать идиоме STL при определении пустого вектора:
vector<int> ivec;
будет ошибкой написать:
ivec[0] = 1024;
У нас еще нет ни одного элемента вектора ivec; количество элементов выясняется с помощью функции size().
Можно допустить и противоположную ошибку. Если мы определили вектор некоторого размера, например:
vector<int> ia( 10 );
то вставка элементов увеличивает его размер, добавляя новые элементы к существующим. Хотя это и кажется очевидным, тем не менее, начинающий программист вполне мог бы написать:
const int size = 7;
int ia[ size ] = { 0, 1, 1, 2, 3, 5, 8 };
vector< int > ivec( size );
for ( int ix = 0; ix < size; ++ix )
| ivec.push_back( ia[ ix ] );
Имелась в виду инициализация вектора ivec значениями элементов ia, вместо чего получился вектор ivec размера 14.
Следуя идиоме STL, можно не только добавлять, но и удалять элементы вектора. (Все это мы рассмотрим подробно и с примерами в главе 6.)
Упражнение 3.24
Имеются ли ошибки в следующих определениях?
int ia[ 7 ] = { 0, 1, 1, 2, 3, 5, 8 };
(a) vector< vector< int > > ivec;
(b) vector< int > ivec = { 0, 1, 1, 2, 3, 5, 8 };
(c) vector< int > ivec( ia, ia+7 );
(d) vector< string > svec = ivec;
| (e) vector< string > svec( 10, string( "null" ));
Упражнение 3.25
Реализуйте следующую функцию:
bool is_equa1( const int*ia, int ia_size,
| const vector<int> &ivec );
Функция is_equal() сравнивает поэлементно два контейнера. В случае разного размера контейнеров “хвост” более длинного в расчет не принимается. Понятно, что, если все сравниваемые элементы равны, функция возвращает true, если отличается хотя бы один – false. Используйте итератор для перебора элементов. Напишите функцию main(), обращающуюся к is_equal().
Класс complex
Класс комплексных чисел complex – еще один класс из стандартной библиотеки. Как обычно, для его использования нужно включить заголовочный файл:
#include <comp1ex>
Комплексное число состоит из двух частей – вещественной и мнимой. Мнимая часть представляет собой квадратный корень из отрицательного числа. Комплексное число принято записывать в виде
2 + 3i
где 2 – действительная часть, а 3i – мнимая. Вот примеры определений объектов типа complex:
// чисто мнимое число: 0 + 7-i
comp1ex< double > purei( 0, 7 );
// мнимая часть равна 0: 3 + Oi
comp1ex< float > rea1_num( 3 );
// и вещественная, и мнимая часть равны 0: 0 + 0-i
comp1ex< long double > zero;
// инициализация одного комплексного числа другим
| comp1ex< double > purei2( purei );
Поскольку complex, как и vector, является шаблоном, мы можем конкретизировать его типами float, double и long double, как в приведенных примерах. Можно также определить массив элементов типа complex:
complex< double > conjugate[ 2 ] = {
complex< double >( 2, 3 ),
complex< double >( 2, -3 )
| };
Вот как определяются указатель и ссылка на комплексное число:
complex< double > *ptr = &conjugate[0];
| complex< double > &ref = *ptr;
Комплексные числа можно складывать, вычитать, умножать, делить, сравнивать, получать значения вещественной и мнимой части. (Более подробно мы будем говорить о классе complex в разделе 4.6.)
Директива typedef
Директива typedef позволяет задать синоним для встроенного либо пользовательского типа данных. Например:
typedef double wages;
typedef vector<int> vec_int;
typedef vec_int test_scores;
typedef bool in_attendance;
| typedef int *Pint;
Имена, определенные с помощью директивы typedef, можно использовать точно так же, как спецификаторы типов:
// double hourly, weekly;
wages hourly, weekly;
// vector<int> vecl( 10 );
vec_int vecl( 10 );
// vector<int> test0( c1ass_size );
const int c1ass_size = 34;
test_scores test0( c1ass_size );
// vector< bool > attendance;
vector< in_attendance > attendance( c1ass_size );
// int *table[ 10 ];
| Pint table [ 10 ];
Эта директива начинается с ключевого слова typedef, за которым идет спецификатор типа, и заканчивается идентификатором, который становится синонимом для указанного типа.
Для чего используются имена, определенные с помощью директивы typedef? Применяя мнемонические имена для типов данных, можно сделать программу более легкой для восприятия. Кроме того, принято употреблять такие имена для сложных составных типов, в противном случае воспринимаемых с трудом (см. пример в разделе 3.14), для объявления указателей на функции и функции-члены класса (см. раздел 13.6).
Ниже приводится пример вопроса, на который почти все дают неверный ответ. Ошибка вызвана непониманием директивы typedef как простой текстовой макроподстановки. Дано определение:
typedef char *cstring;
Каков тип переменной cstr в следующем объявлении:
extern const cstring cstr;
Ответ, который кажется очевидным:
const char *cstr
Однако это неверно. Спецификатор const относится к cstr, поэтому правильный ответ – константный указатель на char:
char *const cstr;
Спецификатор volatile
Объект объявляется как volatile (неустойчивый, асинхронно изменяемый), если его значение может быть изменено незаметно для компилятора, например переменная, обновляемая значением системных часов. Этот спецификатор сообщает компилятору, что не нужно производить оптимизацию кода для работы с данным объектом.
Спецификатор volatile используется подобно спецификатору const:
volatile int disp1ay_register;
volatile Task *curr_task;
volatile int ixa[ max_size ];
| volatile Screen bitmap_buf;
display_register – неустойчивый объект типа int. curr_task – указатель на неустойчивый объект класса Task. ixa – неустойчивый массив целых, причем каждый элемент такого массива считается неустойчивым. bitmap_buf – неустойчивый объект класса Screen, каждый его член данных также считается неустойчивым.
Единственная цель использования спецификатора volatile – сообщить компилятору, что тот не может определить, кто и как может изменить значение данного объекта. Поэтому компилятор не должен выполнять оптимизацию кода, использующего данный объект.
Класс pair
Класс pair (пара) стандартной библиотеки С++ позволяет нам определить одним объектом пару значений, если между ними есть какая-либо семантическая связь. Эти значения могут быть одинакового или разного типа. Для использования данного класса необходимо включить заголовочный файл:
#inc1ude <uti1ity>
Например, инструкция
pair< string, string > author( "James", "Joyce" );
создает объект author типа pair, состоящий из двух строковых значений.
Отдельные части пары могут быть получены с помощью членов first и second:
string firstBook;
if ( Joyce.first == "James" &&
Joyce.second == "Joyce" )
| firstBook = "Stephen Hero";
Если нужно определить несколько однотипных объектов этого класса, удобно использовать директиву typedef:
typedef pair< string, string > Authors;
Authors proust( "marcel", "proust" );
Authors joyce( "James", "Joyce" );
| Authors musil( "robert", "musi1" );
Вот другой пример употребления пары. Первое значение содержит имя некоторого объекта, второе – указатель на соответствующий этому объекту элемент таблицы.
class EntrySlot;
extern EntrySlot* 1ook_up( string );
typedef pair< string, EntrySlot* > SymbolEntry;
SymbolEntry current_entry( "author", 1ook_up( "author" ));
// ...
if ( EntrySlot *it = 1ook_up( "editor" ))
{
current_entry.first = "editor";
current_entry.second = it;
| }
(Мы вернемся к рассмотрению класса pair в разговоре о контейнерных типах в главе 6 и об обобщенных алгоритмах в главе 12.)
Типы классов
Механизм классов позволяет создавать новые типы данных; с его помощью введены типы string, vector, complex и pair, рассмотренные выше. В главе 2 мы рассказывали о концепциях и механизмах, поддерживающих объектный и объектно-ориентированный подход, на примере реализации класса Array. Здесь мы, основываясь на объектном подходе, создадим простой класс String, реализация которого поможет понять, в частности, перегрузку операций – мы говорили о ней в разделе 2.3. (Классы подробно рассматриваются в главах 13, 14 и 15. Мы дали краткое описание класса для того, чтобы приводить более интересные примеры. Читатель, только начинающий изучение С++, может пропустить этот раздел и подождать более систематического описания классов в следующих главах.)
Наш класс String должен поддерживать инициализацию объектом класса String, строковым литералом и встроенным строковым типом, равно как и операцию присваивания ему значений этих типов. Мы используем для этого конструкторы класса и перегруженную операцию присваивания. Доступ к отдельным символам String будет реализован как перегруженная операция взятия индекса. Кроме того, нам понадобятся: функция size() для получения информации о длине строки; операция сравнения объектов типа String и объекта String со строкой встроенного типа; а также операции ввода/вывода нашего объекта. В заключение мы реализуем возможность доступа к внутреннему представлению нашей строки в виде строки встроенного типа.
Определение класса начинается ключевым словом class, за которым следует идентификатор – имя класса, или типа. В общем случае класс состоит из секций, предваряемых словами public (открытая) и private (закрытая). Открытая секция, как правило, содержит набор операций, поддерживаемых классом и называемых методами или функциями-членами класса. Эти функции-члены определяют открытый интерфейс класса, другими словами, набор действий, которые можно совершать с объектами данного класса. В закрытую секцию обычно включают данные-члены, обеспечивающие внутреннюю реализацию. В нашем случае к внутренним членам относятся _string – указатель на char, а также _size типа int. _size будет хранить информацию о длине строки, а _string – динамически выделенный массив символов. Вот как выглядит определение класса:
#inc1ude <iostream>
class String;
istream& operator>>( istream&, String& );
ostream& operator<<( ostream&, const String& );
class String {
public:
// набор конструкторов
// для автоматической инициализации
// String strl; // String()
// String str2( "literal" ); // String( const char* );
// String str3( str2 ); // String( const String& );
String();
String( const char* );
String( const String& );
// деструктор
~String();
// операторы присваивания
// strl = str2
// str3 = "a string literal"
String& operator=( const String& );
String& operator=( const char* );
// операторы проверки на равенство
// strl == str2;
// str3 == "a string literal";
bool operator==( const String& );
bool operator==( const char* );
// перегрузка оператора доступа по индексу
// strl[ 0 ] = str2[ 0 ];
char& operator[]( int );
// доступ к членам класса
int size() { return _size; }
char* c_str() { return _string; }
private:
int _size;
char *_string;
| }
Класс String имеет три конструктора. Как было сказано в разделе 2.3, механизм перегрузки позволяет определять несколько реализаций функций с одним именем, если все они различаются количеством и/или типами своих параметров. Первый конструктор
String();
является конструктором по умолчанию, потому что не требует явного указания начального значения. Когда мы пишем:
String str1;
для str1 вызывается такой конструктор.
Два оставшихся конструктора имеют по одному параметру. Так, для
String str2("строка символов");
вызывается конструктор
String(const char*);
а для
String str3(str2);
конструктор
String(const String&);
Тип вызываемого конструктора определяется типом фактического аргумента. Последний из конструкторов, String(const String&), называется копирующим, так как он инициализирует объект копией другого объекта.
Если же написать:
String str4(1024);
то это вызовет ошибку компиляции, потому что нет ни одного конструктора с параметром типа int.
Объявление перегруженного оператора имеет следующий формат:
return_type operator op (parameter_list);
где operator – ключевое слово, а op – один из предопределенных операторов: +, =, ==, [] и так далее. (Точное определение синтаксиса см. в главе 15.) Вот объявление перегруженного оператора взятия индекса:
char& operator[] (int);
Этот оператор имеет единственный параметр типа int и возвращает ссылку на char. Перегруженный оператор сам может быть перегружен, если списки параметров отдельных конкретизаций различаются. Для нашего класса String мы создадим по два различных оператора присваивания и проверки на равенство.
Для вызова функции-члена применяются операторы доступа к членам – точка (.) или стрелка (->). Пусть мы имеем объявления объектов типа String:
String object("Danny");
String *ptr = new String ("Anna");
| String array[2];
Вот как выглядит вызов функции size() для этих объектов:
vector<int> sizes( 3 );
// доступ к члену для objects (.);
// objects имеет размер 5
sizes[ 0 ] = object.size();
// доступ к члену для pointers (->)
// ptr имеет размер 4
sizes[ 1 ] = ptr->size();
// доступ к члену (.)
// array[0] имеет размер 0
| sizes[ 2 ] = array[0].size();
Она возвращает соответственно 5, 4 и 0.
Перегруженные операторы применяются к объекту так же, как обычные:
String namel( "Yadie" );
String name2( "Yodie" );
// bool operator==(const String&)
if ( namel == name2 )
return;
else
// String& operator=( const String& )
| namel = name2;
Объявление функции-члена должно находиться внутри определения класса, а определение функции может стоять как внутри определения класса, так и вне его. (Обе функции size() и c_str() определяются внутри класса.) Если функция определяется вне класса, то мы должны указать, кроме всего прочего, к какому классу она принадлежит. В этом случае определение функции помещается в исходный файл, допустим, String.C, а определение самого класса – в заголовочный файл (String.h в нашем примере), который должен включаться в исходный:
// содержимое исходного файла: String.С
// включение определения класса String
#inc1ude "String.h"
// включение определения функции strcmp()
#inc1ude <cstring>
bool // тип возвращаемого значения
String:: // класс, которому принадлежит функция
operator== // имя функции: оператор равенства
(const String &rhs) // список параметров
{
if ( _size != rhs._size )
return false;
return strcmp( _strinq, rhs._string ) ?
false : true;
| }
Напомним, что strcmp() – функция стандартной библиотеки С. Она сравнивает две строки встроенного типа, возвращая 0 в случае равенства строк и ненулевое значение в случае неравенства. Условный оператор (?:) проверяет значение, стоящее перед знаком вопроса. Если оно истинно, возвращается значение выражения, стоящего слева от двоеточия, в противном случае – стоящего справа. В нашем примере значение выражения равно false, если strcmp() вернула ненулевое значение, и true – если нулевое. (Условный оператор рассматривается в разделе 4.7.)
Операция сравнения довольно часто используется, реализующая ее функция получилась небольшой, поэтому полезно объявить эту функцию встроенной (inline). Компилятор подставляет текст функции вместо ее вызова, поэтому время на такой вызов не затрачивается. (Встроенные функции рассматриваются в разделе 7.6.) Функция-член, определенная внутри класса, является встроенной по умолчанию. Если же она определена вне класса, чтобы объявить ее встроенной, нужно употребить ключевое слово inline:
inline bool
String::operator==(const String &rhs)
{
// то же самое
| }
Определение встроенной функции должно находиться в заголовочном файле, содержащем определение класса. Переопределив оператор == как встроенный, мы должны переместить сам текст функции из файла String.C в файл String.h.
Ниже приводится реализация операции сравнения объекта String со строкой встроенного типа:
inline bool
String::operator==(const char *s)
{
return strcmp( _string, s ) ? false : true;
| }
Имя конструктора совпадает с именем класса. Считается, что он не возвращает значение, поэтому не нужно задавать возвращаемое значение ни в его определении, ни в его теле. Конструкторов может быть несколько. Как и любая другая функция, они могут быть объявлены встроенными.
#include <cstring>
// default constructor
inline String::String()
{
_size = 0;
_string = 0;
}
inline String::String( const char *str )
{
if ( ! str ) {
_size = 0; _string = 0;
}
else {
_size = str1en( str );
_string = new char[ _size + 1 ];
strcpy( _string, str );
}
// copy constructor
inline String::String( const String &rhs )
{
size = rhs._size;
if ( ! rhs._string )
_string = 0;
else {
_string = new char[ _size + 1 ];
strcpy( _string, rhs._string );
}
| }
Поскольку мы динамически выделяли память с помощью оператора new, необходимо освободить ее вызовом delete, когда объект String нам больше не нужен. Для этой цели служит еще одна специальная функция-член – деструктор, автоматически вызываемый для объекта в тот момент, когда этот объект перестает существовать. (См. главу 7 о времени жизни объекта.) Имя деструктора образовано из символа тильды (~) и имени класса. Вот определение деструктора класса String. Именно в нем мы вызываем операцию delete, чтобы освободить память, выделенную в конструкторе:
inline String: :~String() { delete [] _string; }
В обоих перегруженных операторах присваивания используется специальное ключевое слово this.
Когда мы пишем:
String namel( "orville" ), name2( "wilbur" );
| namel = "Orville Wright";
this является указателем, адресующим объект name1 внутри тела функции операции присваивания.
this всегда указывает на объект класса, через который происходит вызов функции. Если
obj[ 1024 ];
то внутри size() значением this будет адрес, хранящийся в ptr. Внутри операции взятия индекса this содержит адрес obj. Разыменовывая this (использованием *this), мы получаем сам объект. (Указатель this детально описан в разделе 13.4.)
inline String&
String::operator=( const char *s )
{
if ( ! s ) {
_size = 0;
delete [] _string;
_string = 0;
}
else {
_size = str1en( s );
delete [] _string;
_string = new char[ _size + 1 ];
strcpy( _string, s );
}
| return *this;
}
При реализации операции присваивания довольно часто допускают одну ошибку: забывают проверить, не является ли копируемый объект тем же самым, в который происходит копирование. Мы выполним эту проверку, используя все тот же указатель this:
inline String&
String::operator=( const String &rhs )
{
// в выражении
// namel = *pointer_to_string
// this представляет собой name1,
// rhs - *pointer_to_string.
| if ( this != &rhs ) {
Вот полный текст операции присваивания объекту String объекта того же типа:
inline String&
String::operator=( const String &rhs )
{
if ( this != &rhs ) {
delete [] _string;
_size = rhs._size;
if ( ! rhs._string )
_string = 0;
else {
_string = new char[ _size + 1 ];
strcpy( _string, rhs._string );
}
}
return *this;
| }
Операция взятия индекса практически совпадает с ее реализацией для массива Array, который мы создали в разделе 2.3:
#include <cassert>
inline char&
String::operator[] ( int elem )
{
assert( elem >= 0 && elem < _size );
return _string[ elem ];
| }
Операторы ввода и вывода реализуются как отдельные функции, а не члены класса. (О причинах этого мы поговорим в разделе 15.2. В разделах 20.4 и 20.5 рассказывается о перегрузке операторов ввода и вывода библиотеки iostream.) Наш оператор ввода может прочесть не более 4095 символов. setw() – предопределенный манипулятор, он читает из входного потока заданное число символов минус 1, гарантируя тем самым, что мы не переполним наш внутренний буфер inBuf. (В главе 20 манипулятор setw() рассматривается детально.) Для использования манипуляторов нужно включить соответствующий заголовочный файл:
#include <iomanip>
inline istream&
operator>>( istream &io, String &s )
{
// искусственное ограничение: 4096 символов
const int 1imit_string_size = 4096;
char inBuf[ limit_string_size ];
// setw() входит в библиотеку iostream
// он ограничивает размер читаемого блока до 1imit_string_size-l
io >> setw( 1imit_string_size ) >> inBuf;
s = mBuf; // String::operator=( const char* );
return io;
| }
Оператору вывода необходим доступ к внутреннему представлению строки String. Так как operator<< не является функцией-членом, он не имеет доступа к закрытому члену данных _string. Ситуацию можно разрешить двумя способами: объявить operator<< дружественным классу String, используя ключевое слово friend (дружественные отношения рассматриваются в разделе 15.2), или реализовать встраиваемую (inline) функцию для доступа к этому члену. В нашем случае уже есть такая функция: c_str() обеспечивает доступ к внутреннему представлению строки. Воспользуемся ею при реализации операции вывода:
inline ostream&
operator<<( ostream& os, const String &s )
{
return os << s.c_str();
| }
Ниже приводится пример программы, использующей класс String. Эта программа берет слова из входного потока и подсчитывает их общее число, а также количество слов "the" и "it" и регистрирует встретившиеся гласные.
#include <iostream>
#inc1ude "String.h"
int main() {
int aCnt = 0, eCnt = 0, iCnt = 0, oCnt = 0, uCnt = 0,
theCnt = 0, itCnt = 0, wdCnt = 0, notVowel = 0;
// Слова "The" и "It"
// будем проверять с помощью operator==( const char* )
String but, the( "the" ), it( "it" );
// operator>>( ostream&, String& )
while ( cin >> buf ) {
++wdCnt;
// operator<<( ostream&, const String& )
cout << buf << ' ';
if ( wdCnt % 12 == 0 )
cout << endl;
// String::operator==( const String& ) and
// String::operator==( const char* );
if ( buf == the | | buf == "The" )
++theCnt;
else
if ( buf == it || buf == "It" )
++itCnt;
// invokes String::s-ize()
for ( int ix =0; ix < buf.sizeO; ++ix )
{
// invokes String:: operator [] (int)
switch( buf[ ix ] )
{
case 'a': case 'A': ++aCnt; break;
case 'e': case 'E': ++eCnt; break;
case 'i': case 'I': ++iCnt; break;
case 'o': case '0': ++oCnt; break;
case 'u': case 'U': ++uCnt; break;
default: ++notVowe1; break;
}
}
}
// operator<<( ostream&, const String& )
cout << "\n\n"
<< "Слов: " << wdCnt << "\n\n"
<< "the/The: " << theCnt << '\n'
<< "it/It: " << itCnt << "\n\n"
<< "согласных: " < <notVowel << "\n\n"
<< "a: " << aCnt << '\n'
<< "e: " << eCnt << '\n'
<< "i: " << ICnt << '\n'
<< "o: " << oCnt << '\n'
<< "u: " << uCnt << endl;
| }
Протестируем программу: предложим ей абзац из детского рассказа, написанного одним из авторов этой книги (мы еще встретимся с этим рассказом в главе 6). Вот результат работы программы:
Alice Emma has long flowing red hair. Her Daddy says when the
wind blows through her hair, it looks almost alive, 1ike a fiery
bird in flight. A beautiful fiery bird, he tells her, magical but
untamed. "Daddy, shush, there is no such thing," she tells him, at
the same time wanting him to tell her more. Shyly, she asks,
"I mean, Daddy, is there?"
Слов: 65
the/The: 2
it/It: 1
согласных: 190
a: 22
e: 30
i: 24
о: 10
u: 7
Упражнение 3.26
В наших реализациях конструкторов и операций присваивания содержится много повторов. Попробуйте вынести повторяющийся код в отдельную закрытую функцию-член, как это было сделано в разделе 2.3. Убедитесь, что новый вариант работоспособен.
Упражнение 3.27
Модифицируйте тестовую программу так, чтобы она подсчитывала и согласные b, d, f, s, t.
Упражнение 3.28
Напишите функцию-член, подсчитывающую количество вхождений символа в строку String, используя следующее объявление:
class String {
public:
// ...
int count( char ch ) const;
// ...
| };
Упражнение 3.29
Реализуйте оператор конкатенации строк (+) так, чтобы он конкатенировал две строки и возвращал результат в новом объекте String. Вот объявление функции:
class String {
public:
// ...
String operator+( const String &rhs ) const;
// ...
| };
Выражения
В главе 3 мы рассмотрели типы данных – как встроенные, так и предоставленные стандартной библиотекой. Здесь мы разберем предопределенные операции, такие, как сложение, вычитание, сравнение и т.п., рассмотрим их приоритеты. Скажем, результатом выражения 3+4*5 является 23, а не 35 потому, что операция умножения (*) имеет более высокий приоритет, чем операция сложения (+). Кроме того, мы обсудим вопросы преобразований типов данных – и явных, и неявных. Например, в выражении 3+0.7 целое значение 3 станет вещественным перед выполнением операции сложения.
Что такое выражение?
Выражение состоит из одного или более операндов, в простейшем случае – из одного литерала или объекта. Результатом такого выражения является r-значение его операнда. Например:
void mumble() {
3.14159;
"melancholia";
upperBound;
| }
Результатом вычисления выражения 3.14159 станет 3.14159 типа double, выражения "melancholia" – адрес первого элемента строки типа const char*. Значение выражения upperBound – это значение объекта upperBound, а его типом будет тип самого объекта.
Более общим случаем выражения является один или более операндов и некоторая операция, применяемая к ним:
salary + raise
ivec[ size/2 ] * delta
| first_name + " " + 1ast_name
Операции обозначаются соответствующими знаками. В первом примере сложение применяется к salary и raise. Во втором выражении size делится на 2. Частное используется как индекс для массива ivec. Получившийся в результате операции взятия индекса элемент массива умножается на delta. В третьем примере два строковых объекта конкатенируются между собой и со строковым литералом, создавая новый строковый объект.
Операции, применяемые к одному операнду, называются унарными (например, взятие адреса (&) и разыменование (*)), а применяемые к двум операндам – бинарными. Один и тот же символ может обозначать разные операции в зависимости от того, унарна она или бинарна. Так, в выражении
*ptr
* представляет собой унарную операцию разыменования. Значением этого выражения является значение объекта, адрес которого содержится в ptr. Если же написать:
var1 * var2
то звездочка будет обозначать бинарную операцию умножения.
Результатом вычисления выражения всегда, если не оговорено противное, является r-значение. Тип результата арифметического выражения определяется типами операндов. Если операнды имеют разные типы, производится преобразование типов в соответствии с предопределенным набором правил. (Мы детально рассмотрим эти правила в разделе 4.14.)
Выражение может являться составным, то есть объединять в себе несколько подвыражений. Вот, например, выражение, проверяющее на неравенство нулю указатель и объект, на который он указывает (если он на что-то указывает)[7]:
ptr != 0 && *ptr != 0
Выражение состоит из трех подвыражений: проверку указателя ptr, разыменования ptr и проверку результата разыменования. Если ptr определен как
int *ptr = &ival;
то результатом разыменования будет 1024 и оба сравнения дадут истину. Результатом всего выражения также будет истина (оператор && обозначает логическое И).
Если посмотреть на этот пример внимательно, можно заметить, что порядок выполнения операций очень важен. Скажем, если бы операция разыменования ptr производилась до его сравнения с 0, в случае нулевого значения ptr это скорее всего вызвало бы крах программы. В случае операции И порядок действий строго определен: сначала оценивается левый операнд, и если его значение равно false, правый операнд не вычисляется вовсе. Порядок выполнения операций определяется их приоритетами, не всегда очевидными, что вызывает у начинающих программистов на С и С++ множество ошибок. Приоритеты будут приведены в разделе 4.13, а пока мы расскажем обо всех операциях, определенных в С++, начиная с наиболее привычных.
Арифметические операции
Таблица 4.1. Арифметические операции
Символ операции
| Значение
| Использование
| *
| Умножение
| expr * expr
| /
| Деление
| expr / expr
| %
| Остаток от деления
| expr % expr
| +
| Сложение
| expr + expr
| -
| Вычитание
| expr – expr
|
Деление целых чисел дает в результате целое число. Дробная часть результата, если она есть, отбрасывается:
int iva12 = 21 / 7;
И ival1, и ival2 в итоге получат значение 3.
Операция остаток (%), называемая также делением по модулю, возвращает остаток от деления первого операнда на второй, но применяется только к операндам целого типа (char, short, int, long). Результат положителен, если оба операнда положительны. Если же один или оба операнда отрицательны, результат зависит от реализации, то есть машинно-зависим. Вот примеры правильного и неправильного использования деления по модулю:
3.14 % 3; // ошибка: операнд типа double
21 % 6; // правильно: 3
21 % 7; // правильно: 0
21 % -5; // машинно-зависимо: -1 или 1
int iva1 = 1024;
double dval = 3.14159;
iva1 % 12; // правильно:
| iva1 % dval; // ошибка: операнд типа double
Иногда результат вычисления арифметического выражения может быть неправильным либо не определенным. В этих случаях говорят об арифметических исключениях (хотя они не вызывают возбуждения исключения в программе). Арифметические исключения могут иметь чисто математическую природу (скажем, деление на 0) или происходить от представления чисел в компьютере – как переполнение (когда значение превышает величину, которая может быть выражена объектом данного типа). Например, тип char содержит 8 бит и способен хранить значения от 0 до 255 либо от -128 до 127 в зависимости от того, знаковый он или беззнаковый. В следующем примере попытка присвоить объекту типа char значение 256 вызывает переполнение:
Не нашли, что искали? Воспользуйтесь поиском по сайту:
©2015 - 2024 stydopedia.ru Все материалы защищены законодательством РФ.
|