Язык программирования C++. Вводный курс

         

Наследование и подтипизация классов


В главе 6 для иллюстрации обсуждения абстрактных контейнерных типов мы частично реализовали систему текстового поиска и инкапсулировали ее в класс TextQuery. Однако мы не написали к ней никакой вызывающей программы, отложив реализацию поддержки формулирования запросов со стороны пользователя до рассмотрения объектно-ориентированного программирования. В этой главе язык запросов будет реализован в виде иерархии классов Query с одиночным наследованием. Кроме того, мы модифицируем и расширим класс TextQuery из главы 6 для получения полностью интегрированной системы текстового поиска.

Программа для запуска нашей системы текстового поиска будет выглядеть следующим образом:

#include "TextQuery.h"

int main()

{

   TextQuery tq;

   tq.build_up_text();

   tq.query_text();

}

build_text_map() – это слегка видоизмененная функция-член doit() из главы 6. Ее основная задача – построить отображение для хранения позиций всех значимых слов текста. (Если помните, мы не храним семантически нейтральные слова типа союзов if, and, but и т.д. Кроме того, мы заменяем заглавные буквы на строчные и устраняем суффиксы, обозначающие множественное число: например, testifies преобразуется в testify, а marches в march.) С каждым словом ассоциируется вектор позиций, в котором хранятся номера строки и колонки каждого вхождения слова в текст.

query_text()

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

Enter a query - please separate each item by a space.

Terminate query (or session) with a dot( . ).

==> fiery && ( bird || shyly )

         fiery ( 1 ) lines match

         bird ( 1 ) lines match

         shyly ( 1 ) lines match


          ( bird || shyly ) ( 2 ) lines match

         fiery && ( bird || shyly ) ( 1 ) lines match

Requested query: fiery && ( bird || shyly )

( 3 ) like a fiery bird in flight. A beautiful fiery bird, he tells her.

В нашей системе мы выбрали следующий язык запросов:

·                  одиночное слово, например Alice или untamed. Выводятся все строки, в которых оно встречается, причем каждой строке предшествует ее номер, заключенный в скобки. (Строки печатаются в порядке возрастания номеров). Например:

==> daddy

    daddy ( 3 ) lines match

Requested query: daddy

( 1 ) Alice Emma has long flowing red hair. Her Daddy says

( 4 ) magical but untamed. "Daddy, shush, there is no such thing,"

( 6 ) Shyly, she asks, "I mean, Daddy, is there?"

·                  запрос “НЕ”, формулируемый с помощью оператора !. Выводятся все строки, где не встречается указанное слово. Например, так формулируется отрицание запроса 1:

==> ! daddy

    daddy ( 3 ) lines match

    ! daddy ( 3 ) lines match

Requested query: ! daddy

( 2 ) when the wind blows through her hair, it looks almost alive,

( 3 ) like a fiery bird in flight. A beautiful fiery bird, he tells her,

( 5 ) she tells him, at the same time wanting him to tell her more.

запрос “ИЛИ”, формулируемый с помощью оператора ||. Выводятся все строки, в которых встречается хотя бы одно из двух указанных слов:

==> fiery || untamed

        fiery ( 1 ) lines match

        untamed ( 1 ) lines match

        fiery || untamed ( 2 ) lines match

Requested query: fiery || untamed

( 3 ) like a fiery bird in flight. A beautiful fiery bird, he tells her,

( 4 ) magical but untamed. "Daddy, shush, there is no such thing,"

запрос “И”, формулируемый с помощью оператора &&. Выводятся все строки, где оба указанных слова встречаются, причем располагаются рядом. Сюда входит и случай, когда одно слово является последним в строке, а другое – первым в следующей:



==> untamed && Daddy

        untamed ( 1 ) lines match

        daddy ( 3 ) lines match

        untamed && daddy ( 1 ) lines match

Requested query: untamed && daddy

( 4 ) magical but untamed. "Daddy, shush, there is no such thing,"

Эти элементы можно комбинировать:

fiery && bird || shyly

Однако обработка производится слева направо, и все элементы имеют одинаковые приоритеты. Поэтому наш составной запрос интерпретируется как fiery bird ИЛИ shyly, а не как fiery bird ИЛИ fiery shyly:

==> fiery && bird || shyly

        fiery ( 1 ) lines match

        bird ( 1 ) lines match

        fiery && bird ( 1 ) lines match

        shyly ( 1 ) lines match

        fiery && bird || shyly ( 2 ) lines match

Requested query: fiery && bird || shyly

( 3 ) like a fiery bird in flight. A beautiful fiery bird, he tells her,

( 6 ) Shyly, she asks, "I mean, Daddy, is there?"

Чтобы можно было группировать части запроса, наша система должна поддерживать скобки. Например:

fiery && (bird || shyly)

выдает все вхождения fiery bird или fiery shyly1. [O.A.5] Результат исполнения этого запроса приведен в начале данного раздела. Кроме того, система не должна многократно отображать одну и ту же строку.


Навигация по элементам отображения


После того как мы построили отображение, хотелось бы распечатать его содержимое. Мы можем сделать это, используя итератор, начальное и конечное значение которого получают с помощью функций-членов begin() и end(). Вот текст функции display_map_text():

void

display_map_text( map<string,loc*> *text_map )

{

    typedef map<string,loc*> tmap;

    tmap::iterator iter = text_map->begin(),

    iter_end = text_map->end();

    while ( iter != iter_end )

    {

        cout << "word: " << (*iter).first << " (";

        int loc_cnt = 0;

        loc *text_locs = (*iter).second;

        loc::iterator liter = text_locs->begin(),

                      liter_end = text_locs->end();

        while (liter != liter_end ) {

            if ( loc_cnt )

                cout << ',';

            else ++loc_cnt;

            cout << '(' << (*liter).first

                 << ',' << (*liter).second << ')';

            ++liter;

        }

        cout << ")\n";

        ++iter;

    }

    cout << endl;

}

Если наше отображение не содержит элементов, данная функция не нужна. Проверить, пусто ли оно, можно с помощью функции-члена size():

if ( text_map->size() )

    display_map_text( text_map );

Но более простым способом, без подсчета элементов, будет вызов функции-члена empty():

if ( ! text_map->empty() )

    display_map_text( text_map );



Навигация по множеству


Для проверки наших кодов реализуем небольшую функцию, выполняющую поиск по одному слову (поддержка языка запросов будет добавлена в главе 17). Если слово найдено, мы будем показывать каждую строку, в которой оно содержится. Слово может повторяться в строке, например:

tomorrow and tomorrow and tomorrow

однако такая строка будет представлена только один раз.

Одним из способов не учитывать повторное вхождение слова в строку является использование множества, как показано в следующем фрагменте кода:

// получим указатель на вектор позиций

loc ploc = (*text_map)[ query_text ];

// переберем все позиции

// вставим все номера строк в множество

set< short > occurrence_lines;

loc::iterator liter = ploc->begin(),

              liter_end = ploc->end();

while ( liter != liter_end ) {

    occurrence_lines.insert( occurrence_lines.end(),

          (*liter).first );

    ++liter;

}

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

register int size = occurrence_lines.size();

cout << "\n" << query_text

     << " встречается " << size

     << " раз(а):")

     << "\n\n";

set< short >::iterator it=occurrence_lines.begin();

for ( ; it != occurrence_lines.end(); ++it ) {

    int line = -it;

    cout << "\t( строка "

         << line + 1 << " ) "

         << (*text_file)[line] << endl;

}

(Полная реализация query_text() представлена в следующем разделе.)

Класс set

поддерживает операции size(), empty() и erase()

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

(объединение) и set_difference()

(разность). (Они использованы при реализации языка запросов в главе 17.)

Упражнение 6.23

Добавьте в программу множество слов, в которых заключающее 's' не подчиняется общим правилам и не должно удаляться. Примерами таких слов могут быть Pythagoras, Brahms и Burne_Jones. Включите в функцию suffix_s() из раздела 6.10 проверку этого набора.

Упражнение 6.24

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



Неявное преобразование типов


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

·                  арифметическое выражение с операндами разных типов: все операнды приводятся к наибольшему типу из встретившихся. Это называется арифметическим преобразованием. Например:

int   ival = 3;

double dva1 = 3.14159;

// ival преобразуется в double: 3.0

ival + dva1;

·                  присваивание значения выражения одного типа объекту другого типа. В этом случае результирующим является тип объекта, которому значение присваивается. Так, в первом примере литерал 0

типа int

присваивается указателю типа int*, значением которого будет 0. Во втором примере double

преобразуется в int.

// 0 преобразуется в нулевой указатель типа int*

int *pi = 0;

// dva1 преобразуется в int: 3

ivat = dva1;

·                  передача функции аргумента, тип которого отличается от типа соответствующего формального параметра. Тип фактического аргумента приводится к типу параметра:

extern double sqrt( double );

// 2 преобразуется в double: 2.0

cout << "Квадратный корень из 2: " << sqrt( 2 ) << endt;

·                  возврат из функции значения, тип которого не совпадает с типом возвращаемого результата, заданным в объявлении функции. Тип фактически возвращаемого значения приводится к объявленному. Например:

double difference( int ivati, int iva12 )

{

    // результат преобразуется в double

    return ivati - iva12;

}



Неявный указатель this


У каждого объекта класса есть собственная копия данных-членов. Например:

int main() {

   Screen myScreen( 3, 3 ), bufScreen;

   myScreen.clear();

   myScreen.move( 2, 2 );

   myScreen.set( '*' );

   myScreen.display();

   bufScreen.resize( 5, 5 );

   bufScreen.display();

}

У объекта myScreen

есть свои члены _width, _height, _cursor и _screen, а у объекта bufScreen – свои. Однако каждая функция-член класса существует в единственном экземпляре. Их и вызывают myScreen и bufScreen.

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

выглядит следующим образом:

inline void Screen::move( int r, int c )

{

   if ( checkRange( r, c ) )      // позиция на экране задана корректно?

   {

      int row = (r-1) * _width;   // смещение строки

      _cursor = row + c - 1;

   }

}

Если функция move()

вызывается для объекта myScreen, то члены _width и _height, к которым внутри нее имеются обращения, – это члены объекта myScreen. Если же она вызывается для объекта bufScreen, то и обращения производятся к членам данного объекта. Каким же образом _cursor, которым манипулирует move(), оказывается членом то myScreen, то bufScreen? Дело в указателе this.

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

указатель с тем же спецификатором. Например, внутри функции-члена move()

класса Screen

указатель this

имеет тип Screen*, а в неконстантной функции-члене List – тип List*.

Поскольку this

адресует объект, для которого вызвана функция-член, то при вызове move() для myScreen он указывает на объект myScreen, а при вызове для bufScreen – на объект bufScreen. Таким образом, член _cursor, с которым работает функция move(), в первом случае принадлежит объекту myScreen, а во втором – bufScreen.


Понять все это можно, если представить себе, как компилятор реализует объект this. Для его поддержки необходимо две трансформации:

1.      Изменить определение функции-члена класса, добавив дополнительный параметр:



// псевдокод, показывающий, как происходит расширение

// определения функции-члена

// ЭТО НЕ КОРРЕКТНЫЙ КОД C++

inline void Screen::move( Screen *this, int r, int c )

{

   if ( checkRange( r, c ) )

   {

      int row = (r-1) * this->_width;

      this->_cursor = row + c - 1;

   }
}

В этом определении использование указателя this для доступа к членам _width и _cursor

сделано явным.

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

myScreen.move( 2, 2 );

транслируется в

move( &myScreen, 2, 2 );

Программист может явно обращаться к указателю this внутри функции. Так, вполне корректно, хотя и излишне, определить функцию-член home()

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



inline void Screen::home()

{

   this->_cursor = 0;
}

Однако бывают случаи, когда без такого обращения не обойтись, как мы видели на примере функции-члена copy()

класса Screen. В следующем подразделе мы рассмотрим и другие примеры.


Немного о комментариях


Комментарии помогают человеку читать текст программы; писать их грамотно считается правилом хорошего тона. Комментарии могут характеризовать используемый алгоритм, пояснять назначение тех или иных переменных, разъяснять непонятные места. При компиляции комментарии выкидываются из текста программы поэтому размер получающегося исполняемого модуля не увеличивается.

В С++ есть два типа комментариев. Один– такой же, как и в С, использующий символы /* для обозначения начала и */ для обозначения конца комментария. Между этими парами символов может находиться любой текст, занимающий одну или несколько строк: вся последовательность между /* и */

считается комментарием. Например:

/*

 * Это первое знакомство с определением класса в C++.

 * Классы используются как в объектном, так и в

 * объектно-ориентированном программировании. Реализация

 * класса Screen представлена в главе 13.

*/

class Screen {

    /* Это называется телом класса */

public:

    void home();    /* переместить курсор в позицию 0,0 */

    void refresh ();/* перерисовать экран               */

private:

    /* Классы поддерживают "сокрытие информации"    */

    /* Сокрытие информации ограничивает доступ из   */

    /* программы к внутреннему представлению класса */

    /* (его данным). Для этого используется метка   */

    /* "private:"                                   */

    int height, width;

}

Слишком большое число комментариев, перемежающихся с кодом программы, может ухудшить читаемость текста. Например, объявления переменных width и height в данном тексте окружены комментариями и почти не заметны. Рекомендуется писать развернутое объяснение перед блоком текста. Как и любая программная документация, комментарии должны обновляться в процессе модификации кода. Увы, нередко случается, что они относятся к устаревшей версии.

Комментарии в стиле С не могут быть вложенными. Попробуйте откомпилировать нижеследующую программу в своей системе. Большинство компиляторов посчитают ее ошибочной:




#include <iostream>

/* комментарии /* */ не могут быть вложенными.

 * Строку "не вкладываются" компилятор рассматривает,

 * как часть программы. Это же относится к данной и следующей строкам

 */

int main() {

    cout << "Здравствуй, мир\n";
}

Один из способов решить проблему вложенных комментариев – поставить пробел между звездочкой и косой чертой:

/* * /

Последовательность символов */

считается концом комментария только в том случае, если между ними нет пробела.

Второй тип комментариев – однострочный. Он начинается последовательностью символов // и ограничен концом строки. Часть строки вправо от двух косых черт игнорируется компилятором. Вот пример нашего класса Screen с использованием двух строчных комментариев:



/*

 * Первое знакомство с определением класса в C++.

 * Классы используются как в объектном, так и в

 * объектно-ориентированном программировании. Реализация

 * класса Screen представлена в главе 13.

 */

class Screen {

    // Это называется телом класса

public:

    void home();     // переместить курсор в позицию 0,0

    void refresh (); // перерисовать экран

private:

    /* Классы поддерживают "сокрытие информации".    */

    /* Сокрытие информации ограничивает доступ из   */

    /* программы к внутреннему представлению класса */

    /* (его данным). Для этого используется метка   */

    /* "private:"                                   */

    int height, width;
}

Обычно в программе употребляют сразу оба типа комментариев. Строчные комментарии удобны для кратких пояснений – в одну или полстроки, а комментарии, ограниченные /* и */, лучше подходят для развернутых многострочных пояснений.


Неоднозначность


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

class String {

   // ...

public:

   String( const char * = 0 );

   bool operator== ( const String & ) const;

   // нет оператора operator== ( const char * )

};

и такое использование оператора operator==:

String flower( "tulip" );

void foo( const char *pf ) {

   // вызывается перегруженный оператор String::operator==()

   if ( flower == pf )

      cout << pf << " is a flower!\en";

      // ...

}

Тогда при сравнении

flower == pf

вызывается оператор равенства класса String:

String::operator==( const String & ) const;

Для трансформации правого операнда pf из типа const char* в тип String

параметра operator==()

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

String( const char * )

Если добавить в определение класса String

конвертер в тип const char*:

class String {

   // ...

public:

   String( const char * = 0 );

   bool operator== ( const String & ) const;

   operator const char*();  // новый конвертер

};

то показанное использование operator==()

становится неоднозначным:

// проверка на равенство больше не компилируется!

if (flower == pf)

Из-за добавления конвертера operator const

char*()

встроенный оператор сравнения

bool operator==( const char *, const char * )

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

типа String

может быть преобразован в тип const char *.

Теперь для использования operator==() в foo()

есть две устоявших операторных функции. Первая из них

String::operator==( const String & ) const;

требует применения определенного пользователем преобразования правого операнда pf из типа const char* в тип String. Вторая

bool operator==( const char *, const char * )


требует применения пользовательского преобразования левого операнда flower из типа String в тип const char*.

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

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

Упражнение 15.17

Назовите пять множеств функций-кандидатов, рассматриваемых при разрешении перегрузки оператора с операндами типа класса.

Упражнение 15.18

Какой из операторов operator+()

будет выбран в качестве наилучшего из устоявших для оператора сложения в main()? Перечислите все функции-кандидаты, все устоявшие функции и преобразования типов, которые надо применить к аргументам для каждой устоявшей функции.



namespace NS {

   class complex {

      complex( double );

      // ...

   };

   class LongDouble {

      friend LongDouble operator+( LongDouble &, int ) { /* ... */ }

   public:

      LongDouble( int );

      operator double();

      LongDouble operator+( const complex & );

      // ...

   };

   LongDouble operator+( const LongDouble &, double );

}

int main() {

   NS::LongDouble ld(16.08);

   double res = ld + 15.05;   // какой operator+?

   return 0;
}

16


Несколько слов о заголовочных файлах


Заголовочный файл предоставляет место для всех extern-объявлений объектов, объявлений функций и определений встроенных функций. Это называется локализацией объявлений. Те исходные файлы, где объект или функция определяется или используется, должны включать

заголовочный файл.

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

Пример с addToken()

имеет следующий заголовочный файл:

// ----- token.h -----

typedef unsigned char uchar;

const uchar INLINE = 128;

// ...

const uchar IT = ...;

const uchar GT = ...;

extern uchar lastTok;

extern int addToken( uchar );

inline bool is_relational( uchar tok )

  { return (tok >= LT && tok <= GT); }

// ----- lex.C -----

#include "token.h"

// ...

// ----- token.C -----

#include   "token.h"

// ...

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

Чтобы это стало возможным, заголовочный файл не должен содержать объявлений встроенных (inline) функций и объектов. Любая из следующих инструкций является определением и, следовательно, не может быть использована в заголовочном файле:

extern int ival = 10;

double fica_rate;

<
extern void dummy () {}

Хотя переменная i

объявлена с ключевым словом extern, явная инициализация превращает ее объявление в определение. Точно так же и функция dummy(), несмотря на явное объявление как extern, определяется здесь же: пустые фигурные скобки содержат ее тело. Переменная fica_rate определяется и без явной инициализации: об этом говорит отсутствие ключевого слова extern.

Включение такого заголовочного файла в два или более исходных файла одной программы вызовет ошибку связывания – повторные определения объектов.

В файле token.h, приведенном выше, константа INLINE и встроенная функция is_relational()

кажутся нарушающими правило. Однако это не так.

Определения символических констант и встроенных функций являются специальными видами определений: те и другие могут появиться в программе несколько раз.

При возможности компилятор заменяет имя символической константы ее значением. Этот процесс называют подстановкой константы. Например, компилятор подставит 128 вместо INLINE

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

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



// ----- заголовочный файл -----

const int buf_chunk = 1024;

extern char *const bufp;

// ----- исходный файл -----
char *const bufp = new char[buf_chunk];

Хотя bufp

объявлена как const, ее значение не может быть вычислено во время компиляции (она инициализируется с помощью оператора new, который требует вызова библиотечной функции). Такая конструкция в заголовочном файле означала бы, что константа определяется каждый раз, когда этот заголовочный файл включается. Символическая константа – это любой объект, объявленный со спецификатором const. Можете ли вы сказать, почему следующее объявление, помещенное в заголовочный файл, вызывает ошибку связывания, если такой файл включается в два различных исходных?





// ошибка: не должно быть в заголовочном файле
const char* msg = "?? oops: error: ";

Проблема вызвана тем, что msg не константа. Это неконстантный указатель, адресующий константу. Правильное объявление выглядит так (полное описание объявлений указателей см. в главе 3):

const char *const  msg = "?? oops: error: ";

Такое определение может появиться в разных файлах.

Схожая ситуация наблюдается и со встроенными функциями. Для того чтобы компилятор мог подставить тело функции “по месту”, он должен видеть ее определение. (Встроенные функции были представлены в разделе 7.6.)

Следовательно, встроенная функция, необходимая в нескольких исходных файлах, должна быть определена в заголовочном файле. Однако спецификация inline – только “совет” компилятору. Будет ли функция встроенной везде или только в данном конкретном месте, зависит от множества обстоятельств. Если компилятор пренебрегает спецификацией inline, он генерирует определение функции в исполняемом файле. Если такое определение появится в данном файле больше одного раза, это будет означать ненужную трату памяти.

Большинство компиляторов выдают предупреждение в любом из следующих случаев (обычно это требует включения режима выдачи предупреждений):

·                  само определение функции не позволяет встроить ее. Например, она слишком сложна. В таком случае попробуйте переписать функцию или уберите спецификацию inline и поместите определение функции в исходный файл;

·                  конкретный вызов функции может не быть “подставлен по месту”. Например, в оригинальной реализации С++ компании AT&T (cfront) такая подстановка невозможна для второго вызова в пределах одного и того же выражения. В такой ситуации выражение следует переписать, разделив вызовы встроенных функций.

Перед тем как употребить спецификацию inline, изучите поведение функции во время выполнения. Убедитесь, что ее действительно можно встроить. Мы не рекомендуем объявлять функции встроенными и помещать их определения в заголовочный файл, если они не могут быть таковыми по своей природе.



Упражнение 8.3

Установите, какие из приведенных ниже инструкций являются объявлениями, а какие – определениями, и почему:



(a) extern int ix = 1024;

(b) int iy;

(c) extern void reset( void *p ) { /* ... */ }

(d) extern const int *pi;
(e) void print( const matrix & );

Упражнение 8.4

Какие из приведенных ниже объявлений и определений вы поместили бы в заголовочный файл? В исходный файл? Почему?



(a) int var;

(b) inline bool is_equal( const SmallInt &, const SmallInt & ){ }

(c) void putValues( int *arr, int size );

(d) const double pi = 3.1416;
(e) extern int total = 255;


Объединение– класс, экономящий память


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

Рассмотрим пример, иллюстрирующий использование объединения. Лексический анализатор, входящий в состав компилятора, разбивает программу на последовательность лексем. Так, инструкция

int i = 0;

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

1.      Ключевое слово int.

2.      Идентификатор i.

3.      Оператор =

4.      Константа 0

типа int.

5.      Точка с запятой.

Лексический анализатор передает эти лексемы синтаксическому анализатору, парсеру, который идентифицирует полученную последовательность. Полученная информация должна дать парсеру возможность распознать эту последовательность лексем как объявление. Для этого с каждой лексемой ассоциируется информация, позволяющая парсеру увидеть следующее:

Type ID Assign Constant Semicolon

(Тип ИД Присваивание Константа Точка с запятой)

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

Type <==> int

ID <==> i

Constant <==> 0

Для Assign и Semicolon

дополнительной информации не нужно, так как у них может быть только одно значение: соответственно := и ;.

Таким образом, в представлении лексемы могло бы быть два члена – token и value. token – это уникальный код, показывающий, что лексема имеет тип Type, ID, Assign, Constant или Semicolon, например 85 для ID и 72 для Semicolon.value

содержит конкретное значение лексемы. Так, для лексемы ID в предыдущем объявлении value

будет содержать строку "i", а для лексемы Type – некоторое представление типа int.

Представление члена value


несколько проблематично. Хотя для любой отдельной лексемы в нем хранится всего одно значение, их типы для разных лексем могут различаться. Для лексемы ID в value

хранится строка символов, а для Constant – целое число.

Конечно, для хранения данных нескольких типов можно использовать класс. Разработчик компилятора может объявить, что value принадлежит к типу класса, в котором для каждого типа данных есть отдельный член.

Применение класса решает проблему представления value. Однако для любой данной лексемы value

имеет лишь один из множества возможных типов и, следовательно, будет задействован только один член класса, хотя памяти выделяется столько, сколько нужно для хранения всех членов. Чтобы память резервировалась только для нужного в данный момент члена, применяется объединение. Вот как оно определяется:



union TokenValue {

   char _cval;

   int _ival;

   char *_sval;

   double _dval;
};

Если самым большим типом среди всех членов TokenValue является dval, то размер TokenValue

будет равен размеру объекта типа double. По умолчанию члены объединения открыты. Имя объединения можно использовать в программе всюду, где допустимо имя класса:



// объект типа TokenValue

TokenValue last_token;

// указатель на объект типа TokenValue
TokenValue *pt = new TokenValue;

Обращение к членам объединения, как и к членам класса, производится с помощью операторов доступа:



last_token._ival = 97;
char ch = pt->_cval;

Члены объединения можно объявлять открытыми, закрытыми или защищенными:



union TokenValue {

public:

   char _cval;

   // ...

private:

   int priv;

}

int main() {

   TokenValue tp;

   tp._cval = '\n';   // правильно

   // ошибка: main() не может обращаться к закрытому члену

   //         TokenValue::priv

   tp.priv = 1024;
}

У объединения не бывает статических членов или членов, являющихся ссылками. Его членом не может быть класс, имеющий конструктор, деструктор или копирующий оператор присваивания. Например:





union illegal_members {

   Screen s;      // ошибка: есть конструктор

   Screen *ps;    // правильно

   static int is; // ошибка: статический член

   int &rfi;      // ошибка: член-ссылка
};

Для объединения разрешается определять функции-члены, включая конструкторы и деструкторы:



union TokenValue {

public:

   TokenValue(int ix) : _ival(ix) { }

   TokenValue(char ch) : _cval(ch) { }

   // ...

   int ival() { return _ival; }

   char cval() { return _cval; }

private:

   int _ival;

   char _cval;

   // ...

};

int main() {

   TokenValue tp(10);

   int ix = tp.ival();

   //...
}

Вот пример работы объединения TokenValue:



enum TokenKind ( ID, Constant /* и другие типы лексем */ }

class Token {

public:

   TokenKind tok;

   TokenValue val;
};

Объект типа Token

можно использовать так:



int lex() {

   Token curToken;

   char *curString;

   int curIval;

   // ...

   case ID:  // идентификатор

      curToken.tok = ID;

      curToken.val._sval = curString;

      break;

   case Constant:   // целая константа

      curToken.tok = Constant;

      curToken.val._ival = curIval;

      break;

   // ... и т.д.
}

Опасность, связанная с применением объединения, заключается в том, что можно случайно извлечь хранящееся в нем значение, пользуясь не тем членом. Например, если в последний раз значение присваивалось _ival, то вряд ли понадобится значение, оказавшееся в _sval. Это, по всей вероятности, приведет к ошибке в программе.

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

роль такого объекта играет член tok:



char *idVal;

// проверить значение дискриминанта перед тем, как обращаться к sval

if ( curToken.tok == ID )
   idVal = curToken.val._sval;

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





#include <cassert>

// функции доступа к члену объединения sval

string Token::sval() {

   assert( tok==ID );

   return val._sval;
}

Имя в определении объединения задавать необязательно. Если оно не используется в программе как имя типа для объявления других объектов, его можно опустить. Например, следующее определение объединения Token эквивалентно приведенному выше, но без указания имени:



class Token {

public:

   TokenKind tok;

   // имя типа объединения опущено

   union {

      char _cval;

      int _ival;

      char *_sval;

      double _dval;

   } val;
};

Существует анонимное объединение – объединение без имени, за которым не следует определение объекта. Вот, например, определение класса Token, содержащее анонимное объединение:



class Token {

public:

   TokenKind tok;

   // анонимное объединение

   union {

      char _cval;

      int _ival;

      char *_sval;

      double _dval;

   };
};

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



int lex() {

   Token curToken;

   char *curString;

   int curIval;

   // ... выяснить, что находится в лексеме

   // ... затем установить curToken

   case ID:

      curToken.tok = ID;

      curToken._sval = curString;

      break;

   case Constant:   // целая константа

      curToken.tok = Constant;

      curToken._ival = curIval;

      break;

   // ... и т.д.
}

Анонимное объединение позволяет убрать один уровень доступа, поскольку обращение к его членам идет как к членам класса Token. У него не может быть закрытых или защищенных членов, а также функций-членов. Такое объединение, определенное в глобальной области видимости, должно быть объявлено в безымянном пространстве имен или иметь модификатор static.


Объектно-ориентированное проектирование


Из чего складывается объектно-ориентированное проектирование четырех рассмотренных выше видов запросов? Как решаются проблемы их внутреннего представления?

С помощью наследования можно определить взаимосвязи между независимыми классами запросов. Для этого мы вводим в рассмотрение абстрактный класс Query, который будет служить для них базовым

(соответственно сами эти классы будут считаться производными). Абстрактный класс можно представить себе как неполный, который становится более или менее завершенным, когда из него порождаются производные классы, – в нашем случае AndQuery, OrQuery, NotQuery и NameQuery.

В нашем абстрактном классе Query

определены данные и функции-члены, общие для всех четырех типов запроса. При порождении из Query

производного класса, скажем AndQuery, мы выделяем уникальные характеристики каждого вида запроса. К примеру, NameQuery – это специальный вид Query, в котором операндом всегда является строка. Мы будем называть NameQuery производным и говорить, что Query

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

Основное преимущество иерархии наследования в том, что мы программируем открытый интерфейс абстрактного базового класса, а не отдельных производных от него специализированных типов, что позволяет защитить наш код от последующих изменений иерархии. Например, мы определяем eval() как открытую виртуальную функцию абстрактного базового класса Query. Пользовательский код, записанный в виде:

_rop->eval();

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


и динамического связывания.

Когда мы говорим о полиморфизме в языке C++, то имеем в виду главным образом способность указателя или ссылки на базовый класс адресовать любой из производных от него. Если определить обычную функцию eval() следующим образом:



// pquery может адресовать любой из классов, производных от Query

void eval( const Query *pquery )

{

   pquery->eval();
}

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



int main()

{

   AndQuery aq;

   NotQuery notq;

   OrQuery *oq = new OrQuery;

   NameQuery nq( "Botticelli" );

   // правильно: любой производный от Query класс

   // компилятор автоматически преобразует в базовый класс

   eval( &aq );

   eval( &notq );

   eval( oq );

   eval( &nq );
}

В то же время попытка передать eval()

адрес объекта класса, не являющегося производным от Query, вызовет ошибку компиляции:



int main()

{

   string name( "Scooby-Doo" );

   // ошибка: тип string не является производным от Query

   eval( &name );
}

Внутри eval()

выполнение инструкции вида

pquery->eval();

должно вызывать нужную виртуальную функцию-член eval() в зависимости от фактического класса объекта, адресуемого указателем pquery. В примере выше pquery

последовательно адресует объекты AndQuery, NotQuery, OrQuery и NameQuery. В каждой точке вызова определяется фактический тип класса объекта и вызывается подходящий экземпляр eval().

Механизм, с помощью которого это достигается, называется динамическим связыванием. (Мы вернемся к проектированию и использованию виртуальных функций в разделе 17.5.)

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



Хотя для полиморфной манипуляции объектом требуется, чтобы доступ к нему осуществлялся с помощью указателя или ссылки, сам по себе факт их использования не обязательно приводит к полиморфизму. Рассмотрим такие объявления:



// полиморфизма нет

int *pi;

// нет поддержанного языком полиморфизма

void *pvi;

// pquery может адресовать объект любого производного от Query класса
Query *pquery;

В C++ полиморфизм существует только в пределах отдельных иерархий классов. Указатели типа void*

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

Язык C++ обеспечивает поддержку полиморфизма следующими способами:

·                  путем неявного преобразования указателя или ссылки на производный класс к указателю или ссылке на открытый базовый:

Query *pquery = new NameQuery( "Class" );

·                  через механизм виртуальных функций:

pquery->eval();

·                  с помощью операторов dynamic_cast и typeid (они подробно обсуждаются в разделе 19.1):



if ( NameQuery *pnq =
     dynamic_cast< NameQuery* >( pquery )) ...

Проблему представления запроса мы решим, определив каждый операнд в классах AndQuery, NotQuery и OrQuery как указатель на тип Query*. Например:



class AndQuery {

public:

   // ...

private:

   Query *_lop;

   Query *_rop;
};

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



_rop->eval();

На рис. 17. 1 показана иерархия наследования, состоящая из абстрактного класса Query и четырех производных от него классов. Как этот рисунок транслируется в код программы на C++?

Query

AndQuery             OrQuery           NotQuery          NameQuery

 

Рис. 17.1. Иерархия классов Query

В разделе 2.4 мы рассматривали реализацию иерархии классов IntArray. Синтаксическая структура определения иерархии, изображенной на рис. 17.1, аналогична:

class Query { ... };

class AndQuery   : public Query { ... };

class OrQuery    : public Query { ... };

class NotQuery   : public Query { ... };
class NameQuery  : public Query { ... };

Наследование задается с помощью списка базовых классов. В случае одиночного наследования этот список имеет вид:

: уровень-доступа базовый-класс

где уровень-доступа – это одно из ключевых слов public, protected, private (смысл защищенного и закрытого наследования мы обсудим в разделе 18.3), а базовый-класс – имя ранее определенного класса. Например, Query является открытым базовым классом для любого из четырех классов запросов.

Класс, встречающийся в списке базовых, должен быть предварительно определен. Следующего опережающего объявления Query

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

// ошибка: Query должен быть определен

class Query;
class NameQuery : piblic Query { ... };

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

приводит к ошибке компиляции:

// ошибка: опережающее объявление не должно

// включать списка базовых классов
class NameQuery : public Query;

Правильный вариант в данном случае выглядит так:

// опережающее объявление как производного,

// так и обычного класса содержит только имя класса

class Query;
<


class NameQuery;

Главное различие между базовыми классами Query и IntArray (см. раздел 2.4) состоит в том, что Query не представляет никакого реального объекта в нашем приложении. Пользователи класса IntArray

вполне могут определять и использовать объекты этого типа непосредственно. Что же касается Query, то разрешается определять лишь указатели и ссылки на него, используя их для косвенного манипулирования объектами производных классов. О Query

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

Упражнение 17.1

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



книга                       аудио-книга

аудиокассета                детская кукла

видеокассета                видеоигра для приставки SEGA

книга с подневной оплатой   видеоигра для приставки SONY
книга на компакт-диске      видеоигра для приставки Nintendo

Упражнение 17.2

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

(a) Форматы графических файлов (gif, tiff, jpeg, bmp и т.д.)

(b) Геометрические примитивы (прямоугольник, круг, сфера, конус и т.д.)

(c) Типы языка C++ (класс, функция, функция-член и т.д.)


Объектно-ориентированный подход


Вспомним спецификацию нашего массива в предыдущем разделе. Мы говорили о том, что некоторым пользователям может понадобиться упорядоченный массив, в то время как большинство, скорее всего, удовлетворится и неупорядоченным. Если представить себе, что наш массив 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 <IntArray.h>

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.size(); ++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 <cassert>

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 <iostream>

#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) Курс_продажи


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


В этом разделе мы спроектируем и реализуем абстракцию массива, используя механизм классов С++. Первоначальный вариант будет поддерживать только массив элементов типа 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
// минимальным элементом myArray



int min_val = myArray.min();

Чтобы найти минимальный элемент в динамически созданном объекте типа 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() уже определена. Члены класса – функции и данные – не могут иметь одинаковые имена.



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



IntArray array;
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. Давайте определим для него три конструктора:



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

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





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

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



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

{

  // инициализация членов данных

  _size = sz;

  ia = new int[_size];

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

  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.)

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



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 (rhs._size,rhs.ia); }

Имеется еще одна специальная функция-член – деструктор,

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



class IntArray {
public:

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

  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 <cassert>

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

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


Объекты-функции


Наша функция min()

дает хороший пример как возможностей, так и ограничений механизма шаблонов:

template <typename Type>

const Type&

min( const Type *p, int size )

{

   Type minval = p[ 0 ];

   for ( int ix = 1; ix < size; ++ix )

      if ( p[ ix ] < minval )

         minval = p[ ix ];

      return minval;

}

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

будет работать не со всеми.

Это ограничение вызвано использованием оператора “меньше”: в некоторых случаях базовый тип его не поддерживает. Так, класс изображения Image

может и не предоставлять реализации такого оператора, но мы об этом не знаем и пытаемся найти минимальный кадр анимации в данном массиве изображений. Однако попытка конкретизировать min() для такого массива приведет к ошибке компиляции:

error: invalid types applied to the < operator: Image < Image

(ошибка: оператор < применен к некорректным типам: Image < Image)

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

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

template < typename Type,

           bool (*Comp)(const Type&, const Type&)>

const Type&

min( const Type *p, int size, Comp comp )

{

   Type minval = p[ 0 ];

   for ( int ix = 1; ix < size; ++ix )

      if ( Comp( p[ ix ] < minval ))

         minval = p[ ix ];

      return minval;

}

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


Альтернативная стратегия параметризации заключается в применении объекта-функции вместо указателя (примеры мы видели в предыдущем разделе). Объект-функция – это класс, перегружающий оператор вызова (operator()). Такой оператор инкапсулирует семантику обычного вызова функции. Объект-функция, как правило, передается обобщенному алгоритму в качестве аргумента, хотя можно определять и независимые объекты-функции. Например, если бы был определен объект-функция AddImages, который принимает два изображения, объединяет их некоторым образом и возвращает новое изображение, то мы могли бы объявить его следующим образом:

AddImages AI;

Чтобы объект-функция удовлетворял нашим требованиям, мы применяем оператор вызова, предоставляя необходимые операнды в виде объектов класса Image:



Image im1("foreground.tiff"), im2("background.tiff");

// ...

// вызывает Image AddImages::operator()(const Image1&, const Image2&);
Image new_image = AI (im1, im2 );

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

Ниже приведена измененная реализация шаблона min() (отметим, что это объявление допускает также и передачу указателя на функцию, но без проверки прототипа):



template < typename

Type,

           typename Comp >

const Type&

min( const Type *p, int size, Comp comp )

{

   Type minval = p[ 0 ];

   for ( int ix = 1; ix < size; ++ix )

      if ( Comp( p[ ix ] < minval ))

         minval = p[ ix ];

      return minval;
}

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

Есть три источника появления объектов-функций:

1.      из набора предопределенных арифметических, сравнительных и логических объектов-функций стандартной библиотеки;

2.      из набора предопределенных адаптеров функций, позволяющих специализировать или расширять предопределенные (или любые другие) объекты-функции;

3.      определенные нами собственные объекты-функции для передачи обобщенным алгоритмам. К ним можно применять и адаптеры функций.

В этом разделе мы рассмотрим все три источника объектов-функций.


Объекты-исключения


Объявлением исключения в catch-обработчике могут быть объявления типа или объекта. В каких случаях это следует делать? Тогда, когда необходимо получить значение или как-то манипулировать объектом, созданным в выражении throw. Если классы исключений спроектированы так, что в объектах-исключениях при возбуждении сохраняется некоторая информация и если в объявлении исключения фигурирует такой объект, то инструкции внутри catch-обработчика могут обращаться к информации, сохраненной в объекте выражением throw.

Изменим реализацию класса исключения pushOnFull, сохранив в объекте-исключении то значение, которое не удалось поместить в стек. Catch-обработчик, сообщая об ошибке, теперь будет выводить его в cerr. Для этого мы сначала модифицируем определение типа класса pushOnFull

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

// новый класс исключения:

// он сохраняет значение, которое не удалось поместить в стек

class pushOnFull {

public:

   pushOnFull( int i ) : _value( i ) { }

   int value { return _value; }

private:

   int _value;

};

Новый закрытый член _value

содержит число, которое не удалось поместить в стек. Конструктор принимает значение типа int и сохраняет его в члене _data. Вот как вызывается этот конструктор для сохранения значения из выражения throw:

void iStack::push( int value )

{

   if ( full() )

      // значение, сохраняемое в объекте-исключении

      throw pushOnFull( value );

   // ...

}

У класса pushOnFull

появилась также новая функция-член value(), которую можно использовать в catch-обработчике для вывода хранящегося в объекте-исключении значения:

catch ( pushOnFull eObj ) {

   cerr << "trying to push value " << eObj.value()

        << " on a full stack\n";

}

Обратите внимание, что в объявлении исключения в catch-обработчике фигурирует объект eObj, с помощью которого вызывается функция-член value() класса pushOnFull.

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




enum EHstate { noErr, zeroOp, negativeOp, severeError };

enum EHstate state = noErr;

int mathFunc( int i ) {

   if ( i == 0 ) {

      state = zeroOp;

      throw state;    // создан объект-исключение

   }

   // иначе продолжается обычная обработка
}

В этом примере объект state не используется в качестве объекта-исключения. Вместо этого выражением throw

создается объект-исключение типа EHstate, который инициализируется значением глобального объекта state. Как программа может различить их? Для ответа на этот вопрос мы должны присмотреться к объявлению исключения в catch-обработчике более внимательно.

Это объявление ведет себя почти так же, как объявление формального параметра. Если при входе в catch-обработчик исключения выясняется, что в нем объявлен объект, то он инициализируется копией объекта-исключения. Например, следующая функция calculate()

вызывает определенную выше mathFunc(). При входе в catch-обработчик внутри calculate()

объект eObj

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



void calculate( int op ) {

   try {

      mathFunc( op );

   }

   catch ( EHstate eObj ) {

      // eObj - копия сгенерированного объекта-исключения

   }
}

Объявление исключения в этом примере напоминает передачу параметра по значению. Объект eObj

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

Как и в случае параметров функции, в объявлении исключения может фигурировать ссылка. Тогда catch-обработчик будет напрямую ссылаться на объект-исключение, сгенерированный выражением throw, а не создавать его локальную копию:



void calculate( int op ) {

try {

      mathFunc( op );

   }

   catch ( EHstate &eObj ) {

      // eObj ссылается на сгенерированный объект-исключение

   }
}

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



В последнем случае catch-обработчик сможет модифицировать объект-исключение. Однако переменные, определенные в выражении throw, остаются без изменения. Например, модификация eObj

внутри catch-обработчика не затрагивает глобальную переменную state, установленную в выражении throw:



void calculate( int op ) {

try {

      mathFunc( op );

   }

   catch ( EHstate &eObj ) {

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

      eObj = noErr;  // глобальная переменная state не изменилась

   }
}

Catch-обработчик переустанавливает eObj в noErr

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


Объекты-исключения и виртуальные функции


Если сгенерированный объект-исключение имеет тип производного класса, а обрабатывается catch-обработчиком для базового, то этот обработчик не может использовать особенности производного класса. Например, к функции-члену value(), которая объявлена в классе pushOnFull, нельзя обращаться в catch-обработчике Excp:

catch ( const Excp &eObj ) {

   // ошибка: в классе Excp нет функции-члена value()

   cerr << "попытка поместить значение " << eObj.value()

        << " в полный стек\n";

}

Но мы можем перепроектировать иерархию классов исключений и определить виртуальные функции, которые можно вызывать из catch-обработчика для базового класса Excp с целью получения доступа к функциям-членам более специализированного производного:

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

class Excp {

public:

   virtual void print( string msg ) {

      cerr << "Произошло исключение"

           << endl;

   }

};

class stackExcp : public Excp { };

class pushOnFull : public stackExcp {

public:

   virtual void print() {

      cerr << "попытка поместить значение " << _value

           << " в полный стек\n";

   }

   // ...

};

Функцию print()

теперь можно использовать в catch-обработчике следующим образом:

int main() {

   try {

      // iStack::push() возбуждает исключение pushOnFull

   } catch ( Excp eObj ) {

     eObj.print();    // хотим вызвать виртуальную функцию,

                      // но вызывается экземпляр из базового класса

   }

}

Хотя возбужденное исключение имеет тип pushOnFull, а функция print()

виртуальна, инструкция eObj.print() печатает такую строку:

Произошло исключение

Вызываемая print()

является членом базового класса Excp, а не замещает ее в производном. Но почему?

Вспомните, что объявление исключения в catch-обработчике ведет себя почти так же, так объявление параметра. Когда управление попадает в catch-обработчик, то, поскольку в нем объявлен объект, а не ссылка, eObj


инициализируется копией подобъекта Excp базового класса объекта исключения. Поэтому eObj – это объект типа Excp, а не pushOnFull. Чтобы вызвать виртуальные функции из производных классов, в объявлении исключения должен быть указатель или ссылка:



int main() {

   try {

      // iStack::push() возбуждает исключение pushOnFull

   } catch ( const Excp &eObj ) {

     eObj.print();   // вызывается виртуальная функция

                      // pushOnFull::print()

   }
}

Объявление исключения в этом примере тоже относится к базовому классу Excp, но так как eObj – ссылка и при этом именует объект-исключение типа pushOnFull, то для нее можно вызывать виртуальные функции, определенные в классе pushOnFull. Когда catch-обработчик обращается к виртуальной функции print(), вызывается функция из производного класса, и программа печатает следующую строку:

попытка поместить значение 879 в полный стек

Таким образом, ссылка в объявлении исключения позволяет вызывать виртуальные функции, ассоциированные с классом объекта-исключения.


Объекты классов


Определение класса, например Screen, не приводит к выделению памяти. Память выделяется только тогда, когда определяется объект типа класса. Так, если имеется следующая реализация Screen:

class Screen {

public:

   // функции-члены

private:

   string           _screen;

   string:size_type _cursor;

   short            _height;

   short            _width;

};

то определение

Screen myScreen;

выделяет область памяти, достаточную для хранения четырех членов Screen. Имя myScreen

относится к этой области. У каждого объекта класса есть собственная копия данных-членов. Изменение членов myScreen не отражается на значениях членов любого другого объекта типа Screen.

Область видимости объекта класса зависит от его положения в тексте программы. Он определяется в иной области, нежели сам тип класса:

class Screen {

   // список членов

};

int main()

{

   Screen mainScreen;

}

Тип Screen

объявлен в глобальной области видимости, тогда как объект mainScreen – в локальной области функции main().

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

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

Screen bufScreen = myScreen;

// bufScreen._height = myScreen._height;

// bufScreen._width = myScreen._width;

// bufScreen._cursor = myScreen._cursor;

// bufScreen._screen = myScreen._screen;

Указатели и ссылки на объекты класса также можно объявлять. Указатель на тип класса разрешается инициализировать адресом объекта того же класса или присвоить ему такой адрес. Аналогично ссылка инициализируется l-значением объекта того же класса. (В объектно-ориентированном программировании указатель или ссылка на объект базового класса могут относиться и к объекту производного от него класса.)




int main()

{

   Screen myScreen, bufScreen[10];

   Screen *ptr = new Screen;

   myScreen = *ptr;

   delete ptr;

   ptr = bufScreen;

   Screen &ref = *ptr;

   Screen &ref2 = bufScreen[6];
}

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

Для доступа к данным или функциям-членам объекта класса следует пользоваться соответствующими операторами. Оператор “точка” (.) применяется, когда операндом является сам объект или ссылка на него; а “стрелка” (->) – когда операндом служит указатель на объект:



#include "Screen.h"

bool isEqual( Screen& s1, Screen *s2 )

{ // возвращает false, если объекты не равны, и true - если равны

   if (s1.height() != s2->height() ||

       s2.width() != s2->width() )

          return false;

   for ( int ix = 0; ix < s1.height(); ++ix )

      for ( int jy = 0; jy < s2->width(); ++jy )

         if ( s1.get( ix, jy ) != s2->get( ix, jy ) )

            return false;

   return true;    // попали сюда? значит, объекты равны
}

isEqual() – это не являющаяся членом функция, которая сравнивает два объекта Screen. У нее нет права доступа к закрытым членам Screen, поэтому напрямую обращаться к ним она не может. Сравнение проводится с помощью открытых функций-членов данного класса.

Для получения высоты и ширины экрана isEqual()

должна пользоваться функциями-членами height() и width() для чтения закрытых членов класса. Их реализация тривиальна:



class Screen {

public:

   int height() { return _height; }

   int width()  { return _width; }

   // ...

private:

   short _heigh, _width;

   // ...
};

Применение оператора доступа к указателю на объект класса эквивалентно последовательному выполнению двух операций: применению оператора разыменования (*) к указателю, чтобы получить адресуемый объект, и последующему применению оператора “точка” для доступа к нужному члену класса. Например, выражение

s2->height()

можно переписать так:

(*s2).height()

Результат будет одним и тем же.


Объявление и определение класса


О классе говорят, что он определен, как только встретилась скобка, закрывающая его тело. После этого становятся известными все члены класса, а следовательно, и его размер.

Можно объявить класс, не определяя его. Например:

class Screen;   // объявление класса Screen

Это объявление вводит в программу имя Screen и указывает, что оно относится к типу класса.

Тип объявленного, но еще не определенного класса допустимо использовать весьма ограниченно. Нельзя определять объект типа класса, если сам класс еще не определен, поскольку размер класса в этом момент неизвестен и компилятор не знает, сколько памяти отвести под объект.

Однако указатель или ссылку на объект такого класса объявлять можно, так как они имеют фиксированный размер, не зависящий от типа. Но, поскольку размеры класса и его членов неизвестны, применять оператор разыменования (*) к такому указателю, а также использовать указатель или ссылку для обращения к члену не разрешается, пока класс не будет полностью определен.

Член некоторого класса можно объявить принадлежащим к типу какого-либо класса только тогда, когда компилятор уже видел определение этого класса. До этого объявляются лишь члены, являющиеся указателями или ссылками на такой тип. Ниже приведено определение StackScreen, один из членов которого служит указателем на Screen, который объявлен, но еще не определен:

class Screen;   // объявление

class StackScreen {

   int topStack;

   // правильно: указатель на объект Screen

   Screen *stack;

   void (*handler)();

};

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

class LinkScreen {

   Screen window;

   LinkScreen *next;

   LinkScreen *prev;

};

Упражнение 13.1

Пусть дан класс Person со следующими двумя членами:

string _name;

string _address;

и такие функции-члены:

Person( const string &n, const string &s )

      : _name( n ), _address( a ) { }

string name() { return _name; }

string address() { return _address; }

Какие члены вы объявили бы в секции public, а какие– в секции private? Поясните свой выбор.

Упражнение 13.2

Объясните разницу между объявлением и определением класса. Когда вы стали бы использовать объявление класса? А определение?



Объявление mutable


При объявлении объекта класса Screen

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

const Screen cs ( 5, 5 );

Если мы хотим прочитать символ, находящийся в позиции (3,4), то попробуем сделать так:

// прочитать содержимое экрана в позиции (3,4)

// Увы! Это не работает

cs.move( 3, 4 );

char ch = cs.get();

Но такая конструкция не работает: move() – это не константная функция-член, и сделать ее таковой непросто. Определение move()

выглядит следующим образом:

inline void Screen::move( int r, int c )

{

   if ( checkRange( r, c ) )

   {

      int row = (r-1) * _width;

      _cursor = row + c - 1;      // модифицирует _cursor

   }

}

Обратите внимание, что move()изменяет член класса _cursor, следовательно, не может быть объявлена константной.

Но почему нельзя модифицировать _cursor для константного объекта класса Screen? Ведь _cursor – это просто индекс. Изменяя его, мы не модифицируем содержимое экрана, а лишь пытаемся установить позицию внутри него. Модификация _cursor

должна быть разрешена несмотря на то, что у класса Screen

есть спецификатор const.

Чтобы разрешить модификацию члена класса, принадлежащего константному объекту, объявим его изменчивым (mutable). Член с таким спецификатором не бывает константным, даже если он член константного объекта. Его можно обновлять, в том числе функцией-членом со спецификатором const. Объявлению изменчивого члена класса должно предшествовать ключевое слово mutable:

class Screen {

public:

   // функции-члены

private:

   string                     _screen;

   mutable string::size_type  _cursor; // изменчивый член

   short                       _height;

   short                       _width;

};

Теперь любая константная функция способна модифицировать _cursor, и move()

может быть объявлена константной. Хотя move() изменяет данный член, компилятор не считает это ошибкой.




// move() - константная функция-член

inline void Screen::move( int r, int c ) const

{

   // ...

   // правильно: константная функция-член может модифицировать члены

   // со спецификатором mutable

   _cursor = row + c - 1;

   // ...
}

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

Отметим, что изменчивым объявлен только член _cursor, тогда как _screen, _height и _width не имеют спецификатора mutable, поскольку их значения в константном объекте класса Screen

изменять нельзя.

Упражнение 13.3

Объясните, как будет вести себя copy() при следующих вызовах:



Screen myScreen;
myScreen.copy( myScreen );

Упражнение 13.4

К дополнительным перемещениям курсора можно отнести его передвижение вперед и назад на один символ. Из правого нижнего угла экрана курсор должен попасть в левый верхний угол. Реализуйте функции forward() и backward().

Упражнение 13.5

Еще одной полезной возможностью является перемещение курсора вниз и вверх на одну строку. По достижении верхней или нижней строки экрана курсор не перепрыгивает на противоположный край; вместо этого подается звуковой сигнал, и курсор остается на месте. Реализуйте функции up() и down(). Для подачи сигнала следует вывести на стандартный вывод cout

символ с кодом '007'.

Упражнение 13.6

Пересмотрите описанные функции-члены класса Screen и объявите те, которые сочтете нужными, константными. Объясните свое решение.


Объявление виртуального базового класса


Для указания виртуального наследования в объявление базового класса вставляется модификатор virtual. Так, в данном примере ZooAnimal

становится виртуальным базовым для Bear и Raccoon:

// взаимное расположение ключевых слов public и virtual

// несущественно

class Bear : public virtual ZooAnimal { ... };

class Raccoon : virtual public ZooAnimal { ... };

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

использует виртуальное наследование:

extern void dance( const Bear* );

extern void rummage( const Raccoon* );

extern ostream&

      operator<<( ostream&, const ZooAnimal& );

int main()

{

   Panda yin_yang;

   dance( &yin_yang );   // правильно

   rummage( &yin_yang ); // правильно

   cout << yin_yang;     // правильно

   // ...

}

Любой класс, который можно задать в качестве базового, разрешается сделать виртуальным, причем он способен содержать все те же элементы, что обычные базовые классы. Так выглядит объявление ZooAnimal:

#include <iostream>

#include <string>

class ZooAnimal;

extern ostream&

      operator<<( ostream&, const ZooAnimal& );

class ZooAnimal {

public:

   ZooAnimal( string name,

              bool onExhibit, string fam_name )

            : _name( name ),

              _onExhibit( onExhibit ), _fam_name( fam_name )

   {}

   virtual ~ZooAnimal();

   virtual ostream& print( ostream& ) const;

   string name() const { return _name; }

   string family_name() const { return _fam_name; }

   // ...

protected:

   bool   _onExhibit;

   string _name;

   string _fam_name;

   // ...

<
};

К объявлению и реализации непосредственного базового класса при использовании виртуального наследования добавляется ключевое слово virtual. Вот, например, объявление нашего класса Bear:



class Bear : public virtual ZooAnimal {

public:

   enum DanceType {

        two_left_feet, macarena, fandango, waltz };

   Bear( string name, bool onExhibit=true )

       : ZooAnimal( name, onExhibit, "Bear" ),

         _dance( two_left_feet )

   {}

   virtual ostream& print( ostream& ) const;

   void dance( DanceType );

   // ...

protected:

   DanceType _dance;

   // ...
};

А вот объявление класса Raccoon:



class Raccoon : public virtual ZooAnimal {

public:

   Raccoon( string name, bool onExhibit=true )

       : ZooAnimal( name, onExhibit, "Raccoon" ),

         _pettable( false )

   {}

   virtual ostream& print( ostream& ) const;

   bool pettable() const { return _pettable; }

   void pettable( bool petval ) { _pettable = petval; }

   // ...

protected:

   bool _pettable;

   // ...
};


Объявления друзей в шаблонах классов


·                  обычный (не шаблонный) дружественный класс или дружественная функция. В следующем примере функция foo(), функция-член bar() и класс foobar

объявлены друзьями всех конкретизаций шаблона QueueItem:

class Foo {

   void bar();

};

template <class T>

class QueueItem {

   friend class foobar;

   friend void foo();

   friend void Foo::bar();

   // ...

};

Ни класс foobar, ни функцию foo() не обязательно объявлять или определять в глобальной области видимости перед объявлением их друзьями шаблона QueueItem.

Однако перед тем как объявить другом какой-либо из членов класса Foo, необходимо определить его. Напомним, что член класса может быть введен в область видимости только через определение объемлющего класса. QueueItem не может ссылаться на Foo::bar(), пока не будет найдено определение Foo;

·                  связанный

дружественный шаблон класса или функции. В следующем примере определено взаимно однозначное соответствие между классами, конкретизированными по шаблону QueueItem, и их друзьями – также конкретизациями шаблонов. Для каждого класса, конкретизированного по шаблону QueueItem, ассоциированные конкретизации foobar, foo() и Queue::bar()

являются друзьями.

template <class Type>

   class foobar { ... };

template <class Type>

   void foo( QueueItem<Type> );

template <class Type>

class Queue {

      void bar();

      // ...

};

template <class Type>

class QueueItem {

   friend class foobar<Type>;

   friend void foo<Type>( QueueItem<Type> );

   friend void Queue<Type>::bar();

   // ...

};

Прежде чем шаблон класса можно будет использовать в объявлениях друзей, он сам должен быть объявлен или определен. В нашем примере шаблоны классов foobar и Queue, а также шаблон функции foo()


следует объявить до того, как они объявлены друзьями в QueueItem.

Синтаксис, использованный для объявления foo() другом, может показаться странным:

friend void foo<Type>( QueueItem<Type> );

За именем функции следует список явных аргументов шаблона: foo<Type>. Такой синтаксис показывает, что в качестве друга объявляется конкретизированный шаблон функции foo(). Если бы список явных аргументов был опущен:

friend void foo( QueueItem<Type> );

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

·                  несвязанный

дружественный шаблон класса или функции. В следующем примере имеется отображение один-ко-многим между конкретизациями шаблона класса QueueItem и его друзьями. Для каждой конкретизации типа QueueItem все конкретизации foobar, foo() и Queue<T>::bar()

являются друзьями:



template <class Type>

class QueueItem {

   template <class T>

      friend class foobar;

   template <class T>

      friend void foo( QueueItem<T> );

   template <class T>

      friend class Queue<T>::bar();

   // ...
};

Следует отметить, что этот вид объявлений друзей в шаблоне класса не поддерживается компиляторами, написанными до принятия стандарта C++.


Объявления друзей в шаблонах Queue и QueueItem


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

необходимо объявить другом QueueItem, чтобы можно было создавать и манипулировать объектами последнего.

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

другом любого экземпляра QueueItem:

template <class Type>

class QueueItem {

   // любой экземпляр Queue является другом

   // любого экземпляра QueueItem

   template <class T> friend class Queue;

};

Однако нет смысла объявлять, например, класс Queue, конкретизированный типом string, другом QueueItem, конкретизированного типом complex<double>. Queue<string>

должен быть другом только для класса QueueItem<string>. Таким образом, нам нужно взаимно однозначное соответствие между экземплярами Queue и QueueItem, конкретизированными одинаковыми типами. Чтобы добиться этого, применим второй метод объявления друзей:

template <class Type>

class QueueItem {

   // для любого экземпляра QueueItem другом является

   // только конкретизированный тем же типом экземпляр Queue

   friend class Queue<Type>;

   // ...

};

Данное объявление говорит о том, что для любой конкретизации QueueItem некоторым типом экземпляр Queue, конкретизированный тем же типом, является другом. Так, экземпляр Queue, конкретизированный типом int, будет другом экземпляра QueueItem, тоже конкретизированного типом int. Но для экземпляров QueueItem, конкретизированных типами complex<double> или string, этот экземпляр Queue

другом не будет.

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




// как задать аргумент типа Queue?
ostream& operator<<( ostream &, ??? );

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

ostream& operator<<( ostream &, const Queue<int> & );

Так мы определили оператор вывода для класса, конкретизированного из шаблона Queue

типом int. Но что, если Queue – это очередь элементов типа string?

ostream& operator<<( ostream &, const Queue<string> & );

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

ostream& operator<<( ostream &, const Queue<Type> & );

Однако из этого перегруженного оператора вывода придется сделать шаблон функции:



template <class Type> ostream&
   operator<<( ostream &, const Queue<Type> & );

Теперь всякий раз, когда оператору ostream

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



template <class Type>

ostream& operator<<( ostream &os, const Queue<Type> &q )

{

   os << "< ";

   QueueItem<Type> *p;

   for ( p = q.front; p; p = p->next )

      os << *p << " ";

   os << " >";

   return os;
}

Если очередь объектов типа int

содержит значения 3, 5, 8, 13, то распечатка ее содержимого с помощью такого оператора дает

< 3 5 8 13 >

Обратите внимание, что оператор вывода обращается к закрытому члену front

класса Queue. Поэтому оператор необходимо объявить другом Queue:



template <class Type>

class Queue {

   friend ostream&

      operator<<( ostream &, const Queue<Type> & );

   // ...
};

Здесь, как и при объявлении друга в шаблоне класса Queue, создается взаимно однозначное соответствие между конкретизациями Queue и оператора operator<<().



Распечатка элементов Queue производится оператором вывода operator<<()

класса QueueItem:

os << *p;

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



template <class Type>

ostream& operator<<( ostream &os, const QueueItem<Type> &qi )

{

   os << qi.item;

   return os;
}

Поскольку здесь имеется обращение к закрытому члену item класса QueueItem, оператор следует объявить другом шаблона QueueItem. Это делается следующим образом:



template <class Type>

class QueueItem {

   friend class Queue<Type>;

   friend ostream&

      operator<<( ostream &, const QueueItem<Type> & );

   // ...
};

Оператор вывода класса QueueItem

полагается на то, что item

умеет распечатывать себя:

os << qi.item;

Это порождает тонкую зависимость типов при конкретизации Queue. Любой определенный пользователем и связанный с Queue

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

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

Следующая программа демонстрирует конкретизацию и использование функций-друзей шаблонов классов Queue и QueueItem:



#include <iostream>

#include "Queue.h"

int main() {

   Queue<int> qi;

   // конкретизируются оба экземпляра

   //   ostream& operator<<(ostream &os, const Queue<int> &)

   //   ostream& operator<<(ostream &os, const QueueItem<int> &)

   cout << qi << endl;

   int ival;

   for ( ival = 0; ival < 10; ++ival )

      qi.add( ival );

   cout << qi << endl;

   int err_cnt = 0;

   for ( ival = 0; ival < 10; ++ival ) {

      int qval = qi.remove();

      if ( ival != qval ) err_cnt++;

   }

   cout << qi << endl;

   if ( !err_cnt )

      cout << "!! queue executed ok\n";

   else cout << "?? queue errors: " << err_cnt << endl;

   return 0;
}

После компиляции и запуска программа выдает результат:

< >

< 0 1 2 3 4 5 6 7 8 9 >

< >

!! queue executed ok

Упражнение 16.6

Пользуясь шаблоном класса Screen, определенным в упражнении 16.5, реализуйте операторы ввода и вывода (см. упражнение 15.6 из раздела 15.2) в виде шаблонов. Объясните, почему вы выбрали тот, а не иной способ объявления друзей класса Screen, добавленных в его шаблон.


Объявления и определения


Как было сказано в главе 7, объявление

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

// объявление функции calc()

// определение находится в другом файле

void calc(int);

int main()

{

    int loc1 = get(); // ошибка: get() не объявлена

    calc(loc1);       // правильно: calc() объявлена

    // ...

}

Определение объекта имеет две формы:

type_specifier object_name;

type_specifier object_name = initializer;

Вот, например, определение obj1. Здесь obj1

инициализируется значением 97:

int obj1 = 97;

Следующая инструкция задает obj2, хотя начальное значение не задано:

int obj2;

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

будут равны нулю:

int var1 = 0;

int var2;

Глобальный объект можно определить в программе только один раз. Поскольку он должен быть объявлен в исходном файле перед использованием, то для программы, состоящей из нескольких файлов, необходима возможность объявить объект, не определяя его. Как это сделать?

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

extern int i;

Эта инструкция “обещает”, что в программе имеется определение, подобное

int i;

extern-объявление не выделяет места под объект. Оно может встретиться несколько раз в одном и том же исходном файле или в разных файлах одной программы. Однако обычно находится в общедоступном заголовочном файле, который включается в те модули, где необходимо использовать глобальный объект:

// заголовочный файл

extern int obj1;

extern int obj2;

// исходный файл

int obj1 = 97;

int obj2;

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

extern const double pi = 3.1416; // определение

const double pi; // ошибка: повторное определение pi

Ключевое слово extern

может быть указано и при объявлении функции – для явного обозначения его подразумеваемого смысла: “определено в другом месте”. Например:

extern void putValues( int*, int );



Объявления перегруженных функций


Теперь, научившись объявлять, определять и использовать функции в программах, познакомимся с перегрузкой – еще одним аспектом в C++. Перегрузка позволяет иметь несколько одноименных функций, выполняющих схожие операции над аргументами разных типов.

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

1 + 3

вызывается операция целочисленного сложения, тогда как вычисление выражения

1.0 + 3.0

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

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



Объявления перегруженных функций-членов


Функции-члены класса можно перегружать:

class myClass {

public:

   void f( double );

char f( char, char );  // перегружает myClass::f( double )

   // ...

};

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

class myClass {

public:

   void mf();

   double mf();   // ошибка: так перегружать нельзя

   // ...

};

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

class myClass {

public:

   void mf();

   void mf();   // ошибка: повторное объявление

   // ...

};

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

Множество перегруженных функций-членов может содержать как статические, так и нестатические функции:

class myClass {

public:

   void mcf( double );

   static void mcf( int* );   // перегружает myClass::mcf( double )

    // ...

};

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



Область видимости


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

и область видимости класса.

Локальная область– это часть исходного текста программы, содержащаяся в определении функции (или в блоке). Любая функция имеет собственную такую часть, и каждая составная инструкция (или блок) внутри функции также представляет собой отдельную локальную область.

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

Объекты, функции, типы и шаблоны могут быть определены в глобальной области видимости. Программисту разрешено задать пользовательские

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

Каждое определение класса представляет собой отдельную область видимости класса. (О таких областях мы расскажем в главе 13.)

Имя может обозначать различные сущности в зависимости от области видимости. В следующем фрагменте программы имя s1

относится к четырем разным сущностям:

#include <iostream>

#include <string>

// сравниваем s1 и s2 лексикографически

int lexicoCompare( const string &sl, const string &s2 ) { ... }

// сравниваем длины s1 и s2

int sizeCompare( const string &sl, const string &s2 ) { ... }

typedef int ( PFI)( const string &, const string & );

// сортируем массив строк

void sort( string *s1, string *s2, PFI compare =lexicoCompare )

    { ... }

string sl[10] = { "a", "light", "drizzle", "was", "falling",

                  "when", "they", "left", "the", "school" };

int main()

{

    // вызов sort() со значением по умолчанию параметра compare

    // s1 - глобальный массив

    sort( s1, s1 + sizeof(s1)/sizeof(s1[0]) - 1 );

    // выводим результат сортировки

    for ( int i = 0; i < sizeof(s1) / sizeof(s1[0]); ++i )

         cout << s1[ i ].c_str() << "\n\t";

<
}

Поскольку определения функций lexicoCompare(), sizeCompare() и sort()

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

Имя, введенное с помощью объявления, можно использовать от точки объявления до конца области видимости (включая вложенные области). Так, имя s1 параметра функции lexicoCompare()

разрешается употреблять до конца ее области видимости, то есть до конца ее определения.

Имя глобального массива s1

видимо с точки его объявления до конца исходного файла, включая вложенные области, такие, как определение функции main().

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

void s1(); // ошибка: повторное объявление s1

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

В С++ имя должно быть объявлено до момента его первого использования в выражении. В противном случае компилятор выдаст сообщение об ошибке. Процесс сопоставления имени, используемого в выражении, с его объявлением называется разрешением. С помощью этого процесса имя получает конкретный смысл. Разрешение имени зависит от способа его употребления и от его области видимости. Мы рассмотрим этот процесс в различных контекстах. (В следующем подразделе описывается разрешение имен в локальной области видимости; в разделе 10.9 – разрешение в шаблонах функций;  в конце главы 13 – в области видимости классов, а в разделе 16.12 – в шаблонах классов.)

Области видимости и разрешение имен – понятия времени компиляции. Они применимы к отдельным частям текста программы. Компилятор интерпретирует текст программы согласно правилам областей видимости и правилам разрешения имен.


Область видимости и время жизни


В этой главе обсуждаются два важных вопроса, касающиеся объявлений в С++. Где употребляется объявленное имя? Когда можно безопасно использовать объект или вызывать функцию, т.е. каково время жизни сущности в программе? Для ответа на первый вопрос мы введем понятие областей видимости и покажем, как они ограничивают применение имен в исходном файле программы. Мы рассмотрим разные типы таких областей: глобальную и локальную, а также более сложное понятие областей видимости пространств имен, которое появится в конце главы. Отвечая на второй вопрос, мы опишем, как объявления вводят глобальные объекты и функции (сущности, “живущие” в течение всего времени работы программы), локальные (“живущие” на определенном отрезке выполнения) и динамически размещаемые объекты (временем жизни которых управляет программист). Мы также исследуем свойства времени выполнения, характерные для этих объектов и функций.



Область видимости класса A


Тело класса определяет область видимости. Объявления членов класса внутри тела вводят их имена в область видимости класса.

Для обращения к ним применяются операторы доступа (точка и стрелка) и оператор разрешения области видимости (::). Когда употребляется оператор доступа, то предшествующее ему имя обозначает объект или указатель на объект типа класса, а следующее за ним имя должно находиться в области видимости этого класса. Аналогично при использовании оператора разрешения области видимости поиск имени, следующего за ним, идет в области видимости класса, имя которого стоит перед оператором. (В главах 17 и 18 мы увидим, что производный класс может обращаться к членам своих базовых.)

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

class String {

public:

   typedef int index_type;

   // тип параметра - это на самом деле String::index_type

   char& operator[]( index_type )

};

Порядок объявления членов класса в его теле важен: нельзя ссылаться на члены, которые будут объявлены позже. Например, если объявление оператора operator[]()

находится раньше объявления typedef index_type, то приведенное ниже объявление operator[]() оказывается ошибочным, поскольку в нем используется еще неизвестное имя index_type:

class String {

public:

   // ошибка: имя index_type не объявлено

   char &operator[]( index_type );

   typedef int index_type;

};

Однако из этого правила есть два исключения. Первое касается имен, использованных в определениях встроенных функций-членов, второе – имен, применяемых как аргументы по умолчанию. Рассмотрим обе ситуации.

Разрешение имен в определениях встроенных функций-членов происходит в два этапа. Сначала объявление функции (т.е. тип возвращаемого значения и список параметров) обрабатывается в том месте, где оно встретилось в определении класса. Затем тело функции обрабатывается во всей области видимости, сразу после того, как были просмотрены объявления всех членов. Посмотрим на наш пример, в котором оператор operator[]()


определен как встроенный внутри тела класса:



class String {

public:

   typedef int index_type;

   char &operator[]( index_type elem )

      { return _string[ elem ]; }

private:

   char *_string;
};

На первом этапе просматриваются имена, использованные в объявлении operator[](), чтобы найти имя типа параметра index_type. Поскольку первый шаг выполняется тогда, когда в теле класса встретилось определение функции-члена, то имя index_type

должно быть объявлено до определения operator[]().

Обратите внимание, что член _string

объявлен в теле класса после определения operator[](). Это правильно, и _string не является в теле operator[]()

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

Аргументы по умолчанию также разрешаются на втором шаге. Например, в объявлении функции-члена clear()

используется имя статического члена bkground, который определен позже:



class Screen {

public:

   // bkground относится к статическому члену,

   // объявленному позже в определении класса

   Screen& clear( char = bkground );

private:

   static const char bkground = '#';
};

Хотя такие аргументы в объявлениях функций-членов разрешаются во всей области видимости класса, программа будет считаться ошибочной, если он ссылается на нестатический член. Нестатический член должен быть привязан к объекту своего класса или к указателю на такой объект, иначе использовать его нельзя. Употребление подобных членов в качестве аргументов по умолчанию нарушает это ограничение. Если переписать предыдущий пример так:



class Screen {

public:

   // ...

   // ошибка: bkground - нестатический член

   Screen& clear( char = bkground );

private:

   const char bkground = '#';
};

то имя аргумента по умолчанию разрешается нестатическим членом bkground, а это считается ошибкой.



Определения членов класса, появляющиеся вне его тела, – это еще один пример части программы, которая находится в области видимости класса. В ней имена членов распознаются несмотря на то, что оператор доступа или оператор разрешения области видимости при обращении к ним не применяется. Как же разрешаются имена в определениях членов?

Как правило, если такое определение появляется вне тела, то часть программы, следующая за именем определяемого члена, считается находящейся в области видимости класса вплоть до конца определения члена. Вынесем определение оператора operator[]() из класса String:



class String {

public:

   typedef int index_type;

   char& operator[]( index_type );

private:

   char *_string;

};

// в operator[]() есть обращения к index_type и _string

inline char& operator[]( index_type elem )

{

   return _string[ elem ];
}

Обратите внимание, что в списке параметров встречается typedef index_type без квалифицирующего имени класса String::.Текст, следующий за именем члена String::operator[] и до конца определения функции, находится в области видимости класса. Объявленные в этой области типы рассматриваются при разрешении имен типов, использованных в списке параметров функции-члена.

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



class Account:

   // ...

private:

   static double _interestRate;

   static double initInterestRate();

};

// ссылается на Account::initInterest()
double Account::_interestRate = initInterest();

Инициализатор _interestRate

вызывает статическую функцию-член Account::initInterest()

несмотря на то, что ее имя не квалифицировано именем класса.

Не только инициализатор, но и все, что следует за именем статического члена _interestRate до завершающей точки с запятой, находится в области видимости класса Account. Поэтому в определении статического члена name



может быть обращение к члену класса nameSize:



class Account:

   // ...

private:

   static const int nameSize = 16;

   static const char name[nameSize];

// nameSize не квалифицировано именем класса Account
const char Account::name[nameSize] = "Savins Account";

Хотя член nameSize не квалифицирован именем класса Account, определение name не является ошибкой, так как оно находится в области видимости своего класса и может ссылаться на его члены после того, как компилятор прочитал Account::name.

В определении члена, которое появляется вне тела, часть программы перед определяемым именем не находится в области видимости класса. При обращении к члену в этой части следует пользоваться оператором разрешения области видимости. Например, если типом статического члена является typedef Money, определенный в классе Account, то имя Money

должно быть квалифицировано, когда статический член данных определяется вне тела класса:



class Account {

   typedef double Money;

   //...

private:

   static Money _interestRate;

   static Money initInterest();

};

// Money должно быть квалифицировано именем класса Account::
Account::Money Account::_interestRate = initInterest();

С каждым классом ассоциируется отдельная область видимости, причем у разных классов эти области различны. К членам одного класса нельзя напрямую обращаться в определениях членов другого класса, если только один из них не является для второго базовым. (Наследование и базовые классы рассматриваются в главах 17 и 18.)


Область видимости класса и наследование


У каждого класса есть собственная область видимости, в которой определены имена членов и вложенные типы (см. разделы 13.9 и 13.10). При наследовании область видимости производного класса вкладывается в область видимости непосредственного базового. Если имя не удается разрешить в области видимости производного класса, то поиск определения продолжается в области видимости базового.

Именно эта иерархическая вложенность областей видимости классов при наследовании и делает возможным обращение к именам членов базового класса так, как если бы они были членами производного. Рассмотрим сначала несколько примеров одиночного наследования, а затем перейдем к множественному. Предположим, есть упрощенное определение класса ZooAnimal:

class ZooAnimal {

public:

   ostream &print( ostream& ) const;

   // сделаны открытыми только ради демонстрации разных случаев

   string is_a;

   int    ival;

private:

   double dval;

};

и упрощенное определение производного класса Bear:

class Bear : public ZooAnimal

{

public:

   ostream &print( ostream& ) const;

   // сделаны открытыми только ради демонстрации разных случаев

   string name;

   int    ival;

};

Когда мы пишем:

Bear bear;

bear.is_a;

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

·         bear – это объект класса Bear. Сначала поиск имени is_a

ведется в области видимости Bear. Там его нет.

·         Поскольку класс Bear

производный от ZooAnimal, то далее поиск is_a

ведется в области видимости последнего. Обнаруживается, что имя принадлежит его члену. Разрешение закончилось успешно.

Хотя к членам базового класса можно обращаться напрямую, как к членам производного, они сохраняют свою принадлежность к базовому классу. Как правило, не имеет значения, в каком именно классе определено имя. Но это становится важным, если в базовом и производном классах есть одноименные члены. Например, когда мы пишем:


bear.ival;

ival – это член класса Bear, найденный на первом шаге описанного выше процесса разрешения имени.

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

bear.ZooAnimal::ival;

Тем самым мы говорим компилятору, что объявление ival следует искать в области видимости класса ZooAnimal.

Проиллюстрируем использование оператора разрешения области видимости на несколько абсурдном примере (надеемся, вы никогда не напишете чего-либо подобного в реальном коде):



int ival;

int Bear::mumble( int ival )

{

   return ival +        // обращение к параметру

        ::ival +        // обращение к глобальному объекту

        ZooAnimal::ival +

        Bear::ival;
}

Неквалифицированное обращение к ival

разрешается в пользу формального параметра. (Если бы переменная ival не была определена внутри mumble(), то имел бы место доступ к члену класса Bear. Если бы ival не была определена и в Bear, то подразумевался бы член ZooAnimal. А если бы ival не было и там, то речь шла бы о глобальном объекте.)

Разрешение имени члена класса всегда предшествует выяснению того, является ли обращение к нему корректным. На первый взгляд, это противоречит интуиции. Например, изменим реализацию mumble():



int dval;

int Bear::mumble( int ival )

{

   // ошибка: разрешается в пользу закрытого члена ZooAnimal::dval

   return ival + dval;
}

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

(a)    Определено ли dval в локальной области видимости функции-члена класса Bear? Нет.

(b)   Определено ли dval в области видимости Bear? Нет.

(c)    Определено ли dval в области видимости ZooAnimal? Да. Обращение разрешается в пользу этого имени.



После того как имя разрешено, компилятор проверяет, возможен ли доступ к нему. В данном случае нет: dval

является закрытым членом, и прямое обращение к нему из mumble()

запрещено. Правильное (и, возможно, имевшееся в виду) разрешение требует явного употребления оператора разрешения области видимости:

return ival + ::dval;  // правильно

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



int dval;

int Bear::mumble( int ival )

{

   foo( dval );

   // ...
}

Если бы функция foo()

была перегруженной, то перемещение члена ZooAnimal::dval из закрытой секции в защищенную вполне могло бы изменить всю последовательность вызовов внутри mumble(), а разработчик об этом даже и не подозревал бы.

Если в базовом и производном классах есть функции-члены с одинаковыми именами и сигнатурами, то их поведение такое же, как и поведение данных-членов: член производного класса лексически скрывает в своей области видимости член базового. Для вызова члена базового класса необходимо применить оператор разрешения области видимости:



ostream& Bear::print( ostream &os) const

{

   // вызывается ZooAnimal::print(os)

   ZooAnimal::print( os );

   os << name;

   return os;
}


Область видимости класса при множественном наследовании


Как влияет множественное наследование на алгоритм просмотра области видимости класса? Все непосредственные базовые классы просматриваются одновременно, что может приводить к неоднозначности в случае, когда в нескольких из них есть одноименные члены. Рассмотрим на нескольких примерах, как возникает неоднозначность и какие меры можно предпринять для ее устранения. Предположим, есть следующий набор классов:

class Endangered

{

public:

   ostream& print( ostream& ) const;

   void highlight();

   // ...

};

class ZooAnimal {

public:

   bool onExhibit() const;

   // ...

private:

   bool highlight( int zoo_location );

   // ...

};

class Bear : public ZooAnimal {

public:

   ostream& print( ostream& ) const;

   void dance( dance_type ) const;

   // ...

};

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

class Panda : public Bear, public Endangered {

public:

   void cuddle() const;

   // ...

};

Хотя при наследовании функций print() и highlight() из обоих базовых классов Bear и Endangered

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

В то время как неоднозначность двух унаследованных функций print() очевидна с первого взгляда, наличие конфликта между членами highlight() удивляет (ради этого пример и составлялся): ведь у них разные уровни доступа и разные прототипы. Более того, экземпляр из Endangered – это член непосредственного базового класса, а из ZooAnimal – член класса, стоящего на две ступеньки выше в иерархии.

Однако все это не имеет значения (впрочем, как мы скоро увидим, может иметь, но в случае виртуального наследования). Bear наследует закрытую функцию-член highlight() из ZooAnimal; лексически она видна, хотя вызывать ее из Bear или Panda

запрещено. Значит, Panda

наследует два лексически видимых члена с именем highlight, поэтому любое неквалифицированное обращение к этому имени приводит к ошибке компиляции.


Поиск имени начинается в ближайшей области видимости, объемлющей его вхождение. Например, в коде



int main()

{

   Panda yin_yang;

   yin_yang.dance( Bear::macarena );
}

ближайшей будет область видимости класса Panda, к которому принадлежит yin_yang. Если же мы напишем:



void Panda::mumble()

{

   dance( Bear::macarena );

   // ...
}

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

В случае множественного наследования имитируется одновременный просмотр всех поддеревьев наследования – в нашем случае это класс Endangered и поддерево Bear/ZooAnimal. Если объявление обнаружено только в поддереве одного из базовых классов, то разрешение имени заканчивается успешно, как, например, при таком вызове dance():



// правильно: Bear::dance()
yin_yang.dance( Bear::macarena );

Если же объявление найдено в двух или более поддеревьях, то обращение считается неоднозначным и компилятор выдает сообщение об ошибке. Так будет при неквалифицированном обращении к print():



int main()

{

   // ошибка: неоднозначность: одна из

   //         Bear::print( ostream& ) const

   //         Endangered::print( ostream& ) const

   Panda yin_yang;

   yin_yang.print( cout );
}

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



int main()

{

   // правильно, но не лучшее решение

   Panda yin_yang;

   yin_yang.Bear::print( cout );
}

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



сумму значений члена dval

класса Base1 и члена

dval

класса Derived.

(b)   Присвойте вещественную часть члена cval класса MI

члену fval

класса Base2.

(c)    Присвойте значение члена cval

класса Base1

первому символу члена sval

класса Derived.

Упражнение 18.12

Дана следующая иерархия классов, в которых имеются функции-члены print():



class Base {

public:

   void print( string ) const;

   // ...

};

class Derived1 : public Base {

public:

   void print( int ) const;

   // ...

};

class Derived2 : public Base {

public:

   void print( double ) const;

   // ...

};

class MI : public Derived1, public Derived2 {

public:

   void print( complex<double> ) const;

   // ...
};

(a)    Почему приведенный фрагмент дает ошибку компиляции?



MI mi;

string dancer( "Nejinsky" );
mi.print( dancer );

(b)   Как изменить определение MI, чтобы этот фрагмент компилировался и выполнялся правильно?





inline void Panda::highlight() {

   Endangered::highlight();

}

inline ostream&

Panda::print( ostream &os ) const

{

   Bear::print( os );

   Endangered::print( os );

   return os;
}

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

Упражнение 18.9

Дана следующая иерархия классов:



class Base1 {

public:

   // ...

protected:

   int    ival;

   double dval;

   char   cval;

   // ...

private:

   int    *id;

   // ...

};

class Base2 {

public:

   // ...

protected:

   float fval;

   // ...

private:

   double dval;

   // ...

};

class Derived : public Base1 {

public:

   // ...

protected:

   string sval;

   double dval;

   // ...

};

class MI : public Derived, public Base2 {

public:

   // ...

protected:

   int             *ival;

   complex<double> cval;

   // ...
};

и структура функции-члена MI::foo():



int ival;

double dval;

void MI::

foo( double dval )

{

   int id;

   // ...
}

(a)    Какие члены видны в классе MI? Есть ли среди них такие, которые видны в нескольких базовых?

(b)   Какие члены видны в MI::foo()?

Упражнение 18.10

Пользуясь иерархией классов из упражнения 18.9, укажите, какие из следующих присваиваний недопустимы внутри функции-члена MI::bar():



void MI::

bar()

{

   int sval;

   // вопрос упражнения относится к коду, начинающемуся с этого места ...

}

(a) dval = 3.14159; (d) fval = 0;

(b) cval = 'a';     (e) sval = *ival;
(c) id = 1;

Упражнение 18.11

Даны иерархия классов из упражнения 18.9 и скелет функции-члена MI::foobar():



int id;

void MI::

foobar( float cval )

{

   int dval;

   // вопросы упражнения относятся к коду, начинающемуся с этого места ...
}

(a)    Присвойте локальной переменной dval


Обобщенные алгоритмы


Операции, описанные в предыдущих разделах, составляют набор, поддерживаемый непосредственно контейнерами vector и deque. Согласитесь, что это весьма небогатый интерфейс и ему явно не хватает базовых операций find(), sort(), merge() и т.д. Планировалось вынести общие для всех контейнеров операции в набор обобщенных алгоритмов, которые могут применяться ко всем контейнерным типам, а также к массивам встроенных типов. (Обобщенные алгоритмы описываются в главе 12 и в Приложении.) Эти алгоритмы связываются с определенным типом контейнера с помощью передачи им в качестве параметров пары соответствующих итераторов. Вот как выглядят вызовы алгоритма find() для списка, вектора и массива разных типов:

#include <list>

#include <vector>

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

vector<string> svec;

list<double> dtist;

// соответствующий заголовочный файл

#include <algorithm>

vector<string>::iterator viter;

list<double>::iterator liter;

#int *pia;

// find() возвращает итератор на найденный элемент

// для массива возвращается указатель ...

pia =   find( &ia[0], &ia[6], some_int_value );

liter = find( dlist.begin(), dlist.end(), some_double_value );

viter = find( svec.begin(), svec.end(), some_string_value );

Контейнер list

поддерживает дополнительные операции, такие, как sort() и merge(), поскольку в нем не реализован произвольный доступ к элементам. (Эти операции описаны в разделе 12.6.)

 Теперь вернемся к нашей поисковой системе.

Упражнение 6.11

Напишите программу, в которой определены следующие объекты:

int ia[] = { 1, 5, 34 };

int ia2[] = { 1, 2, 3 };

int ia3[] = { 6, 13, 21, 29, 38, 55, 67, 89 };

vector<int> ivec;

Используя различные операции вставки и подходящие значения ia, ia2 и ia3, модифицируйте вектор ivec

так, чтобы он содержал последовательность:

{

0, 1, 1, 2, 3, 5, 8, 13, 21, 55, 89 }

Упражнение 6.12

Напишите программу, определяющую данные объекты:

int ia[] = { 0, 1, 1, 2, 3, 5, 8, 13, 21, 55, 89 };

list<int> ilist( ia, ia+11 );

Используя функцию-член erase() с одним параметром, удалите из ilist все нечетные элементы.


В нашу реализацию класса Array

(см. главу 2) мы включили функции-члены для поддержки операций min(), max() и sort(). Однако в стандартном классе vector эти, на первый взгляд фундаментальные, операции отсутствуют. Для нахождения минимального или максимального значения элементов вектора следует вызвать один из обобщенных алгоритмов. Алгоритмами они называются потому, что реализуют такие распространенные операции, как min(), max(), find() и sort(), а обобщенными (generic)– потому, что применимы к различным контейнерным типам: векторам, спискам, массивам. Контейнер связывается с применяемым к нему обобщенным алгоритмом посредством пары итераторов (мы говорили о них в разделе 6.5), указывающих, какие элементы следует посетить при обходе контейнера. Специальные объекты-функции

позволяют переопределить семантику операторов в обобщенных алгоритмах. Итак, в этой главе рассматриваются обобщенные алгоритмы, объекты-функции и итераторы.




Первые два аргумента любого обобщенного алгоритма (разумеется, есть исключения, которые только подтверждают правило) – это пара итераторов, обычно называемых first и last, ограничивающих диапазон элементов внутри контейнера или встроенного массива, к которым применяется этот алгоритм. Как правило, диапазон элементов (иногда его называют интервалом с включенной левой границей) обозначается следующим образом:

// читается так: включает первый и все последующие элементы,

// кроме последнего

[ first, last )

Эта запись говорит о том, что диапазон начинается с элемента first и продолжается до элемента last, исключая последний. Если

first == last

то говорят, что диапазон пуст.

К паре итераторов предъявляется следующее требование: если начать с элемента first и последовательно применять оператор инкремента, то возможно достичь элемента last. Однако компилятор не в состоянии проверить выполнение этого ограничения; если оно нарушается, поведение программы не определено, обычно все заканчивается аварийным остановом и дампом памяти.

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

Некоторые алгоритмы существуют в нескольких версиях: в одной используется встроенный оператор, а во второй – объект-функция или указатель на функцию, которая предоставляет альтернативную реализацию оператора. Например, unique() по умолчанию сравнивает два соседних элемента с помощью оператора равенства, определенного для типа объектов в контейнере. Но если такой оператор равенства не определен или мы хотим сравнивать элементы иным способом, то можно передать либо объект-функцию, либо указатель на функцию, обеспечивающую нужную семантику. Встречаются также алгоритмы с похожими, но разными именами. Так, предикатные версии всегда имеют имя, оканчивающееся на _if, например find_if(). Скажем, есть алгоритм replace(), реализованный с помощью встроенного оператора равенства, и replace_if(), которому передается объект-предикат или указатель на функцию.



Обобщенные алгоритмы в алфавитном порядке


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

Первыми двумя аргументами всех обобщенных алгоритмов (естественно, не без исключений) является пара итераторов, обычно first и last, обозначающих диапазон элементов внутри контейнера или встроенного массива, над которым работает алгоритм. Этот диапазон (часто называемый интервалом с включенной левой границей), как правило, записывается в виде:

// следует читать: включая first и все последующие

// элементы до last, но не включая сам last

[ first, last )

Это означает, что диапазон начинается с first и заканчивается last, однако сам элемент last

не включается. Если

first == last

то говорят, что диапазон пуст.

К паре итераторов предъявляется такое требование: last должен быть достижим, если начать с first и последовательно применять оператор инкремента. Однако компилятор не может проверить выполнение данного ограничения. Если требование не будет выполнено, поведение программы не определено; обычно это заканчивается ее крахом и дампом памяти.

В объявлении каждого алгоритма подразумевается минимальная поддержка, которую должны обеспечить итераторы (краткое обсуждение пяти категорий итераторов см. в разделе 12.4). Например, алгоритм find(), реализующий однопроходный обход контейнера и выполняющий только чтение, требует итератора чтения InputIterator. Ему также можно передать одно- или двунаправленный итератор или итератор с произвольным доступом. Однако передача итератора записи приведет к ошибке. Не гарантируется, что подобные ошибки (при передаче итератора неподходящей категории) будут обнаружены компилятором, поскольку категории итераторов– это не сами типы, а лишь параметры, которыми конкретизируется шаблон функции.



Обобщенный список


Наш класс ilist

имеет серьезный недостаток: он может хранить элементы только целого типа. Если бы он мог содержать элементы любого типа– как встроенного, так и определенного пользователем, – то его область применения была бы гораздо шире. Модифицировать ilist для поддержки произвольных типов данных позволяет механизм шаблонов (см. главу 16).

При использовании шаблона вместо параметра подставляется реальный тип данных. Например:

list< string > slist;

создает экземпляр списка, способного содержать объекты типа string, а

list< int > ilist;

создает список, в точности повторяющий наш ilist. С помощью шаблона класса можно обеспечить поддержку произвольных типов данных одним экземпляром кода. Рассмотрим последовательность действий, уделив особое внимание классу list_item.

Определение шаблона класса начинается ключевым словом template, затем следует список параметров в угловых скобках. Параметр представляет собой идентификатор, перед которым стоит ключевое слово class или typename. Например:

template <class elemType>

class list_item;

Эта инструкция объявляет list_item

шаблоном класса с единственным параметром-типом. Следующее объявление эквивалентно предыдущему:

template <typename elemType>

class list_item;

Ключевые слова class и typename

имеют одинаковое значение, можно использовать любое из них. Более удобное для запоминания typename появилось в стандарте С++ сравнительно недавно и поддерживается еще не всеми компиляторами. Поскольку наши тексты были написаны до появления этого ключевого слова, в них употребляется class. Шаблон класса list_item

выглядит так:

template <class elemType>

class list_item {

public:

    list_item( elemType value, list_item *item = 0 )

             : _value( value ) {

        if ( !item )

            _next = 0;

        else {

            _next = item->_next;

            item->_next = this;

        }

    }

    elemType value() { return _value; }

    list_item* next() { return _next; }

    void next( list_item *link ) { _next = link; }

    void value( elemType new_value ) { _value = new_value; }

private:

    elemType   _value;

    list_item *_next;

<
};

Все упоминания типа int в определении класса ilist_item

заменены на параметр elemType. Когда мы пишем:

list_item<doub1e> *ptr = new list_item<doub1e>( 3.14 );

компилятор подставляет double

вместо elemType и создает экземпляр list_item, поддерживающий данный тип.

Аналогичным образом модифицируем класс ilist в шаблон класса list:



template <class elemType>

class list {

public:

list()

       : _at_front( 0 ), _at_end( 0 ), _current( 0 ),

         _size( 0 ) {}

    1ist( const list& );

    list& operator=( const list& );

    ~list() { remove_all(); }

    void insert ( list_item<elemType> *ptr, elemType value );

    void insert_end( elemType value );

    void insert_front( elemType value );

    void insert_all( const list &rhs );

    int remove( elemType value );

    void remove_front();

    void remove_all();

    list_item<elemType> *find( elemType value );

    list_item<elemType> *next_iter();

    list_item<elemType>* init_iter( list_item<elemType> *it );

    void disp1ay( ostream &os = cout );

    void concat( const list& );

    void reverse ();

    int size() { return _size; }

private:

    void bump_up_size()   { ++_size; }

    void bump_down_size() { --_size; }

    list_item<elemType> *_at_front;

    1ist_item<elemType> *_at_end;

    list_item<elemType> *_current;

    int _size;
};

Объекты шаблона класса list

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

(Шаблоны являются важной составной частью концепции программирования на С++. В главе 6 мы рассмотрим набор классов контейнерных типов, предоставляемых стандартной библиотекой С++. Неудивительно, что она содержит шаблон класса, реализующего операции со списками, равно как и шаблон класса, поддерживающего векторы; мы рассматривали их в главах 2 и 3.)



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

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



namespace Primer_Third_Edition

{

    template <typename elemType>

    class list_item{ ... };

    template <typename elemType>

    class list{ ... };

    // ...
}

Для использования такого класса в пользовательской программе необходимо написать следующее:



// наш заголовочный файл

#include "list.h"

// сделаем наши определения видимыми в программе

using namespace Primer_Third_Edition;

// теперь можно использовать наш класс list

list< int > ilist;
// ...

(Пространства имен описываются в разделах 8.5 и 8.6.)

Упражнение 5.16

Мы не определили деструктор для ilist_item, хотя класс содержит указатель на динамическую область памяти. Причина заключается в том, что класс не выделяет память для объекта, адресуемого указателем _next, и, следовательно, не несет ответственности за ее освобождение. Начинающий программист мог бы допустить ошибку, вызвав деструктор для ilist_item:



ilist_item::~ilist_item()

{

    delete _next;
}

Посмотрите на функции remove_all() и remove_front() и объясните, почему наличие такого деструктора является ошибочным.

Упражнение 5.17

Наш класс ilist не поддерживает следующие операции:





void ilist::remove_end();
void ilist::remove( ilist_item* );

Как вы думаете, почему мы их не включили? Реализуйте их.

Упражнение 5.18

Модифицируйте функцию find()

так, чтобы вторым параметром она принимала адрес элемента, с которого нужно начинать поиск. Если этот параметр не задан, поиск начинается с первого элемента. (Поскольку мы добавляем второй параметр, имеющий значение по умолчанию, открытый интерфейс данной функции не меняется. Программы, использующие предыдущую версию find(), будут работать без модификации.)



class ilist {

public:

    // ...

    ilist_item* find( int value, ilist_item *start_at = 0 );

    // ...
};

Упражнение 5.19

Используя новую версию find(), напишите функцию count(), которая подсчитывает количество вхождений элементов с заданным значением. Подготовьте тестовую программу.

Упражнение 5.20

Модифицируйте insert(int value)

так, чтобы она возвращала указатель на вставленный объект ilist_item.

Упражнение 5.21

Используя модифицированную версию insert(), напишите функцию:



void ilist::

insert( ilist_item *begin,

       int *array_of_value,
       int elem_cnt );

где array_of_value

указывает на массив значений, который нужно вставить в ilist, elem_cnt – на размер этого массива, а begin – на элемент, после которого производится вставка. Например, если есть ilist:

 (3)( 0 1 21 )

и массив:

int ia[] = { 1, 2, 3, 5, 8, 13 };

вызов этой новой функции



ilist_item *it = mylist.find( 1 );
mylist.insert( it, ia, 6 );

изменит список таким образом:

 (9) ( 0 1 1 2 3 5 8 13 21 )

Упражнение 5.22

Функции concat() и reverse()

модифицируют оригинальный список. Это не всегда желательно. Напишите аналогичную пару функций, которые создают новый объект ilist:



ilist ilist::reverse_copy();
ilist ilist::concat_copy( const ilist &rhs );


Обрабатываем знаки препинания


После того как мы разбили каждую строку на слова, необходимо избавиться от знаков препинания. Пока из строки

magical but untamed. "Daddy, shush, there is no such thing,"

у нас получился такой набор слов:

magical

but

untamed.

"Daddy,

shush,

there

is

no

such

thing,"

Как нам теперь удалить ненужные знаки препинания? Для начала определим строку, содержащую все символы, которые мы хотим удалить:

string filt_elems( "\",.;:!?)(\\/" );

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

обозначает символ двойной кавычки, а не конец строки, а \\ – символ обратной косой черты.)

Теперь можно применить функцию-член find_first_of() для поиска всех вхождений нежелательных символов:

while (( pos = word.find_first_of( filt_elems, pos ))

            != string::npos )

Найденный символ удаляется с помощью функции-члена erase():

word.erase(pos,1);

Первый аргумент этой функции означает позицию подстроки, а второй – ее длину. Мы удаляем один символ, находящийся в позиции pos. Второй аргумент является необязательным; если его опустить, будут удалены все символы от pos до конца строки.

Вот полный текст функции filter_text(). Она имеет два параметра: указатель на вектор строк, содержащий текст, и строку с символами, которые нужно убрать.

void

filter_text( vector<string> *words, string filter )

{

    vector<string>::iterator iter = words->begin();

    vector<string>::iterator iter_end = words->end();

    // Если filter не задан, зададим его сами

    if ( ! filter.size() )

        filter.insert( 0, "\".," );

    while ( iter != iter_end ) {

        string::size_type pos = 0;

        // удалим каждый найденный элемент

        while (( pos =  (*iter).find_first_of( filter, pos ))

                    != string::npos )

            (*iter).erase(pos,1);

        iter++;

    }

<
}

Почему мы не увеличиваем значение pos на каждой итерации? Что было бы, если бы мы написали:



while (( pos =  (*iter).find_first_of( filter, pos ))

           != string::npos )

{

     (*iter).erase(pos,1);

    ++ pos; //

неправильно...
}

Возьмем строку

thing,"

На первой итерации pos

получит значение 5 , т.е. позиции, в которой находится запятая. После удаления запятой строка примет вид

thing"

Теперь в 5-й позиции стоит двойная кавычка. Если мы увеличим значение pos, то пропустим этот символ.

Так мы будем вызывать функцию filter_text():



string filt_elems( "\",.;:!?)(\\/" );
filter_text( text_locations->first, filt_elems );

А вот часть распечатки, сделанной тестовой версией filter_text():

filter_text: untamed.

found! : pos: 7.

after: untamed

filter_text: "Daddy,

found! : pos: 0.

after: Daddy,

found! : pos: 5.

after: Daddy

filter_text: thing,"

found! : pos: 5.

after: thing"

found! : pos: 5.

after: thing

filter_text: "I

found! : pos: 0.

after: I

filter_text: Daddy,

found! : pos: 5.

after: Daddy

filter_text: there?"

found! : pos: 5.

after: there"

found! : pos: 5.

after: there

Упражнение 6.15

Напишите программу, которая удаляет все символы, кроме STL из строки:

"/.+(STL).$1/"

используя сначала erase(pos,count), а затем erase(iter,iter).

Упражнение 6.16

Напишите программу, которая с помощью разных функций вставки из строк



string sentence( "kind of" );

string s1 ( "whistle" )
string s2 ( "pixie" )

составит предложение

"A whistling-dixie kind of walk"


Обработка исключений


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



Обработка исключения типа класса


Если исключения организуются в иерархии, то исключение типа некоторого класса может быть перехвачено обработчиком, соответствующим любому его открытому базовому классу. Например, исключение типа pushOnFull перехватывается обработчиками исключений типа stackExcp или Excp.

int main() {

   try {

      // ...

   }

   catch ( Excp ) {

      // обрабатывает исключения popOnEmpty и pushOnFull

   }

   catch ( pushOnFull ) {

      // обрабатывает исключение pushOnFull

   }

Здесь порядок catch-обработчиков желательно изменить. Напоминаем, что они просматриваются в порядке появления после try-блока. Как только будет найден обработчик, способный обработать данное исключение, поиск прекращается. В примере выше Excp

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

catch ( pushOnFull ) {

   // обрабатывает исключение pushOnFull

}

catch ( Excp ) {

   // обрабатывает другие исключения

}

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

Если исключения организованы в иерархии, то пользователи библиотеки классов могут выбрать в своем приложении уровень детализации при работе с исключениями, возбужденными внутри библиотеки. Например, кодируя функцию main(), мы решили, что исключения типа pushOnFull

должны обрабатываться несколько иначе, чем прочие, и потому написали для них специализированный catch-обработчик. Что касается остальных исключений, то они обрабатываются единообразно:

catch ( pushOnFull eObj ) {

   // используется функция-член value() класса pushOnFull

   // см. раздел 11.3

   cerr << "попытка поместить значение " << eObj.value()

        << " в полный стек\n";

}

catch ( Excp ) {

   // используется функция-член print() базового класса

   Excp::print( "произошло исключение" );

<
}

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

Объявление исключения в catch-обработчике (находящееся в скобках после слова catch) очень похоже на объявление параметра функции. В приведенном примере оно напоминает параметр, передаваемый по значению. Объект eObj

инициализируется копией значения объекта-исключения точно так же, как передаваемый по значению формальный параметр функции инициализируется значением фактического аргумента. Как и в случае с параметрами функции, в объявлении исключения можно использовать ссылки. Тогда catch-обработчик имеет доступ непосредственно к объекту-исключению, созданному выражением throw, а не к его локальной копии. Чтобы избежать копирования больших объектов, параметры типа класса следует объявлять как ссылки; в объявлениях исключений тоже желательно делать исключения типа класса ссылками. В зависимости от того, что находится в таком объявлении (объект или ссылка), поведение обработчика различается (мы покажем эти различия в данном разделе).

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

throw;

Как ведет себя эта инструкция, если она расположена в catch-обработчике исключений базового класса? Например, каким будет тип повторно возбужденного исключения, если mathFunc()

возбуждает исключение типа divideByZero?



void calculate( int parm ) {

   try {

      mathFunc( parm );  // возбуждает исключение divideByZero

   }

   catch ( mathExcp mExcp ) {

      // частично обрабатывает исключение

      // и генерирует объект-исключение еще раз

      throw;

   }
<


}

Будет ли повторно возбужденное исключение иметь тип divideByZero–тот же, что и исключение, возбужденное функцией mathFunc()? Или тип mathExcp, который указан в объявлении исключения в catch-обработчике?

Напомним, что выражение throw

повторно генерирует исходный

объект-исключение. Так как исходный объект имеет тип divideByZero, то повторно возбужденное исключение будет такого же типа. В catch-обработчике объект mExcp

инициализируется копией подобъекта объекта типа divideByZero, который соответствует его базовому классу MathExcp. Доступ к ней осуществляется только внутри catch-обработчика, она не является исходным объектом-исключением, который повторно генерируется.

Предположим, что классы в нашей иерархии исключений имеют деструкторы:



class pushOnFull {

public:

   pushOnFull( int i ) : _value( i ) { }

   int value() { return _value; }

   ~pushOnFull();  // вновь объявленный деструктор

private:

   int _value;
};

Когда они вызываются? Чтобы ответить на этот вопрос, рассмотрим catch-обработчик:



catch ( pushOnFull eObj ) {

   cerr << "попытка поместить значение " << eObj.value()

        << " в полный стек\n";
}

Поскольку в объявлении исключения eObj

объявлен как локальный для catch-обработчика объект, а в классе pushOnFull

есть деструктор, то eObj

уничтожается при выходе из обработчика. Когда же вызывается деструктор для объекта-исключения, созданного в момент возбуждения исключения, – при входе в catch-обработчик или при выходе из него? Однако уничтожать исключение в любой из этих точек может быть слишком рано. Можете сказать, почему? Если catch-обработчик возбуждает исключение повторно, передавая его выше по цепочке вызовов, то уничтожать объект-исключение нельзя до момента выхода из последнего catch-обработчика.


Обратные итераторы


Операции begin() и end()

возвращают соответственно итераторы, указывающие на первый элемент и на элемент, расположенный за последним. Можно также вернуть обратный итератор, обходящий контейнер от последнего элемента к первому. Во всех контейнерах для поддержки такой возможности используются операции rbegin() и rend(). Есть константные и неконстантные версии обратных итераторов:

vector< int > vec0;

const vector< int > vec1;

vector< int >::reverse_iterator r_iter0 = vec0.rbegin();

vector< int >::const_reverse_iterator r_iter1 = vec1.rbegin();

Обратный итератор применяется так же, как прямой. Разница состоит в реализации операторов перехода к следующему и предыдущему элементам. Для прямого итератора оператор ++

дает доступ к следующему элементу контейнера, тогда как для обратного – к предыдущему. Например, для обхода вектора в обратном направлении следует написать:

// обратный итератор обходит вектор от конца к началу

vector< type >::reverse_iterator r_iter;

for ( r_iter = vec0.rbegin();   // r_iter указывает на последний элемент

      r_iter != vec0.rend();    // пока не достигли элемента перед первым

      r_iter++ )                // переходим к предыдущему элементу

{ /* ... */ }

Инвертирование семантики операторов инкремента и декремента может внести путаницу, но зато позволяет программисту передавать алгоритму пару обратных итераторов вместо прямых. Так, для сортировки вектора в порядке убывания мы передаем алгоритму sort()

пару обратных итераторов:

// сортирует вектор в порядке

возрастания

sort( vec0.begin(), vec0.end() );

// сортирует вектор в порядке убывания

sort( vec0.rbegin(), vec0.rend() );



Очередь и очередь с приоритетами


Абстракция очереди реализует метод доступа FIFO (first in, first out – “первым вошел, первым вышел”): объекты добавляются в конец очереди, а извлекаются из начала. Стандартная библиотека предоставляет две разновидности этого метода: очередь FIFO, или простая очередь, и очередь с приоритетами, которая позволяет сопоставлять элементы с их приоритетами.

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

Для использования queue и priority_queue

необходимо включить заголовочный файл:

#include <queue>

Полный набор операций с контейнерами queue и priority_queue приведен в таблице 6.6.

Таблица 6.6. Операции с queue и priority_queue

Операция

Действие

empty()

Возвращает true, если очередь пуста, и false в противном случае

size()

Возвращает количество элементов в очереди

pop()

Удаляет первый элемент очереди, но не возвращает его значения. Для очереди с приоритетом первым является элемент с наивысшим приоритетом

front()

Возвращает значение первого элемента очереди, но не удаляет его. Применимо только к простой очереди

back()

Возвращает значение последнего элемента очереди, но не удаляет его. Применимо только к простой очереди

top()

Возвращает значение элемента с наивысшим приоритетом, но не удаляет его. Применимо только к очереди с приоритетом

push(item)

Помещает новый элемент в конец очереди. Для очереди с приоритетом позиция элемента определяется его приоритетом.

Элементы priority_queue

отсортированы в порядке убывания приоритетов. По умолчанию упорядочение основывается на операции “меньше”, определенной над парами элементов. Конечно, можно явно задать указатель на функцию или объект-функцию, которая будет использоваться для сортировки. (В разделе 12.3 можно найти более подробное объяснение и иллюстрации использования такой очереди.)



Ограничение прав на создание объекта


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

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

class Account {

   friend class vector< Account >;

public:

   explicit Account( const char*, double = 0.0 );

   // ...

private:

   Account();

   // ...

};

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

Конструкторы, не являющиеся открытыми, в реальных программах C++ чаще всего используются для:

·                  предотвращения копирования одного объекта в другой объект того же класса (эта проблема рассматривается в следующем подразделе);

·                  указания на то, что конструктор должен вызываться только в случае, когда данный класс выступает в роли базового в иерархии наследования, а не для создания объектов, которыми программа может манипулировать напрямую (см. обсуждение наследования и объектно-ориентированного программирования в главе 17).



Окончательная программа


Ниже представлен полный текст программы, разработанной в этой главе, с двумя модификациями: мы инкапсулировали все структуры данных и функции в класс TextQuery (в последующих главах мы обсудим подобное использование классов), кроме того, текст был изменен, так как наш компилятор поддерживал стандарт С++ не полностью.

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

// стандартные заголовочные файлы С++

#include <algorithm>

#include <string>

#include <vector>

#include <utility>

#include <map>

#include <set>

// заголовочный файл iostream, не отвечающий стандарту

#include <fstream.h>

// заголовочные файлы С

#include <stddef.h>

#include <ctype.h>

// typedef для удобства чтения

typedef pair<short,short>           location;

typedef vector<location,allocator>  loc;

typedef vector<string,allocator>    text;

typedef pair<text*,loc*>            text_loc;

class TextQuery {

public:

    TextQuery() { memset( this, 0, sizeof( TextQuery )); }

    static void

      filter_elements( string felems ) { filt_elems = felems; }

    void query_text();

    void display_map_text();

    void display_text_locations();

    void doit() {

        retrieve_text();

        separate_words();

        filter_text();

        suffix_text();

        strip_caps();

        build_word_map();

    }

private:

    void retrieve_text();

    void separate_words():

    void filter_text();

    void strip_caps();

    void suffix_textQ;

    void suffix_s( string& );

    void build_word_map();

private:

    vector<string,allocator>      *lines_of_text;

    text_loc                      *text_locations;

    map< string,loc*,

        less<string>,allocator>   *word_map;

    static string                 filt_elems;

};

string TextQuery::filt_elems( "\", •;: !?)(\V" );

int main()

{

    TextQuery tq;

    tq.doit();

    tq.query_text();

    tq.display_map_text();

}

void

TextQuery::

retrieve_text()

{

    string file_name;

    cout << "please enter file name: ";

    cin >> file_name;

    ifstream infile( file_name.c_str(), ios::in );

    if ( !infile ) {

        cerr << "oops' unable to open file "

             << file_name <<    " -- bailing out!\n";

        exit( -1 );

    }

    else cout << "\n";

    lines_of_text = new vector<string,allocator>;

    string textline;

    while ( getline( infile, textline, '\n' ))

        lines_of_text->push_back( textline );

}

void

TextQuery::

separate_words()

{

    vector<string,allocator> *words =

               new vector<string,allocator>;

    vector<location,allocator> *locations =

               new vector<location,allocator>;

    for ( short line_pos = 0; line_pos < lines_of_text->size();

        line_pos++ )

    {

        short word_pos = 0;

        string textline = (*lines_of_text)[ line_pos ];

        string::size_type eol = textline.1ength();

        string::size_type pos = 0, prev_pos = 0;

        while (( pos = textline.find_first_of( ' ', pos ))

                    != string::npos )

        {

            words->push_back(

                textline.substr( prev_pos, pos - prev_pos ));

            locations->push_back(

                make_pair( line_pos, word_pos ));

            word_pos++; pos++; prev_pos = pos;

        }

        words->push_back(

            textline.substr( prev_pos, pos - prev_pos ));

        locations->push_back(make_pair(line_pos,word_pos));

    }

    text_locations = new text_loc( words, locations );

}

void

TextQuery::

filter_text()

{

    if ( filt_elems.empty() )

        return;

    vector<string,allocator> *words = text_locations->first;

    vector<string,allocator>::iterator iter = words->begin();

    vector<string,allocator>::iterator iter_end = words->end();

    while ( iter != iter_end )

    {

        string::size_type pos = 0;

        while ((pos = (*iter).find_first_of(filt_elems, pos))

                   != string::npos )

            (*iter).erase(pos,l);

        ++iter;

    }

}

void

TextQuery::

suffix_text()

{

    vector<string,allocator> *words = text_locations->first;

    vector<string,allocator>::iterator iter = words->begin();

    vector<string,allocator>::iterator iter_end = words->end() ;

    while ( iter != iter_end ) {

        if ( (*iter).size() <= 3 )

            { iter++; continue; }

        if ( (*iter)[ (*iter).size()-l ] == 's' )

            suffix_s( *iter );

        // дополнительная обработка суффиксов...

        iter++;

    }

}

void

TextQuery::

suffix_s( string &word )

{

    string::size_type spos = 0;

    string::size_type pos3 = word.size()-3;

    // "ous", "ss", "is", "ius"

    string suffixes( "oussisius" );

    if ( ! word.compare( pos3, 3, suffixes, spos, 3 ) ||

         ! word.compare( pos3, 3, suffixes, spos+6, 3) ||

         ! word.compare( pos3+l, 2, suffixes, spos+2, 2 ) ||

         ! word.compare( pos3+l, 2, suffixes, spos+4, 2 ))

            return;

    string ies( "ies" );

    if ( ! word.compare( pos3, 3, ies ))

    {

        word.replace( pos3, 3, 1, 'у' );

        return;

    }

    string ses( "ses" );

    if ( ! word.compare( pos3, 3, ses ))

    {

        word.erase( pos3+l, 2 );

        return;

    }

    // удалим 's' в конце

    word.erase( pos3+2 );

    // удалим "'s"

    if ( word[ pos3+l ] == '\'' )

        word.erase( pos3+l );

}

void

TextQuery::

strip_caps()

{

    vector<string,allocator> *words = text_locations->first;

    vector<string,allocator>::iterator iter = words->begin();

    vector<string,allocator>::iterator iter_end = words->end();

    string caps( "ABCDEFGHI3KLMNOPQRSTUVWXYZ" );

    while ( iter != iter_end ) {

        string::size_type pos = 0;

        while (( pos = (*iter).find_first_of( caps, pos ))

                    != string::npos )

            (*iter)[ pos ] = to1ower( (*iter)[pos] );

        ++iter;

    }

}

void

TextQuery::

build_word_map()

{

    word_map = new map<string,loc*,less<string>,allocator>;

    typedef map<string,loc*,less<string>,allocator>::value_type

        value_type;

    typedef set<string,less<string>,allocator>::difference_type

        diff_type;

    set<string,less<string>,allocator> exclusion_set;

    ifstream infile( "exclusion_set" );

    if ( !infile )

    {

        static string default_excluded_words[25] = {

          "the","and","but","that","then","are","been",

          "can","can't","cannot","could","did","for",

          "had","have","him","his","her","its"."into",

          "were","which","when","with","would"

        };

        cerr <<

           "warning! unable to open word exclusion file! -- "

             << "using default set\n";

        copy( default_excluded_words,

              default_excluded_words+25,

              inserter(exclusion_set, exclusion_set.begin()));

    }

    else {

        istream_iterator< string, diff_type >

            input_set( infile ), eos;

        copy( input_set, eos,

            inserter( exclusion_set, exclusion_set.begin() ));

    }

    // пробежимся по всем словам, вставляя пары

    vector<string,allocator> *text_words =

        text_locations->first;

    vector<location,allocator> *text.locs =

        text_locations->second;

    register int elem_cnt = text_words->size();

    for ( int ix = 0; ix < elem_cnt; ++-ix )

    {

        string textword = ( *text_words )[ ix ];

        if ( textword.size() < 3 ||

            exclusion_set.count( textword ))

                continue;

        if ( ! word_map->count((*text_words)[ix] ))

        { // слово отсутствует, добавим:

            loc *ploc = new vector<location,allocator>;

            ploc->push_back( (*text_locs)[ix] );

            word_map->

                insert( value_type( (*text_words)[ix],ploc ));

        }

        else (*word_map) [(*text_words) [ix]]->

                push_back( (*text_locs) [ix] );

    }

}

void

TextQuery::

query_text()

{

    string query_text;

    do {

        cout

        << "enter a word against which to search the text.\n"

        << "to quit, enter a single character ==> ";

        cin >> query_text;

        if ( query_text.size() < 2 ) break;

        string caps( "ABCDEFGHIJKLMNOPQRSTUVWXYZ" );

        string::size_type pos = 0;

        while (( pos = query_text.find_first_of( caps, pos ))

                    != string::npos )

            query_text[ pos ] = to1ower( query_text[pos] );

        // query_text должно быть введено

        if ( !word_map->count( query_text )) {

            cout << "\nSorry. There are no entries for "

                 << query_text << ".\n\n";

            continue;

        }

        loc *ploc = (*word_map) [ query_text ];

        set<short,less<short>,allocator> occurrence_1i nes;

        loc::iterator liter = ploc->begin(),

                      liter_end = ploc->end();

        while ( liter != liter_end ) {

             occurrence_lines.1nsert(

                   occurrence_lines.end(), (*liter).first);

             ++liter;

        }

        register int size = occurrence_lines.size();

        cout << "\n" << query_text

             << " occurs " << size

             << (size == 1 ? " time:" : " times:")

             << "\n\n";

        set<short,less<short>,allocator>::iterator

              it=occurrence_lines.begin();

        for ( ; it != occurrence_"lines.end(); ++it ) {

            int line = *it;

            cout << "\t( line "

                 // будем нумеровать строки с 1,

                 // как это принято везде

                 << line + 1 << " ) "

                 << (*lines_of_text)[line] << endl;

        }

        cout << endl;

    }

    while ( ! query_text.empty() );

    cout << "Ok, bye!\n";

}

void

TextQuery::

display_map_text()

{

    typedef map<string,loc*, less<string>, allocator> map_text;

    map_text::iterator iter = word_map->begin(),

                       iter_end = word_map->end();

    while ( iter != iter_end ) {

        cout << "word: " << (*iter).first << " (";

        int           loc_cnt = 0;

        loc          *text_locs = (*iter).second;

        loc::iterator liter     = text_locs->begin(),

                      liter_end = text_locs->end();

        while ( liter != liter_end )

        {

            if ( loc_cnt )

                cout << ",";

            else ++loc_cnt;

            cout << "(" << (*liter).first

                 << "," << (*liter).second << ")";

            ++"liter;

        }

        cout << ")\n";

        ++iter;

    }

    cout << endl;

}

void

TextQuery::

disp1ay_text_locations()

{

    vector<string,allocator> *text_words =

        text_locations->first;

    vector<location,allocator> *text_locs =

        text_locations->second;

    register int elem_cnt = text_words->size();

    if ( elem_cnt != text_locs->size() )

    {

        cerr

         << "oops! internal error: word and position vectors "

         << "are of unequal size\n"

         << "words: " << elem_cnt << " "

         << "locs: " << text_locs->size()

         << " -- bailing out!\n";

        exit( -2 );

    }

    for ( int ix=0; ix < elem_cnt; ix++ )

    {

        cout << "word: " << (*text_words)[ ix ] << "\t"

             << "location: ("

             << (*text_locs)[ix].first << ","

             << (*text.locs)[ix].second << ")"

             << "\n";

    }

    cout << endl;

<
}

Упражнение 6.25

Объясните, почему нам потребовался специальный класс inserter для заполнения набора стоп-слов (это упоминается в разделе 6.13.1, а детально рассматривается в 12.4.1).



set<string> exclusion_set;

ifstream    infile( "exclusion_set" );

copy( default_excluded_words, default_excluded_words+25,
      inserter(exclusion_set, exclusion_set.begin() ));

Упражнение 6.26

Первоначальная реализация поисковой системы отражает процедурный подход: набор глобальных функций оперирует набором независимых структур данных. Окончательный вариант представляет собой альтернативный подход, когда мы инкапсулируем функции и данные в класс TextQuery. Сравните оба способа. Каковы недостатки и преимущества каждого?

Упражнение 6.27

В данной версии программы имя файла с текстом вводится по запросу. Более удобно было бы задавать его как параметр командной строки; в главе 7 мы покажем, как это делается. Какие еще параметры командной строки желательно реализовать?


Опасность увеличения размера программы


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

Account acct( "Tina Lee" );

int swt;

// ...

switch( swt ) {

case 0:

   return;

case 1:

   // что-то сделать

   return;

case 2:

   // сделать что-то другое

   return;

// и так далее

}

компилятор подставит деструктор перед каждой инструкцией return. Деструктор класса Account

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

можно заменить инструкцией break с тем, чтобы у функции была единственная точка выхода:

// переписано для обеспечения единственной точки выхода

switch( swt ) {

case 0:

   break;

case 1:

   // что-то сделать

   break;

case 2:

   // сделать что-то другое

   break;

// и так далее

}

// единственная точка выхода

return;

Упражнение 14.6

Напишите подходящий деструктор для приведенного набора членов класса, среди которых pstring

адресует динамически выделенный массив символов:

class NoName {

public:

   ~NoName();

   // ...

private:

   char    *pstring;

   int     ival;

   double  dval;

};

Упражнение 14.7

Необходим ли деструктор для класса, который вы выбрали в упражнении 14.3? Если нет, объясните почему. В противном случае предложите реализацию.

Упражнение 14.8

Сколько раз вызываются деструкторы в следующем фрагменте:

void mumble( const char *name, fouble balance, char acct_type )

{

   Account acct;

   if ( ! name )

      return;

   if ( balance <= 99 )

      return;

   switch( acct_type ) {

      case 'z': return;

      case 'a':

      case 'b': return;

   }

   // ...

}



Операции инкремента и декремента


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

#include <vector>

#include <cassert>

int main()

{

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

    vector<int> ivec( 10 );

    int ix_vec = 0, ix_ia = 9;

    while ( ix_vec < 10 )

        ivec[ ix_vec++ ] = ia[ ix_ia-- ];

    int *pia = &ia[9];

    vector<int>::iterator iter = ivec.begin();

    while ( iter != ivec.end() )

        assert( *iter++ == *pia-- );

}

Выражение

ix_vec++

является постфиксной формой оператора инкремента. Значение переменной ix_vec

увеличивается после того, как ее текущее значение употреблено в качестве индекса. Например, на первой итерации цикла значение ix_vec

равно 0. Именно это значение применяется как индекс массива ivec, после чего ix_vec

увеличивается и становится равным 1, однако новое значение используется только на следующей итерации. Постфиксная форма операции декремента работает точно так же: текущее значение ix_ia берется в качестве индекса для ia, затем ix_ia

уменьшается на 1.

Существует и префиксная форма этих операторов. При использовании такой формы текущее значение сначала уменьшается или увеличивается, а затем используется новое значение. Если мы пишем:

// неверно: ошибки с границами индексов в

// обоих случаях

int ix_vec = 0, ix_ia = 9;

while ( ix_vec < 10 )

    ivec[ ++ix_vec ] = ia[ --ix_ia ];

значение ix_vec

увеличивается на единицу и становится равным 1 до первого использования в качестве индекса. Аналогично ix_ia

получает значение 8 при первом использовании. Для того чтобы наша программа работала правильно, мы должны скорректировать начальные значения переменных ix_ivec и ix_ia:

// правильно

int ix_vec = -1, ix_ia = 8;

while ( ix_vec < 10 )

    ivec[ ++ix_vec ] = ia[ --ix_ia ];


В качестве последнего примера рассмотрим понятие стека. Это фундаментальная абстракция компьютерного мира, позволяющая помещать и извлекать элементы в последовательности LIFO (last in, fist out – последним вошел, первым вышел). Стек реализует две основные операции – поместить (push) и извлечь (pop).

Текущий свободный элемент называют вершиной стека. Операция push присваивает этому элементу новое значение , после чего вершина смещается вверх (становится на 1 больше). Пусть наш стек использует для хранения элементов вектор. Какую из форм операции увеличения следует применить? Сначала мы используем текущее значение, потом увеличиваем его. Это постфиксная форма:

stack[ top++ ] = value;

Что делает операция pop? Уменьшает значение вершины (текущая вершина показывает на пустой элемент), затем извлекает значение. Это префиксная форма операции уменьшения:

int value = stack[ --top ];

(Реализация класса stack

приведена в конце этой главы. Стандартный класс stack

рассматривается в разделе 6.16.)

Упражнение 4.8

Как вы думаете, почему язык программирования получил название С++, а не ++С?


Операции присваивания


Инициализация задает начальное значение переменной. Например:

int ival = 1024;

int *pi = 0;

В результате операции присваивания объект получает новое значение, при этом старое пропадает:

ival = 2048;

pi = &iva1;

Иногда путают инициализацию и присваивание, так как они обозначаются одним и тем же знаком =. Объект инициализируется только один раз– при его определении. В то же время операция может быть применена к нему многократно.

Что происходит, если тип объекта не совпадает с типом значения, которое ему хотят присвоить? Допустим,

ival = 3.14159; // правильно?

В таком случае компилятор пытается трансформировать тип объекта, стоящего справа, в тип объекта, стоящего слева. Если такое преобразование возможно, компилятор неявно изменяет тип, причем при потере точности обычно выдается предупреждение. В нашем случае вещественное значение 3.14159 преобразуется в целое значение 3, и это значение присваивается переменной ival.

Если неявное приведение типов невозможно, компилятор сигнализирует об ошибке:

pi = ival; // ошибка

Неявная трансформация типа int в тип указатель на int

невозможна. (Набор допустимых неявных преобразований типов мы обсудим в разделе 4.14.)

Левый операнд операции присваивания должен быть l-значением. Очевидный пример неправильного присваивания:

1024 = ival; // ошибка

Возможно, имелось в виду следующее:

int value = 1024;

value = ival; // правильно

Однако недостаточно потребовать, чтобы операнд слева от знака присваивания был l-значением.

Так, после определений

const int array_size =   8;

int ia[ array_size ] =   { 0, 1, 2, 2, 3, 5, 8, 13 };

int *pia = ia;

выражение

array_size = 512; // ошибка

ошибочно, хотя array_size и является l-значением: объявление array_size константой не дает возможности изменить его значение. Аналогично

ia = pia; // ошибка

ia – тоже l-значение, но оно не может быть значением массива.

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

pia + 2=1; // ошибка

Хотя pia+2

дает адрес ia[2], присвоить ему значение нельзя. Если мы хотим изменить элемент ia[2], то нужно воспользоваться операцией разыменования. Корректной будет следующая запись:


*(pia + 2) = 1; // правильно

Операция присваивания имеет результат – значение, которое было присвоено самому левому операнду. Например, результатом такой операции

ival = 0;

является 0, а результат

ival = 3.14159;

равен 3. Тип результата – int в обоих случаях. Это свойство операции присваивания можно использовать в подвыражениях. Например, следующий цикл



extern char next_char();

int main()

{

    char ch = next_char();

    while ( ch != '\n' ) {

        // сделать что-то ...

        ch = next_char();

    }

    // ...
}

может быть переписан так:



extern char next_char();

int main()

{

    char ch;

    while (( ch = next_char() ) != '\n' ) {

        // сделать что-то ...

    }

    // ...
}

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

next_char() != '\n'

и его результат, true или false, присваивается переменной ch. (Приоритеты операций будут рассмотрены в разделе 4.13.)

Аналогично несколько операций присваивания могут быть объединены, если это позволяют типы операндов. Например:



int main ()

{

    int ival, jval;

    ival = jval = 0; // правильно: присваивание 0 обеим переменным
    // ...

}

Обеим переменным ival и jval

присваивается значение 0. Следующий пример неправилен, потому что типы pval и ival

различны, и неявное преобразование типов невозможно. Отметим, что 0

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



int main ()

{

    int ival; int *pval;

    ival = pval = 0; // ошибка: разные типы
    // ...

}

Верен или нет приведенный ниже пример, мы сказать не можем, , поскольку определение jval в нем отсутствует:



int main()

{

    // ...

    int ival = jval = 0; // верно или нет?

    // ...
}

Это правильно только в том случае, если переменная jval определена в программе ранее и имеет тип, приводимый к int. Обратите внимание: в этом случае мы присваиваем 0

значение jval и инициализируем ival. Для того чтобы инициализировать нулем обе переменные, мы должны написать:





int main()

{

    // правильно: определение и инициализация

    int ival = 0, jval = 0;

    // ...
}

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



int arraySum( int ia[], int sz )

{

    int sum = 0;

    for ( int i = 0; i < sz; ++i )

        sum = sum + ia[ i ];

    return sum;
}

Для более компактной записи С и С++ предоставляют составные операции присваивания. С использованием такого оператора данный пример можно переписать следующим образом:



int arraySum( int ia[], int sz )

{

    int sum = 0;

    for ( int i =0; i < sz; ++i )

        // эквивалентно: sum = sum + ia[ i ];

        sum += ia[ i ];

    return sum;
}

Общий синтаксис составного оператора присваивания таков:

a op= b;

где op=

является одним из десяти операторов:

+=     -=     *=     /=     %=

<<=    >>=    &=     ^=     |=

Запись a op= b в точности эквивалентна записи a = a op b.

Упражнение 4.6

Найдите ошибку в данном примере. Исправьте запись.



int main() {

    float fval;

    int ival;

    int *pi;

    fval = ival = pi = 0;
}

Упражнение 4.7

Следующие выражения синтаксически правильны, однако скорее всего работают не так, как предполагал программист. Почему? Как их изменить?



(a) if ( ptr = retrieve_pointer() != 0 )

(b) if ( ival = 1024 )
(c) ival += ival + 1;


Операции с комплексными числами


Класс комплексных чисел стандартной библиотеки С++ представляет собой хороший пример использования объектной модели. Благодаря перегруженным арифметическим операциям объекты этого класса используются так, как будто они принадлежат одному из встроенных типов данных. Более того, в подобных операциях могут одновременно принимать участие и переменные встроенного арифметического типа, и комплексные числа. (Отметим, что здесь мы не рассматриваем общие вопросы математики комплексных чисел. См. [PERSON68] или любую книгу по математике.) Например, можно написать:

#inc1ude <complex>

comp1ex< double > a;

comp1ex< double > b;

// ...

complex< double > с = a * b + a / b;

Комплексные и арифметические типы разрешается смешивать в одном выражении:

complex< double > complex_obj = a + 3.14159;

Аналогично комплексные числа инициализируются арифметическим типом, и им может быть присвоено такое значение:

double dval = 3.14159;

complex_obj = dval;

Или

int ival = 3;

complex_obj = ival;

Однако обратное неверно. Например, следующее выражение вызовет ошибку компиляции:

// ошибка: нет неявного преобразования

// в арифметический тип

double dval = complex_obj;

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

double re =

complex_obj.real();

double im = complex_obj.imag();

или эквивалентный синтаксис вызова функции:

double re =

real(complex_obj);

double im = imag(complex_obj);

Класс комплексных чисел поддерживает четыре составных оператора присваивания: +=, -=, *= и /=. Таким образом,

complex_obj += second_complex_obj;

Поддерживается и ввод/вывод комплексных чисел. Оператор вывода печатает вещественную и мнимую части через запятую, в круглых скобках. Например, результат выполнения операторов вывода




complex< double > complex0( 3.14159, -2.171 );

comp1ex< double > complex1( complexO.real() );
cout << complexO << " " << complex1 << endl;

выглядит так:

( 3.14159, -2.171 ) ( 3.14159, 0.0 )

Оператор ввода понимает любой из следующих форматов:



// допустимые форматы для ввода комплексного числа

// 3.14159        ==> comp1ex( 3.14159 );

// ( 3.14159 )    ==> comp1ex( 3.14159 );

// ( 3.14, -1.0 ) ==> comp1ex( 3.14, -1.0 );

// может быть считано как

// cin >> a >> b >> с

// где a, b, с - комплексные числа

3.14159 ( 3.14159 ) ( 3.14, -1.0 )

Кроме этих операций, класс комплексных чисел имеет следующие функции-члены: sqrt(), abs(), polar(), sin(), cos(), tan(), exp(), log(), log10() и pow().

Упражнение 4.9

Реализация стандартной библиотеки С++, доступная нам в момент написания книги, не поддерживает составных операций присваивания, если правый операнд не является комплексным числом. Например, подобная запись недопустима:

complex_obj += 1;

 (Хотя согласно стандарту С++ такое выражение должно быть корректно, производители часто не успевают за стандартом.) Мы можем определить свой собственный оператор для реализации такой операции. Вот вариант функции, реализующий оператор сложения для complex<double>:



#include <complex>

inline complex<double>&

operator+=( complex<double> &cval, double dval )

{

    return cval += complex<double>( dval );
}

 (Это пример перегрузки оператора для определенного типа данных, детально рассмотренной в главе 15.)

Используя этот пример, реализуйте три других составных оператора присваивания для типа complex<double>. Добавьте свою реализацию к программе, приведенной ниже, и запустите ее для проверки.



#include <iostream>

#include <complex>

// определения операций...

int main() {

    complex< double > cval ( 4.0, 1.0 );

    cout << cval << endl;

    cval += 1;

    cout << cval << endl;

    cval -= 1;

    cout << cval << endl;

    cval *= 2;

    cout << cval << endl;

    cout /= 2;

    cout << cval << endl;
<


}

Упражнение 4.10

Стандарт С++ не специфицирует реализацию операций инкремента и декремента для комплексного числа. Однако их семантика вполне понятна: если уж мы можем написать:

cval += 1;

что означает увеличение на 1

вещественной части cval, то и операция инкремента выглядела бы вполне законно. Реализуйте эти операции для типа complex<double> и выполните следующую программу:



#include <iostream>

#include <complex>

// определения операций...

int main() {

    complex< double > cval( 4.0, 1.0 );

    cout << cval << endl;

    ++cva1;

    cout << cval << endl;
}


Операции с последовательными контейнерами


Функция-член push_back()

позволяет добавить единственный элемент в конец контейнера. Но как вставить элемент в произвольную позицию? А целую последовательность элементов? Для этих случаев существуют более общие операции.

Например, для вставки элемента в начало контейнера можно использовать:

vector< string > svec;

list< string > slist;

string spouse( "Beth" );

slist.insert( slist.begin(), spouse );

svec.insert( svec.begin(), spouse );

Первый параметр функции-члена insert() (итератор, адресующий некоторый элемент контейнера) задает позицию, а второй – вставляемое перед этой позицией значение. В примере выше элемент добавляется в начало контейнера. А так можно реализовать вставку в произвольную позицию:

string son( "Danny" );

list<string>::iterator iter;

iter = find( slist.begin(), slist.end(), son );

slist.insert( iter, spouse );

Здесь find()

возвращает позицию элемента в контейнере, если элемент найден, либо итератор end(), если ничего не найдено. (Мы вернемся к функции find() в конце следующего раздела.) Как можно догадаться, push_back()

эквивалентен следующей записи:

// эквивалентный вызов: slist.push_back( value );

slist.insert( slist.end(), value );

Вторая форма функции-члена insert()

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

vector<string> svec;

string anna( "Anna" );

svec.insert( svec.begin(), 10, anna );

insert()

имеет и третью форму, помогающую вставить в контейнер несколько элементов. Допустим, имеется следующий массив:

string sarray[4] = { "quasi", "simba", "frollo", "scar" };

Мы можем добавить все его элементы или только некоторый диапазон в наш вектор строк:

svec.insert( svec.begin(), sarray, sarray+4 );

svec.insert( svec.begin() + svec.size()/2,

<
    sarray+2, sarray+4 );

Такой диапазон отмечается и с помощью пары итераторов



// вставляем элементы svec

// в середину svec_two

svec_two.insert( svec_two.begin() + svec_two.size()/2,
                 svec.begin(), svec.end() );

или любого контейнера, содержащего строки:[14]



list< string > slist;

// ...

// вставляем элементы svec

// перед элементом, содержащим stringVal

list< string >::iterator iter =

    find( slist.begin(), slist.end(), stringVal );
slist.insert( iter, svec.begin(), svec.end() );


Операции сравнения и логические операции


Таблица 4.2. Операции сравнения и логические операции

Символ операции

Значение

Использование

!

Логическое НЕ

!expr

Меньше

expr1 < expr2

<=

Меньше или равно

expr1 <= expr2

Больше

expr1 > expr2

>=

Больше или равно

expr1 >= expr2

==

Равно

expr1 == expr2

!=

Не равно

expr1 != expr2

&&

Логическое И

expr1 && expr2

||

Логическое ИЛИ

expr1 || expr2

Примечание. Все операции в результате дают значение типа bool

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

преобразуется в 1, а false – в 0. Вот фрагмент кода, подсчитывающего количество элементов вектора, меньших некоторого заданного значения:

vector<int>::iterator iter = ivec.beg-in() ;

while ( iter != ivec.end() ) {

    // эквивалентно: e1em_cnt = e1em_cnt + (*iter < some_va1ue)

    // значение true/false выражения *iter < some_va1ue

    // превращается в 1 или 0

    e1em_cnt += *iter < some_va1ue;

    ++iter;

}

Мы просто прибавляем результат операции “меньше” к счетчику. (Пара +=

обозначает составной оператор присваивания, который складывает операнд, стоящий слева, и операнд, стоящий справа. То же самое можно записать более компактно: elem_count = elem_count + n. Мы рассмотрим такие операторы в разделе 4.4.)

Логическое И (&&) возвращает истину только тогда, когда истинны оба операнда. Логическое ИЛИ (||) дает истину, если истинен хотя бы один из операндов. Гарантируется, что операнды вычисляются слева направо и вычисление заканчивается, как только результирующее значение становится известно. Что это значит? Пусть даны два выражения:

expr1 && expr2

expr1 || expr2

Если в первом из них expr1

равно false, значение всего выражения тоже будет равным false вне зависимости от значения expr2, которое даже не будет вычисляться. Во втором выражении expr2 не оценивается, если expr1


равно true, поскольку значение всего выражения равно true вне зависимости от expr2.

Подобный способ вычисления дает возможность удобной проверки нескольких выражений в одном операторе AND:



while ( ptr != О &&

        ptr->va1ue < upperBound &&

        ptr->va1ue >= 0 &&

        notFound( ia[ ptr->va1ue ] ))
{ ... }

Указатель с нулевым значением не указывает ни на какой объект, поэтому применение к нулевому указателю операции доступа к члену вызвало бы ошибку (ptr->value). Однако, если ptr

равен 0, проверка на первом шаге прекращает дальнейшее вычисление подвыражений. Аналогично на втором и третьем шагах проверяется попадание величины ptr->value в нужный диапазон, и операция взятия индекса не применяется к массиву ia, если этот индекс неправилен.

Операция логического НЕ дает true, если ее единственный оператор равен false, и наоборот. Например:



bool found = false;

// пока элемент не найден

// и ptr указывает на объект (не 0)

while ( ! found && ptr ) {

    found = 1ookup( *ptr );

    ++ptr;
}

Подвыражение

! found

дает true, если переменная found

равна false. Это более компактная запись для

found == false

Аналогично

if ( found )

эквивалентно более длинной записи

if ( found == true )

Использование операций сравнения достаточно очевидно. Нужно только иметь в виду, что, в отличие от И и ИЛИ, порядок вычисления операндов таких выражений не определен. Вот пример, где возможна подобная ошибка:



// Внимание! Порядок вычислений не определен!

if ( ia[ index++ ] < ia[ index ] )
    // поменять местами элементы

Программист предполагал, что левый операнд оценивается первым и сравниваться будут элементы ia[0] и ia[1]. Однако компилятор не гарантирует вычислений слева направо, и в таком случае элемент ia[0]

может быть сравнен сам с собой. Гораздо лучше написать более понятный и машинно-независимый код:



if ( ia[ index ] < ia[ index+1 ] )

    // поменять местами элементы
    ++index;



Еще один пример возможной ошибки. Мы хотели убедиться, что все три величины ival, jval и kval различаются. Где мы промахнулись?



// Внимание! это не сравнение 3 переменных друг с другом

if ( ival != jva1 != kva1 )
    // do something ...

Значения 0, 1 и 0 дают в результате вычисления такого выражения true. Почему? Сначала проверяется ival != jval, а потом итог этой проверки (true/false – преобразованной к 1/0) сравнивается с kval. Мы должны были явно написать:



if ( ival != jva1 && ival != kva1 && jva1 != kva1 )
    // сделать что-то ...

Упражнение 4.4

Найдите неправильные или непереносимые выражения, поясните. Как их можно изменить? (Заметим, что типы объектов не играют роли в данных примерах.)



(a) ptr->iva1 != 0

(с) ptr != 0 && *ptr++

(e) vec[ iva1++ ] <= vec[ ival ];
(b) ival != jva1 < kva1 (d) iva1++ && ival

Упражнение 4.5

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


Операция list_merge()


void list::merge( list rhs );

template <class Compare>

   void list::merge( list rhs, Compare comp );

Элементы двух упорядоченных списков объединяются либо на основе оператора “меньше”, определенного для типа элементов в контейнере, либо на основе указанной пользователем операции сравнения. (Заметьте, что элементы списка rhs перемещаются в список, для которого вызвана функция-член merge(); по завершении операции список rhs

будет пуст.) Например:

int array1[ 10 ] = { 34, 0, 8, 3, 1, 13, 2, 5, 21, 1 };

int array2[ 5 ] = { 377, 89, 233, 55, 144 };

list< int > ilist1( array1, array1 + 10 );

list< int > ilist2( array2, array2 + 5 );

// для объединения требуется, чтобы оба списка были упорядочены

ilist1.sort(); ilist2.sort();

ilist1.merge( ilist2 );

После выполнения операции merge() список ilist2

пуст, а ilist1

содержит первые 15 чисел Фибоначчи в порядке возрастания.



Операция list::remove()


void list::remove( const elemType &value );

Операция remove()

удаляет все элементы с заданным значением:

ilist1.remove( 1 );



Операция list::remove_if()


template < class Predicate >

   void list::remove_if( Predicate pred );

Операция remove_if()

удаляет все элементы, для которых выполняется указанное условие, т.е. предикат pred

возвращает true. Например:

class Even {

public:

   bool operator()( int elem ) { return ! (elem % 2 ); }

};

ilist1.remove_if( Even() );

удаляет все четные числа из списка, определенного при рассмотрении merge().



Операция list::reverse()


void list::reverse();

Операция reverse()

изменяет порядок следования элементов списка на противоположный:

ilist1.reverse();



Операция list::sort()


void list::sort();

template <class Compare>

   void list::sort( Compare comp );

По умолчанию sort()

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

list1.sort();

упорядочивает list1 по возрастанию, а

list1.sort( greater<int>() );

упорядочивает list1 по убыванию, используя оператор “больше”.



Операция list::splice()


void list::splice( iterator pos, list rhs );

void list::splice( iterator pos, list rhs, iterator ix );

void list::splice( iterator pos, list rhs,

                   iterator first, iterator last );

Операция splice()

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

int array[ 10 ] = { 0, 1, 1, 2, 3, 5, 8, 13, 21, 34 };

list< int > ilist1( array, array + 10 );

list< int > ilist2( array, array + 2 );       // содержит 0, 1

то следующее обращение к splice()

перемещает первый элемент ilist1 в ilist2. Теперь ilist2

содержит элементы 0, 1 и 0, тогда как в ilist1

элемента 0

больше нет.

// ilist2.end() указывает на позицию, куда нужно переместить элемент

// элементы вставляются перед этой позицией

// ilist1 указывает на список, из которого перемещается элемент

// ilist1.begin() указывает на сам перемещаемый элемент

ilis2.splice( ilist2.end(), ilist1, ilist1.begin() );

В следующем примере применения splice() передаются два итератора, ограничивающие диапазон перемещаемых элементов:

list< int >::iterator first, last;

first = ilist1.find( 2 );

last  = ilist1.find( 13 );

ilist2.splice( ilist2.begin(), ilist1, first, last );

В данном случае элементы 2, 3, 5 и 8

удаляются из ilist1 и вставляются в начало ilist2. Теперь ilist1

содержит пять элементов 1, 1, 13, 21 и 34. Для их перемещения в ilist2

можно воспользоваться третьей вариацией операции splice():

list< int >::iterator pos = ilist2.find( 5 );

ilist2.splice( pos, ilist1 );

Итак, список ilist1

пуст. Последние пять элементов перемещены в позицию списка ilist2, предшествующую той, которую занимает элемент 5.