Язык программирования C++. Вводный курс
Введение
Функцию можно рассматривать как операцию, определенную пользователем. В общем случае она задается своим именем. Операнды функции, или формальные параметры, задаются в списке параметров, через запятую. Такой список заключается в круглые скобки. Результатом функции может быть значение, которое называют возвращаемым. Об отсутствии возвращаемого значения сообщают ключевым словом void. Действия, которые производит функция, составляют ее тело; оно заключено в фигурные скобки. Тип возвращаемого значения, ее имя, список параметров и тело составляют определение функции. Вот несколько примеров:inline int abs( int obj ) { // возвращает абсолютное значение iobj return( iobj < 0 ? -iobj : iobj ); } inline int min( int p1, int p2 ) { // возвращает меньшую из двух величин return( pi < p2 ? pi : p2 ); } int gcd( int vl, int v2 ) { // возвращает наибольший общий делитель while ( v2 ) { int temp = v2; v2 = vl % v2; vl = temp; } return vl; |
}
Выполнение функции происходит тогда, когда в тексте программы встречается оператор вызова. Если функция принимает параметры, при ее вызове должны быть указаны фактические параметры, аргументы. Их перечисляют внутри скобок, через запятую. В следующем примере main() дважды вызывает abs() и по одному разу min() и gcd(). Функция main()
определяется в файле main.C.
#include int main() { // прочитать значения из стандартного ввода cout << "Введите первое значение: "; int i; cin >> i; if ( !cin ) { cerr << "!? Ошибка ввода - аварийный выход!\n"; return -1; } cout << "Введите второе значение: "; int j; cin >> j; if ( !cin ) { cerr << "!? Ошибка ввода - аварийный выход!\n"; return -2; } cout << "\nmin: " << min( i, j ) << endl; i = abs( i ); j = abs( j ); cout << "НОД: " << gcd( i, j ) << endl; return 0;
| |
}
Вызов функции может обрабатываться двумя разными способами. Если она объявлена встроенной (inline), то компилятор подставляет в точку вызова ее тело. Во всех остальных случаях происходит нормальный вызов, который приводит к передаче управления ей, а активный в этот момент процесс на время приостанавливается. По завершении работы выполнение программы продолжается с точки, непосредственно следующей за точкой вызова. Работа функции завершается выполнением последней инструкции ее тела или специальной инструкции return.
Функция должна быть объявлена до момента ее вызова, попытка использовать необъявленное имя приводит к ошибке компиляции. Определение функции может служить ее объявлением, но ему разрешено появиться в программе только один раз. Поэтому обычно его помещают в отдельный исходный файл. Иногда в одном файле находятся определения нескольких функций, логически связанных друг с другом. Чтобы использовать их в другом исходном файле, необходим механизм, позволяющий объявить ее, не определяя.
Объявление функции состоит из типа возвращаемого значения, имени и списка параметров. Вместе эти три элемента составляют прототип. Объявление может появиться в файле несколько раз.
В нашем примере файл main.C не содержит определений abs(), min() и gcd(), поэтому вызов любой из них приводит к ошибке компиляции. Чтобы компиляция была успешной, их необязательно определять, достаточно только объявить:
int abs( int ); int min( int, int ); |
(В таком объявлении можно не указывать имя параметра, ограничиваясь названием типа.)
Объявления (а равно определения встроенных функций[17]) лучше всего помещать в заголовочные файлы, которые могут включаться всюду, где необходимо вызвать функцию. Таким образом, все файлы используют одно общее объявление. Если его необходимо модифицировать, изменения будут локализованы. Вот так выглядит заголовочный файл для нашего примера. Назовем его localMath.h:
// определение функции находится в файле gcd.С int gcd( int, int ); inline int abs(int i) { return( i<0 ? -i : i ); } inline int min(int vl.int v2) { return( vl |
В объявлении функции описывается ее интерфейс. Он содержит все данные о том, какую информацию должна получать функция (список параметров) и какую информацию она возвращает. Для пользователей важны только эти данные, поскольку лишь они фигурируют в точке вызова. Интерфейс помещается в заголовочный файл, как мы поступили с функциями min(), abs() и gcd().
При выполнении наша программа main.C, получив от пользователя значения:
Введите первое значение: 15
Введите второе значение: 123
выдаст следующий результат:
mm: 15
НОД: 3
Абстрактные контейнерные типы в качестве параметров
Абстрактные контейнерные типы, представленные в главе 6, также используются для объявления параметров функции. Например, можно определить putValues() как имеющую параметр типа vectorвместо встроенного типа массива.
Контейнерный тип является классом и обеспечивает значительно большую функциональность, чем встроенные массивы. Так, vector
“знает” собственный размер. В предыдущем подразделе мы видели, что размер параметра-массива неизвестен функции и для его передачи приходится задавать дополнительный параметр. Использование vector
позволяет обойти это ограничение. Например, можно изменить определение нашей putValues() на такое:
#include #include const lineLength =12; // количество элементов в строке void putValues( vector { cout << "( " << vec.size() << " )< "; for ( int i = 0; i < vec.size(); ++1 ) { if ( i % lineLength == 0 && i ) cout << "\n\t"; // строка заполнена cout << vec[ i ]; // разделитель, печатаемый после каждого элемента, // кроме последнего if ( 1 % lineLength != lineLength-1 && i != vec.size()-1 ) cout << ", "; } cout << " >\n";
| |
}
Функция main(), вызывающая нашу новую функцию putValues(), выглядит так:
void putValues( vector int main() { int i, j[ 2 ]; // присвоить i и j некоторые значения vector vecl[0] = i; putValues( vecl ); vector // добавим элементы к vec2 for ( int ix = 0; ix < sizeof( j ) / sizeof( j[0] ); ++ix ) // vec2[ix] == j [ix] vec2.push_back( j[ix] ); putValues( vec2 ); return 0;
| |
}
Заметим, что параметр putValues()передается по значению. В подобных случаях контейнер со всеми своими элементами всегда копируется в стек вызванной функции. Поскольку операция копирования весьма неэффективна, такие параметры лучше объявлять как ссылки.
Как бы вы изменили объявление putValues()?
Вспомним, что если функция не модифицирует значение своего параметра, то предпочтительнее, чтобы он был ссылкой на константный тип:
void putValues( const vector
Абстрактные контейнерные типы
В этой главе мы продолжим рассмотрение типов данных, начатое в главе 3, представим дополнительную информацию о классах vector и string и познакомимся с другими контейнерными типами, входящими в состав стандартной библиотеки С++. Мы также расскажем об операторах и выражениях, упомянутых в главе 4, сосредоточив внимание на тех операциях, которые поддерживаются объектами контейнерных типов.Последовательный контейнер содержит упорядоченный набор элементов одного типа. Можно выделить два основных типа контейнеров – вектор (vector) и список (list). (Третий последовательный контейнер – двусторонняя очередь (deque) – обеспечивает ту же функциональность, что и vector, но особенно эффективно реализует операции вставки и удаления первого элемента. deque
следует применять, например, при реализации очереди, из которой извлекается только первый элемент. Все сказанное ниже относительно вектора применимо также и к deque.)
Ассоциативный контейнер эффективно реализует операции проверки существования и извлечения элемента. Два основных ассоциативных контейнера – это отображение (map) и множество (set). map
состоит из пар ключ/значение, причем ключ используется для поиска элемента, а значение содержит хранимую информацию. Телефонный справочник хорошо иллюстрирует понятие отображения: ключом является фамилия и имя абонента, а значением – его телефонный номер.
Элемент контейнера set
содержит только ключ, поэтому set эффективно реализует операцию проверки его существования. Этот контейнер можно применить, например, при реализации системы текстового поиска для хранения списка так называемых стоп-слов – слов, не используемых при поиске, таких, как и, или, не, так и тому подобных. Программа обработки текста считывает каждое слово и проверяет, есть ли оно в указанном списке. Если нет, то слово добавляется в базу данных.
В контейнерах map и set не может быть дубликатов – повторяющихся ключей. Для поддержки дубликатов существуют контейнеры multimap и multiset. Например, multimap
можно использовать при реализации такого телефонного справочника, в котором содержится несколько номеров одного абонента.
В последующих разделах мы детально рассмотрим контейнерные типы и разработаем небольшую программу текстового поиска.
Адаптеры функций для объектов-функций
В стандартной библиотеке имеется также ряд адаптеров функций, предназначенных для специализации и расширения как унарных, так и бинарных объектов-функций. Адаптеры– это специальные классы, разбитые на следующие две категории:· связыватели (binders). Это адаптеры, преобразующие бинарный объект-функцию в унарный объект, связывая один из аргументов с конкретным значением. Например, для подсчета в контейнере всех элементов, которые меньше или равны 10, следует передать алгоритму count_if()
объект-функцию less_equal, один из аргументов которого равен 10. В следующем разделе мы покажем, как это сделать;
· отрицатели (negators). Это адаптеры, изменяющие значение истинности объекта-функции на противоположное. Например, для подсчета всех элементов внутри контейнера, которые больше 10, мы могли бы передать алгоритму count_if()
отрицатель объекта-функции less_equal, один из аргументов которого равен 10. Конечно, в данном случае проще передать связыватель объекта-функции greater, ограничив один из аргументов со значением 10.
В стандартную библиотеку входит два предопределенных адаптера-связывателя: bind1st и bind2nd, причем bind1st
связывает некоторое значение с первым аргументом бинарного объекта-функции, а bind2nd – со вторым. Например, для подсчета внутри контейнера всех элементов, которые меньше или равны 10, мы могли бы передать алгоритму count_if() следующее:
count_if( vec.begin(), vec.end(), |
bind2nd( less_equal
В стандартной библиотеке также есть два предопределенных адаптера-отрицателя: not1 и not2. not1 инвертирует значение истинности унарного предиката, являющегося объектом-функцией, а not2 – значение бинарного предиката. Для отрицания рассмотренного выше связывателя объекта-функции less_equal
можно написать следующее:
count_if( vec.begin(), vec.end(), |
not1( bind2nd( less_equal
Другие примеры использования связывателей и отрицателей приведены в Приложении, вместе с примерами использования каждого алгоритма.
Алгоритм accumulate()
template < class InputIterator, class Type > Type accumulate( InputIterator first, InputIterator last, Type init ); template < class InputIterator, class Type, class BinaryOperation > Type accumulate( InputIterator first, InputIterator last, |
Type init, BinaryOperation op );
Первый вариант accumulate()
вычисляет сумму значений элементов последовательности из диапазона, ограниченного парой итераторов [first,last), с начальным значением, которое задано параметром init. Например, если дана последовательность {1,1,2,3,5,8} и начальное значение 0, то результатом работы алгоритма будет 20. Во втором варианте вместо оператора сложения к элементам применяется переданная бинарная операция. Если бы мы передали алгоритму accumulate()
объект-функцию times
#include #include #include #include /* * выход: * accumulate() * работает с последовательностью {1,2,3,4} * результат для сложения по умолчанию: 10 * результат для объекта-функции plus */ int main() { int ia[] = { 1, 2, 3, 4 }; list int ia_result = accumulate(&ia[0], &ia[4], 0); int ilist_res = accumulate( ilist.begin(), ilist.end(), 0, plus cout << "accumulate()\n\t" << "работает с последовательностью {1,2,3,4}\n\t" << "результат для сложения по умолчанию: " << ia_result << "\n\t" << "результат для объекта-функции plus << ilist_res << endl; return 0;
| |
}
Алгоритм adjacent_difference()
template < class InputIterator, class OutputIterator > OutputIterator adjacent_difference( InputIterator first, InputIterator last, OutputIterator result ); template < class InputIterator, class OutputIterator > class BinaryOperation > OutputIterator adjacent_difference( InputIterator first, InputIterator last, |
OutputIterator result, BinaryOperation op );
Первый вариант adjacent_difference()
создает новую последовательность, в которой значение каждого элемента, кроме первого, равно разности между текущим и предыдущим элементами исходной последовательности. Например, если дано {0,1,1,2,3,5,8}, то первым элементом новой последовательности будет копия: 0. Вторым – разность первых двух элементов исходной последовательности: 1. Третий элемент равен разности третьего и второго элементов: 1?1=0, и т.д. В результате мы получим последовательность {0,1,0,1,1,2,3}.
Во втором варианте разность соседних элементов вычисляется с помощью указанной бинарной операции. Возьмем ту же исходную последовательность и передадим объект-функцию times
В обоих вариантах итератор OutputIterator
указывает на элемент, расположенный за последним элементом новой последовательности. adjacent_difference() – это один из численных алгоритмов, для его использования в программу необходимо включить заголовочный файл
#include #include #include #include #include int main() { int ia[] = { 1, 1, 2, 3, 5, 8 }; list list adjacent_difference(ilist.begin(), ilist.end(), ilist_result.begin() ); // на выходе печатается: // 1 0 1 1 2 3 copy( ilist_result.begin(), ilist_result.end(), ostream_iterator cout << endl; adjacent_difference(ilist.begin(), ilist.end(), ilist_result.begin(), times // на выходе печатается: // 1 1 2 6 15 40 copy( ilist_result.begin(), ilist_result.end(), ostream_iterator cout << endl;
| |
}
Алгоритм adjacent_find()
template < class ForwardIterator > ForwardIterator adjacent_find( ForwardIterator first, ForwardIterator last ); template < class ForwardIterator, class BinaryPredicate > ForwardIterator adjacent_find( ForwardIterator first, |
ForwardIterator last, Predicate pred );
adjacent_find()
ищет первую пару одинаковых соседних элементов в диапазоне, ограниченном итераторами [first,last). Если соседние дубликаты найдены, то алгоритм возвращает однонаправленный итератор, указывающий на первый элемент пары, в противном случае возвращается last.
Например, если дана последовательность {0,1,1,2,2,4}, то будет найдена пара [1,1] и возвращен итератор, указывающий на первую единицу.
#include #include #include #include class TwiceOver { public: bool operator() ( int val1, int val2 ) { return val1 == val2/2 ? true : false; } }; int main() { int ia[] = { 1, 4, 4, 8 }; vector< int, allocator > vec( ia, ia+4 ); int *piter; vector< int, allocator >::iterator iter; // piter указывает на ia[1] piter = adjacent_find( ia, ia+4 ); assert( *piter == ia[ 1 ] ); // iter указывает на vec[2] iter = adjacent_find( vec.begin(), vec.end(), TwiceOver() ); assert( *iter == vec[ 2 ] ); // пришли сюда: все хорошо cout << "ok: adjacent-find() завершился успешно!\n"; return 0;
| |
}
Алгоритм binary_search()
template < class ForwardIterator, class Type > bool binary_search( ForwardIterator first, ForwardIterator last, const Type &value ); template < class ForwardIterator, class Type > bool binary_search( ForwardIterator first, ForwardIterator last, const Type &value, |
Compare comp );
binary_search()
ищет значение value в отсортированной последовательности, ограниченной парой итераторов [first,last). Если это значение найдено, возвращается true, иначе – false. В первом варианте предполагается, что контейнер отсортирован с помощью оператора “меньше”. Во втором варианте порядок определяется указанным объектом-функцией.
#include #include #include int main() { int ia[] = {29,23,20,22,17,15,26,51,19,12,35,40}; vector< int, allocator > vec( ia, ia+12 ); sort( &ia[0], &ia[12] ); bool found_it = binary_search( &ia[0], &ia[12], 18 ); assert( found_it == false ); vector< int > vec( ia, ia+12 ); sort( vec.begin(), vec.end(), greater found_it = binary_search( vec.begin(), vec.end(), 26, greater assert( found_it == true );
| |
}
Алгоритм copy_backward()
template < class BidirectionalIterator1, class BidirectionalIterator2 > BidirectionalIterator2 copy_backward( BidirectionalIterator1 first, BidirectionalIterator1 last1, |
BidirectionalIterator2 last2 )
copy_backward()
ведет себя так же, как copy(), только элементы копируются в обратном порядке: копирование начинается с last1-1 и продолжается до first. Кроме того, элементы помещаются в целевой контейнер с конца, от позиции last2-1, пока не будет скопировано last1-first элементов.
Например, если дана последовательность {0,1,2,3,4,5}, мы можем скопировать последние три элемента (3,4,5) на место первых трех (0,1,2), установив first
равным адресу значения 0, last1– адресу значения 3, а last2 – адресу значения 5. Тогда элемент 5 попадает на место элемента 2, элемент 4 – на место 1, а элемент 3 – на место 0. В результате получим последовательность {3,4,5,3,4,5}.
#include #include #include #include class print_elements { public: void operator()( string elem ) { cout << elem << ( _line_cnt++%8 ? " " : "\n\t" ); } static void reset_line_cnt() { _line_cnt = 1; } private: static int _line_cnt; }; int print_elements::_line_cnt = 1; /* печатается: исходный список строк: The light untonsured hair grained and hued like pale oak после copy_backward( begin+1, end-3, end ): The light untonsured hair light untonsured hair grained and hued */ int main() { string sa[] = { "The", "light", "untonsured", "hair", "grained", "and", "hued", "like", "pale", "oak" }; vector< string, allocator > svec( sa, sa+10 ); cout << "исходный список строк:\n\t"; for_each( svec.begin(), svec.end(), print_elements() ); cout << "\n\n"; copy_backward( svec.begin()+1, svec.end()-3, svec.end() ); print_elements::reset_line_cnt(); cout << "после copy_backward( begin+1, end-3, end ):\n"; for_each( svec.begin(), svec.end(), print_elements() ); cout << "\n";
| |
}
Алгоритм copy()
template < class InputIterator, class OutputIterator > OutputIterator copy( InputIterator first1, InputIterator last, |
OutputIterator first2 )
copy()
копирует последовательность элементов, ограниченную парой итераторов [first,last), в другой контейнер, начиная с позиции, на которую указывает first2. Алгоритм возвращает итератор, указывающий на элемент второго контейнера, следующий за последним вставленным. Например, если дана последовательность {0,1,2,3,4,5}, мы можем сдвинуть элементы на один влево с помощью такого вызова:
int ia[] = {0, 1, 2, 3, 4, 5 }; // сдвинуть элементы влево на один, получится {1,2,3,4,5,5} |
copy( ia+1, ia+6, ia );
copy()
начинает копирование со второго элемента ia, копируя 1 в первую позицию, и так далее, пока каждый элемент не окажется в позиции на одну левее исходной.
#include #include #include #include /* печатается: 0 1 1 3 5 8 13 сдвиг массива влево на 1: 1 1 3 5 8 13 13 сдвиг вектора влево на 2: 1 3 5 8 13 8 13 */ int main() { int ia[] = { 0, 1, 1, 3, 5, 8, 13 }; vector< int, allocator > vec( ia, ia+7 ); ostream_iterator< int > ofile( cout, " " ); cout << "исходная последовательность элементов:\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n'; // сдвиг влево на один элемент copy( ia+1, ia+7, ia ); cout << "сдвиг массива влево на 1:\n"; copy( ia, ia+7, ofile ); cout << '\n'; // сдвиг влево на два элемента copy( vec.begin()+2, vec.end(), vec.begin() ); cout << "сдвиг вектора влево на 2:\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n';
| |
}
Алгоритм count_if()
template < class InputIterator, class Predicate > iterator_traits count_if( InputIterator first,
| |
InputIterator last, Predicate pred );
count_if()
применяет предикат pred к каждому элементу из диапазона, ограниченного парой итераторов [first,last). Алгоритм сообщает, сколько раз предикат оказался равным true.
#include #include #include class Even { public: bool operator()( int val ) { return val%2 ? false : true; } }; int main() { int ia[] = {0,1,1,2,3,5,8,13,21,34}; list< int,allocator > ilist( ia, ia+10 ); /* * не поддерживается в текущей реализации ***************************************************** typedef iterator_traits distance_type; distance_type ia_count, list_count; // счетчик четных элементов: 4 ia_count = count_if( &ia[0], &ia[10], Even() ); list_count = count_if( ilist.begin(), ilist_end(), bind2nd(less ****************************************************** */ int ia_count = 0; count_if( &ia[0], &ia[10], Even(), ia_count ); // печатается: // count_if(): есть 4 четных элемент(а). cout << "count_if(): есть " << ia_count << " четных элемент(а).\n"; int list_count = 0; count_if( ilist.begin(), ilist.end(), bind2nd(less // печатается: // count_if(): есть 7 элемент(ов), меньших 10. cout << "count_if(): есть " << list_count << " элемент(ов), меньших 10.\n";
| |
}
Алгоритм count()
template < class InputIterator, class Type > iterator_traits count( InputIterator first,
| |
InputIterator last, const Type& value );
count()
сравнивает каждый элемент со значением value в диапазоне, ограниченном парой итераторов [first,last), с помощью оператора равенства. Алгоритм возвращает число элементов, равных value. (Отметим, что в имеющейся у нас реализации стандартной библиотеки поддерживается более ранняя спецификация count().)
#include #include #include #include #include #include #include /*********************************************************************** * прочитанный текст: Alice Emma has long flowing red hair. Her Daddy says when the wind blows through her hair, it looks almost alive, like a fiery bird in flight. A beautiful fiery bird, he tells her, magical but untamed. "Daddy, shush, there is no such thing," she tells him, at the same time wanting him to tell her more. Shyly, she asks, "I mean, Daddy, is there?" ************************************************************************ * программа выводит: * count(): fiery встречается 2 раз(а) ************************************************************************ */ int main() { ifstream infile( "alice_emma" ); assert ( infile != 0 ); list typedef list istream_iterator< string, diff_type > instream( infile ), eos; copy( instream, eos, back_inserter( textlines )); string search_item( "fiery" ); /********************************************************************* * примечание: ниже показан интерфейс count(), принятый в * стандартной библиотеке. В текущей реализации библиотеки * от RogueWave поддерживается более ранняя версия, в которой * типа distance_type еще не было, так что count() * возвращала результат в последнем аргументе * * вот как должен выглядеть вызов: * * typedef iterator_traits * distance_type dis_type; * * dis_type elem_count; * elem_count = count( textlines.begin(), textlines.end(), * search_item ); *********************************************************************** int elem_count = 0; list ibegin = textlines.begin(), iend = textlines.end(); // устаревшая форма count() count( ibegin, iend, search_item, elem_count ); cout << "count(): " << search_item << " встречается " << elem_count << " раз(а)\n";
| |
}
Алгоритм equal_range()
template< class ForwardIterator, class Type > pair< ForwardIterator, ForwardIterator > equal_range( ForwardIterator first, ForwardIterator last, const Type &value ); template< class ForwardIterator, class Type, class Compare > pair< ForwardIterator, ForwardIterator > equal_range( ForwardIterator first, ForwardIterator last, const Type &value, |
Compare comp );
equal_range()
возвращает пару итераторов: первый представляет значение итератора, возвращаемое алгоритмом lower_bound(), второй – алгоритмом upper_bound(). (О семантике этих алгоритмов рассказано в их описаниях.) Например, дана последовательность:
int ia[] = {12,15,17,19,20,22,23,26,29,35,40,51};
Обращение к equal_range() со значением 21
возвращает пару итераторов, в которой оба указывают на значение 22. Обращение же со значением 22 возвращает пару итераторов, где first
указывает на 22, а second – на 23. В первом варианте при сравнении используется оператор “меньше”, определенный для типа элементов контейнера; во втором – предикат comp.
#include #include #include #include /* печатается: последовательность элементов массива после сортировки: 12 15 17 19 20 22 23 26 29 35 40 51 результат equal_range при поиске значения 23: *ia_iter.first: 23 *ia_iter.second: 26 результат equal_range при поиске отсутствующего значения 21: *ia_iter.first: 22 *ia_iter.second: 22 последовательность элементов вектора после сортировки: 51 40 35 29 26 23 22 20 19 17 15 12 результат equal_range при поиске значения 26: *ivec_iter.first: 26 *ivec_iter.second: 23 результат equal_range при поиске отсутствующего значения 21: *ivec_iter.first: 20 *ivec_iter.second: 20 */ int main() { int ia[] = { 29,23,20,22,17,15,26,51,19,12,35,40 }; vector< int, allocator > ivec( ia, ia+12 ); ostream_iterator< int > ofile( cout, " " ); sort( &ia[0], &ia[12] ); cout << "последовательность элементов массива после сортировки:\n"; copy( ia, ia+12, ofile ); cout << "\n\n"; pair< int*,int* > ia_iter; ia_iter = equal_range( &ia[0], &ia[12], 23 ); cout << "результат equal_range при поиске значения 23:\n\t" << "*ia_iter.first: " << *ia_iter.first << "\t" << "*ia_iter.second: " << *ia_iter.second << "\n\n"; ia_iter = equal_range( &ia[0], &ia[12], 21 ); cout << "результат equal_range при поиске " << "отсутствующего значения 21:\n\t" << "*ia_iter.first: " << *ia_iter.first << "\t" << "*ia_iter.second: " << *ia_iter.second << "\n\n"; sort( ivec.begin(), ivec.end(), greater cout << "последовательность элементов вектора после сортировки:\n"; copy( ivec.begin(), ivec.end(), ofile ); cout << "\n\n"; typedef vector< int, allocator >::iterator iter_ivec; pair< iter_ivec, iter_ivec > ivec_iter; ivec_iter = equal_range( ivec.begin(), ivec.end(), 26, greater cout << "результат equal_range при поиске значения 26:\n\t" << "*ivec_iter.first: " << *ivec_iter.first << "\t" << "*ivec_iter.second: " << *ivec_iter.second << "\n\n"; ivec_iter = equal_range( ivec.begin(), ivec.end(), 21, greater cout << "результат equal_range при поиске отсутствующего значения 21:\n\t" << "*ivec_iter.first: " << *ivec_iter.first << "\t" << "*ivec_iter.second: " << *ivec_iter.second << "\n\n";
| |
}
Алгоритм equal()
template< class InputIterator1, class InputIterator2 > bool equal( InputIterator1 first1, InputIterator1 last, InputIterator2 first2 ); template< class InputIterator1, class InputIterator2, class BinaryPredicate > bool equal( InputIterator1 first1, InputIterator1 last, |
InputIterator2 first2, BinaryPredicate pred );
equal()
возвращает true, если обе последовательности одинаковы в диапазоне, ограниченном парой итераторов [first,last). Если вторая последовательность содержит дополнительные элементы, они игнорируются. Чтобы убедиться в тождественности данных последовательностей, необходимо написать:
if ( vec1.size() == vec2.size() && |
equal( vec1.begin(), vec1.end(), vec2.begin() );
или воспользоваться оператором равенства, определенном в классе самого контейнера: vec1 == vec2. Если второй контейнер содержит меньше элементов, чем первый, и алгоритму приходится просматривать элементы за концом контейнера, то поведение программы не определено. По умолчанию для сравнения применяется оператор равенства в классе элементов контейнера, а во втором варианте алгоритма – указанный предикат pred.
#include #include #include class equal_and_odd{ public: bool operator()( int val1, int val2 ) { return ( val1 == val2 && ( val1 == 0 || val1 % 2 )) ? true : false; } }; int main() { int ia[] = { 0,1,1,2,3,5,8,13 }; int ia2[] = { 0,1,1,2,3,5,8,13,21,34 }; bool res; // true: обе последовательности совпадают до длины ia // печатается: int ia[7] равно int ia2[9]? Да. res = equal( &ia[0], &ia[7], &ia2[0] ); cout << "int ia[7] равно int ia2[9]? " << ( res ? "Да" : "Нет" ) << ".\n"; list< int, allocator > ilist( ia, ia+7 ); list< int, allocator > ilist2( ia2, ia2+9 ); // печатается: список ilist равен ilist2? Да. res = equal( ilist.begin(), ilist.end(), ilist2.begin() ); cout << "список ilist равен ilist2? " << ( res ? "Да" : "Нет" ) << ".\n"; // false: 0, 2, 8 не являются равными и нечетными // печатается: список ilist equal_and_odd() ilist2? Нет. res = equal( ilist.begin(), ilist.end(), ilist2.begin(), equal_and_odd() ); cout << "список ilist equal_and_odd() ilist2? " << ( res ? "Да" : "Нет" ) << ".\n"; return 0;
| |
}
Алгоритм fill_n()
template< class ForwardIterator, class Size, class Type > void fill_n( ForwardIterator first, |
Size n, const Type& value );
fill_n()
присваивает count
элементам из диапазона [first,first+count)
значение value.
#include #include #include #include class print_elements { public: void operator()( string elem ) { cout << elem << ( _line_cnt++%8 ? " " : "\n\t" ); } static void reset_line_cnt() { _line_cnt = 1; } private: static int _line_cnt; }; int print_elements::_line_cnt = 1; /* печатается: исходная последовательность элементов массива: 0 1 1 2 3 5 8 массив после fill_n( ia+2, 3, 9 ): 0 1 9 9 9 5 8 исходная последовательность строк: Stephen closed his eyes to hear his boots crush crackling wrack and shells последовательность после применения fill_n(): Stephen closed his xxxxx xxxxx xxxxx xxxxx xxxxx xxxxx crackling wrack and shells */ int main() { int value = 9; int count = 3; int ia[] = { 0, 1, 1, 2, 3, 5, 8 }; ostream_iterator< int > iofile( cout, " " ); cout << "исходная последовательность элементов массива:\n"; copy( ia, ia+7, iofile ); cout << "\n\n"; fill_n( ia+2, count, value ); cout << "массив после fill_n( ia+2, 3, 9 ):\n"; copy( ia, ia+7, iofile ); cout << "\n\n"; string replacement( "xxxxx" ); string sa[] = { "Stephen", "closed", "his", "eyes", "to", "hear", "his", "boots", "crush", "crackling", "wrack", "and", "shells" }; vector< string, allocator > svec( sa, sa+13 ); cout << "исходная последовательность строк:\n\t"; for_each( svec.begin(), svec.end(), print_elements() ); cout << "\n\n"; fill_n( svec.begin()+3, count*2, replacement ); print_elements::reset_line_cnt(); cout << "последовательность после применения fill_n():\n\t"; for_each( svec.begin(), svec.end(), print_elements() ); cout << "\n";
| |
}
Алгоритм fill()
template< class ForwardIterator, class Type > void fill( ForwardIterator first, |
ForwardIterator last, const Type& value );
fill()
помещает копию значения value в каждый элемент диапазона, ограниченного парой итераторов [first,last).
#include #include #include #include /* печатается: исходная последовательность элементов массива: 0 1 1 2 3 5 8 массив после fill(ia+1,ia+6): 0 9 9 9 9 9 8 исходная последовательность элементов списка: c eiffel java ada perl список после fill(++ibegin,--iend): c c++ c++ c++ perl */ int main() { const int value = 9; int ia[] = { 0, 1, 1, 2, 3, 5, 8 }; ostream_iterator< int > ofile( cout, " " ); cout << "исходная последовательность элементов массива:\n"; copy( ia, ia+7, ofile ); cout << "\n\n"; fill( ia+1, ia+6, value ); cout << "массив после fill(ia+1,ia+6):\n"; copy( ia, ia+7, ofile ); cout << "\n\n"; string the_lang( "c++" ); string langs[5] = { "c", "eiffel", "java", "ada", "perl" }; list< string, allocator > il( langs, langs+5 ); ostream_iterator< string > sofile( cout, " " ); cout << "исходная последовательность элементов списка:\n"; copy( il.begin(), il.end(), sofile ); cout << "\n\n"; typedef list iterator ibegin = il.begin(), iend = il.end(); fill( ++ibegin, --iend, the_lang ); cout << "список после fill(++ibegin,--iend):\n"; copy( il.begin(), il.end(), sofile ); cout << "\n\n";
| |
}
Алгоритм find_end()
template< class ForwardIterator1, class ForwardIterator2 > ForwardIterator1 find_end( ForwardIterator1 first1, ForwardIterator1 last1, ForwardIterator2 first2, ForwardIterator2 last2 ); template< class ForwardIterator1, class ForwardIterator2, class BinaryPredicate > ForwardIterator1 find_end( ForwardIterator1 first1, ForwardIterator1 last1, ForwardIterator2 first2, ForwardIterator2 last2, |
BinaryPredicate pred );
В последовательности, ограниченной итераторами [first1,last1), ведется поиск последнего вхождения последовательности, ограниченной парой [first2,last2). Например, если первая последовательность – это Mississippi, а вторая – ss, то find_end()
возвращает итератор, указывающий на первую s во втором вхождении ss. Если вторая последовательность не входит в первую, то возвращается last1. В первом варианте используется оператор равенства, определенный для типа элементов контейнера, а во втором – бинарный предикат, переданный пользователем.
#include #include #include #include int main() { int array[ 17 ] = { 7,3,3,7,6,5,8,7,2,1,3,7,6,3,8,4,3 }; int subarray[ 3 ] = { 3, 7, 6 }; int *found_it; // find найти последнее вхождение последовательности 3,7,6 // в массив и вернуть адрес первого ее элемента ... found_it = find_end( &array[0], &array[17], &subarray[0], &subarray[3] ); assert( found_it == &array[10] ); vector< int, allocator > ivec( array, array+17 ); vector< int, allocator > subvec( subarray, subarray+3 ); vector< int, allocator >::iterator found_it2; found_it2 = find_end( ivec.begin(), ivec.end(), subvec.begin(), subvec.end(), equal_to assert( found_it2 == ivec.begin()+10 ); cout << "ok: find_end правильно вернула начало " << "последнего вхождения последовательности: 3,7,6!\n";
| |
}
Алгоритм find_first_of()
template< class ForwardIterator1, class ForwardIterator2 > ForwardIterator1 find_first_of( ForwardIterator1 first1, ForwardIterator1 last1, ForwardIterator2 first2, ForwardIterator2 last2 ); template< class ForwardIterator1, class ForwardIterator2, class BinaryPredicate > ForwardIterator1 find_first_of( ForwardIterator1 first1, ForwardIterator1 last1, ForwardIterator2 first2, ForwardIterator2 last2, |
BinaryPredicate pred );
Последовательность, ограниченная парой [first2,last2), содержит элементы, поиск которых ведется в последовательности, ограниченной итераторами [first1,last1). Допустим, нужно найти первую гласную в последовательности символов synesthesia. Для этого определим вторую последовательность как aeiou. find_first_of()
возвращает итератор, указывающий на первое вхождение любого элемента последовательности гласных букв, в данном случае e. Если же первая последовательность не содержит ни одного элемента из второй, то возвращается last1. В первом варианте используется оператор равенства, определенный для типа элементов контейнера, а во втором – бинарный предикат pred.
#include #include #include #include int main() { string s_array[] = { "Ee", "eE", "ee", "Oo", "oo", "ee" }; // возвращает первое вхождение "ee" -- &s_array[2] string to_find[] = { "oo", "gg", "ee" }; string *found_it = find_first_of( s_array, s_array+6, to_find, to_find+3 ); // печатается: // найдено: ee // &s_array[2]: 0x7fff2dac // &found_it: 0x7fff2dac if ( found_it != &s_array[6] ) cout << "найдено: " << *found_it << "\n\t" << "&s_array[2]:\t" << &s_array[2] << "\n\t" << "&found_it:\t" << found_it << "\n\n"; vector< string, allocator > svec( s_array, s_array+6); vector< string, allocator > svec_find( to_find, to_find+2 ); // возвращает вхождение "oo" -- svec.end()-2 vector< string, allocator >::iterator found_it2; found_it2 = find_first_of( svec.begin(), svec.end(), svec_find.begin(), svec_find.end(), equal_to // печатает: // тоже найдено: oo // &svec.end()-2: 0x100067b0 // &found_it2: 0x100067b0 if ( found_it2 != svec.end() ) cout << "тоже найдено: " << *found_it2 << "\n\t" << "&svec.end()-2:\t" << svec.end()-2 << "\n\t" << "&found_it2:\t" << found_it2 << "\n";
| |
}
Алгоритм find_if()
template< class InputIterator, class Predicate > InputIterator find_if( InputIterator first, |
InputIterator last, Predicate pred );
К каждому элементу из диапазона [first,last)
последовательно применяется предикат pred. Если он возвращает true, поиск прекращается. find_if()
возвращает итератор типа InputIterator, указывающий на найденный элемент; в противном случае возвращается last.
#include #include #include #include #include // альтернатива оператору равенства // возвращает true, если строка содержится в объекте-члене FriendSet class OurFriends { // наши друзья public: bool operator()( const string& str ) { return ( friendset.count( str )); } static void FriendSet( const string *fs, int count ) { copy( fs, fs+count, inserter( friendset, friendset.end() )); } private: static set< string, less }; set< string, less int main() { string Pooh_friends[] = { "Пятачок", "Тигра", "Иа-Иа" }; string more_friends[] = { "Квазимодо", "Чип", "Пятачок" }; list // заполнить список друзей Пуха OurFriends::FriendSet( Pooh_friends, 3 ); list our_mutual_friend = find_if( lf.begin(), lf.end(), OurFriends()); // печатается: // Представьте-ка, наш друг Пятачок - также друг Пуха. if ( our_mutual_friend != lf.end() ) cout << "Представьте-ка, наш друг " << *our_mutual_friend << " также друг Пуха.\n"; return 0;
| |
}
Алгоритм find()
template< class InputIterator, class T > InputIterator find( InputIterator first, |
InputIterator last, const T &value );
Элементы из диапазона, ограниченного парой итераторов [first,last), сравниваются со значением value с помощью оператора равенства, определенного для типа элементов контейнера. Как только соответствие найдено, поиск прекращается. find()
возвращает итератор типа InputIterator, указывающий на найденный элемент; в противном случае возвращается last.
#include #include #include #include int main() { int array[ 17 ] = { 7,3,3,7,6,5,8,7,2,1,3,8,7,3,8,4,3 }; int elem = array[ 9 ]; int *found_it; found_it = find( &array[0], &array[17], elem ); // печатается: поиск первого вхождения 1 найдено! cout << "поиск первого вхождения " << elem << "\t" << ( found_it ? "найдено!\n" : "не найдено!\n" ); string beethoven[] = { "Sonata31", "Sonata32", "Quartet14", "Quartet15", "Archduke", "Symphony7" }; string s_elem( beethoven[ 1 ] ); list< string, allocator > slist( beethoven, beethoven+6 ); list< string, allocator >::iterator iter; iter = find( slist.begin(), slist.end(), s_elem ); // печатается: поиск первого вхождения Sonata32 найдено! cout << "поиск первого вхождения " << s_elem << "\t" << ( found_it ? "найдено!\n" : "не найдено!\n" );
| |
}
Алгоритм for_each()
template< class InputIterator, class Function > Function for_each( InputIterator first, |
InputIterator last, Function func );
for_each()
применяет объект-функцию func к каждому элементу в диапазоне [first,last). func не может изменять элементы, поскольку итератор записи не гарантирует поддержки присваивания. Если же модификация необходима, следует воспользоваться алгоритмом transform(). func
может возвращать значение, но оно игнорируется.
#include #include #include template void print_elements( Type elem ) { cout << elem << " "; } int main() { vector< int, allocator > ivec; for ( int ix = 0; ix < 10; ix++ ) ivec.push_back( ix ); void (*pfi)( int ) = print_elements; for_each( ivec.begin(), ivec.end(), pfi ); return 0;
| |
}
Алгоритм generate_n()
template< class OutputIterator, class Size, class Generator > void |
generate_n( OutputIterator first, Size n, Generator gen );
generate_n()
заполняет последовательность, начиная с first, n раз вызывая gen, который может быть объектом-функцией или указателем на функцию.
#include #include #include class even_by_twos { public: even_by_twos( int seed = 0 ) : _seed( seed ){} int operator()() { return _seed += 2; } private: int _seed; }; template void print_elements( Type elem ) { cout << elem << " "; } int main() { list< int, allocator > ilist( 10 ); void (*pfi)( int ) = print_elements; generate_n( ilist.begin(), ilist.size(), even_by_twos() ); // печатается: // generate_n с even_by_twos(): // 2 4 6 8 10 12 14 16 18 20 cout << "generate_n с even_by_twos():\n"; for_each( ilist.begin(), ilist.end(), pfi ); cout << "\n"; generate_n( ilist.begin(), ilist.size(), even_by_twos( 100 ) ); // печатается: // generate_n с even_by_twos( 100 ): // 102 104 106 108 110 112 114 116 118 120 cout << "generate_n с even_by_twos( 100 ):\n"; for_each( ilist.begin(), ilist.end(), pfi );
| |
}
Алгоритм generate()
template< class ForwardIterator, class Generator > void generate( ForwardIterator first, |
ForwardIterator last, Generator gen );
generate()
заполняет диапазон, ограниченный парой итераторов [first,last), путем последовательного вызова gen, который может быть объектом-функцией или указателем на функцию.
#include #include #include int odd_by_twos() { static int seed = -1; return seed += 2; } template void print_elements( Type elem ) { cout << elem << " "; } int main() { list< int, allocator > ilist( 10 ); void (*pfi)( int ) = print_elements; generate( ilist.begin(), ilist.end(), odd_by_twos ); // печатается: // элементы в списке, первый вызов: // 1 3 5 7 9 11 13 15 17 19 cout << "элементы в списке, первый вызов:\n"; for_each( ilist.begin(), ilist.end(), pfi ); generate( ilist.begin(), ilist.end(), odd_by_twos ); // печатается: // элементы в списке, второй вызов: // 21 23 25 27 29 31 33 35 37 39 cout << "\n\nэлементы в списке, второй вызов:\n"; for_each( ilist.begin(), ilist.end(), pfi ); return 0;
| |
}
Алгоритм includes()
template< class InputIterator1, class InputIterator2 > bool includes( InputIterator1 first1, InputIterator1 last1, InputIterator2 first2, InputIterator2 last2 ); template< class InputIterator1, class InputIterator2, class Compare > bool includes( InputIterator1 first1, InputIterator1 last1, InputIterator2 first2, InputIterator2 last2, |
Compare comp );
includes()
проверяет, каждый ли элемент последовательности [first1,last1)
входит в последовательность [first2,last2). Первый вариант предполагает, что последовательности отсортированы в порядке, определяемом оператором “меньше”; второй – что порядок задается параметром-типом comp.
#include #include #include int main() { int ia1[] = { 13, 1, 21, 2, 0, 34, 5, 1, 8, 3, 21, 34 }; int ia2[] = { 21, 2, 8, 3, 5, 1 }; // алгоритму includes следует передавать отсортированные контейнеры sort( ia1, ia1+12 ); sort( ia2, ia2+6 ); // печатает: каждый элемент ia2 входит в ia1? Да bool res = includes( ia1, ia1+12, ia2, ia2+6 ); cout << "каждый элемент ia2 входит в ia1? " << (res ? "Да" : "Нет") << endl; vector< int, allocator > ivect1( ia1, ia1+12 ); vector< int, allocator > ivect2( ia2, ia2+6 ); // отсортирован в порядке убывания sort( ivect1.begin(), ivect1.end(), greater sort( ivect2.begin(), ivect2.end(), greater res = includes( ivect1.begin(), ivect1.end(), ivect2.begin(), ivect2.end(), greater // печатает: каждый элемент ivect2 входит в ivect1? Да cout << "каждый элемент ivect2 входит в ivect1? " << (res ? "Да" : "Нет") << endl;
| |
}
Алгоритм inner_product()
template< class InputIterator1, class InputIterator2 class Type > Type inner_product( InputIterator1 first1, InputIterator1 last, InputIterator2 first2, Type init ); template< class InputIterator1, class InputIterator2 class Type, class BinaryOperation1, class BinaryOperation2 > Type inner_product( InputIterator1 first1, InputIterator1 last, InputIterator2 first2, Type init, |
BinaryOperation1 op1, BinaryOperation2 op2 );
Первый вариант суммирует произведения соответственных членов обеих последовательностей и прибавляет результат к начальному значению init. Первая последовательность ограничена итераторами [first1,last1), вторая начинается с first2 и обходится синхронно с первой. Например, если даны последовательности {2,3,5,8} и {1,2,3,4}, то результат вычисляется следующим образом:
2*1 + 3*2 + 5*3 + 8*4
Если начальное значение равно 0, алгоритм вернет 55.
Во втором варианте вместо сложения используется бинарная операция op1, а вместо умножения – бинарная операция op1. Например, если для приведенных выше последовательностей применить вычитание в качестве op1 и сложение в качестве op2, то результат будет вычисляться так:
(2+1) - (3+2) - (5+3) - (8+4)
inner_product() – это один из численных алгоритмов. Для его использования в программу необходимо включить заголовочный файл
#include #include #include int main() { int ia[] = { 2, 3, 5, 8 }; int ia2[] = { 1, 2, 3, 4 }; // перемножить пары элементов из обоих массивов, // сложить и добавить начальное значение: 0 int res = inner_product( &ia[0], &ia[4], &ia2[0], 0 ); // печатает: скалярное произведение массивов: 55 cout << "скалярное произведение массивов: " << res << endl; vector vector // сложить пары элементов из обоих векторов, // вычесть из начального значения: 0 res = inner_product( vec.begin(), vec.end(), vec2.begin(), 0, minus // печатает: скалярное произведение векторов: -28 cout << "скалярное произведение векторов: " << res << endl; return 0;
| |
}
Алгоритм inplace_merge()
template< class BidirectionalIterator > void inplace_merge( BidirectionalIterator first, BidirectionalIterator middle, BidirectionalIterator last ); template< class BidirectionalIterator, class Compare > void inplace_merge( BidirectionalIterator first, BidirectionalIterator middle, |
BidirectionalIterator last, Compare comp );
inplace_merge()
объединяет две соседние отсортированные последовательности, ограниченные парами итераторов [first,middle) и [middle,last). Результирующая последовательность затирает исходные, начиная с позиции first. В первом варианте для упорядочения элементов используется оператор “меньше”, определенный для типа элементов контейнера, во втором – операция сравнения, переданная программистом.
#include #include #include template void print_elements( Type elem ) { cout << elem << " "; } /* * печатает: ia разбит на два отсортированных подмассива: 12 15 17 20 23 26 29 35 40 51 10 16 21 41 44 54 62 65 71 74 ia inplace_merge: 10 12 15 16 17 20 21 23 26 29 35 40 41 44 51 54 62 65 71 74 ivec разбит на два отсортированных подвектора: 51 40 35 29 26 23 20 17 15 12 74 71 65 62 54 44 41 21 16 10 ivec inplace_merge: 74 71 65 62 54 51 44 41 40 35 29 26 23 21 20 17 16 15 12 10 */ int main() { int ia[] = { 29,23,20,17,15,26,51,12,35,40, 74,16,54,21,44,62,10,41,65,71 }; vector< int, allocator > ivec( ia, ia+20 ); void (*pfi)( int ) = print_elements; // отсортировать обе подпоследовательности sort( &ia[0], &ia[10] ); sort( &ia[10], &ia[20] ); cout << "ia разбит на два отсортированных подмассива: \n"; for_each( ia, ia+20, pfi ); cout << "\n\n"; inplace_merge( ia, ia+10, ia+20 ); cout << "ia inplace_merge:\n"; for_each( ia, ia+20, pfi ); cout << "\n\n"; sort( ivec.begin(), ivec.begin()+10, greater sort( ivec.begin()+10, ivec.end(), greater cout << "ivec разбит на два отсортированных подвектора: \n"; for_each( ivec.begin(), ivec.end(), pfi ); cout << "\n\n"; inplace_merge( ivec.begin(), ivec.begin()+10, ivec.end(), greater cout << "ivec inplace_merge:\n"; for_each( ivec.begin(), ivec.end(), pfi ); cout << endl;
| |
}
Алгоритм iter_swap()
template< class ForwardIterator1, class ForwardIterator2 > void |
iter_swap( ForwardIterator1 a, ForwardIterator2 b );
iter_swap()
обменивает значения элементов, на которые указывают итераторы a и b.
#include #include #include int main() { int ia[] = { 5, 4, 3, 2, 1, 0 }; list< int,allocator > ilist( ia, ia+6 ); typedef list< int, allocator >::iterator iterator; iterator iter1 = ilist.begin(),iter2, iter_end = ilist.end(); // отсортировать список "пузырьком" ... for ( ; iter1 != iter_end; ++iter1 ) for ( iter2 = iter1; iter2 != iter_end; ++iter2 ) if ( *iter2 < *iter1 ) iter_swap( iter1, iter2 ); // печатается: // ilist после сортировки "пузырьком" с помощью iter_swap(): // { 0 1 2 3 4 5 } cout << "ilist после сортировки "пузырьком" с помощью iter_swap(): { "; for ( iter1 = ilist.begin(); iter1 != iter_end; ++iter1 ) cout << *iter1 << " "; cout << "}\n"; return 0;
| |
}
Алгоритм lexicographical_compare()
template< class InputIterator1, class InputIterator2 > bool lexicographical_compare( InputIterator1 first1, InputIterator1 last1, InputIterator1 first2, InputIterator2 last2 ); template< class InputIterator1, class InputIterator2, class Compare > bool lexicographical_compare( InputIterator1 first1, InputIterator1 last1, InputIterator1 first2, InputIterator2 last2, |
Compare comp );
lexicographical_compare()
сравнивает соответственные пары элементов из двух последовательностей, ограниченных диапазонами [first1,last1) и [first2,last2). Сравнение продолжается, пока не будет найдена первая пара различных элементов, не достигнута пара [last1,last2] или хотя бы один из элементов last1 или last2
(если последовательности имеют разные длины). При обнаружении первой пары различных элементов алгоритм возвращает:
· если меньше элемент первой последовательности, то true, иначе false;
· если last1
достигнут, а last2
нет, то true;
· если last2
достигнут, а last1
нет, то false;
· если достигнуты и last1, и last2
(т.е. все элементы одинаковы), то false. Иными словами, первая последовательность лексикографически не меньше второй.
Например, даны такие последовательности:
string arr1[] = { "Piglet", "Pooh", "Tigger" }; |
string arr2[] = { "Piglet", "Pooch", "Eeyore" };
В них первая пара элементов одинакова, а вторая различна. Pooh считается больше, чем Pooch, так как c
лексикографически меньше h (такой способ сравнения применяется при составлении словарей). В этом месте алгоритм заканчивается (третья пара элементов не сравнивается). Результатом сравнения будет false.
Tю тЄюЁюь трЁшрэЄх рыуюЁшЄьр тьхёЄю юяхЁрЄюЁр ёЁртэхэш шёяюы№чєхЄё яЁхфшърЄэvщ юс·хъЄ:
#include #include #include #include #include аааааааааа class size_compare { public: аааааааааа bool operator()( const string &a, const string &b ) { аааааааааа аааа return a.length() <= b.length(); аааааааааа } }; аааааааааа int main() { аааааааааа string arr1[] = { "Piglet", "Pooh", "Tigger" }; аааааааааа string arr2[] = { "Piglet", "Pooch", "Eeyore" }; ааааааааааааааааа аааааааааа bool res; ааааааааааааааааа аааааааааа // эр тЄюЁюь ¤ыхьхэЄх яюыєўрхь false аааааааааа // Pooch ьхэ№°х Pooh аааааааааа // эр ЄЁхЄ№хь ¤ыхьхэЄх Єюцх яюыєўшыш сv false аааааааааа res = lexicographical_compare( arr1, arr1+3, ааааааааааааааааааааааааааааааааааа arr2, arr2+3 ); аааааааааа assert( res == false ); ааааааааааааааааа аааааааааа // яюыєўрхь true: фышэр ърцфюую ¤ыхьхэЄр ilist2 аааааааааа // ьхэ№°х ышсю Ёртэр фышэх ёююЄтхЄёЄтхээюую аааааааааа // ¤ыхьхэЄр ilist1 аааааааааа list< string, allocator > ilist1( arr1, arr1+3 ); аааааааааа list< string, allocator > ilist2( arr2, arr2+3 ); ааааааааааааааааа аааааааааа res = lexicographical_compare( аааааааааа аааааааа ilist1.begin(), ilist1.end(), аааааааааа аааааааа ilist2.begin(), ilist2.end(), size_compare() ); ааааааааааааааааа аааааааааа assert( res == true ); аааааааааа аааааааааа cout << "ok: lexicographical_compare чртхЁ°шыё єёях°эю!\n"; |
Алгоритм lower_bound()
template< class ForwardIterator, class Type > ForwardIterator lower_bound( ForwardIterator first, ForwardIterator last, const Type &value ); template< class ForwardIterator, class Type, class Compare > ForwardIterator lower_bound( ForwardIterator first, ForwardIterator last, const Type &value, |
class Compare );
lower_bound()
возвращает итератор, указывающий на первую позицию в отсортированной последовательности, ограниченной диапазоном [first,last), в которую можно вставить значение value, не нарушая упорядоченности. В этой позиции находится значение, большее либо равное value. Например, если дана такая последовательность:
int ia = = {12,15,17,19,20,22,23,26,29,35,40,51};
то обращение к lower_bound() с аргументом value=21
возвращает итератор, указывающий на 23. Обращение с аргументом 22
возвращает тот же итератор. В первом варианте алгоритма используется оператор “меньше”, определенный для типа элементов контейнера, а во втором для упорядочения элементов применяется объект comp.
#include #include #include int main() { int ia[] = {29,23,20,22,17,15,26,51,19,12,35,40}; sort( &ia[0], &ia[12] ); int search_value = 18; int *ptr = lower_bound( ia, ia+12, search_value ); // печатается: // Первый элемент, перед которым можно вставить 18, - это 19 // Предыдущее значение равно 17 cout << "Первый элемент, перед которым можно вставить " << search_value << ", – это " << *ptr << endl << "Предыдущее значение равно " << *(ptr-1) << endl; vector< int, allocator > ivec( ia, ia+12 ); // отсортировать в порядке возрастания ... sort( ivec.begin(), ivec.end(), greater search_value = 26; vector< int, allocator >::iterator iter; // необходимо указать, как именно // осуществлялась сортировка ... iter = lower_bound( ivec.begin(), ivec.end(), search_value, greater // печатается: // Первый элемент, перед которым можно вставить 26, - это 26 // Предыдущее значение равно 29 cout << "Первый элемент, перед которым можно вставить " << search_value << ", - это " << *iter << endl << "Предыдущее значение равно " << *(iter-1) << endl; return 0;
| |
}
Алгоритм make_heap()
template< class RandomAccessIterator > void make_heap( RandomAccessIterator first, RandomAccessIterator last ); template< class RandomAccessIterator, class Compare > void make_heap( RandomAccessIterator first, |
RandomAccessIterator last, Compare comp );
make_heap()
преобразует в хип последовательность, ограниченную диапазоном [first,last). В первом варианте для сравнения используется оператор “меньше”, определенный для типа элементов контейнера, а во втором – операция comp.
Алгоритм max_element()
template< class ForwardIterator > ForwardIterator max_element( ForwardIterator first, ForwardIterator last ); template< class ForwardIterator, class Compare > ForwardIterator max_element( ForwardIterator first, |
ForwardIterator last, Compare comp );
max_element()
возвращает итератор, указывающий на элемент, который содержит наибольшее значение в последовательности, ограниченной диапазоном [first,last). В первом варианте используется оператор “больше”, определенный для типа элементов контейнера; во втором – операция сравнения comp.
Алгоритм max()
template< class Type > const Type& max( const Type &aval, const Type &bval ); template< class Type, class Compare > const Type& |
max( const Type &aval, const Type &bval, Compare comp );
max()
возвращает наибольшее из двух значений aval и bval. В первом варианте используется оператор “больше”, определенный в классе Type; во втором – операция сравнения comp.
Алгоритм merge()
template< class InputIterator1, class InputIterator2, class OutputIterator > OutputIterator merge( InputIterator1 first1, InputIterator1 last1, InputIterator2 first2, InputIterator2 last2, OutputIterator result ); template< class InputIterator1, class InputIterator2, class OutputIterator, class Compare > OutputIterator merge( InputIterator1 first1, InputIterator1 last1, InputIterator2 first2, InputIterator2 last2, |
OutputIterator result, Compare comp );
merge()
объединяет две отсортированные последовательности, ограниченные диапазонами [first1,last1) и [first2,last2), в единую отсортированную последовательность, начинающуюся с позиции result. Результирующий итератор записи указывает на элемент за концом новой последовательности. В первом варианте для упорядочения используется оператор “меньше”, определенный для типа элементов контейнера; во втором – операция сравнения comp.
#include #include #include #include #include template void print_elements( Type elem ) { cout << elem << " "; } void (*pfi)( int ) = print_elements; int main() { int ia[] = {29,23,20,22,17,15,26,51,19,12,35,40}; int ia2[] = {74,16,39,54,21,44,62,10,27,41,65,71}; vector< int, allocator > vec1( ia, ia +12 ), vec2( ia2, ia2+12 ); int ia_result[24]; vector< int, allocator > vec_result(vec1.size()+vec2.size()); sort( ia, ia +12 ); sort( ia2, ia2+12 ); // печатается: // 10 12 15 16 17 19 20 21 22 23 26 27 29 35 // 39 40 41 44 51 54 62 65 71 74 merge( ia, ia+12, ia2, ia2+12, ia_result ); for_each( ia_result, ia_result+24, pfi ); cout << "\n\n"; sort( vec1.begin(), vec1.end(), greater sort( vec2.begin(), vec2.end(), greater merge( vec1.begin(), vec1.end(), vec2.begin(), vec2.end(), vec_result.begin(), greater // печатается: 74 71 65 62 54 51 44 41 40 39 35 29 27 26 23 22 // 21 20 19 17 16 15 12 10 for_each( vec_result.begin(), vec_result.end(), pfi ); cout << "\n\n";
| |
}
Алгоритм min_element()
template< class ForwardIterator > ForwardIterator min_element( ForwardIterator first, ForwardIterator last ); template< class ForwardIterator, class Compare > ForwardIterator min_element( ForwardIterator first, |
ForwardIterator last, Compare comp );
max_element()
возвращает итератор, указывающий на элемент, который содержит наименьшее значение последовательности, ограниченной диапазоном [first,last). В первом варианте используется оператор “меньше”, определенный для типа элементов контейнера; во втором – операция сравнения comp.
// иллюстрирует max(), min(), max_element(), min_element() #include #include #include int main() { int ia[] = { 7, 5, 2, 4, 3 }; const vector< int, allocator > ivec( ia, ia+5 ); int mval = max( max( max( max(ivec[4],ivec[3]), ivec[2]),ivec[1]),ivec[0]); // вывод: результат вложенных вызовов max() равен: 7 cout << "результат вложенных вызовов max() равен: " << mval << endl; mval = min( min( min( min(ivec[4],ivec[3]), ivec[2]),ivec[1]),ivec[0]); // вывод: результат вложенных вызовов min() равен: 2 cout << "результат вложенных вызовов min() равен: " << mval << endl; vector< int, allocator >::const_iterator iter; iter = max_element( ivec.begin(), ivec.end() ); // вывод: результат вложенных вызовов max_element() также равен: 7 cout << "результат вложенных вызовов max_element() также равен: " << *iter << endl; iter = min_element( ivec.begin(), ivec.end() ); // вывод: результат вложенных вызовов min_element() также равен: 2 cout << "результат вложенных вызовов min_element() также равен: " << *iter << endl;
| |
}
Алгоритм min()
template< class Type > const Type& min( const Type &aval, const Type &bval ); template< class Type, class Compare > const Type& |
min( const Type &aval, const Type &bval, Compare comp );
min()
возвращает меньшее из двух значений aval и bval. В первом варианте используется оператор “меньше”, определенный для типа Type; во втором – операция сравнения comp.
Алгоритм mismatch()
template< class InputIterator1, class InputIterator2 > pair mismatch( InputIterator1 first, InputIterator1 last, InputIterator2 first2 ); template< class InputIterator1, class InputIterator2, class BinaryPredicate > pair mismatch( InputIterator1 first, InputIterator1 last,
| |
InputIterator2 first2, BinaryPredicate pred );
mismatch()
сравнивает две последовательности и находит первую позицию, где элементы различны. Возвращается пара итераторов, каждый из которых указывает на эту позицию в соответствующей последовательности. Если все элементы одинаковы, то каждый итератор в паре указывает на элемент last в своем контейнере. Так, если даны последовательности meet и meat, то оба итератора указывают на третий элемент. В первом варианте для сравнения элементов применяется оператор равенства, а во втором – операция сравнения, заданная пользователем. Если вторая последовательность длиннее первой, “лишние” элементы игнорируются; если же она короче, то поведение программы не определено.
#include #include #include #include class equal_and_odd{ public: bool operator()( int ival1, int ival2 ) { // оба значения равны друг другу? // оба равны нулю? оба нечетны? return ( ival1 == ival2 && ( ival1 == 0 || ival1%2 )); } }; int main() { int ia[] = { 0,1,1,2,3,5,8,13 }; int ia2[] = { 0,1,1,2,4,6,10 }; pair // печатается: первая пара неодинаковых: ia: 3 и ia2: 4 cout << "первая пара неодинаковых: ia: " << *pair_ia.first << " и ia2: " << *pair_ia.second << endl; list list typedef list pair mismatch( ilist.begin(), ilist.end(), ilist2.begin(), equal_and_odd() ); // печатается: первая пара неодинаковых: либо не равны, либо не нечетны: // ilist: 2 и ilist2: 2 cout << "первая пара неодинаковых: либо не равны, " << "либо не нечетны: \n\tilist: " << *pair_ilist.first << " и ilist2: " << *pair_ilist.second << endl;
| |
}
Алгоритм next_permutation()
template < class BidirectionalIterator > bool next_permutation( BidirectionalIterator first, BidirectionalIterator last ); template < class BidirectionalIterator, class Compare > bool next_permutation( BidirectionalIterator first, |
BidirectionalIterator last, class Compare );
next_permutation()
берет последовательность, ограниченную диапазоном [first,last), и, считая ее перестановкой, возвращает следующую за ней (о том, как упорядочиваются перестановки, говорилось в разделе 12.5). Если следующей перестановки не существует, алгоритм возвращает false, иначе – true. В первом варианте для определения следующей перестановки используется оператор “меньше” в классе элементов контейнера, а во втором – операция сравнения comp. Последовательные обращения к next_permutation()
генерируют все возможные перестановки только в том случае, когда исходная последовательность отсортирована. Если бы в показанной ниже программе мы предварительно не отсортировали строку musil, получив ilmsu, то не удалось бы сгенерировать все перестановки.
#include #include #include void print_char( char elem ) { cout << elem ; } void (*ppc)( char ) = print_char; /* печатается: ilmsu ilmus ilsmu ilsum ilums ilusm imlsu imlus imslu imsul imuls imusl islmu islum ismlu ismul isulm isuml iulms iulsm iumls iumsl iuslm iusml limsu limus lismu lisum liums liusm lmisu lmius lmsiu lmsui lmuis lmusi lsimu lsium lsmiu lsmui lsuim lsumi luims luism lumis lumsi lusim lusmi milsu milus mislu misul miuls miusl mlisu mlius mlsiu mlsui mluis mlusi msilu msiul msliu mslui msuil msuli muils muisl mulis mulsi musil musli silmu silum simlu simul siulm siuml slimu slium slmiu slmui sluim slumi smilu smiul smliu smlui smuil smuli suilm suiml sulim sulmi sumil sumli uilms uilsm uimls uimsl uislm uisml ulims ulism ulmis ulmsi ulsim ulsmi umils umisl umlis umlsi umsil umsli usilm usiml uslim uslmi usmil usmli */ int main() { vector // последовательность символов: musil vec[0] = 'm'; vec[1] = 'u'; vec[2] = 's'; vec[3] = 'i'; vec[4] = 'l'; int cnt = 2; sort( vec.begin(), vec.end() ); for_each( vec.begin(), vec.end(), ppc ); cout << "\t"; // генерируются все перестановки строки "musil" while( next_permutation( vec.begin(), vec.end())) { for_each( vec.begin(), vec.end(), ppc ); cout << "\t"; if ( ! ( cnt++ % 8 )) { cout << "\n"; cnt = 1; } } cout << "\n\n"; return 0;
| |
}
Алгоритм nth_element()
template < class RandomAccessIterator > void nth_element( RandomAccessIterator first, RandomAccessIterator nth, RandomAccessIterator last ); template < class RandomAccessIterator, class Compare > void nth_element( RandomAccessIterator first, RandomAccessIterator nth, |
RandomAccessIterator last, Compare comp );
nth_element()
переупорядочивает последовательность, ограниченную диапазоном [first,last), так что все элементы, меньшие чем тот, на который указывает итератор nth, оказываются перед ним, а все большие элементы – после. Например, если есть массив
int ia[] = {29,23,20,22,17,15,26,51,19,12,35,40};
то вызов nth_element(), в котором nth
указывает на седьмой элемент (его значение равно 26):
nth_element( &ia[0], &ia[6], &ia[2] );
генерирует последовательность, в которой семь элементов, меньших 26, оказываются слева от 26, а четыре элемента, больших 26, справа:
{23,20,22,17,15,19,12,26,51,35,40,29}
При этом не гарантируется, что элементы, расположенные по обе стороны от nth, упорядочены. В первом варианте для сравнения используется оператор “меньше”, определенный для типа элементов контейнера, во втором – бинарная операция сравнения, заданная программистом.
#include #include #include /* печатается: исходный вектор: 29 23 20 22 17 15 26 51 19 12 35 40 вектор, отсортированный относительно элемента 26 12 15 17 19 20 22 23 26 51 29 35 40 вектор, отсортированный по убыванию относительно элемента 23 40 35 29 51 26 23 22 20 19 17 15 12 */ int main() { int ia[] = {29,23,20,22,17,15,26,51,19,12,35,40}; vector< int,allocator > vec( ia, ia+12 ); ostream_iterator cout << "исходный вектор: "; copy( vec.begin(), vec.end(), out ); cout << endl; cout << "вектор, отсортированный относительно элемента " << *( vec.begin()+6 ) << endl; nth_element( vec.begin(), vec.begin()+6, vec.end() ); copy( vec.begin(), vec.end(), out ); cout << endl; cout << " вектор, отсортированный по убыванию " << "относительно элемента " << *( vec.begin()+6 ) << endl; nth_element( vec.begin(), vec.begin()+6, vec.end(), greater copy( vec.begin(), vec.end(), out ); cout << endl;
| |
}
Алгоритм partial_sort_copy()
template < class InputIterator, class RandomAccessIterator > RandomAccessIterator partial_sort_copy( InputIterator first, InputIterator last, RandomAccessIterator result_first, RandomAccessIterator result_last ); template < class InputIterator, class RandomAccessIterator, class Compare > RandomAccessIterator partial_sort_copy( InputIterator first, InputIterator last, RandomAccessIterator result_first, RandomAccessIterator result_last, |
Compare comp );
partial_sort_copy()
ведет себя так же, как partial_sort(), только частично упорядоченная последовательность копируется в контейнер, ограниченный диапазоном [result_first,result_last]
(если мы задаем отдельный контейнер для копирования результата, то в нем оказывается упорядоченная последовательность). Например, даны два массива:
int ia[] = {29,23,20,22,17,15,26,51,19,12,35,40}; |
int ia2[5];
Тогда обращение к partial_sort_copy(), где в качестве middle
указан восьмой элемент:
partial_sort_copy( &ia[0], &ia[7], &ia[12], |
&ia2[0], &ia2[5] );
заполняет массив ia2
пятью отсортированными элементами: {12,15,17,19,20}. Оставшиеся два элемента отсортированы не будут.
#include #include #include /* * печатается: исходный вектор: 69 23 80 42 17 15 26 51 19 12 35 8 результат применения partial_sort() к вектору: семь элементов 8 12 15 17 19 23 26 80 69 51 42 35 результат применения partial_sort_copy() к первым семи элементам вектора в порядке убывания 26 23 19 17 15 12 8 */ int main() { int ia[] = { 69,23,80,42,17,15,26,51,19,12,35,8 }; vector< int,allocator > vec( ia, ia+12 ); ostream_iterator cout << "исходный вектор: "; copy( vec.begin(), vec.end(), out ); cout << endl; cout << "результат применения partial_sort() к вектору: " << "семь элементов \n"; partial_sort( vec.begin(), vec.begin()+7, vec.end() ); copy( vec.begin(), vec.end(), out ); cout << endl; vector< int, allocator > res(7); cout << " результат применения partial_sort_copy() к первым семи \n\t" << "элементам вектора в порядке убывания \n"; partial_sort_copy( vec.begin(), vec.begin()+7, res.begin(), res.end(), greater copy( res.begin(), res.end(), out ); cout << endl;
| |
}
Алгоритм partial_sort()
template < class RandomAccessIterator > void partial_sort( RandomAccessIterator first, RandomAccessIterator middle, RandomAccessIterator last ); template < class RandomAccessIterator, class Compare > void partial_sort( RandomAccessIterator first, RandomAccessIterator middle, |
RandomAccessIterator last, Compare comp );
partial_sort()
сортирует часть последовательности, укладывающуюся в диапазон [first,middle). Элементы в диапазоне [middle,last)
остаются неотсортированными. Например, если дан массив
int ia[] = {29,23,20,22,17,15,26,51,19,12,35,40};
то вызов partial_sort(),где middle
указывает на шестой элемент:
partial_sort( &ia[0], &ia[5], &ia[12] );
генерирует последовательность, в которой наименьшие пять (т.е. middle-first) элементов отсортированы:
{12,15,17,19,20,29,23,22,26,51,35,40}.
Элементы от middle до last-1 не расположены в каком-то определенном порядке, хотя значения каждого из них лежат вне отсортированной последовательности. В первом варианте для сравнения используется оператор “меньше”, определенный для типа элементов контейнера, а во втором – операция сравнения comp.
Алгоритм partial_sum()
template < class InputIterator, class OutputIterator > OutputIterator partial_sum( InputIterator first, InputIterator last, OutputIterator result ); template < class InputIterator, class OutputIterator, class BinaryOperation > OutputIterator partial_sum( InputIterator first, InputIterator last, |
OutputIterator result, BinaryOperation op );
Первый вариант partial_sum()
создает из последовательности, ограниченной диапазоном [first,last), новую последовательность, в которой значение каждого элемента равно сумме всех предыдущих, включая и данный. Так, из последовательности {0,1,1,2,3,5,8} будет создана {0,1,2,4,7,12,20}, где, например, четвертый элемент равен сумме трех предыдущих (0,1,1) и его самого (2), что дает значение 4.
Во втором варианте вместо оператора сложения используется бинарная операция, заданная программистом. Предположим, мы задали последовательность {1,2,3,4} и объект-функцию times
указывает на элемент за последним элементом новой последовательности.
partial_sum() – это один из численных алгоритмов. Для его использования необходимо включить в программу стандартный заголовочный файл
#include #include #include /* * печатается: элементы: 1 3 4 5 7 8 9 частичная сумма элементов: 1 4 8 13 20 28 37 частичная сумма элементов с использованием times 1 3 12 60 420 3360 30240 */ int main() { const int ia_size = 7; int ia[ ia_size ] = { 1, 3, 4, 5, 7, 8, 9 }; int ia_res[ ia_size ]; ostream_iterator< int > outfile( cout, " " ); vector< int, allocator > vec( ia, ia+ia_size ); vector< int, allocator > vec_res( vec.size() ); cout << "элементы: "; copy( ia, ia+ia_size, outfile ); cout << endl; cout << "частичная сумма элементов:\n"; partial_sum( ia, ia+ia_size, ia_res ); copy( ia_res, ia_res+ia_size, outfile ); cout << endl; cout << "частичная сумма элементов с использованием times partial_sum( vec.begin(), vec.end(), vec_res.begin(), times copy( vec_res.begin(), vec_res.end(), outfile ); cout << endl;
| |
}
Алгоритм partition()
template < class BidirectionalIterator, class UnaryPredicate > BidirectionalIterator partition( BidirectionalIterator first, |
BidirectionalIterator last, UnaryPredicate pred );
partition()
переупорядочивает элементы в диапазоне [first,last). Все элементы, для которых предикат pred
равен true, помещаются перед элементами, для которых он равен false. Например, если дана последовательность {0,1,2,3,4,5,6} и предикат, проверяющий целое число на четность, то мы получим две последовательности – {0,2,4,6} и {1,3,5}. Хотя гарантируется, что четные элементы будут помещены перед нечетными, их первоначальное взаимное расположение может и не сохраниться, т.е. 4 может оказаться перед 2, а 5 перед 1. Сохранение относительного порядка обеспечивает алгоритм stable_partition(), рассматриваемый ниже.
#include #include #include class even_elem { public: bool operator()( int elem ) { return elem%2 ? false : true; } }; /* * печатается: исходная последовательность: 29 23 20 22 17 15 26 51 19 12 35 40 разбиение, основанное на четности элементов: 40 12 20 22 26 15 17 51 19 23 35 29 разбиение, основанное на сравнении с 25: 12 23 20 22 17 15 19 51 26 29 35 40 */ int main() { const int ia_size = 12; int ia[ia_size] = { 29,23,20,22,17,15,26,51,19,12,35,40 }; vector< int, allocator > vec( ia, ia+ia_size ); ostream_iterator< int > outfile( cout, " " ); cout << "исходная последовательность: \n"; copy( vec.begin(), vec.end(), outfile ); cout << endl; cout << "разбиение, основанное на четности элементов:\n"; partition( &ia[0], &ia[ia_size], even_elem() ); copy( ia, ia+ia_size, outfile ); cout << endl; cout << "разбиение, основанное на сравнении с 25:\n"; partition( vec.begin(), vec.end(), bind2nd(less copy( vec.begin(), vec.end(), outfile ); cout << endl;
| |
}
Алгоритм pop_heap()
template< class RandomAccessIterator > void pop_heap( RandomAccessIterator first, RandomAccessIterator last ); template< class RandomAccessIterator, class Compare > void pop_heap( RandomAccessIterator first, |
RandomAccessIterator last, Compare comp );
pop_heap() в действительности не исключает наибольший элемент, а переупорядочивает хип. Он переставляет элементы в позициях first и last-1, а затем перестраивает в хип последовательность в диапазоне [first,last-1). После этого “вытолкнутый” элемент можно получить посредством функции-члена back()
контейнера либо по-настоящему исключить его с помощью pop_back(). В первом варианте при сравнении используется оператор “меньше”, определенный для типа элементов контейнера, а во втором – операция comp.
Алгоритм prev_permutation()
template < class BidirectionalIterator > bool prev_permutation( BidirectionalIterator first, BidirectionalIterator last ); template < class BidirectionalIterator, class Compare > bool prev_permutation( BidirectionalIterator first, |
BidirectionalIterator last, class Compare );
prev_permutation()
берет последовательность, ограниченную диапазоном [first,last), и, рассматривая ее как перестановку, возвращает предшествующую ей (о том, как упорядочиваются перестановки, говорилось в разделе 12.5). Если предыдущей перестановки не существует, алгоритм возвращает false, иначе true. В первом варианте для определения предыдущей перестановки используется оператор “меньше” для типа элементов контейнера, а во втором – бинарная операция сравнения, заданная программистом.
#include #include #include // печатается: n d a n a d d n a d a n a n d a d n int main() { vector< char, allocator > vec( 3 ); ostream_iterator< char > out_stream( cout, " " ); vec[0] = 'n'; vec[1] = 'd'; vec[2] = 'a'; copy( vec.begin(), vec.end(), out_stream ); cout << "\t"; // сгенерировать все перестановки "dan" while( prev_permutation( vec.begin(), vec.end() )) { copy( vec.begin(), vec.end(), out_stream ); cout << "\t"; } cout << "\n\n";
| |
}
Алгоритм push_heap()
template< class RandomAccessIterator > void push_heap( RandomAccessIterator first, RandomAccessIterator last ); template< class RandomAccessIterator, class Compare > void push_heap( RandomAccessIterator first, |
RandomAccessIterator last, Compare comp );
push_heap()
предполагает, что последовательность, ограниченная диапазоном [first,last-1), – хип и что новый добавляемый к хипу элемент находится в позиции last-1. Все элементы в диапазоне [first,last) реорганизуются в новый хип. Перед вызовом push_heap()
необходимо вставить новый элемент в конец контейнера, возможно, применив функцию push_back()
(это показано в примере ниже). В первом варианте при сравнении используется оператор “меньше”, определенный для типа элементов контейнера; во втором – операция comp.
Алгоритм random_shuffle()
template < class RandomAccessIterator > void random_shuffle( RandomAccessIterator first, RandomAccessIterator last ); template < class RandomAccessIterator, class RandomNumberGenerator > void random_shuffle( RandomAccessIterator first, RandomAccessIterator last, |
RandomNumberGenerator rand);
random_shuffle()
переставляет элементы из диапазона [first,last) в случайном порядке. Во втором варианте можно передать объект-функцию или указатель на функцию, генерирующую случайные числа. Ожидается, что генератор rand возвращает значение типа double в интервале [0,1].
#include #include #include int main() { vector< int, allocator > vec; for ( int ix = 0; ix < 20; ix++ ) vec.push_back( ix ); random_shuffle( vec.begin(), vec.end() ); // печатает: // random_shuffle для последовательности 1 .. 20: // 6 11 9 2 18 12 17 7 0 15 4 8 10 5 1 19 13 3 14 16 cout << "random_shuffle для последовательности 1 .. 20:\n"; copy( vec.begin(), vec.end(), ostream_iterator< int >( cout," " ));
| |
}
Алгоритм remove_copy_if()
template< class InputIterator, class OutputIterator, class Predicate > OutputIterator remove_copy_if( InputIterator first, InputIterator last, |
OutputIterator result, Predicate pred );
remove_copy_if()
копирует все элементы, для которых предикат pred равен false, в контейнер, на начало которого указывает итератор result. Возвращаемый итератор указывает на элемент, расположенный за последним скопированным. Исходный контейнер остается без изменения.
#include #include #include /* печатается: исходная последовательность: 0 1 1 2 3 5 8 13 21 34 последовательность после применения remove_if < 10: 13 21 34 последовательность после применения remove_copy_if четное: 1 1 3 5 13 21 */ class EvenValue { public: bool operator()( int value ) { return value % 2 ? false : true; } }; int main() { int ia[] = { 0, 1, 1, 2, 3, 5, 8, 13, 21, 34 }; vector< int, allocator >::iterator iter; vector< int, allocator > vec( ia, ia+10 ); ostream_iterator< int > ofile( cout, " " ); cout << "исходная последовательность:\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n'; iter = remove_if( vec.begin(), vec.end(), bind2nd(less vec.erase( iter, vec.end() ); cout << "последовательность после применения remove_if < 10:\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n'; vector< int, allocator > vec_res( 10 ); iter = remove_copy_if( ia, ia+10, vec_res.begin(), EvenValue() ); cout << "последовательность после применения remove_copy_if четное:\n"; copy( vec_res.begin(), iter, ofile ); cout << '\n';
| |
}
Алгоритм remove_copy()
template< class InputIterator, class OutputIterator, class Type > OutputIterator remove_copy( InputIterator first, InputIterator last, |
OutputIterator result, const Type &value );
remove_copy()
копирует все элементы, кроме имеющих значение value, в контейнер, на начало которого указывает result. Возвращаемый итератор указывает на элемент за последним скопированным. Исходный контейнер не изменяется.
#include #include #include #include /* печатается: исходный вектор: 0 1 0 2 0 3 0 4 0 5 вектор после remove до erase(): 1 2 3 4 5 3 0 4 0 5 вектор после erase(): 1 2 3 4 5 массив после remove_copy() 1 2 3 4 5 */ int main() { int value = 0; int ia[] = { 0, 1, 0, 2, 0, 3, 0, 4, 0, 5 }; vector< int, allocator > vec( ia, ia+10 ); ostream_iterator< int > ofile( cout," "); vector< int, allocator >::iterator vec_iter; cout << "исходный вектор:\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n'; vec_iter = remove( vec.begin(), vec.end(), value ); cout << "вектор после remove до erase():\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n'; // удалить из контейнера неподходящие элементы vec.erase( vec_iter, vec.end() ); cout << "вектор после erase():\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n'; int ia2[5]; vector< int, allocator > vec2( ia, ia+10 ); remove_copy( vec2.begin(), vec2.end(), ia2, value ); cout << "массив после remove_copy():\n"; copy( ia2, ia2+5, ofile ); cout << endl;
| |
}
Алгоритм remove_if()
template< class ForwardIterator, class Predicate > ForwardIterator remove_if( ForwardIterator first, |
ForwardIterator last, Predicate pred );
remove_if()
удаляет из диапазона [first,last) все элементы, для которых значение предиката pred равно true. remove_if() (как и remove()) фактически не исключает удаленные элементы из контейнера. Вместо этого каждый оставляемый элемент перемещается в очередную позицию, начиная с first. Возвращаемый итератор указывает на элемент, следующий за позицией, в которую помещен последний неудаленный элемент. Обычно этот итератор затем передается алгоритму erase(), который удаляет неподходящие элементы. (Для встроенных массивов лучше использовать алгоритм remove_copy_if().)
Алгоритм remove()
template< class ForwardIterator, class Type > ForwardIterator remove( ForwardIterator first, |
ForwardIterator last, const Type &value );
remove()
удаляет из диапазона [first,last) все элементы со значением value. Этот алгоритм (как и remove_if()) на самом деле не исключает элементы из контейнера (т.е. размер контейнера сохраняется), а перемещает каждый оставляемый элемент в очередную позицию, начиная с first. Возвращаемый итератор указывает на элемент, следующий за позицией, в которую помещен последний неудаленный элемент. Рассмотрим, например, последовательность {0,1,0,2,0,3,0,4}. Предположим, что нужно удалить все нули. В результате получится последовательность {1,2,3,4,0,4,0,4}. 1 помещена в первую позицию, 2– во вторую, 3 – в третью и 4 – в четвертую. Элементы, начиная с 0 в пятой позиции, – это “отходы” алгоритма. Возвращенный итератор указывает на 0 в пятой позиции. Обычно этот итератор затем передается алгоритму erase(), который удаляет неподходящие элементы. (При работе со встроенным массивом лучше использовать алгоритмы remove_copy() и remove_copy_if(), а не remove() и remove_if(), поскольку его размер невозможно изменить)
Алгоритм replace_copy_if()
template< class ForwardIterator, class OutputIterator, class Predicate, class Type > OutputIterator replace_copy_if( ForwardIterator first, ForwardIterator last, class OutputIterator result, |
Predicate pred, const Type& new_value );
replace_copy_if()
ведет себя так же, как replace_if(), только новая последовательность копируется в контейнер, начиная с result. Возвращаемый итератор указывает на элемент, расположенный за последним скопированным. Исходный контейнер остается без изменения.
#include #include #include /* исходная последовательность: 0 1 1 2 3 5 8 13 21 34 последовательность после применения replace_if < 10 с заменой на 0: 0 0 0 0 0 0 0 13 21 34 последовательность после применения replace_if четное с заменой на 0: 0 1 1 0 3 5 0 13 21 0 */ class EvenValue { public: bool operator()( int value ) { return value % 2 ? false : true; } }; int main() { int new_value = 0; int ia[] = { 0, 1, 1, 2, 3, 5, 8, 13, 21, 34 }; vector< int, allocator > vec( ia, ia+10 ); ostream_iterator< int > ofile( cout, " " ); cout << "исходная последовательность:\n"; copy( ia, ia+10, ofile ); cout << '\n'; replace_if( &ia[0], &ia[10], bind2nd(less cout << "последовательность после применения replace_if < 10 " << "с заменой на 0:\n"; copy( ia, ia+10, ofile ); cout << '\n'; replace_if( vec.begin(), vec.end(), EvenValue(), new_value ); cout << "последовательность после применения replace_if четное" << "с заменой на 0:\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n';
| |
}
Алгоритм replace_copy()
template< class InputIterator, class InputIterator, class Type > OutputIterator replace_copy( InputIterator first, InputIterator last, class OutputIterator result, |
const Type& old_value, const Type& new_value );
replace_copy()
ведет себя так же, как replace(), только новая последовательность копируется в контейнер, начиная с result. Возвращаемый итератор указывает на элемент, расположенный за последним скопированным. Исходный контейнер остается без изменения.
#include #include #include /* печатается: исходная последовательность: Christopher Robin Mr. Winnie the Pooh Piglet Tigger Eeyore последовательность после применения replace(): Christopher Robin Pooh Piglet Tigger Eeyore */ int main() { string oldval( "Mr. Winnie the Pooh" ); string newval( "Pooh" ); ostream_iterator< string > ofile( cout, " " ); string sa[] = { "Christopher Robin", "Mr. Winnie the Pooh", "Piglet", "Tigger", "Eeyore" }; vector< string, allocator > vec( sa, sa+5 ); cout << "исходная последовательность:\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n'; replace( vec.begin(), vec.end(), oldval, newval ); cout << "последовательность после применения replace():\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n'; vector< string, allocator > vec2; replace_copy( vec.begin(), vec.end(), inserter( vec2, vec2.begin() ), newval, oldval ); cout << "последовательность после применения replace_copy():\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n';
| |
}
Алгоритм replace_if()
template< class ForwardIterator, class Predicate, class Type > void replace_if( ForwardIterator first, ForwardIterator last, |
Predicate pred, const Type& new_value );
replace_if()
заменяет значения всех элементов в диапазоне [first,last), для которых предикат pred
равен true, на new_value.
Алгоритм replace()
template< class ForwardIterator, class Type > void replace( ForwardIterator first, ForwardIterator last, |
const Type& old_value, const Type& new_value );
replace()
заменяет в диапазоне [first,last) все элементы со значением old_value на new_value.
Алгоритм reverse_copy()
template< class BidirectionalIterator, class OutputIterator > OutputIterator reverse_copy( BidirectionalIterator first, |
BidirectionalIterator last, OutputIterator result );
reverse_copy()
ведет себя так же, как reverse(), только новая последовательность копируется в контейнер, начиная с result. Возвращаемый итератор указывает на элемент, расположенный за последним скопированным. Исходный контейнер остается без изменения.
#include #include #include #include /* печатается: Исходная последовательность строк: Signature of all things I am here to read seaspawn and seawrack that rusty boot Последовательность строк после применения reverse(): boot rusty that seawrack and seaspawn read to here am I things all of Signature */ class print_elements { public: void operator()( string elem ) { cout << elem << ( _line_cnt++%8 ? " " : "\n\t" ); } static void reset_line_cnt() { _line_cnt = 1; } private: static int _line_cnt; }; int print_elements::_line_cnt = 1; int main() { string sa[] = { "Signature", "of", "all", "things", "I", "am", "here", "to", "read", "seaspawn", "and", "seawrack", "that", "rusty", "boot" }; list< string, allocator > slist( sa, sa+15 ); cout << "Исходная последовательность строк:\n\t"; for_each( slist.begin(), slist.end(), print_elements() ); cout << "\n\n"; reverse( slist.begin(), slist.end() ); print_elements::reset_line_cnt(); cout << "Последовательность строк после применения reverse():\n\t"; for_each( slist.begin(), slist.end(), print_elements() ); cout << "\n"; list< string, allocator > slist_copy( slist.size() ); reverse_copy( slist.begin(), slist.end(), slist_copy.begin() );
| |
}
Алгоритм reverse()
template< class BidirectionalIterator > void reverse( BidirectionalIterator first, |
BidirectionalIterator last );
reverse()
меняет порядок элементов контейнера в диапазоне [first,last) на противоположный. Например, если есть последовательность {0,1,1,2,3}, то после обращения получится {3,2,1,1,0}.
Алгоритм rotate_copy()
template< class ForwardIterator, class OutputIterator > OutputIterator rotate_copy( ForwardIterator first, ForwardIterator middle, |
ForwardIterator last, OutputIterator result );
rotate_copy()
ведет себя так же, как rotate(), только новая последовательность копируется в контейнер, начиная с result. Возвращаемый итератор указывает на элемент, расположенный за последним скопированным. Исходный контейнер остается без изменения.
#include #include #include /* печатается: исходная последовательность: 1 3 5 7 9 0 2 4 6 8 10 вращение вокруг среднего элемента(0) :: 0 2 4 6 8 10 1 3 5 7 9 вращение вокруг предпоследнего элемента(8) :: 8 10 1 3 5 7 9 0 2 4 6 rotate_copy вокруг среднего элемента :: 7 9 0 2 4 6 8 10 1 3 5 */ int main() { int ia[] = { 1, 3, 5, 7, 9, 0, 2, 4, 6, 8, 10 }; vector< int, allocator > vec( ia, ia+11 ); ostream_iterator< int > ofile( cout, " " ); cout << "исходная последовательность:\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n'; rotate( &ia[0], &ia[5], &ia[11] ); cout << "вращение вокруг среднего элемента(0) ::\n"; copy( ia, ia+11, ofile ); cout << '\n'; rotate( vec.begin(), vec.end()-2, vec.end() ); cout << "вращение вокруг предпоследнего элемента(8) ::\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n'; vector< int, allocator > vec_res( vec.size() ); rotate_copy( vec.begin(), vec.begin()+vec.size()/2, vec.end(), vec_res.begin() ); cout << "rotate_copy вокруг среднего элемента ::\n"; copy( vec_res.begin(), vec_res.end(), ofile ); cout << '\n';
| |
}
Алгоритм rotate()
template< class ForwardIterator > void rotate( ForwardIterator first, |
ForwardIterator middle, ForwardIterator last );
rotate()
перемещает элементы из диапазона [first,last) в конец контейнера. Элемент, на который указывает middle, становится первым. Например, для слова "hissboo" вращение вокруг буквы 'b' превращает слово в "boohiss".
Алгоритм search_n()
template< class ForwardIterator, class Size, class Type > ForwardIterator search_n( ForwardIterator first, ForwardIterator last, Size count, const Type &value ); template< class ForwardIterator, class Size, class Type, class BinaryPredicate > ForwardIterator search_n( ForwardIterator first, ForwardIterator last, |
Size count, const Type &value, BinaryPredicate pred );
search_n()
ищет в последовательности [first,last) подпоследовательность, состоящую из count
повторений значения value. Если она не найдена, возвращается last. Например, для поиска подстроки ss в строке Mississippi
следует задать value
равным 's', а count
равным 2. Если же нужно найти две расположенные подряд подстроки ssi, то value
задается равным "ssi", а count
снова 2. search_n()
возвращает итератор на первый элемент со значением value. В первом варианте для сравнения элементов используется оператор равенства, во втором – указанная программистом операция сравнения.
#include #include #include /* печатается: Ожидаем найти два вхождения 'o': o o Ожидаем найти подстроку 'mou': m o u */ int main() { ostream_iterator< char > ofile( cout, " " ); const char blank = ' '; const char oh = 'o'; char str[ 26 ] = "oh my a mouse ate a moose"; char *found_str = search_n( str, str+25, 2, oh ); cout << "Ожидаем найти два вхождения 'o': "; copy( found_str, found_str+2, ofile ); cout << '\n'; vector< char, allocator > vec( str, str+25 ); // найти первую последовательность из трех символов, // ни один из которых не равен пробелу: mou of mouse vector< char, allocator >::iterator iter; iter = search_n( vec.begin(), vec.end(), 3, blank, not_equal_to< char >() ); cout << "Ожидаем найти подстроку 'mou': "; copy( iter, iter+3, ofile ); cout << '\n';
| |
}
Алгоритм search()
template< class ForwardIterator1, class ForwardIterator2 > ForwardIterator search( ForwardIterator1 first1, ForwardIterator1 last1, ForwardIterator2 first2, ForwardIterator2 last2 ); template< class ForwardIterator1, class ForwardIterator2, class BinaryPredicate > ForwardIterator search( ForwardIterator1 first1, ForwardIterator1 last1, ForwardIterator2 first2, ForwardIterator2 last2, |
BinaryPredicate pred );
Если даны два диапазона, то search()
возвращает итератор, указывающий на первую позицию в диапазоне [first1,last1), начиная с которой второй диапазон входит как подпоследовательность. Если подпоследовательность не найдена, возвращается last1. Например, в слове Mississippi
подпоследовательность iss
встречается дважды, и search()
возвращает итератор, указывающий на начало первого вхождения. В первом варианте для сравнения элементов используется оператор равенства, во втором – указанная программистом операция сравнения.
#include #include #include /* печатается: Ожидаем найти подстроку 'ate': a t e Ожидаем найти подстроку 'vat': v a t */ int main() { ostream_iterator< char > ofile( cout, " " ); char str[ 25 ] = "a fine and private place"; char substr[] = "ate"; char *found_str = search(str,str+25,substr,substr+3); cout << "Ожидаем найти подстроку 'ate': "; copy( found_str, found_str+3, ofile ); cout << '\n'; vector< char, allocator > vec( str, str+24 ); vector< char, allocator > subvec(3); subvec[0]='v'; subvec[1]='a'; subvec[2]='t'; vector< char, allocator >::iterator iter; iter = search( vec.begin(), vec.end(), subvec.begin(), subvec.end(), equal_to< char >() ); cout << "Ожидаем найти подстроку 'vat': "; copy( iter, iter+3, ofile ); cout << '\n';
| |
}
Алгоритм set_difference()
template< class InputIterator1, class InputIterator2, class OutputIterator > OutputIterator set_difference( InputIterator1 first1, InputIterator1 last1, InputIterator2 first2, InputIterator2 last2, OutputIterator result ); template< class InputIterator1, class InputIterator2, class OutputIterator, class Compare > OutputIterator set_difference( InputIterator1 first1, InputIterator1 last1, InputIterator2 first2, InputIterator2 last2, |
OutputIterator result, Compare comp );
set_difference()
строит отсортированную последовательность из элементов, имеющихся в первой последовательности [first1,last1), но отсутствующих во второй – [first2,last2). Например, разность последовательностей {0,1,2,3} и {0,2,4,6} равна {1,3}. Возвращаемый итератор указывает на элемент за последним помещенным в выходной контейнер result. В первом варианте предполагается, что обе последовательности были отсортированы с помощью оператора “меньше”, определенного для типа элементов контейнера; во втором для упорядочения используется указанная программистом операция comp.
Алгоритм set_intersection()
template< class InputIterator1, class InputIterator2, class OutputIterator > OutputIterator set_intersection( InputIterator1 first1, InputIterator1 last1, InputIterator2 first2, InputIterator2 last2, OutputIterator result ); template< class InputIterator1, class InputIterator2, class OutputIterator, class Compare > OutputIterator set_intersection( InputIterator1 first1, InputIterator1 last1, InputIterator2 first2, InputIterator2 last2, |
OutputIterator result, Compare comp );
set_intersection()
строит отсортированную последовательность из элементов, встречающихся в обеих последовательностях – [first1,last1) и [first2,last2). Например, пересечение последовательностей {0,1,2,3} и {0,2,4,6} равно {0,2}. Возвращаемый итератор указывает на элемент за последним помещенным в выходной контейнер result. В первом варианте предполагается, что обе последовательности были отсортированы с помощью оператора “меньше”, определенного для типа элементов контейнера; во втором для упорядочения используется указанная программистом операция comp.
Алгоритм set_symmetric_difference()
template< class InputIterator1, class InputIterator2, class OutputIterator > OutputIterator set_symmetric_difference( InputIterator1 first1, InputIterator1 last1, InputIterator2 first2, InputIterator2 last2, OutputIterator result ); template< class InputIterator1, class InputIterator2, class OutputIterator, class Compare > OutputIterator set_symmetric_difference( InputIterator1 first1, InputIterator1 last1, InputIterator2 first2, InputIterator2 last2, |
OutputIterator result, Compare comp );
set_symmetric_difference()
строит отсортированную последовательность из элементов, которые встречаются только в первой последовательности [first1,last1) или только во второй – [first2,last2). Например, симметрическая разность последовательностей {0,1,2,3} и {0,2,4,6} равна {1,3,4,6}. Возвращаемый итератор указывает на элемент за последним помещенным в выходной контейнер result. В первом варианте предполагается, что обе последовательности были отсортированы с помощью оператора “меньше”, определенного для типа элементов контейнера; во втором для упорядочения используется указанная программистом операция comp.
Алгоритм set_union()
template< class InputIterator1, class InputIterator2, class OutputIterator > OutputIterator set_union(InputIterator1 first1, InputIterator1 last1, InputIterator2 first2, InputIterator2 last2, OutputIterator result ); template< class InputIterator1, class InputIterator2, class OutputIterator, class Compare > OutputIterator set_union(InputIterator1 first1, InputIterator1 last1, InputIterator2 first2, InputIterator2 last2, |
OutputIterator result, Compare comp );
set_union()
строит отсортированную последовательность из элементов, которые встречаются либо в первой последовательности [first1,last1), либо во второй – [first2,last2), либо в обеих. Например, объединение последовательностей {0,1,2,3} и {0,2,4,6} равно {0,1,2,3,4,6}. Если элемент присутствует в обеих последовательностях, то копируется экземпляр из первой. Возвращаемый итератор указывает на элемент за последним помещенным в выходной контейнер result. В первом варианте предполагается, что обе последовательности были отсортированы с помощью оператора “меньше”, определенного для типа элементов контейнера; во втором для упорядочения используется указанная программистом операция comp.
#include #include #include #include /* печатается: элементы множества #1: Иа-Иа Пух Пятачок Тигра элементы множества #2: Бука Пух Слонопотам элементы set_union(): Бука Иа-Иа Пух Пятачок Слонопотам Тигра элементы set_intersection(): Пух элементы set_difference(): Иа-Иа Пятачок Тигра элементы_symmetric_difference(): Бука Иа-Иа Пятачок Слонопотам Тигра */ int main() { string str1[] = { "Пух", "Пятачок", "Тигра", "Иа-Иа" }; string str2[] = { "Пух", "Слонопотам", "Бука" }; ostream_iterator< string > ofile( cout, " " ); set set cout << "элементы множества #1:\n\t"; copy( set1.begin(), set1.end(), ofile ); cout << "\n\n"; cout << "элементы множества #2:\n\t"; copy( set2.begin(), set2.end(), ofile ); cout << "\n\n"; set set_union( set1.begin(), set1.end(), set2.begin(), set2.end(), inserter( res, res.begin() )); cout << "элементы set_union():\n\t"; copy( res.begin(), res.end(), ofile ); cout << "\n\n"; res.clear(); set_intersection( set1.begin(), set1.end(), set2.begin(), set2.end(), inserter( res, res.begin() )); cout << "элементы set_intersection():\n\t"; copy( res.begin(), res.end(), ofile ); cout << "\n\n"; res.clear(); set_difference( set1.begin(), set1.end(), set2.begin(), set2.end(), inserter( res, res.begin() )); cout << "элементы set_difference():\n\t"; copy( res.begin(), res.end(), ofile ); cout << "\n\n"; res.clear(); set_symmetric_difference( set1.begin(), set1.end(), set2.begin(), set2.end(), inserter( res, res.begin() )); cout << "элементы set_symmetric_difference():\n\t"; copy( res.begin(), res.end(), ofile ); cout << "\n\n";
| |
}
Алгоритм sort_heap()
template< class RandomAccessIterator > void sort_heap( RandomAccessIterator first, RandomAccessIterator last ); template< class RandomAccessIterator, class Compare > void sort_heap( RandomAccessIterator first, |
RandomAccessIterator last, Compare comp );
sort_heap()
сортирует последовательность в диапазоне [first,last), предполагая, что это правильно построенный хип; в противном случае поведение программы не определено. (Разумеется, после сортировки хип перестает быть хипом!) В первом варианте при сравнении используется оператор “меньше”, определенный для типа элементов контейнера, а во втором – операция comp.
#include #include #include template void print_elements( Type elem ) { cout << elem << " "; } int main() { int ia[] = { 29,23,20,22,17,15,26,51,19,12,35,40 }; vector< int, allocator > vec( ia, ia+12 ); // печатается: 51 35 40 23 29 20 26 22 19 12 17 15 make_heap( &ia[0], &ia[12] ); void (*pfi)( int ) = print_elements; for_each( ia, ia+12, pfi ); cout << "\n\n"; // печатается: 12 17 15 19 23 20 26 51 22 29 35 40 // минимальный хип: в корне наименьший элемент make_heap( vec.begin(), vec.end(), greater for_each( vec.begin(), vec.end(), pfi ); cout << "\n\n"; // печатается: 12 15 17 19 20 22 23 26 29 35 40 51 sort_heap( ia, ia+12 ); for_each( ia, ia+12, pfi ); cout << "\n\n"; // добавим новый наименьший элемент vec.push_back( 8 ); // печатается: 8 17 12 19 23 15 26 51 22 29 35 40 20 // новый наименьший элемент должен оказаться в корне push_heap( vec.begin(), vec.end(), greater for_each( vec.begin(), vec.end(), pfi ); cout << "\n\n"; // печатается: 12 17 15 19 23 20 26 51 22 29 35 40 8 // наименьший элемент должен быть заменен на следующий по порядку pop_heap( vec.begin(), vec.end(), greater for_each( vec.begin(), vec.end(), pfi ); cout << "\n\n";
| |
}
#
#include, директива
использование с using-директивой, 68, 427
использование с директивой связывания, 354
*
умножения оператор
комплексных чисел, 155
ч
члены класса
функции-члены
константные, 611–14
подвижные (volatile), 611–14
Д
деструктор(ы)
для элементов масс??а
освобождение динамической памяти, 693–94
abort(), функция
вызов из terminate() как подразумеваемое поведение, 541
abs(), функция
поддержка для комплексных чисел, 156
accumulate(), обобщенный алгоритм, 1104
adjacent_difference(), обобщенный алгоритм, 1106
adjacent_find(), обобщенный алгоритм, 1107
ainooi
к базовому классу, 880–88
algorithm, заголовочный файл, 584
any(), функция
в классе bitset, 167
append(), функция
конкатенация строк, 287
argc, переменная
счетчик аргументов в командной строке, 356
argv, массив
для доступа к аргументам в командной строке, 356
assert(), макрос, 51
использование для отладки, 226
at(), функция
контроль выхода за границы диапазона во время выполнения, 289
atoi(), функция
применение для обработки аргументов в командной строке, 360
auto_ptr, шаблон класса, 395–400
memory, заголовочный файл, 395
инициализация, 397
подводные камни, 399
aункции
интерфейс
включение объявления исключений в, 546
B
back(), функция
поддержка очереди, 316
back_inserter(), адаптор функции
использование в операции вставки push_back(), 577
begin(), функция
итератор
возврат с помощью, 578
использование, 261
binary_search(), обобщенный алгоритм, 1108
bind1st(), адаптор функции, 573
bind2nd(), адаптор функции, 573
bitset, заголовочный файл, 168
bitset, класс, 165
size(), функция, 167
test(), функция, 167
to_long(), функция, 170
to_string(), функция, 170
заголовочный файл bitset, 168
оператор доступа к биту ([]), 167
операции, 168–71
break, 218–19
break, инструкция
использование для выхода из инструкции switch, 203
сравнение с инструкцией return, 346
C
C, язык
символьные строки
динамическое выделение памяти для, 401
необходимость доступа из класса string, 128
отсутствие завершающего нуля как программная ошибка, 402
C_str(), функция
преобразование объектов класса string в C-строки, 137
C++, язык
std, пространство имен, 426–28
введение в (глава), 12–13
компоненты
(часть 2), 319
типы данных (глава), 98–140
предопределенные операторы (таблица), 727
case, ключевое слово
использование в инструкции switch (таблица), 202
catch-обработчик, 62, 534, 537
критерий выбора, 63
определение, 537
универсальный обработчик, 543–45
cerr, 26
представление стандартного вывода для ошибок с помощью, 1041
char *, указатель
работы с C-строками символов, 92
char, тип, 76
check_range(), пример функции
как закрытая функция-член, 51
cin, 26
использование итератора istream_iterator, 579
представление стандартного ввода с помощью, 1041
class, ключевое слово
typename как синоним, 479
использование в определении класса, 594
использование в определении шаблона класса, 801
использование в параметрах-типах шаблона
класса, 800
функции, 476
const, квалификатор
вопросы разрешения перегрузки функций
параметры-типы, 432
вопросы разрешения перезагрузки функций
использование преобразования квалификаторов, 449
ранжирование преобразований, связанных с инициализацией ссылочных параметров, 473
константная функция-член, 611–14
константные объекты, динамическое выделение и освобождение памяти, 402–3
константные параметры
параметры-ссылки с квалификатором const, 330, 340
передача массива из константных элементов, 336
константный итератор, 262
контейнеры, необходимость константного итератора, 575
преобразование объектов в константы, 101
сравнение с volatile, 127
ссылка, инициализация объектом другого типа, 105
указатели на константные объекты, 101
const_cast, оператор, 180
continue, инструкция, 219
copy(), обобщенный алгоритм, 1109
использование класса inserter, 305
конкатенация векторов с помощью, 557
count(), обобщенный алгоритм, 1112
использование istream_iterator и ostream_iterator, 581
использование с контейнерами multimap и multiset, 311
использование с множествами, 306
использование с отображениями, 298
count(), функция
в классе bitset, 167
count_if(), обобщенный алгоритм, 1114
cout, 26
представление стандартного вывода с помощью, 1041
cпецификации
исключений
для документирования исключений, 546
D
default, ключевое слово
использование в инструкции switch, 202, 205
delete, оператор, 35, 162–63, 744–53
безопасное и небезопасное использование, примеры, 394
для массивов, 749–51
объектов класса, 750
синтаксис, 402
для одиночного объекта, 392
использование класса-распределителя памяти (сноска), 256
размещения, 751–53
deque (двустороння очередь, дека)
использование итераторов с произвольным доступом, 583
как последовательный контейнер, 248–301
применение для реализации стека, 314
требования к вставке и доступу, 252
do-while, инструкция, 216–18
сравнение с инструкциями for и while, 209
E
иници??изация
массива
динамически выделенных объектов классов, 691–94
копиру??ий
конструктор, 680–82
end(), функция
итератор, использование, 261
endl, манипулятор потока iostream, 27
enum, ключевое слово, 112
equal_range(), обобщенный алгоритм
использование с контейнерами multimap и multiset, 310
extern "C"
и перегруженные функции, 438–39
неприменимость безопасного связывания, 440
указатели на функции, 373–75
extern, ключевое слово
использование с указателями на функции, 373
использование с членами пространства имен, 418
как директива связывания, 354
объявление
константы, 386
шаблона функции, 481
объявления объектов
без определения, 382
размещение в заголовочном файле, 384
F
f, суффикс
нотация для литерала с плавающей точкой одинарной точности, 77
find(), обобщенный алгоритм
использование с контейнерами multiset и multimap, 309
поиск объектов в множестве, 306
поиск подстроки, 273
поиск элемента отображения, 298
find_first_of(), обобщенный алгоритм
нахождение знаков препинания, 280
нахождение первого символа в строке, 273
find_last_ of(), 279
find_last_not_of(), 279
for, инструкция, 209–12
использование с инструкцией if, 196
front(), функция
поддержка очереди, 316
front_inserter(), адаптор функции
использование в операции push_front(), 577
fstream, класс
файловый ввод / вывод, 1042
full(), функция
модификация алгоритма динамического роста стека, 317
functional, заголовочный файл, 568
G
get(), функция, 1063–66
getline(), функция, 270, 1066–68
goto, инструкция, 219–22
greater, объект-функция, 571
greater_equal, объект-функция, 571
I
i?enaaeaaiea
почленное для объектов класса, 925–29
i?iecaiaiua eeannu
ae?ooaeuiua ooieoee, 899–925
определение
при одиночном наследовании, 876–78
присваивание
оператор
перегруженный, 925–29
if, инструкция, 192–98
If, инструкция
условный оператор как альтернатива, 158
insert(), функция
вставка символов в строку, 286
добавление элементов в множество, 305
реализация, 266
списки, 222
inserter(), адаптор функции
для вставки с помощью insert(), 577
inserter, класс, 305
Iomanip, заголовочный файл, 136
iostream библиотека
iostream.h, заголовочный файл, пример использования, 563
ввод
istream_iterator, 579
итератор чтения, 582
вывод
ostream_iterator, 580–82
итератор записи, 582
итератор чтения, 582
итераторы, 578–82
манипуляторы
endl, 27
операторы, сцепление, 28–29
iostream.h, заголовочный файл
пример использования для манипуляций с текстом, 563
isalpha(), функция, 206
ctype, заголовочный файл, 283
isdigit(), функция
ctype, заголовочный файл, 283
ispunct(), функция
ctype, заголовочный файл, 283
isspace(), функция
ctype, заголовочный файл, 283
istream_iterator, 579–80
iterator, заголовочный файл, 578
L
less, объект-функция, 572
less_equal, объект-функция, 572
limits, заголовочный файл, 145
list, заголовочный файл, 256
locale, заголовочный файл, 283
l-значение, 81
как возвращаемое значение, подводные камни, 348
оператор присваивания, требования, 149
преобразования, 447
преобразование точного соответствия, 445
точное соответствие при разрешении перегрузки функций, 457
трансформация, 450, 469
преобразование аргументов шаблона функции, 486
M
main(), 15
обработка аргументов в командной строке, 356–65
map, заголовочный файл, 293
использование с контейнером multimap, 309
memory, заголовочный файл, 395
merge(), обобщенный алгоритм
специализированная версия для спискаов, 588
minus(), объект-функция, 570
modulus, объект-функция, 571
multimap (мультиотображение), контейнер, 309–12
map, заголовочный файл, 310
сравнение с отображением, 303
multiplies, объект-функция, 570
multiset (мультимножество), контейнер, 309–12
set, заголовочный файл, 310
N
negate, объект-функция, 571
new оператор, 162–63
для константных объектов, 403–4
для массивов, 400–402
классов, 749–51
для объектов классов, 745
для одиночных объектов, 392–95
использование класса распределителя памяти (сноска), 256
оператор размещения new, 403–4
для объектов класса, 751–53
спецификации
исключений, 546–50
и указат??и на функции, 548–50
статические члены класса, 621–27
данные-члены, 621–27
функции-члены, 626–27
not_equal_to, объект-функция
(код), 571
not1(), адаптор функции
как адаптор-отрицатель, 573
not2(), адаптор функции
как адаптор-отрицатель, 573
numeric, заголовочный файл, 584
использование численных обобщенных алгоритмов, 586
O
oaaeiiu eeannia
конкретизация, 800–811
члены
шаблонов, 826–31
ofstream, тип, 1076–86
фун??ии-члены
volatile, 611–14
функции-члены
константные, 611–14
ostream_iterator, 580–82
P
pair, класс, 127
использование для возврата нескольких значений, 197
plus, объект-функция, 568, 570
pop_back(), функция
для удаления элементов из последовательного контейнера, 267
использование для реализации динамического роста стека, 317
push_back(), функция
векторы, вставка элементов, 123
поддержка в контейнерах, 257
стеки, использования для динамического выделения памяти, 317
push_front(), функция
поддержка в списковых контейнерах, 257
pаголовочные файлы
содержимое
объявления функций, с включением явной спецификации исключений, 546
Q
queue, заголовочный файл, 315
R
register, ключевое слово, 389–90
reinterpret_cast, оператор
опасности, 181
reinterpret_cast, оператор, 181
release()б функция
управление объектами с помощью класса auto_ptr, 400
reserve(), функция
использование для установки емкости контейнера, 255
reset(), функция
в классе bitset, 167
установка указателя auto_ptr, 398
resize(), функция
использование для изменения размера контейнера, 258
return, инструкция
завершение функции с помощью, 346
неявное преобразование типа в, 176
сравнение с выражением throw, 531
r-значение, 81
использование при вычислении выражений, 141
S
set, заголовочный файл, 304, 310
size(), функция
для модификации алгоритма выделения памяти в стеке, 317
sizeof, оператор, 159–62
использование с типом ссылки, 161
использование с типом указателя, 161
как константное выражение, 162
sort(), обобщенный алгоритм
вызов, 120
передача объекта=функции в качестве аргумента, 569
stack, заголовочный файл, 312
static_cast
сравнение с неявным преобразованием, 180
static_cast, оператор
опасности, 181
std, пространство имен, 426–28
string, заголовочный файл, 67
string, строковый тип, 95–98
substr(), функция, 275
пустая строка, 96
смешение объектов типа string и C-строк, 97
switch, инструкция, 207
использование ключевого слова case, 202
использование ключевого слова default, 202, 205
T
terminate(), функция, 541
this, указатель, 616–20
tolower(), функция
locale, заголовочный файл, 283
преобразование заглавных букв в строчные, 283
toupper(), функция
ctype, заголовочный файл, 283
locale, заголовочный файл, 283
true, ключевое слово, 108
typedef
для объявления указателя на функцию, 372
для улучшения читабельности, 295, 369
как синоним существующего имени типа, 431
массива указателей на функции, 369
typename, 242
использование с параметрами шаблона функции, 480
U
unexpected(), функция
для обработки нераспознанных исключений, 547
unique(), обобщенный алгоритм
удаление дубликатов из вектора, 557
unique_copy(), обобщенный алгоритм
запись целых чисел из вектора в стандартный вывод, 579
using-директивы, 423–26
влияние на разрешение перегрузки функции, 463
для объявления перегруженных функций, 437–38
сравнение с using-объявлениями, 423–26
using-объявления, 422–23
влияние на разрешение перегрузки функции, 462
для объявления перегруженных функций, 434–36
сравнение с using-директивами, 423–26
utility, заголовочный файл, 127
V
vector, заголовочный файл, 70, 121, 256
void
в списке параметров функции, 325
указатель, 179
void*
преобразование в void* как стандартное преобразование, 456
volatile, квалификатор, 127
для типа параметра, в связи с перегрузкой функций, 432
для функции-члена, 611–14
использование преобразования квалификаторов, 471
преобразование
квалификаторов, 449
W
while, инструкция, 213–16
сравнение с инструкциями for и do-while, 209
А
абстракция
объекта, класс комплексных чисел как пример, 154
стандартная библиотека, преимущества использования, 165
автоматические объекты, 388–90
объявление с ключевым словом register, 389–90
особенности хранения, 388
адапторы
функций, для объектов-функций, 573
адапторы функций, 573
адрес(а)
как значение указателя, 88
конкретизированных шаблонов функций, 484
алгоритм(ы)
функция
выведение аргумента шаблона, 489
разрешение перегрузки, 511
шаблон как, 475
аргумент(ы), 321
передача, 345
использование указателей для, 87
передача по значению, 327
по умолчанию, 340–43
должны быть хвостовыми, 341
и виртуальные функции, 913
и устоявшие функции, 472–73
тип
преобразования, разрешение перегрузки функции, 444–60
преобразования, расширение типа, 451–53
преобразования, ссылок, 457–59
преобразования, стандартные, 453–57
шаблона класса
для параметров-констант, 805–9
для параметров-типов, 800–811
шаблонов функции
явные, 490–93
шаблонов функций
выведение аргументов, 485–90
явная спецификация, мотивировка, 492
явная спецификация, недостатки, 492
явное специфицирование, 490
арифметические
исключения, 143
объекты-функции, 570
операторы, 142–45
таблица, 142
операции, поддержка для комплексных чисел, 126
преобразования, 175, 142–45
bool в int, 109
неявное выполнение при вычислении выражений, 175
типов, расширение типа перечисления, 112
указатели, 90
ассоциативность
операторов, влияние на вычисление выражений, 171–74
порядок вычисления подвыражений, 142
ассоциативные контейнеры, 248–301
неприменимость обобщенных алгоритмов переупорядочения, 587
ассоциирование
значений, использование класса pair, 127
Б
базовые классы
абстрактные базовые классы, 865–69, 908
видимость классов
при виртуальном наследовании, 983–84
видимость членов
при множественном наследовании, 968–71
при одиночном наследовании, 966–68
виртуальные базовые классы, 974–87
деструкторы, 896–99
доступ
к базовым классам, 958–64
к закрытым базовым классам, 963
к защищенным членам, 871
к членам, 880–88
доступ к элементам отображения с помощью, 299
конструирование
виртуальное наследование, 974–82
множественное наследование, 950–51
одиночное наследование, 889–96
почленная инициализация, 925–27
конструкторы, 889–99
определение базового класса
при виртуальном наследовании, 976–78
при множественном наследовании, 950–55
при одиночном наследовании, 871–75
преобразование к базовому классу, 865–69
при выведении аргументов шаблона функции, 487
присваивание, почленное присваивание, 927–29
байты
запись с помощью put(), 1063
чтение с помощью get(), 1063–66
безопасное связывание, 384
перегруженных функций, 440
бесконечный
рекурсия, 351
цикл, избежание в операциях поиска в строке, 274
бинарные
операторы, 141
битовое поле
как средство экономии памяти, 643–45
битовый вектор, 164
в сравнении с классом bitset, 164
блок
try-блок, 533–37
инструкций, 188
комментария, 24
функции, 321
функциональный try-блок, 533–37
и конструкторы, 1024–26
больше (>), оператор
поддержка в арифметических типах данных, 30
булевский(е)
константы, операторы, дающие в результате, 146
стандартные преобразования при разрешении перегрузки функции, 453
тип bool, 108–10
В
вектор(ы)
find(), обобщенный алгоритм, 554
емкость, связь с размером, 258
идиоматическое употребление в STL, 123
объектов класса, 689–96
присваивание, сравнение со встроенными массивами, 122
сравнение со списками, 251–52
требования к вставке и доступу, 252
увеличение размера, 253–56
вертикальная табуляция ()
как escape-последовательность, 77
взятия адреса (&) оператор
использование в определении ссылки, 104, 105
использование с именем функции, 367
как унарный оператор, 141
взятия индекса оператор ([]), 736
использование в векторах, 121
использование в классе bitset, 168
использование в отображениях, 294
отсутствие поддержки в контейнерах multimap и multiset, 312
взятия остатка, оператор (%), 142
видимость
определения символической константы, 386
переменных в условии цикла, 211, 379–81
роль в выборе функции-кандидата при разрешении перегрузки функции, 460
требование к встроенным функциям, 353, 387
членов класса, 607, 645–52
висячий
проблемы висячего else, описание и устранение, 195
указатель, 389
как проблема динамически выделенного объекта, 394
возврат каретки (\\r)
как escape-последовательность, 77
время жизни, 381
auto_ptr, влияние на динамически выделенные объекты, 395
автоматических объектов, 388
динамически выделенных объектов, 392
сравнение с указателями на них, 394
и область видимости (глава), 376–428
локальных объектов
автоматических и статических, 388
влияние раскрутки стека на объекты типа класса, 541
проблема возврата ссылки на локальный объект, 348
вставка элементов
в вектор, 123
в контейнер, с помощью адапторов функций, 577
в контейнеры multimap и multiset, 311
в отображение, 294
в последовательные контейнеры, 265
в стек, 314
использование push_back(), 257
итераторы, обозначение диапазона, 575–77
различные механизмы для разных типов контейнеров, 252
встроенные функции, 133, 322
объекты-функции, 559, 566
объявление, 352–53
шаблонов функций как, 481
определение, размещение в заголовочном файле, 385
перегруженные операторы вызова, 559
преимущества, 352
сравнение с не-встроенными функциями-членами, 605–7
встроенный(е)
массивы
запрет иниициализации другим массивом, 115
запрет использования в качестве возвращаемого значения функции, 324
запрет присваивания другому массиву, 324
запрет ссылаться на, 115
инициализация при выделении из хипа, 400
отсутствие поддержки операции erase(), 557
поддержка в обобщенных алгоритмах, 553
сравнение с векторами, 122
типы данных
арифметические, 30–33
выполнение
непоследовательные инструкции, 20
условное, 20
выражения
(глава), 141–87
использование аргументов по умолчанию, 342
порядок вычисления подвыражений, 142
разрешение имен, 377
вычисление
логических операторов, 146
порядок вычисления подвыражений, 142
вычитание
minus, объект-функция, 570
комплексных чисел, 154
Г
глобальное пространство имен
проблема засорения, 66, 406
глобальные объекты
и функции, 381–87
сравнение с параметрами и возвращаемыми значениями функций, 349–50
глобальные функции, 381
горизонтальная табуляция (\\t)
как escape-последовательность, 77
Д
данные члены, 595–96
данные-члены
битовые поля, 643–45
изменчивые (mutable), 614–16
статические, 621–28
в шаблонах классов, 821–24
указатель this, 616–21
члены базового и производного классов, 870–79
двойная кавычка (\\ ")
как escape-последовательность, 77
двойная обратная косая черта (\\)
как escape-последовательность, 77
двунаправленный итератор, 583
декремента оператор (--)
встроенный, 153–54
перегруженный, 740–44
постфиксная форма, 153, 743
префиксная форма, 153, 742
деление
комплексных чисел, 155
целочисленное, 143
деления по модулю оператор (%), 142
деструктор(ы), 682–89
для элементов массива, 690
динамическое выделение памяти
для массива, 162, 400–402
исчерпание памяти, исключение bad_alloc, 393
как требование к динамически растущему вектору, 253
объектов, 392–406
управление с помощью класса auto_ptr, 395
динамическое освобождение памяти
для массивов, 400–402
объектов, 392–406
константных, 402–3
одиночных объектов, 392–95
оператор delete, 134, 392, 394, 744–53
управление с помощью класса auto_ptr, 395
утечка памяти, 395
директивы, 21–24
директивы связывания, 353–55
в связи с перегрузкой, 438
использование с указателями на функции, 373
для элементов массива
динамическое выделение памяти, 691–94
доступ
к контейнеру
использование итератора для, 261
последовательный доступ как критерий выбора типа, 252
к массиву, 31
индекс, 45
индексирование, 113
к пространству имен
механизмы, компромиссные решения, 68
к членам, 598–99, 607–8
оператор доступа к членам ->, 740
произвольный, итератор с произвольным доступом, 583
уровни, protected, 49
друзья, 730–33
и специальные права доступа, 137, 599–600
перегруженные операторы, 730–33
См. также доступ, класс(ы), наследование, 815–21
Е
емкость контейнерных типов
в сравнении с размером, 253
начальная, связь с размером, 258
З
забой (, 77
заголовочные файлы
как средство повторного использования объявлений функций, 323
по имени
algorithm, 72, 584
bitset, 167
complex, 125
fstream, 1042
functional, 568
iomanip, 136
iterator, 578
limits, 145
locale, 283
map, 293
memory, 395
numeric, 584, 586
queue, 315
set, 304
sstream, 1044
stack, 312
string, 68
vector, 70, 121, 256
предкомпилированные, 385
содержимое
включение определения шаблона функции, преимущества и недостатки, 495
встроенные функции, 353
директивы связывания, 354
объявления, 82, 385–87
объявления явных специализаций шаблонов, 503
спецификация аргументов по умолчанию, 341
запись активации, 327
автоматическое включение объектов в, 388
запятая (,)
неправильное использование для индексации массива, 117
оператор, 163
звонок ()
как escape-последовательность, 77
знак вопроса ()
как escape-последовательность, 77
И
И, оператор, 142
идентификатор, 83
использования в качестве спецификатора типа класса, 129
как часть определения массива, 113
соглашения по именованию, 83
иерархии
определение, 862–69
идентификация членов, 870–80
исключений, в стандартной библиотеке C++, 1026–29
поддержка мезанизма классов, 128
изменчивый (mutable) член, 614–16
именование
соглашения об именовании идентификаторов, 83
имя, 83
typedef, как синоним, 126–27
именование членов класса, 607–8
квалифицированные имена, 410–12
статических членов класса, 622–23
членов вложенных пространств имен, 412–14
шаблонов функций как членов пространства имен, 524
область видимости объявления, 376
параметра шаблона
функции, 478
перегруженные операторы, 727–28
переменной, 83
псевдонимы пространства имен, как альтернативные имена, 420–21
разрешение, 377
в локальной области видимости, 379
в области видимости класса, 649–52
в определении шаблона функции, 514–20
инициализация
векторов, 121
сравнение с инициализацией встроенных массивов, 122
комплексного числа, 154
массива
динамически выделенного, 400
динамически выделенных объектов классов, 749
многомерного, 116
указателей на функции, 369
недопустимость инициализации другим массивом, 115
объектов
автоматических, 388
автоматических, по сравнению с локальными статическими, 391
глобальных, инициализация по умолчанию, 382
динамически выделенных, 393
константных, 101
статических локальных, 390, 391
поведение auto_ptr, 397
сравнение с присваиванием, 148
ссылок, 104
указателя на функцию, 367
влияние на спецификацию исключений, 549
вопросы, связанные с перегруженными функциями, 439
инкремента оператор (++)
встроенный, 154
перегруженный, 740–44
постфиксная форма, 153, 743
префиксная форма, 153, 742
инструкции, 188–98
break
для выхода из инструкции switch, 203
break, инструкция, 218–19
continue, 219
do-while, 216–17
сравнение с инструкциями for и while, 209
for, 209–13
goto, 219–21
if, 20, 192–98
if-else, условный оператор как альтернатива, 158
switch, 201–3
использование ключевого слова default, 202, 205
while, 213–16
сравнение с инструкциями for и do-while, 209
блок, 188
объявления, 189–92
простые, 188–89
составные, 188–89
инструкция
while, 21
использование преобразования квалификаторов, 449
использование шаблонов, 62
итератор с произвольным доступом, 583
итератор(ы), 123, 261
begin(), доступ к элементам контейнера, 261
end(), доступ к элементам контейнера, 261
iterator, заголовочный файл, 578
абстракция, использование а обобщенных алгоритмах для обхода, 552
адаптор, 557
вставка элементов в последовательные контейнеры, 266
доступ к подмножеству контейнера с помощью, 262
использование в обобщенных алгоритмах, 575–83
категории, 582–83
двунаправленный итератор, 583
итератор записи, 582
итератор с произвольным доступом, 583
итератор чтения, 582
однонаправленный итератор, 583
обозначение интервала с включенной левой границей, 583
обратные итераторы, 578
потоковык итераторы ввода/вывода, 578–82
istream_iterator, 579–80
ostream_iterator, 580–82
запись целых чисел из вектора в стандартный вывод, 578
чтение целых чисел из стандартного ввода в вектор, 579
требования к поведению, выдвигаемые обобщенными алгоритмами, 584
удаление элементов из последовательного контейнера, 267
К
китайский язык
поддержка двухбайтовых символьных литералов, 77
класс(ы)
возвращаемые значения, 347–49
вопросы эффективности, 712–18
друзья, 599–600, 731
заголовок, 594
объединение, 638–43
объявление, сравнение с определением класса, 600–601
определение, 594–601
сравнение с объявлением класса, 600–601
параметры
вопросы эффективности, 330, 712–18
для возврата сразу нескольких значений, 350
для передачи сразу нескольких параметров, 350
тело, 594
командная строка
класс, 363–65
опции, 356–65
argc, argv - аргументы main(), 356
использование встроенного массива для обработки, 356
пример программы, 361–63
комментарии, 24–26
блочные, 25
комплексные числа, 18, 125–26
выражения с участием, 155
заголовочный файл complex, 125
как абстракция класса, 30
операции, 154–58
представление, 156
типы данных, 30
композиция
объектов, 963–65
сравнение с наследованием, 960–62
конкретизация
шаблона функции, 482
явное объявление специализации
шаблона функции, 497–98
Конкретизация
шаблона функции
разрешение перегрузки, 506–13
конктеризация
точка конкретизации, 518
константы
константные выражения
sizeof() как пример, 162
размер массива должен быть, 113
литерал, 76–78
подстановка, 386
преобразование объектов в, 101
ссылки, рассматриваемые как, 104
конструктор(ы)
вызовы виртуальных функций в, 923–25
для базовых классов, 899
почленная инициализация, 925–30
при виртуальном наследовании, 974–82
при единичном наследовании, 896
при множественном наследовании, 950–51
для элементов массива
список инициализации массива, 689–91
и функциональные try-блоки, 1024–26
как коверторы, 761–64
конструкторы по умолчанию, 678–79
для элементов вектора, 694–96
копирующие конструкторы, 237, 680–82
почленная инициализация, 703–9, 925–30
ограничение возможности созданий объектов, 680
список инициализации членов, 696–703
контейнерные типы
определение, 256–61
контейнерные типы, 248–301
вопросы выделения памяти при копировании, 577
емкость, 253
связь с размером, 253–58
и итераторы, 261–65
инициализация, с помощью пары итераторов, 263
очереди с приоритетами, 315
параметры, 338–40, 350
преимущества, автоматическое управление памятью, 402
размер, 258
связь с емкостью, 253–56
требования к типам, с которыми конкретизируется контейнер, 259
копирование
вопросы выделения памяти, 577
использование ссылок для избежания, 330
как операция инициализации, 258
массивов, 115
сравнение со стоимостью произвольного доступа, 252
строк, 96
копирующий
конструктор, 43, 131
для динамического увеличения размера вектора, 255
оператор присваивания, реализация, 237
Л
лексикографическое упорядочение, 289
в обобщенных алгоритмах перестановок, 586
в обобщенныых алгоритмах сравнения, 586
при сортировке строк, 366–75
литеральные константы, 76–78
C-строки
сравнение с символьными литералами, 114
f суффикс, 77
U суффикс, 76
с плавающей точкой, 77
логические встроенные операторы, 145–48
оператор ИЛИ (||), 146
оператор НЕ (!), 147
логические объекты-функции
logical_and, 572
logical_not, 572
logical_or, 572
локализация
влияние глобального объекта на, 349
константной переменной или объекта, 100
локальность объявления, 190, 385
на уровне файла, использование безымянного пространства имен, 419
локальная область видимости, 376, 378–81
try-блок, 535
доступ к членам в глобальной области видимости, скрытым за локальными объектами, 411
имена в пространстве имен, скрытые за локальными объектами, 414
переменная, неинициализированная, 388
разрешение имени, 379
локальные объекты, 388–92
проблема возврата ссылки на, 348
статические, 388, 390–92
М
массив(ы), 113–20
в сравнении с векторами, 122
динамическое выделение и освобождение, 400–402
массивов объектов классов, 691–94, 744–53
индексирование, 31, 113–16
многомерных массивов, 116–17
отсутствие контроля выхода за границы диапазона, 116
инициализация, 31, 114–15
динамически выделенных массивов, 400
динамически выделенных массивов объектов класса, 690–94
многомерных массивов, 116–17
недопустимость инициализации другим массивом, 115
использование оператора sizeof(), 159
как параметры функций, 335–39
для передачи нескольких параметров, 350
многомерные, 338
преобразование массива в указатель, 448
многомерные, 116–17
недопустимость использования auto_ptr, 395
недопустимость использования в качестве возвращаемого значения функции, 324
недопустимость присваивания другому массиву, 115
недопустимость ссылок на массив, 115
обход
с помощью манипуляции указателем, 118
с помощью пары итераторов, 263–64
объектов класса, 689–96
определение, 30, 113
перегруженный оператор
delete[], 749–51
new[], 749–51
поддержка обобщенными алгоритмами, 553
размер, не является частью типа параметра, 335
связь с типом указателей, 118–20
указателей на функции, 369–70
меньше, оператор
поддержка в арифметических типах данных, 30
требование о поддержке типом элементов контейнера, 259
минус(-)
для выделения опций в командной строке, 357
многоточие (...), 343–44
использование в типах функций, 367
множество (set), контейнерный тип
set, заголовочный файл, 304
size(), 307
обход, 306–7
ограничение на изменение порядка, 587
определени, 304–6
поиск элементов, 306
сравнение с отображением, 292
модели компиляции
с разделением, 834–37
шаблонов класса
с включением, 833
с разделением, 834–36
шаблонов классов, 831–38
шаблонов функций, 494–98
с включением, 494–95
с разделением, 495–97
Н
наилучшая из устоявших функций, 442
неинициализированный
автоматический объект, 388
глобальный объект, 382
локальный статический объект, 391
неоднозначность
перегруженных
функций, диагносцирование во время разрешения перегрузки, 454
указателя, стандартные преобразования, 456
шаблона функции
аргумента, разрешение с помощью явной спецификации, 492
конкретизации, ошибка, 484
конкретизация, опасность перегрузки, 505
неявные преобразования типов, 176
новая строка ()
как escape-последовательность, 77
О
область видимости, 376–81
видимость класса, 645–52
и определение класса, 594
разрешение имен в, 649–52
глобальная область видимости, 376
и время жизни (глава), 376–428
и перегрузка, 434–38
локальная область видимости, 378–81
обращение к скрытым членам глобальной области видимости, 411
разрешение имен в, 379
объявлений исключений в catch-обработчиках, 540
параметра шаблона
функции, 478–81
пространства имен, 376
управляющих переменных в инструкции for, 379
область видимости глобального пространства имен, 376, 406
доступ к скрытым членам с помощью оператора разрешения области видимости, 411
обобщенные алгоритмы
(глава), 552–92
algorithm, заголовочный файл, 584
numeric, заголовочный файл, 584
алфавитный указатель (приложение), 1103–94
генерирования, 586
использование итераторов, 575–83
категории и описания, 583–87
когда не надо использовать, 587–92
модификации, 586
независимость от типа, 552, 553
нотация для диапазона элементов, 583
обзор, 552–56
объекты-функции как аргументы, 567
использование предопределенных объектов-функций, 569
перестановки, 586
подстановки, 585
пример использования, 556–66
работа с хипом, 587
сравнения, 586
удаления, 585
численные, 586
обработка исключений
bad_alloc, исключение нехватки памяти, 393
обратная косая черта (
как escape-символ, 280
как префикс escape-последовательности, 77
обратные итераторы, 578
обход
заполнение множества с помощью, 305
использование с контейнерами multimap и multiset, 309
множества, 306–7
невозможность обхода перечислений, 112
обход отображения, 303
отображения текста на вектор позиций, 298–301
параллельный обход двух векторов, 296
объединение
разновидность класса, 638–43
объект(ы)
автоматические, 388–89
объявление с ключевым словом register, 389–90
глобальные
и функции, 381–87
сравнение с параметрами и возвращаемыми значениями функций, 349–50
использование памяти, 82
локальные, 388–92
определение, 87
переменные как, 81
члены пространства имен, 407–8
объектное программирование, 593
объектно-ориентированное программирование
проектирование
(пример), 46–55
объекты-функции, 566–75
functional, заголовочный файл, 568
арифметические, 570
использование в обобщенных алгоритмах, 552
источники, 568
логические, 572
предопределенные, 568–70
преимущества по сравнению с указателями на функции, 567
реализация, 573–75
сравнительные, 571
Объекты-функции
адапторы функций для, 573
объявление
инструкция, 14
объявления
базового класса, виртуальное, 976–78
в части инициализации цикла for, 210
видимость имени, вводимого объявлением, 376
друзей, в шаблоне класса, 815–21
и определение, 382–83
инструкция, 189–92
исключения, 538
класса bitset, 167
объектов, 169
класса, сравнение с определением, 600–601
локальность, 190
перегруженное
оператора, 131
функции, 429
пространства имен, 407
сопоставление объявлений в разных файлах, 383
указателя на функцию, 366
включение спецификации исключений в, 548
функции, 322
задание аргументов по умолчанию, 341
как часть шаблона функции, 477
размещение в заголовочном файле, 385
функции-члена, перегруженное, 776–78
шаблона функции
определение используемых имен, 516
связь с определением, 515
требования к размещению явных объявлений конкретизации, 497
явная специализация, 499
явной конкретизации
шаблона класса, 837–38
шаблона функции, 497–98
одиночная кавычка (_)
как escape-последовательность, 77
однонаправленный итератор, 583
оператор "меньше"
характеристики и синтаксис, 146
оператор ввода, 27
оператор вывода, 1045
перегрузка, 1069. См. cout. См. cout
оператор вызова функции, 736–38
операторы
встроенные
(глава), 141–87, 141–87
sizeof, 159–62
арифметические, 142–45
бинарные, 141
декремента (--), 153–54
доступа к членам класса (. и ->), 607–8
запятая, 163
инкремента (++), 153–54
логические, 145–48
побитовые, 164–66
приоритеты, 171–74
равенства, 145–48
разрешения области видимости (
), 410–12
составного присваивания, 152
сравнения, 145–48
перегруженные
delete, 744–49
delete(), размещения, 751–53
delete[], 749–51
new, 744–49
new(), размещения, 751–53
new[], 749–51
взятия индекса ([]), 736
вопросы проектирования, 728–30
вызова функции (()), 736–38
вызова функции для объектов-функций, 567
декремента (--), 740–44
доступа к членам (->), 738–40
имена, 727–28
инкремента (++), 740–44
объявленные как друзья, 730–33
присваивания (=), 733–35
с параметрами-ссылками, преимущества, 335
члены и не-члены класса, 723–27
определения, 15
typedef, 126
базового класса, 871–75
иерархии классов, 862–69
исключений, как иерархий классов, 1013–14
класса, 594–601
сравнение с определением класса, 600–601
класса-диспетчера запросов (пример), 934–39
массива, 113
многомерных массивов, 116
множеств, 304–6
недопустимость размещения в заголовочном файле, 385
объекта, 382
объектов класса bitset, 169
объектов класса complex, 125
последовательных контейнеров, 256–61
производного класса, 876–78
пространств имен, 406–20
членов, 415–17
сравнение с объявлениями, 381–83
функции
и локальная область видимости, 378
как часть шаблона функции, 477
шаблона класса, 791–800
разрешение имен в, 844–46
опции
в командной строке, 356–65
отображения, 292–309
map, заголовочный файл, 293
заполнение, 293
невозможность переупорядочения, 587
недопустимость использования итераторов с произвольным доступом, 583
сравнение с множествами, 292
текста
заполнение, 292–98
определение, 292–98
отрицатели
как адапторы функций, 573
очереди, 315–16
queue, заголовочный файл, 315
size(), 315
top(), функция, 316
очереди с приоритетами, 315, 316
очередь с приоритетами, 315
size(), 315
top(), функция, 316
ошибки
assert(), макрос, 226
бесконечная рекурсия, 351
в инструкции if, 193
в циклах, 197
зацикливание, 93
висячие указатели, 389
как избежать, 394
динамического выделения памяти, 395
итератор, использование, 226
компиляции, конфликты в области видимости using-объявления, 437
массив
индекс за концом, 94
области видимости, подводные камни using-директивы, 426
оператор присваивания вместо оператора равенства, 100
порядка вычисления подвыражений, 142
проблема висячего else, 195
проблемы константных ссылок и указателей, 106
проблемы побитовых операторов, 166
проблемы, связанные с глобальными объектами, 349
пропуска
завершающего нуля в C-строке, 402
скобок при освобождении динамически выделенного массива, 402
редактора связей
повторные определения, 386
смещения на единицу при доступе к массиву, 31
фазы связывания при наличии объявления в нескольких файлах, 383
Ошибки
конкретизации шаблона функции, 484
П
память
утечка, 35
параметр(ы)
объявление, сравнение с объявлением исключений, 540
размер, важность для передачи по значению, 327
списки параметров
переменной длины, многоточие, 343
различия перегруженных функций, 431
ссылочные, 329–33
влияние на преобразования при разрешении перегрузки функции, 457
преимущества эффективности, 330, 540
ранжирование, 471
сравнение с параметрами-указателями, 333–35
шаблона
использование указателей на константы, 101
не являюшиеся типами, 476
являюшиеся типами, проверка, 325–26
параметры функций
аргументы по умолчаниюю, 340–43
использования многоточия, 343–44
массивы, 335–39
при разрешении перегруженных функций, 430
проверка типов, 325–26
списки параметров, 325
сравнение параметров указательного и ссылочного типов, 333–35
сравнение с глобальными объектами, 349–50
ссылки, 107, 329–33
использование для возврата нескольких значений, 197
на константы, 331
преимущества в эффективности, 330
сравнение с параметрами-указателями, 333–35
тип возвращаемого значения
тип pair, 197
указатели, 329
указатели на функции, 370–73
переменные
глобальные параметры и возвращаемые значения, 349–50
константные, 100
объявление как член пространства имен, 408
переносимость
знак остатка, 143
перестановки, обобщенные алгоритмы, 589
перечисления, 110–13
основания для включения в язык, 110
расширение типа при разрешении перегрузки функции, 452
точное соответствие при разрешении перегрузки функции, 445
по умолчанию
аргументы, 340–43
и виртуальные функции, 910–13
влияние на выбор устоявших функций, 472
и устоявшие функции, 472–73
конструктор, см. конструктор, 678–79
побитовый(е)
оператор И (&), 164
оператор И с присваиванием (&=), 152, 164
оператор ИЛИ (!), 165
оператор ИСКЛЮЧАЮЩЕЕ ИЛИ (^), 165
оператор НЕ (~), 164
оператор сдвига (<<,>>), 165
операторы, 164–66
поддержка в классе bitset, 170
повторное возбуждение
исключения, 542–43
позиция
разрешение аргумента по позиции в списке, 341
поиск
rfind(), 278
подстрок, 280
элементов
множества, 306
отображения текста, 298–99
ПОО (правило одного определения), 382, 416–18
последовательные контейнеры, 248–319
вставка элементов, 265
критерии выбора, 252
обобщенные алгоритмы, 269–70
определение, 256
перестановка элементов, 269
присваивание, 268
удаление элементов, 267
предостережения
использование знакового бита в битовых векторах, 166
неопределенность порядка вычисления бинарных операторов сравнения, 147
опасности приведения типов, 178
подводные камни
using-директивы, 426
возврата l-значение, 348
возврата ссылки на объект, 348
глобальные объекты, 349
приведения типов, 181
шаблона класса auto_ptr, 399
представление
влияние на расширение типа перечисления, 452
информация о реализации в заголовочном файле limits, 145
строк, 92
целых чисел,
143
преобразование
bool в int, 109
l-значения в r-значение, 446–47
арифметическое, 177–78
бинарного объекта-функции в унарный, использование адаптора-связывателя, 573
выбор преобразования между типами классов, 764–76
выведение аргументов шаблона функции, 486
как точное соответствие при разрешении перегрузки функции, 459
квалификаторов
влияние на последовательность преобразований, 470
при выведении аргументов шаблона функции, 487
ранжирование при разрешении перегрузки функции, 470
конверторы, 445
конструкторы
конструкторы как конверторы, 761–64
множественные, разрешение неоднозначности приведения, 468
недопустимость преобразований между типами указателей на функции, 439
неявные преобразования типов, 176
определенное пользователем, 445
последовательности
определенных пользователем преобразований, 764–67
определенных пользователем, ранжирование при разрешении перегрузки функций, 771–76
определенных пользователем, с учетом наследования, 1034–36
стандартных преобразований, 468–72
ранжирование инициализации ссылок при разрешении перегрузки функции, 457
расширения типа, 175
аргументов, 451–53
типа перечисления в арифметические типы, 112
с потерей точности, предупреждение компилятора, 326
стандартное, 453–57
типа аргумента, 444–60
трансформации l-значений, 450
трансформация I-значений
преобразования при выведении аргументов шаблона функции, 486
трансформация I-значения
ранжирование при разрешении перегрузки функции, 468
указателей
в тип void* и обратно, 179
преобразования квалификаторов, 449
стандартные преобразования указателей, 456
трансформации l-значений, массива в указатель, 448
трансформации l-значений, функции в указатель, 448
явные преобразования типов, 144, 175, 178
препроцессор
комментарий
парный(/**/), 25
константы
__cplusplus__, 23
макросы
шаблоны функций как более безопасная альтернатива, 474
предкомпилированные заголовочные файлы, 385
приведение(я), 144
const_cast, оператор, опасность применения,, 180
dynamic_cast (), оператор, 1001–7
dynamic_cast()
идентификация класса объекта во время выполнения, 182
reinterpret_cast
опасности, 181
reinterpret_cast, оператор, 181
static_cast
сравнение с неявными преобразованиями, 180
static_cast, оператор, 181
выбор конкретизируемого шаблона функции, 485
для принудительного установления точного соответствия, 450
опасности, 181
сравнение нового синтаксиса со старым, 182
старый синтаксис, 182–83
применение для подавления оптимизации, 127
примеры
класс IntArray, 45
IntSortedArray, производный класс, 54
класс iStack, 183–87
поддержка динамического выделения памяти, 316–17
преобразование в шаблон stack, 318–19
класс String, 128–39
класс связанного списка, 221–47
обработка аргументов в командной строке, 356–57
система текстового поиска
(глава 6), 248–319
функция sort, 365
шаблон класса Array, 55–62, 849–57
SortedArray, производный класс, 993–98
примитивные типы
(глава), 98–139
присваивание
векторам, сравнение с встроенными массивами, 122
и поведение auto_ptr, 397
комплексных чисел, 155
массиву, недопустимость присваивания другого массива, 115
оператор
и требования к l-значению, 81
перегруженный, 709–12, 733–35
составной, 152
последовательному контейнеру, 268–69
почленное для объектов класса, 709–12
ссылке, 107
указателю на функцию, 367
вопросы, связанные с перегруженностью функции, 439
проверка
выхода за границы диапазона, 289
не выолняется для массивов, 116
типа
назначение и опасности приведения типов, 182
неявные преобразования, 326
объявления, разнесенного по нескольким файлам, 384
отмена с помощью многоточия в списке параметров, 343
параметра, 325–27
сохранения в шаблоне функции, 476
указателя, 88
программа, 14–21
производительность
auto_ptr, 397
классы, локальность ссылок, 191
компиляции
зависимость от размера заголовочного файла, 385
при конкретизации шаблонов функций, 497
контейнеров
емкость, 255
компромиссы при выборе контейнера, 252
сравнение списка и вектора, 254
определения шаблона функции в заголовочном файле, 495
сравнение обработки исключений и вызовов функций, 550
ссылок
объявление исключений в catch-обработчиках, 540
параметры, 330
параметры и типы возвращаемых значений, 389
указателей на функции
проигрыш по сравнению с параметрами-ссылками, 540
проигрыш по сравнению со встроенными функциями, 559
сравнение с объектами-функциями, 567
функций
вопросы, связанные с возвращаемыми значениями, 324
накладные расходы на вызов рекурсивных функций, 351
недостатки, 352
передачи аргументов по значению, 328
преимущества встроенных функций, 133
производные классы
деструкторы, 896–99
конструирование, 889–96
почленная инициализация, 925–27
конструкторы, 892–93
определение
при виртуальном наследовании, 976–78
при множественном наследовании, 950–55
присваивание почленное, 927–28
пространства имен, 406–20
безымянные, 418–20
инкапсуляция сущностей внутри файлов, 419
отличие от других пространств имен, 419
вложенные, 412–14
и using-объявления,
435
объявления перегруженных функций внутри, 434–38
глобальное, 376
доступ к скрытым членам с помощью оператора разрешения области видимости, 411
проблема загрязнения пространства имен, 406
область видимости, 376
std, 426–28
определения, 408–10
определенные пользователем, 407
псевдонимы, 420–21
члены
определения, 416
требование правила одного определения, 416–18
шаблоны функций, 521–24
процедурное программирование
(часть 3), 592–782
псевдоним(ы)
имен типов, typedef, 127
пространства имен, 66, 420–21
Р
равенство
оператор(ы), 145–48
потенциальная возможность выхода за границы, 116
разрешение перегрузки функции, 443
(глава), 429–73
выбор преобразования, 767
детальное описание процедуры, 460–73
наилучшая из устоявших функция, 453
для вызовов с аргументами типа класса, 771–76
и перегрузка, 468–72
ранжирование
последовательностей определенных пользователем преобразований, 1034–36
последовательностей стандартных преобразований, 468–72
устоявшие функции, 465–68
для вызовов операторных функций, 787–88
для вызовов функций-членов, 779–82
и аргументы по умолчанию, 472–73
и наследование, 1034–36
функции-кандидаты, 461–65
для вызовов в области видимости класса, 770–71
для вызовов операторных функций, 783–87
для вызовов с аргументами типа класса, 767–70
для вызовов функций-членов, 778
и наследование, 1031–34
явные приведения как указания компилятору, 451
разрешения области видимости оператор (
)
доступ к членам глобальной области видимости, 411
), 410–12
)
доступ к членам вложенного пространства имен, 412–14
Разрешения области видимости оператор (
)
доступ к шаблону функции как члену пространства имен, 524
разыменования оператор (*)
использование с возвращенным типом указателя, 367
как унарный оператор, 141
не требуется для вызова функции, 368
опасности, связанные с указателями, 333
приоритет, 118
ранжирование
определений шаблона функции, 505
последовательностей стандартных преобразований, 468–72
рассказ об Алисе Эмме, 250
и реализация класса string, 137
рекурсивные функции, 352
С
С, язык
символьные строки
использование итератора istream_iterator, 579
функции
указатели на функции, 373
связыватель
как класс адаптора функции, 573
сигнатура, 325
символ(ы)
литералы
синтаксис записи, 77
массив символов, инициализация, 114, 115
нулевой, для завершения строкового литерала, 78
символы
& (амперсанд)
оператор взятия адреса
использование в определении ссылки, 104
&& (двойной амперсанд)
оператор логического И, 146
символы
(двойное двоеточие)
оператор разрешения области видимости класса, 42
(двойное двоеточие)
оператор разрешения области видимости, 410–12
-- (двойной минус)
оператор декремента, 153, 740–44
- (минус)
использование для обозначения опций в командной строке, 357
! (восклицательный знак)
оператор "логическое НЕ"
вычисление, 147
характеристики и синтаксис, 145
% (процент)
оператор деления по модулю, 142
оператор вычисления остатка, характеристики и синтаксис, 143
%= (процент равно)
оператор вычисления остатка с присваиванием, 152
& (амперсанд)
оператор взятия адреса
использование с именем функции, 164
как унарный оператор, 141
оператор побитового И, 164
&& (двойной амперсанд)
оператор логического И, 142
&= (амперсанд равно)
оператор побитового И с присваиванием, 164
как оператор составного присваивания, 152
() (круглые скобки)
использование оператора вызова для передачи объекта-функции, 567
оператор вызова, перегрузка в объектах-функциях, 559
(обратная косая черта a)
escape-последовательность "звонок", 77
(обратная косая черта n)
escape-последовательность "новая строка", 77
(обратная косая черта v)
escape-последовательность "вертикальная табуляция", 77
(обратная косая черта знак вопроса)
escape-последовательность "знак вопроса", 77
(обратная косая черта)
как escape-символ, 280
* (звездочка)
оператор разыменования
доступ к объектам с помощью, 89
использование для задания типа возвращаемого значения, 366
как унарный оператор, 141
не требуется для вызова функции, 368
определение указателей с помощью, 87
приоритет, 118
оператор умножения
характеристики и синтаксис, 142
*= (звездочка равно)
оператор умножения с присваиванием, 152
, (запятая)
неправильное применение для индексации массива, 117
оператор, 163
. (точка)
оператор "точка", 38
... (многоточие), 343–44
для обозначения универсального catch-обработчика, 544
использование в типах функций, 367
/ (косая черта)
оператор деления
характеристики и синтаксис, 142
/= (косая черта равно)
оператор деления с присваиванием, 152
; (точка с запятой)
для завершения инструкций, 188
?: (знак вопроса двоеточие)
условный оператор, 133, 158
сокращенная запись if-else, 199
[,) (левая квадрнатная, правая круглая скобки)
для обозначения интервала с включенной левой границей, 583
[] (квадратные скобки)
для динамического выделения памяти под массив, 400
для освобождения выделенной под массив памяти, 402
оператор взятия индекса
для доступа к вектору, 121
для проверки битов в битовом векторе, 168
инициализация отображения с помощью, 294
не поддерживается для контейнеров multiset и multimap, 312
оператор взятия индекса, 736
оператор индексирования массива, перегрузка в определении класса массива, 45
\\ " (обратная косая черта двойная кавычка)
escape-последовательность двойной кавычки, 77
\\ (двойная обратная косая черта)
escape-последовательность "обратная косая черта", 77
\\t (обратная косая черта t)
escape-последовательность горизонтальнаятабуляция, 77
^ (крышка)
оператор побитового ИСКЛЮЧАЮЩЕГО ИЛИ, 164
^= (крышка равно)
оператор побитового ИСКЛЮЧАЮЩЕГО ИЛИ с присваиванием, 164
как оператор составного присваивания, 152
__STDC__, 23
_обратная косая черта одиночная кавычка)
escape-последовательность "одиночная кавычка", 77
{} (фигурные скобки)
использование в объявлениях пространств имен, 408
использование в предложении catch, 536
использование в составной директиве связывания, 354
как ограничители составной инструкции, 188
при инициализации вложенного массива, 117
| (вертикальная черта)
оператор побитового ИЛИ, 164
|| (двойная вертикальная черта)
оператор логического ИЛИ
характеристики и синтаксис, 145
оператор логического ИЛИ
вычисление, 146
|= (вертикальная черта равно)
оператор побитового ИЛИ с присваиванием, 164
как оператор составного присваивания, 152
~ (тильда)
оператор побитового НЕ, 164
+ (плюс)
оператор сложения
поддержка в арифметических типах данных, 30
++ (двойной плюс)
оператор инкремента, 153, 740–44
+= (плюс равно)
оператор сложения с присваиванием, 146
+= (плюс равно)оператор сложения с присваиванием
как оператор составного присваивания, 152
< (левая угловая скобка)
оператор "меньше"
вопросы поддержки, 566
использование при сортировке по длине, 558
перегруженный оператор в определении контейнера, 259
<< (двойная левая угловая скобка)
оператор вывода, 26
оператор сдвига влево, 164
<<=(двойная левая угловая скобка равно)
оператор левого сдвига с присваиванием, 152
<> (угловые скобки)
явный шаблон
применение в специализациях, 499
спецификации аргументов, 490
-= (минус равно)
оператор вычитания с присваиванием, 152
= (равно)
оператор присваивания, 100, 733–35
и l-значение, 81
использование с объектами классов, 39
использование с псевдонимами пространств имен, 420
== (двойное равно)
оператор равенства, 100
поддержка в арифметических типах данных, 30
оператор равенства, необходимость наличия в определении контейнера, 259
-> (минус правая угловая скобка)
оператор "стрелка"
перегруженный оператор доступа к членам, 740
>> (двойная правая угловая скобка)
оператор ввода, 1051–63
перегрузка. cin. cin
оператор сдвига вправо, 164
>>=(двойная правая угловая скобка равно)
оператор правого сдвига с присваиванием, 152
символы:, 77
сложения (+) оператор
комплексных чисел, 155
сокрытие информации, 39, 598
вопросы, связанные с вложенными пространствами имен, 414
доступ к
закрытым членам класса, 607
имена в локальной области видимости, 378
объявление члена пространства имен, обход с помощью оператора разрешения области видимости, 411
параметры шаблона, имена в глобальной области видимости, 478
сравнение с перегрузкой, 434
во вложенных областях видимости, 461
члены глобальной области видимости, доступ с помощью оператора разрешения области видимости, 411
составные
выражения, 142
инструкции, 188–89
директивы связывания, 354
присваивания
оператор, 152
операторы над комплексными числами, 156
состояния условий
в применении к библиотеке iostream, 1086–88
спецификации
явные, аргументов шаблона функции, 490
списки
list, заголовочный файл, 256
merge(), обобщенный алгоритм
специализированная реализация для списка, 588
push_front(), поддержка, 257
size(), 221
влияние размера объекта на производительность, 254
как последовательный контейнер, 256–61
неприменимость итераторов с произвольным доступом, 583
неприменимость обобщенных алгоритмов, требующих произвольного доступа, 588
обобщенные, 241–47
поддержка операций merge() и sort(), 269
сравнение с векторами, 251–52
требования к вставке и доступу, 252
списки параметров переменной длины
использование многоточия, 343
сравнения
объекты-функции, 571
операторы, 145–48
поддержка в контейнерах, 258
ссылки
для объявления исключения в catch-обработчике, 543
инициализация
как преобразование точного соответствия, 457–59
ранжирование при разрешении перегрузки функции, 471–72
ссылки на const, 105–8
использование с sizeof(), 161
как тип возвращаемого значения функции, 348
недопустимость массив ссылок, 115
параметры-ссылки, 107, 329–33
необходимость для перегрузки операторов, 335
преимущества эффективности, 330
парамтеры-ссылки
по сравнению с параметрами-указателями, 333–35
сравнение с указателями, 104
статические объекты
объявление локальных объектов как, 390–92
объявление, сравнение с безымянным пространством имен, 419
статические члены класса
указатели на, 636–37
статическое выделение памяти, 33
стек, контейнерный тип, 312–15
stack, заголовочный файл, 312
top(), функция, 154, 313
динамическое выделение памяти, 317
операции (таблица), 313
реализация с помощью контейнера deque, 314
стека, пример класса, 183–87, 183–87
строки
append(), 287–88
assign(), 287
compare(), 289
erase(), 267, 285
insert(), 266
replace(), 290–91
swap(), 268, 288
поиск подстроки, 273–79, 285–86, 290
присваивание, 266
Т
тело
функции, 321
тип
точное соответствие, 445–51
тип(ы)
bool, 108–10
C-строка, 92–95
typedef, синоним типа, 126
арифметические, 30–33
базовые
(глава), 98–139
для определения нескольких объектов одного и того же типа pair, 128
имя класса как, 595
использование с директивой препроцессора include, 68
поверка
назначение и опасности приведения, 182
проверка
неявные преобразования, 326
объявления в нескольких файлах, 384
подавление, многоточие в списке параметров функции, 343
сравнение, функция strcmp(), 133
С-строка
динамическое выделение памяти, 401
точка конкретизации
шаблона функции, 518
точное соответствие, 445–51
У
угловые скобки (<>)
шаблон
использование для определения, 56
спецификации аргументов, 490
явные
специализации шаблона, 498
спецификации аргументов шаблона, 490
указатели, 87–90
sizeof(), использование с, 161
void*, 89
преобразование в тип void* и обратно, 179
адресация
C-строк, 92
объектов, 89
объектов класса, использование оператора ->, 603
элементов массива, 118
вектор указателей, преимущества, 255
висячий
возвращенное значение, указывающее на автоматический объект, 389
указывающий на освобожденную память, 394
использование в обобщенных алгоритмах, 120
как значение, возвращаемое функцией, 370
как итераторы для встроенного массива, 264
константные указатели, 101
на константные объекты, 101
нулевой указатель, 455
как операнд оператора delete, 394
параметры, 329, 334
сравнение с параметрами-ссылками, 333–35
сравнение с массивами, 118–20
сравнение со ссылками, 43, 106
указатели на функции, 365–75
вызов по, 368–69
и спецификации исключений, 548–50
инициализация, 367
как возвращаемые значения, 370–73
как параметры, 370–73
массивы, 369–70
на перегруженные функции, 439–40
на функции, объявленные как extern "C", 373–75
написанные на других языках, 374
недостатки по сравнению со встроенными функциями, 559
присваивание, 367
сравнение с указателями на данные (сноска), 87
указатели на члены, 628–38
указатели на данные-члены, 634
указатели на статические члены, 636–38
указатели на функции-члены, 632
умножения оператор (*)
поддержка в арифметических типах данных, 30
унарные операторы, 141
условный
директивы препроцессора, 21
инструкции
if, 192–98
инструкция
switch, 201–3
оператор (?
)
сравнение с функциями, 352
оператор (?:), 133
сокращение для if-else, 199
условный оператор
инструкция, 188
Ф
файл(ы)
ввод/вывод, 28–29
входной
открытие, 28
выходной
открытие, 29
несколько
размещение определения пространства имен в, 410
сопоставление объявлений в, 383
объявления локальных сущностей
использование безымянного пространства имен, 419
фигурные скобки ({})
использование в объявлениях пространств имен, 408
использование в предложении catch, 535
использование в составной директиве связывания, 354
как ограничители составной инструкции, 188
при инициализации вложенного массива, 117
функции
(глава), 320–75
function, заголовочный файл, 568
try-блок, 536
возвращаемые значения, 346–50
локальный объект, проблема возвращения ссылки на, 348
объект класса, 348–50
объект класса как средство вернуть несколько значений, 350
параметр-ссылка как средство возврата дополнительного значения, 329
сравнение с глобальными объектами, 349–50
указатель на функцию, 372
вызовы, 322
заключенные в try-блок, 536
недостатки, 352
сравнение с обработкой исключений, 542
и глобальные объекты, 381–87
и локальная область видимости, 378
имя функции
перегрузка, 429
преобразуется в указатель, 367
интерфейс
объявление функции как, 323
прототип функции как описание, 323
конверторы, 757–61
конструкторы как, 761–64
локальное хранение, 327
на другом языке, директивы связывания, 353–55
обращение к, 322
объявления
как часть шаблона функции, 477
как члена пространства имен, 407
сравнение с определениями, 382
объявления перегруженных функций, 429–32
и область видимости, 434–38
как перегружаются, 429–32
когда не надо перегружать, 432–34
причины для перегрузки функций, 429
оператор вызова функции (()), 736–38
определение, 321
как часть шаблона функции, 477
сравнение с объявлениями, 382
преимущества, 352
преобразование функции в указатель, 448
прототип, 323–27
рекурсивные, 350–52
сигнатура, 325
списки параметров, 325
тип
недопустимость возврата из функции, 324
преобразование в указатель на функцию, 347
тип возвращаемого значения, 324–25
недопустимость указания для конструкторов, 671
недостаточен для разрешения перегруженных функций, 431
ссылка, 348
указатель на функцию, 370–73
функции-кандидаты, 442, 460–65
вызов с аргументами типа класса, 767–70
для вызовов в области видимости класса, 770–71
для вызовов функций-членов, 778
для перегруженных операторов, 783–87
для шаблонов функций,, 507
наследование и, 1031–34
функции-члены, 129, 596–98, 604–14
встроенные функции
сравнение с не-встроенными, 605–7
вызов, 131
модификация для обработки исключений, 531
независимые от типа, 50
определение, 132
открытые
доступ к закрытым членам с помощью, 40
сравнение с закрытыми, 608–10
перегруженные
и разрешение, 776–82
объявление, 777–78
проблемы, 434
функции-кандидаты, 778
специальные, 610–11
статические, 626–27
устоявшие, перегрузка и, 779–82
Х
хип, 162, 392, 587
выделение памяти для классов в, 749–51
выделение памяти для массива в, 400
выделение памяти для объекта в, 392
исключение bad_alloc, 393
обобщенные алгоритмы, 587, 1191
См. также обобщенные алгоритмы, 1192
Ц
целые
константы, перечисления как средство группировки, 110
расширение булевских константы до целых, 146
расширение типа, 177
стандартные преобразования, 177
при разрешении перегрузки функции, 453
типы данных, 75
цикл(ы), 20
завершение
break, инструкция, 218
continue, инструкция, 219
инструкции
for, 196
while, 213–16
инструкции
do-while, 216–17
for, 209–13
while, 21
ошибки программирования, 198
бесконечные циклы, 274
условие останова, 32
Ч
числа с плавающей точкой
арифметика, характеристики и смежные темы, 145
правила преобразования типов, 177
стандартные преобразования при разрешении перегрузки функции, 453
численные обобщенные алгоритмы, 586
numeric, заголовочный файл, 586
читабельность
typedef, 126
в объявлениях указателей на функции, 369
как синоним контейнерных типпов, 295
имен параметров, 325
имен перегруженных функций, 432
квалификатор const для объявления констант, 100
параметров-ссыслок, 335
разделение обработчиков исключений, 534
рекурсивных функций, 351
члены класса
this
использование в перегруженном операторе присваивания, 710
когда использовать в функциях-членах, 619–21
указатель this, 616–20
битовые поля, 643–45
данные-члены, 594–96
защищенные, 871
изменчивые (mutable), 614–16
статические, 621–25
тип члена, 631–36
доступ, 599–600, 607–8
друзья, 599–600
статические, 621–28
функции-члены, 596–98, 604–16
встроенные и не-встроенные, 605–7
закрытые и открытые, 608–10
конверторы, 757–61
перегруженные, объявления, 776–78
специальные функции-члены, 610–11
спецификации исключений для, 1021–24
статические, 626–28
тип члена, 631–33
члены-классы
открытые и закрытые, 598–99
шаблоны, 826–31
Ш
шаблон класса Array
Array_RC, производный класс, 990–92
шаблоны классов
(глава), 791–857
вложенные типы, 824–26
и пространства имен, 846–48
модели компиляции, 831–38
с включением, 833
с разделением, 834–37
объявления друзей в, 815–21
определения, 791–800
разрешение имен в, 844–46
параметры, 794–97, 805–11
параметры-константы, 805–11
параметры-типы, 800–805
статические члены классов, 821–24
точка конкретизации, для функций-членов, 846
частичные специализации, 842–44
члены
функций, 811–15
явные
объявления конкретизации, 837–38
специализации, 838–42
шаблоны функций
(глава), 592–782
и пространства имен, 521–24
конкретизации, 592–782
модели компиляции, 494–98
с включением, 494–95
с разделением, 495–97
определение, 474–82
параметры, 475–82
для повышения гибкости обобщенных алгоритмом, 566
параметры-константы, 476
параметры-типы, 476
перегрузка, 503–6
передача объектов-функций шаблону, 569
разрешение имен в определениях, 514–20
разрешение перегрузки при конкретизации, 506–14
тип возвращаемого значения и выведение аргументов шаблона, 491
точка конкретизации, 518
явные
аргументы, 490–93
объявления конкретизации, 497–98
спецаиализации, 498–503
Э
эффективность
сравнение с гибкостью при выделении памяти, 33
Я
явное
преобразование, 178–82
преобразование типа, 144, 175
[1]
Во время написания этой книги не все компиляторы С++ поддерживали пространства имен. Если ваш компилятор таков, откажитесь от данной директивы. Большинство программ, приводимых нами, используют компиляторы, не поддерживающие пространство имен, поэтому директива using
в них отсутствует.
[2]
Как было сказано ранее, не все компиляторы поддерживают пространства имен, поэтому эта разница проявляется только для последних версий компиляторов.
[3] Объявление функции inline – это всего лишь подсказка компилятору. Однако компилятор не всегда может сделать функцию встроенной, существуют некоторые ограничения. Подробнее об этом сказано в разделе 7.6.
[4] Вот как выглядит общее решение этой проблемы:
Example2( elemType nval = elemType() ) " _val( nval ) {}
[5]
На самом деле для указателей на функции это не совсем так: они отличаются от указателей на данные (см. раздел 7.9).
[6] STL расшифровывается как Standard Template Library. До появления стандартной библиотеки С++ классы vector, string и другие, а также обобщенные алгоритмы входили в отдельную библиотеку с названием STL.
[7]
Проверку на неравенство 0 можно опустить. Полностью эквивалентна приведенной и более употребима следующая запись: ptr && *ptr.
[8]
До принятия стандарта языка С++ видимость объектов, определенных внутри круглых скобок for, простиралась на весь блок или функцию, содержащую данную инструкцию. Например, употребление двух циклов for внутри одного блока
{
// верно для стандарта С++
// в предыдущих версиях C++ - ошибка:
ival определена дважды
for (int ival = 0; ival < size; ++iva1 ) // ...
for (int ival = size-1; ival > 0; ival ) // ...
}
в ранних версиях языка вызывало ошибку: ival определена дважды. В стандарте С++ данный текст синтаксически правилен, так как каждый экземпляр ival является локальным для своего блока.
[9]
Замечание. Для упрощения программы мы требуем, чтобы каждое слово было отделено пробелом от скобок и логических операторов. Таким образом, запросы вида
(War || Rights)
Civil&&(War||Rights)
не будут поняты нашей системой. Хотя удобство пользователей не должно приноситься в жертву простоте реализации, мы считаем, что в данном случае можно смириться с таким ограничением.
[10]
Иллюстрация Елены Дрискилл (Elena Driskill).
[11]
Отметим, что deque не поддерживает операцию reserve()
[12]
Существующие на сегодняшний день реализации не поддерживают шаблоны с параметрами по умолчанию. Второй параметр – allocator – инкапсулирует способы выделения и освобождения памяти. В С++ он имеет значение по умолчанию, и его задавать не обязательно. Стандартная реализация использует операторы new и delete. Применение распределителя памяти преследует две цели: упростить реализацию контейнеров путем отделения всех деталей, касающихся работы с памятью, и позволить программисту при желании реализовать собственную стратегию выделения памяти. Определения объектов для компилятора, не поддерживающего значения по умолчанию параметров шаблонов, выглядят следующим образом:
vector< string, allocator > svec;
list< int, allocator > ilist;
[13]
Если функция-член push_front()
используется часто, следует применять тип deque, а не vector: в deque
эта операция реализована наиболее эффективно.
[14]
Последняя форма insert()
требует, чтобы компилятор работал с шаблонами функций-членов. Если ваш компилятор еще не поддерживает это свойство стандарта С++, то оба контейнера должны быть одного типа, например два списка или два вектора, содержащих элементы одного типа.
[15]
Программа компилировалась компилятором, не поддерживающим значений параметров по умолчанию шаблонов. Поэтому нам пришлось явно указать аллокатор:
vector
Для компилятора, полностью соответствующего стандарту С++, достаточно отметить тип элементов:
vector
[16]
Конечно, в английском языке существуют исключения из правил. Наш эвристический алгоритм превратит crises
(множ. число от crisis – прим. перев.) в cris. Ошибочка!
[17] Таким образом, как мы видим, определения встроенных функций могут встретиться в программе несколько раз! – Прим. ред.
[18]
Полный текст реализации класса CommandOpt можно найти на Web-сайте издательства Addison-Wesley.
1. Если имеющийся у Вас компилятор пока не поддерживает параметр шаблонов по умолчанию, то конструктору istream_iterator необходимо будет явно передать также и второй аргумент: тип difference_type, способный хранить результат вычитания двух итераторов контейнера, куда помещаются элементы. Например, в разделе 12.2 при изучении программы, которая должна транслироваться компилятором, не поддерживающим параметры шаблонов по умолчанию, мы писали:
typedef vector
istream_iterator< string, diff_type > input_set1( infile1 ), eos;
istream_iterator< string, diff_type > input_set2( infile2 );
Если бы компилятор полностью удовлетворял стандарту C++, достаточно было бы написать так:
istream_iterator< string > input_set1( infile1 ), eos;
istream_iterator< string > input_set2( infile2 );
1 Более подробное обсуждение этой темы с примерами и приблизительными оценками производительности см. в [LIPPMAN96a].
2 В реальной программе мы объявили бы член _name как имеющий тип string. Здесь он объявлен как C-строка, чтобы отложить рассмотрение вопроса об инициализации членов класса до раздела 14.4.
3 Для тех, кто раньше программировал на C: приведенное выше определение класса Account на C выглядело бы так:
typedef struct {
char *_name;
unsigned int _acct_nmbr;
double _balance;
} Account;
4 См. статью Джерри Шварца в [LIPPMAN96b], где приводится дискуссия по этому поводу и описывается решение, остающееся пока наиболее распространенным.
5 Сигнатура ассоциированного конструктора имеет следующий смысл. Копирующий конструктор применяет некоторое значение к каждому элементу по очереди. Задавая в качестве второго аргумента объект класса, мы делаем создание временного объекта излишним:
explicit vector( size_type n, const T& value=T(), const Allocator&=Allocator());
1 Напомним, что для упрощения реализации необходимо, чтобы между любыми двумя словами, включая скобки и операторы запроса, был пробел. В реальной системе такое требование вряд ли разумно, но мы полагаем, что для вводного курсе, каковым является наша книга, это вполне приемлемо.
2 В объявлении унаследованной виртуальной функции, например eval(), в производном классе ключевое слово virtual необязательно. Компилятор делает правильное заключение на основе сравнения с прототипом функции.
3 Увы! Правые скобки не распознаются, пока OrQuery не выведет все ассоциированное с ним частичное решение.
[19]
Полный текст программы можно найти на FTP-сайте издательства Addison-Wesley по адресу, указанному на задней стороне обложки.
1 Здесь есть потенциальная опасность появления висячей ссылки, если пользователь сохранит адрес какого-либо элемента исходного массива перед тем, как grow() скопирует массив в новую область памяти. См. статью Тома Каргилла в [LIPPMAN96b].
1 Кроме того, программист может устанавливать и сбрасывать флаги состояния формата с помощью функций-членов setf() и unsetf(). Мы их рассматривать не будем; исчерпывающие ответы на вопросы, относящиеся к этой теме, можно получить в [STROUSTRUP97].
[O.A.1]Как должны быть оформлены ссылки на книги, указанные в библиографии? Пришлите ваши пожелания.
[O.A.2]Нумерация сносок сбита, как и вся остальная. Необходима проверка.
[O.A.3]Нумерация сносок сбита.
[O.A.4]Нумерация сносок сбита.
[O.A.5]Нумерация сносок сбита.
[O.A.6]Нумерация сносок сбита.
Алгоритм sort()
template< class RandomAccessIterator > void sort( RandomAccessIterator first, RandomAccessIterator last ); template< class RandomAccessIterator, class Compare > void sort( RandomAccessIterator first, |
RandomAccessIterator last, Compare comp );
sort()
переупорядочивает элементы в диапазоне [first,last) по возрастанию, используя оператор “меньше”, определенный для типа элементов контейнера. Во втором варианте порядок устанавливается операцией сравнения comp. (Для сохранения относительного порядка равных элементов пользуйтесь алгоритмом stable_sort().) Мы не приводим пример, специально иллюстрирующий применение алгоритма sort(), поскольку его можно найти во многих других программах, в частности в binary_search(), equal_range() и inplace_merge().
Алгоритм stable_partition()
template< class BidirectionalIterator, class Predicate > BidirectionalIterator stable_partition( BidirectionalIterator first, BidirectionalIterator last, |
Predicate pred );
stable_partition() ведет себя так же, как partition(), но гарантированно сохраняет относительный порядок элементов контейнера. Вот та же программа, что и для алгоритма partition(), но с использованием stable_partition().
#include #include #include /* печатается: исходная последовательность: 29 23 20 22 17 15 26 51 19 12 35 40 устойчивое разбиение по четным элементам: 20 22 26 12 40 29 23 17 15 51 19 устойчивое разбиение по элементам, меньшим 25: 23 20 22 17 15 19 12 29 26 51 35 40 */ class even_elem { public: bool operator()( int elem ) { return elem%2 ? false : true; } }; int main() { int ia[] = { 29,23,20,22,17,15,26,51,19,12,35,40 }; vector< int, allocator > vec( ia, ia+12 ); ostream_iterator< int > ofile( cout, " " ); cout << "исходная последовательность:\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n'; stable_partition( &ia[0], &ia[12], even_elem() ); cout << "устойчивое разбиение по четным элементам:\n"; copy( ia, ia+11, ofile ); cout << '\n'; stable_partition( vec.begin(), vec.end(), bind2nd(less cout << "устойчивое разбиение по элементам, меньшим 25:\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n';
| |
}
Алгоритм stable_sort()
template< class RandomAccessIterator > void stable_sort( RandomAccessIterator first, RandomAccessIterator last ); template< class RandomAccessIterator, class Compare > void stable_sort( RandomAccessIterator first, |
RandomAccessIterator last, Compare comp );
stable_sort()
ведет себя так же, как sort(), но гарантированно сохраняет относительный порядок равных элементов контейнера. Второй вариант упорядочивает элементы на основе заданной программистом операции сравнения comp.
#include #include #include /* печатается: исходная последовательность: 29 23 20 22 12 17 15 26 51 19 12 23 35 40 устойчивая сортировка - по умолчанию в порядке возрастания: 12 12 15 17 19 20 22 23 23 26 29 35 40 51 устойчивая сортировка: в порядке убывания: 51 40 35 29 26 23 23 22 20 19 17 15 12 12 */ int main() { int ia[] = { 29,23,20,22,12,17,15,26,51,19,12,23,35,40 }; vector< int, allocator > vec( ia, ia+14 ); ostream_iterator< int > ofile( cout, " " ); cout << "исходная последовательность:\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n'; stable_sort( &ia[0], &ia[14] ); cout << "устойчивая сортировка - по умолчанию " << "в порядке возрастания:\n"; copy( ia, ia+14, ofile ); cout << '\n'; stable_sort( vec.begin(), vec.end(), greater cout << "устойчивая сортировка: в порядке убывания:\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n';
| |
}
Алгоритм swap_ranges()
template< class ForwardIterator1, class ForwardIterator2 > ForwardIterator2 swap_ranges( ForwardIterator1 first1, ForwardIterator1 last, |
ForwardIterator2 first2 );
swap_ranges()
обменивает элементы из диапазона [first1,last) с элементами другого диапазона, начиная с first2. Эти последовательности могут находиться в одном контейнере или в разных. Поведение программы не определено, если они находятся в одном контейнере и при этом частично перекрываются, а также в случае, когда вторая последовательность короче первой. Алгоритм возвращает итератор, указывающий на элемент за последним переставленным.
#include #include #include /* печатается: исходная последовательность элементов первого контейнера: 0 1 2 3 4 5 6 7 8 9 исходная последовательность элементов второго контейнера: 5 6 7 8 9 массив после перестановки двух половин: 5 6 7 8 9 0 1 2 3 4 первый контейнер после перестановки двух векторов: 5 6 7 8 9 5 6 7 8 9 второй контейнер после перестановки двух векторов: 0 1 2 3 4 */ int main() { int ia[] = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; int ia2[] = { 5, 6, 7, 8, 9 }; vector< int, allocator > vec( ia, ia+10 ); vector< int, allocator > vec2( ia2, ia2+5 ); ostream_iterator< int > ofile( cout, " " ); cout << "исходная последовательность элементов первого контейнера:\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n'; cout << "исходная последовательность элементов второго контейнера:\n"; copy( vec2.begin(), vec2.end(), ofile ); cout << '\n'; // перестановка внутри одного контейнера swap_ranges( &ia[0], &ia[5], &ia[5] ); cout << "массив после перестановки двух половин:\n"; copy( ia, ia+10, ofile ); cout << '\n'; // перестановка разных контейнеров vector< int, allocator >::iterator last = find( vec.begin(), vec.end(), 5 ); swap_ranges( vec.begin(), last, vec2.begin() ); cout << "первый контейнер после перестановки двух векторов:\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n'; cout << "второй контейнер после перестановки двух векторов:\n"; copy( vec2.begin(), vec2.end(), ofile ); cout << '\n';
| |
}
Алгоритм swap()
template< class Type > void |
swap ( Type &ob1, Type &ob2 );
swap()
обменивает значения объектов ob1 и ob2.
#include #include #include /* печатается: исходная последовательность: 3 4 5 0 1 2 после применения swap() в процедуре пузырьковой сортировки: 0 1 2 3 4 5 */ int main() { int ia[] = { 3, 4, 5, 0, 1, 2 }; vector< int, allocator > vec( ia, ia+6 ); for ( int ix = 0; ix < 6; ++ix ) for ( int iy = ix; iy < 6; ++iy ) { if ( vec[iy] < vec[ ix ] ) swap( vec[iy], vec[ix] ); } ostream_iterator< int > ofile( cout, " " ); cout << "исходная последовательность:\n"; copy( ia, ia+6, ofile ); cout << '\n'; cout << "после применения swap() в процедуре " << "пузырьковой сортировки:\n"; copy( vec.begin(), vec.end(), ofile ); cout << '\n';
| |
}
Алгоритм transform()
template< class InputIterator, class OutputIterator, class UnaryOperation > OutputIterator transform( InputIterator first, InputIterator last, OutputIterator result, UnaryOperation op ); template< class InputIterator1, class InputIterator2, class OutputIterator, class BinaryOperation > OutputIterator transform( InputIterator1 first1, InputIterator1 last, InputIterator2 first2, OutputIterator result, |
BinaryOperation bop );
Первый вариант transform()
генерирует новую последовательность, применяя операцию op к каждому элементу из диапазона [first,last). Например, если есть последовательность {0,1,1,2,3,5} и объект-функция Double, удваивающий свой аргумент, то в результате получим {0,2,2,4,6,10}.
Второй вариант генерирует новую последовательность, применяя бинарную операцию bop к паре элементов, один из которых взят из диапазона [first1,last1), а второй – из последовательности, начинающейся с first2. Поведение программы не определено, если во второй последовательности меньше элементов, чем в первой. Например, для двух последовательностей {1,3,5,9} и {2,4,6,8} и объекта-функции AddAndDouble, которая складывает два элемента и удваивает их сумму, результатом будет {6,14,22,34}.
Оба варианта transform()
помещают результирующую последовательность в контейнер с элемента, на который указывает итератор result. Этот итератор может адресовать и элемент любого из входных контейнеров, в таком случае исходные элементы будут заменены на результат выполнения transform(). Выходной итератор указывает на элемент за последним помещенным в результирующий контейнер.
#include #include #include #include /* * печатается: исходный массив: 3 5 8 13 21 преобразование элементов путем удваивания: 6 10 16 26 42 преобразование элементов путем взятия разности: 3 5 8 13 21 */ int double_val( int val ) { return val + val; } int difference( int val1, int val2 ) { return abs( val1 - val2 ); } int main() { int ia[] = { 3, 5, 8, 13, 21 }; vector ostream_iterator cout << "исходный массив: "; copy( ia, ia+5, outfile ); cout << endl; cout << "преобразование элементов путем удваивания: "; transform( ia, ia+5, vec.begin(), double_val ); copy( vec.begin(), vec.end(), outfile ); cout << endl; cout << "преобразование элементов путем взятия разности: "; transform( ia, ia+5, vec.begin(), outfile, difference ); cout << endl;
| |
}
Алгоритм unique_copy()
template< class InputIterator, class OutputIterator > OutputIterator unique_copy( InputIterator first, InputIterator last, OutputIterator result ); template< class InputIterator, class OutputIterator, class BinaryPredicate > OutputIterator unique_copy( InputIterator first, InputIterator last, |
OutputIterator result, BinaryPredicate pred );
unique_copy()
копирует входной контейнер в выходной, заменяя группы одинаковых соседних элементов на один элемент с тем же значением. О том, что понимается под равными элементами, говорилось при описании алгоритма unique(). Чтобы все дубликаты были гарантированно удалены, входной контейнер необходимо предварительно отсортировать. Возвращаемый итератор указывает на элемент за последним скопированным.
#include #include #include #include #include template void print_elements( Type elem ) { cout << elem << " "; } void (*pfi)( int ) = print_elements; void (*pfs)( string ) = print_elements; int main() { int ia[] = { 0, 1, 0, 2, 0, 3, 0, 4, 0, 5 }; vector vector // последовательность не изменяется: нули не стоят рядом // печатается: 0 1 0 2 0 3 0 4 0 5 vec_iter = unique( vec.begin(), vec.end() ); for_each( vec.begin(), vec.end(), pfi ); cout << "\n\n"; // отсортировать вектор, затем применить unique: модифицируется // печатается: 0 1 2 3 4 5 2 3 4 5 sort( vec.begin(), vec.end() ); vec_iter = unique( vec.begin(), vec.end() ); for_each( vec.begin(), vec.end(), pfi ); cout << "\n\n"; // удалить из контейнера ненужные элементы // печатается: 0 1 2 3 4 5 vec.erase( vec_iter, vec.end() ); for_each( vec.begin(), vec.end(), pfi ); cout << "\n\n"; string sa[] = { "enough", "is", "enough", "enough", "is", "good" }; vector vector vector sort( svec.begin(), svec.end() ); svec_iter = unique_copy( svec.begin(), svec.end(), vec_result.begin() ); // печатается: enough good is for_each( vec_result.begin(), svec_iter, pfs ); cout << "\n\n";
| |
}
Алгоритм unique()
template< class ForwardIterator > ForwardIterator unique( ForwardIterator first, ForwardIterator last ); template< class ForwardIterator, class BinaryPredicate > ForwardIterator unique( ForwardIterator first, |
ForwardIterator last, BinaryPredicate pred );
Все группы равных соседних элементов заменяются одним. В первом варианте при сравнении используется оператор равенства, определенный для типа элементов в контейнере. Во втором варианте два элемента равны, если бинарный предикат pred для них возвращает true. Таким образом, слово mississippi
будет преобразовано в misisipi. Обратите внимание, что три буквы 'i' не являются соседними, поэтому они не заменяются одной, как и две пары несоседних 's'. Если нужно, чтобы все одинаковые элементы были заменены одним, придется сначала отсортировать контейнер.
На самом деле поведение unique()
интуитивно не совсем очевидно и напоминает remove(). В обоих случаях размер контейнера не изменяется: каждый уникальный элемент помещается в очередную позицию, начиная с first.
В нашем примере физически будет получено слово misisipippi, где ppi – остаток, “отходы” алгоритма. Возвращаемый итератор указывает на начало этого остатка и обычно передается алгоритму erase() для удаления ненужных элементов. (Поскольку для встроенного массива операция erase() не поддерживается, то лучше воспользоваться алгоритмом unique_copy().)
Алгоритм upper_bound()
template< class ForwardIterator, class Type > ForwardIterator upper_bound( ForwardIterator first, ForwardIterator last, const Type &value ); template< class ForwardIterator, class Type, class Compare > ForwardIterator upper_bound( ForwardIterator first, ForwardIterator last, const Type &value, |
Compare comp );
upper_bound()
возвращает итератор, указывающий на последнюю позицию в отсортированной последовательности [first,last), в которую еще можно вставить значение value, не нарушая упорядоченности. Значения всех элементов, начиная с этой позиции и далее, будут больше, чем value. Например, если дана последовательность:
int ia[] = {12,15,17,19,20,22,23,26,29,35,40,51};
то обращение к upper_bound() с value=21
вернет итератор, указывающий на значение 22, а обращение с value=22 – на значение 23. В первом варианте для сравнения используется оператор “меньше”, определенный для типа элементов контейнера; во втором – заданная программистом операция comp.
#include #include #include #include template void print_elements( Type elem ) { cout << elem << " "; } void (*pfi)( int ) = print_elements; int main() { int ia[] = {29,23,20,22,17,15,26,51,19,12,35,40}; vector sort(ia,ia+12); int *iter = upper_bound(ia,ia+12,19); assert( *iter == 20 ); sort( vec.begin(), vec.end(), greater vector iter_vec = upper_bound( vec.begin(), vec.end(), 27, greater assert( *iter_vec == 26 ); // печатается: 51 40 35 29 27 26 23 22 20 19 17 15 12 vec.insert( iter_vec, 27 ); for_each( vec.begin(), vec.end(), pfi ); cout << "\n\n";
| |
}
Алгоритмы для работы с хипом
В стандартной библиотеке используется макс-хип. Макс-хип– это представленное в виде массива двоичное дерево, для которого значение ключа в каждом узле больше либо равно значению ключа в каждом из узлов-потомков. (Подробное обсуждение макс-хипа можно найти в [SEDGEWICK88]. Альтернативой ему является мин-хип, для которого значение ключа в каждом узле меньше либо равно значению ключа в каждом из узлов-потомков.) В реализации из стандартной библиотеки самое большое значение (корень дерева) всегда оказывается в начале массива. Например, приведенная последовательность букв удовлетворяет требованиям, накладываемым на хип:X T O G S M N A E R A I
В данном примере X – это корневой узел, слева от него находится T, а справа – O. Обратите внимание, что потомки не обязательно должны быть упорядочены (т.е. значение в левом узле не обязано быть меньше, чем в правом). G и S – потомки узла T, а M и N – потомки узла O. Аналогично A и E – потомки G, R и A – потомки S, I – левый потомок M, а N – листовой узел без потомков.
Четыре обобщенных алгоритма для работы с хипом: make_heap(), pop_heap(), push_heap() и sort_heap() – поддерживают его создание и различные манипуляции. В последних трех алгоритмах предполагается, что последовательность, ограниченная парой итераторов, – действительно хип (в противном случае поведение программы не определено). Заметим, что список нельзя использовать как контейнер для хранения хипа, поскольку он не поддерживает произвольный доступ. Встроенный массив для размещения хипа использовать можно, но в этом случае трудно применять алгоритмы pop_heap() и push_heap(), так как они требуют изменения размера контейнера. Мы опишем все четыре алгоритма, а затем проиллюстрируем их работу на примере небольшой программы.
Алгоритмы генерирования и модификации
Шесть алгоритмов генерирования и модификации либо создают и заполняют новую последовательность, либо изменяют значения в существующей.fill(), fill_n(), for_each(), generate(),generate_n(), transform()
Алгоритмы перестановки
Рассмотрим последовательность из трех символов: {a,b,c}. Для нее существует шесть различных перестановок: abc, acb, bac, bca, cab и cba, лексикографически упорядоченных на основе оператора “меньше”. Таким образом, abc– это первая перестановка, потому что каждый элемент меньше последующего. Следующая перестановка – acb, поскольку в начале все еще находится a – наименьший элемент последовательности. Соответственно перестановки, начинающиеся с b, предшествуют тем, которые начинаются с с. Из bac и bcaменьшей является bac, так как последовательность ac лексикографически меньше, чем ca. Если дана перестановка bca, то можно сказать, что предшествующей для нее будет bac, а последующей – cab. Для перестановки abc нет предшествующей, а для cba – последующей.
next_permutation(), prev_permutation()
Алгоритмы поиска
Тринадцать алгоритмов поиска предоставляют различные способы нахождения определенного значения в контейнере. Три алгоритма equal_range(), lower_bound() и upper_bound() выполняют ту или иную форму двоичного поиска. Они показывают, в какое место контейнера можно вставить новое значение, не нарушая порядка сортировки.adjacent_find(), binary_search(), count(),count_if(), equal_range(), find(), find_end(), find_first_of(), find_if(), lower_bound(), |
upper_bound(), search(), search_n()
Алгоритмы работы с хипом
Хип (heap) – это разновидность двоичного дерева, представленного в массиве. Стандартная библиотека предоставляет такую реализацию хипа, в которой значение ключа в любом узле больше либо равно значению ключа в любом потомке этого узла.make_heap(), pop_heap(), push_heap(), sort_heap()
Алгоритмы работы с множествами
Четыре алгоритма этой категории реализуют теоретико-множественные операции над любым контейнерным типом. При объединении создается отсортированная последовательность элементов, принадлежащих хотя бы одному контейнеру, при пересечении– обоим контейнерам, а при взятии разности – принадлежащих первому контейнеру, но не принадлежащих второму. Наконец, симметрическая разность – это отсортированная последовательность элементов, принадлежащих одному из контейнеров, но не обоим.set_union(), set_intersection(), set_difference(), |
set_symmetric_difference()
Алгоритмы сортировки и упорядочения
Четырнадцать алгоритмов сортировки и упорядочения предлагают различные способы упорядочения элементов контейнера. Разбиение (partition)– это разделение элементов контейнера на две группы: удовлетворяющие и не удовлетворяющие некоторому условию. Так, можно разбить контейнер по признаку четности/нечетности чисел или в зависимости от того, начинается слово с заглавной или со строчной буквы. Устойчивый (stable) алгоритм сохраняет относительный порядок элементов с одинаковыми значениями или удовлетворяющих одному и тому же условию. Например, если дана последовательность:{ "pshew", "honey", "Tigger", "Pooh" }
то устойчивое разбиение по наличию/отсутствию заглавной буквы в начале слова генерирует последовательность, в которой относительный порядок слов в каждой категории сохранен:
{ "Tigger", "Pooh", "pshew", "honey" }
При использовании неустойчивой версии алгоритма сохранение порядка не гарантируется. (Отметим, что алгоритмы сортировки нельзя применять к списку и ассоциативным контейнерам, таким, как множество (set) или отображение (map).)
inplace_merge(), merge(), nth_element(), partial_sort(), partial_sort_copy(), partition(), random_shuffle(), reverse(), reverse_copy(), rotate(), rotate_copy(), sort(), stable_sort(), |
stable_partition()
Алгоритмы сравнения
Семь алгоритмов дают разные способы сравнения одного контейнера с другим (алгоритмы min() и max() сравнивают два элемента). Алгоритм lexicographical_compare()выполняет лексикографическое (словарное) упорядочение (см. также обсуждение перестановок и Приложение).
equal(), includes(), lexicographical_compare(), max(), max_element(), |
min(), min_element(), mismatch()
Алгоритмы удаления и подстановки
Пятнадцать алгоритмов удаления и подстановки предоставляют различные способы замены или исключения одного элемента или целого диапазона. unique()удаляет одинаковые соседние элементы. iter_swap() обменивает значения элементов, адресованных парой итераторов, но не модифицирует сами итераторы.
copy(), copy_backwards(), iter_swap(), remove(), remove_copy(), remove_if(),remove_if_copy(), replace(), replace_copy(), replace_if(), replace_copy_if(), swap(), swap_range(), unique(), |
unique_copy()
Альтернативная иерархия классов
Хотя наша иерархия классов Queryпредставляется вполне приемлемой, она вовсе не является единственно возможной. Например, AndQuery и OrQuery
связаны с бинарной операцией, поэтому они в какой-то степени дублируют друг друга. Можно вынести все данные и функции-члены, общие для них, в абстрактный базовый класс BinaryQuery. Поддерево новой иерархии Query изображено на рисунке 17.2:
![]() |
Query
![]() |
BinaryQuery


Рис. 17.2. Альтернативная иерархия классов
Класс BinaryQuery – это тоже абстрактный базовый класс, следовательно, его фактические объекты в приложении не появляются. Разумной реализации eval() для него предложить нельзя, поэтому чисто виртуальная функция, объявленная в Query, в классе BinaryQuery
останется чисто виртуальной. (Подробнее о таких функциях мы поговорим в разделе 17.5.)
Две функции-члена для доступа – lop() и rop(), общие для обоих классов, переносятся выше, в BinaryQuery, и определяются как нестатические встроенные. Аналогично два члена _lop и _rop, объявленные в обоих классах, также переносятся в BinaryQuery и становятся нестатическими и защищенными. Открытые конструкторы обоих производных классов объединяются в один защищенный конструктор BinaryQuery:
| class BinaryQuery : public Query { public: const Query *lop() { return _lop; } const Query *rop() { return _rop; } protected: BinaryQuery( Query *lop, Query *rop ) : _lop( lop ), _rop( rop ) {} Query *_lop; Query *_rop; |
};
Складывается впечатление, что теперь оба производных класса должны предоставить лишь подходящие реализации eval():
| // увы! эти определения классов некорректны class OrQuery : public BinaryQuery { public: virtual void eval(); }; class AndQuery : public BinaryQuery { public: virtual void eval(); |
};
Однако в том виде, в котором мы их определили, эти классы неполны. При компиляции самих определений ошибок не возникает, но если мы попытаемся определить фактический объект:
// ошибка: отсутствует конструктор класса AndQuery AndQuery proust( new NameQuery( "marcel" ), |
то компилятор выдаст сообщение об ошибке: в классе AndQuery нет конструктора, готового принять два аргумента.
Мы предположили, что AndQuery и OrQuery
наследуют конструктор BinaryQuery
точно так же, как они наследуют функции-члены lop() и rop(). Однако производный класс не наследует конструкторов базового. (Это могло бы привести к ошибкам, связанным с неинициализированными членами производного класса. Представьте, что будет, если в AndQuery добавить пару членов, не являющихся объектами классов: унаследованный конструктор базового класса для инициализации объекта производного AndQuery
применять уже нельзя. Однако программист может этого не осознавать. Ошибка проявится не при конструировании объекта AndQuery, а позже, при его использовании. Кстати говоря, перегруженные операторы new и delete наследуются, что иногда приводит к аналогичным проблемам.)
Каждый производный класс должен предоставлять собственный набор конструкторов. В случае классов AndQuery и OrQuery
единственная цель конструкторов – обеспечить интерфейс для передачи двух своих операндов конструктору BinaryQuery. Так выглядит исправленная реализация:
// правильно: эти определения классов корректны class OrQuery : public BinaryQuery { public: OrQuery( Query *lop, Query *rop ) : BinaryQuery( lop, rop ) {} virtual void eval(); }; class AndQuery : public BinaryQuery { public: AndQuery( Query *lop, Query *rop ) : BinaryQuery( lop, rop ) {} virtual void eval(); |
Если мы еще раз взглянем на рис. 17.2, то увидим, что BinaryQuery – непосредственный базовый класс для AndQuery и OrQuery, а Query –для BinaryQuery. Таким образом, Query не является непосредственным базовым классом для AndQuery и OrQuery.
Конструктору производного класса разрешается напрямую вызывать только конструктор своего непосредственного предшественника в иерархии (виртуальное наследование является исключением из этого правила, да и из многих других тоже: см. раздел 18.5). Например, попытка включить конструктор Query в список инициализации членов объекта AndQuery
приведет к ошибке.
При определении объектов классов AndQuery и OrQuery
теперь вызываются три конструктора: для базового Query, для непосредственного базового класса BinaryQuery и для производного AndQuery или OrQuery. (Порядок вызова конструкторов базовых классов отражает обход дерева иерархии наследования в глубину.) Дополнительный уровень иерархии, связанный с BinaryQuery, практически не влияет на производительность, поскольку мы определили его конструкторы как встроенные.
Так как модифицированная иерархия сохраняет открытый интерфейс исходного проекта, то все эти изменения не сказываются на коде, который был написан в расчете на старую иерархию. Хотя модифицировать пользовательский код не нужно, перекомпилировать его все же придется, что может отвратить некоторых пользователей от перехода на новую версию.
Аргументы шаблона для параметров-констант
Параметр шаблона класса может и не быть типом. На аргументы, подставляемые вместо таких параметров, накладываются некоторые ограничения. В следующем примере мы изменяем определение класса Screen (см. главу 13) на шаблон, параметризованный высотой и шириной:template class Screen { public: Screen() : _height( hi ), _width( wid ), _cursor ( 0 ), _screen( hi * wid, '#' ) { } // ... private: string _screen; string::size_type _cursor; short _height; short _width; }; typedef Screen<24,80> termScreen; termScreen hp2621;
| |
Screen<8,24> ancientScreen;
Выражение, с которым связан параметр, не являющийся типом, должно быть константным, т.е. вычисляемым во время компиляции. В примере выше typedef termScreen ссылается на экземпляр шаблона Screen<24,80>, где аргумент шаблона для hi равен 24, а для wid – 80. В обоих случаях аргумент – это константное выражение.
Однако для шаблона BufPtr
конкретизация приводит к ошибке, так как значение указателя, получающееся при вызове оператора new(), становится известно только во время выполнения:
template // ошибка: аргумент шаблона нельзя вычислить во время компиляции
| |
BufPtr< new int[24] > bp;
Не является константным выражением и значение неконстантного объекта. Его нельзя использовать в качестве аргумента для параметра-константы шаблона. Однако адрес любого объекта в области видимости пространства имен, в отличие от адреса локального объекта, является константным выражением (даже если спецификатор const
отсутствует), поэтому его можно применять в качестве аргумента для параметра-константы. Константным выражением будет и значение оператора sizeof:
template template int size_val = 1024; const int c_size_val = 1024; Buf< 1024 > buf0; // правильно Buf< c_size_val > buf1; // правильно Buf< sizeof(size_val) > buf2; // правильно: sizeof(int) BufPtr< &size_val > bp0; // правильно // ошибка: нельзя вычислить во время компиляции
| |
Screen< shi, swi > bpObj2; // расширения типа short до int
· преобразования целых типов:
template |
(Более подробно они описаны в разделе 9.3.)
Рассмотрим следующие объявления:
extern void foo( char * ); extern void bar( void * ); typedef void (*PFV)( void * ); const unsigned int x = 1024; template unsigned int size, PFV handler> class Array { ... }; Array Array Array Array Array |
Объекты a0 и a4
класса Array
определены правильно, так как аргументы шаблона точно соответствуют типам параметров. Объект a2
также определен правильно, потому что аргумент 1024
типа int
приводится к типу unsigned int параметра-константы size с помощью преобразования целых типов. Объявления a1, a3 и a5 ошибочны, так как не существует преобразования между любыми двумя типами функций.
Приведение значения 0
целого типа к типу указателя недопустимо:
template class BufPtr { ... }; // ошибка: 0 имеет тип int // неявное преобразование в нулевой указатель не применяется |
Упражнение 16.3
Укажите, какие из данных конкретизированных шаблонов действительно приводят к конкретизации:
template < class Type > class Stack { }; void f1( Stack< char > ); // (a) class Exercise { // ... Stack< double > &rsd; // (b) Stack< int > si; // (c) }; int main() { Stack< char > *sc; // (d) f1( *sc ); // (e) int iObj = sizeof( Stack< string > ); // (f) |
}
Упражнение 16.4
Какие из следующих конкретизаций шаблонов корректны? Почему?
template < int *ptr > class Ptr ( ... }; template < class Type, int size > class Fixed_Array { ... }; |
(a) const int size = 1024; |
(b) int arr[10]; Ptr< arr > bp2; |
(d) const int hi = 40; const int wi = 80; |
(e) const int size_val = 1024; |
(f) unsigned int fasize = 255; |
(g) const double db = 3.1415; |
Аргументы со значениями по умолчанию
Наличие аргументов со значениями по умолчанию способно расширить множество устоявших функций. Устоявшими являются функции, которые вызываются с данным списком фактических аргументов. Но такая функция может иметь больше формальных параметров, чем задано фактических аргументов, в том случае, когда для каждого неуказанного параметра есть некое значение по умолчанию:extern void ff( int ); extern void ff( long, int = 0 ); int main() { ff( 2L ); // соответствует ff( long, 0 ); ff( 0, 0 ); // соответствует ff( long, int ); ff( 0 ); // соответствует ff( int ); ff( 3.14 ); // ошибка: неоднозначность |
}
Для первого и третьего вызовов функция ff() является устоявшей, хотя передан всего один фактический аргумент. Это обусловлено следующими причинами:
1. для второго формального параметра есть значение по умолчанию;
2. первый параметр типа long
точно соответствует фактическому аргументу в первом вызове и может быть приведен к типу аргумента в третьем вызове за счет последовательности, имеющей ранг стандартного преобразования.
Последний вызов является неоднозначным, поскольку обе устоявших функции могут быть выбраны, если применить стандартное преобразование к первому аргументу. Функции ff(int) не отдается предпочтение только потому, что у нее один параметр.
Упражнение 9.9
Объясните, что происходит при разрешении перегрузки для вызова функции compute()
внутри main(). Какие функции являются кандидатами? Какие из них устоят после первого шага? Какие последовательности преобразований надо применить к фактическому аргументу, чтобы он соответствовал формальному параметру для каждой устоявшей функции? Какая функция будет наилучшей из устоявших?
namespace primerLib { void compute(); void compute( const void * ); } using primerLib::compute; void compute( int ); void compute( double, double = 3.4 ); void compute( char*, char* = 0 ); int main() { compute( 0 ); return 0; |
}
Что будет, если using-объявление поместить внутрь main() перед вызовом compute()? Ответьте на те же вопросы.
10
Арифметические объекты-функции
Предопределенные арифметические объекты-функции поддерживают операции сложения, вычитания, умножения, деления, взятия остатка и вычисления противоположного по знаку значения. Вызываемый оператор – это экземпляр, ассоциированный с типом Type. Если тип является классом, предоставляющим перегруженную реализацию оператора, то именно эта реализация и вызывается.· Сложение: plus
plus // вызывается string::operator+() sres = stringAdd( sval1, sval2 );
| |
dres = BinaryFunc( plus
· Вычитание: minus
minus ires = intSub( ival1, ival2 );
| |
dres = BinaryFunc( minus
· Умножение: multiplies
multiplies cres = complexMultiplies( cval1, cval2 );
| |
dres = BinaryFunc( multiplies
· Деление: divides
divides ires = intDivides( ival1, ival2 );
| |
dres = BinaryFunc( divides
· Взятие остатка: modulus
modulus Ires = IntModulus( Ival1, Ival2 );
| |
ires = BinaryFunc( modulus
· Вычисление противоположного значения: negate
negate ires = intNegate( ires );
| |
Ires = UnaryFunc( negate
Арифметические операции
Таблица 4.1. Арифметические операции| Символ операции | Значение | Использование | |||
| * | Умножение | expr * expr | |||
| / | Деление | expr / expr | |||
| % | Остаток от деления | expr % expr | |||
| + | Сложение | expr + expr | |||
| - | Вычитание | expr – expr |
Деление целых чисел дает в результате целое число. Дробная часть результата, если она есть, отбрасывается:
| int ivall = 21 / 6; |
int iva12 = 21 / 7;
И ival1, и ival2 в итоге получат значение 3.
Операция остаток (%), называемая также делением по модулю, возвращает остаток от деления первого операнда на второй, но применяется только к операндам целого типа (char, short, int, long). Результат положителен, если оба операнда положительны. Если же один или оба операнда отрицательны, результат зависит от реализации, то есть машинно-зависим. Вот примеры правильного и неправильного использования деления по модулю:
| 3.14 % 3; // ошибка: операнд типа double 21 % 6; // правильно: 3 21 % 7; // правильно: 0 21 % -5; // машинно-зависимо: -1 или 1 int iva1 = 1024; double dval = 3.14159; iva1 % 12; // правильно: |
iva1 % dval; // ошибка: операнд типа double
Иногда результат вычисления арифметического выражения может быть неправильным либо не определенным. В этих случаях говорят об арифметических исключениях (хотя они не вызывают возбуждения исключения в программе). Арифметические исключения могут иметь чисто математическую природу (скажем, деление на 0) или происходить от представления чисел в компьютере – как переполнение
(когда значение превышает величину, которая может быть выражена объектом данного типа). Например, тип char
содержит 8 бит и способен хранить значения от 0 до 255
либо от -128 до 127 в зависимости от того, знаковый он или беззнаковый. В следующем примере попытка присвоить объекту типа char значение 256
вызывает переполнение:
| #include int main() { char byte_value = 32; int ival = 8; // переполнение памяти, отведенной под byte_value byte_value = ival * byte_value; cout << "byte_value: " <
| |
}
Для представления числа 256 необходимы 9 бит. Переменная byte_value получает некоторое неопределенное (машинно-зависимое) значение. Допустим, на нашей рабочей станции SGI мы получили 0. Первая попытка напечатать это значение с помощью:
cout << "byte_va1ue: " << byte_va1ue << endl;
привела к результату:
byte_value:
После некоторого замешательства мы поняли, что значение 0 – это нулевой символ ASCII, который не имеет представления при печати. Чтобы напечатать не представление символа, а его значение, нам пришлось использовать весьма странно выглядящее выражение:
static_cast
которое называется явным приведением типа. Оно преобразует тип объекта или выражения в другой тип, явно заданный программистом. В нашем случае мы изменили byte_value на int. Теперь программа выдает более осмысленный результат:
byte_value: 0
На самом деле нужно было изменить не значение, соответствующее byte_value, а поведение операции вывода, которая действует по-разному для разных типов. Объекты типа char
представляются ASCII-символами (а не кодами), в то время как для объектов типа int мы увидим содержащиеся в них значения. (Преобразования типов рассмотрены в разделе 4.14.)
Это небольшое отступление от темы – обсуждение проблем преобразования типов – вызвано обнаруженной нами погрешностью в работе нашей программы и в каком-то смысле напоминает реальный процесс программирования, когда аномальное поведение программы заставляет на время забыть о том, ради достижения какой, собственно, цели она пишется, и сосредоточиться на несущественных, казалось бы, деталях. Такая мелочь, как недостаточно продуманный выбор типа данных, приводящий к переполнению, может стать причиной трудно обнаруживаемой ошибки: из соображений эффективности проверка на переполнение не производится во время выполнения программы.
Стандартная библиотека С++ имеет заголовочный файл limits, содержащий различную информацию о встроенных типах данных, в том числе и диапазоны значений для каждого типа. Заголовочные файлы climits и cfloat
также содержат эту информацию. ( Об использовании этих заголовочных файлов для того, чтобы избежать переполнения и потери значимости, см. главы 4 и 6 [PLAUGER[O.A.1] 92]).
Арифметика вещественных чисел создает еще одну проблему, связанную с округлением. Вещественное число представляется фиксированным количеством разрядов (разным для разных типов – float, double и long double), и точность значения зависит от используемого типа данных. Но даже самый точный тип long double не может устранить ошибку округления. Вещественная величина в любом случае представляется с некоторой ограниченной точностью. (См. [SHAMPINE97] о проблемах округления вещественных чисел.)
Упражнение 4.1
В чем разница между приведенными выражениями с операцией деления?
double dvall = 10.0, dva12 = 3.0; int ivall = 10, iva12 = 3; dvall / dva12; |
Упражнение 4.2
Напишите выражение, определяющее, четным или нечетным является данное целое число.
Упражнение 4.3
Найдите заголовочные файлы limits, climits и cfloat и посмотрите, что они содержат.
Арифметические преобразования типов
Арифметические преобразования приводят оба операнда бинарного арифметического выражения к одному типу, который и будет типом результата выражения. Два общих правила таковы:·
типы всегда приводятся к тому из типов, который способен обеспечить наибольший диапазон значений при наибольшей точности. Это помогает уменьшить потери точности при преобразовании;
· любое арифметическое выражение, включающее в себя целые операнды типов, меньших чем int, перед вычислением всегда преобразует их в int.
· Мы рассмотрим иерархию правил преобразований, начиная с наибольшего типа long double.
Если один из операндов имеет тип long double, второй приводится к этому же типу в любом случае. Например, в следующем выражении символьная константа 'a'
трансформируется в long double (значение 97 для представления ASCII) и затем прибавляется к литералу того же типа: 3.14159L + 'a'.
Если в выражении нет операндов long double, но есть операнд double, все преобразуется к этому типу. Например:
int iva1; float fval; double dval; // fva1 и iva1 преобразуются к double перед сложением |
dval + fva1 + ival;
В том случае, если нет операндов типа double и long double, но есть операнд float, тип остальных операндов меняется на float:
char cvat; int iva1; float fva1; // iva1 и cval преобразуются к float перед сложением |
cvat + fva1 + iva1;
Если у нас нет вещественных операндов , значит, все они представляют собой целые типы. Прежде чем определить тип результата, производится преобразование, называемое приведением к целому: все операнды с типом меньше, чем int, заменяются на int.
При приведении к целому типы char, signed char, unsigned char и short int
преобразуются в int. Тип unsigned short int трансформируется в int, если этот тип достаточен для представления всего диапазона значений unsigned short int
(обычно это происходит в системах, отводящих полслова под short и целое слово под int), в противном случае unsigned short int заменяется на unsigned int.
Тип wchar_t и перечисления приводятся к наименьшему целому типу, способному представить все их значения. Например, в перечислении
enum status { bad, ok };
значения элементов равны 0 и 1. Оба эти значения могут быть представлены типом char, значит char и станет типом внутреннего представления данного перечисления. Приведение к целому преобразует char в int.
В следующем выражении
char cval; bool found; enum mumble { ml, m2, m3 } mval; unsigned long ulong; |
перед определением типа результата cval, found и mval
преобразуются в int.
После приведения к целому сравниваются получившиеся типы операндов. Если один из них имеет тип unsigned long, то остальные будут того же типа. В нашем примере все три объекта, прибавляемые к ulong, приводятся к типу unsigned long.
Если в выражении нет объектов unsigned long, но есть объекты типа long, тип остальных операндов меняется на long. Например:
char cval; long lval; // cval и 1024 преобразуются в long перед сложением |
Из этого правила есть одно исключение: преобразование unsigned int в long
происходит только в том случае, если тип long способен вместить весь диапазон значений unsigned int. (Обычно это не так в 32-битных системах, где и long, и int
представляются одним машинным словом.) Если же тип long не способен представить весь диапазон unsigned int, оба операнда приводятся к unsigned long.
В случае отсутствия операндов типов unsigned long и long, используется тип unsigned int. Если же нет операндов и этого типа, то к int.
Может быть, данное объяснение преобразований типов несколько смутило вас. Запомните основную идею: арифметическое преобразование типов ставит своей целью сохранить точность при вычислении. Это достигается приведением типов всех операндов к типу, способному вместить любое значение любого из присутствующих в выражении операндов.
Автоматические объекты
Автоматический объект размещается в памяти во время вызова функции, в которой он определен. Память для него отводится из программного стека в записи активации функции. Говорят, что такие объекты имеют автоматическую продолжительность хранения, или автоматическую протяженность. Неинициализированный автоматический объект содержит случайное, или неопределенное, значение, оставшееся от предыдущего использования области памяти. После завершения функции ее запись активации выталкивается из программного стека, т.е. память, ассоциированная с локальным объектом, освобождается. Время жизни такого объекта заканчивается с завершением работы функции, и его значение теряется.Поскольку память, отведенная локальному объекту, освобождается при завершении работы функции, адрес автоматического объекта следует использовать с осторожностью. Например, этот адрес не может быть возвращаемым значением, так как после выполнения функции будет относиться к несуществующему объекту:
#include "Matrix.h" Matrix* trouble( Matrix *pm ) { Matrix res; // какие-то действия // результат присвоим res return &res; // плохо! } int main() { Matrix m1; // ... Matrix *mainResult = trouble( &m1 ); // ... |
}
mainResult
получает значение адреса автоматического объекта res. К несчастью, память, отведенная под res, освобождается по завершении функции trouble(). После возврата в main() mainResult
указывает на область памяти, не отведенную никакому объекту. (В данном примере эта область все еще может содержать правильное значение, поскольку мы не вызывали других функций после trouble() и запись ее активации, вероятно, еще не затерта.) Подобные ошибки обнаружить весьма трудно. Дальнейшее использование mainResult в программе скорее всего даст неверные результаты.
Передача в функцию trouble()
адреса m1 автоматического объекта функции main()
безопасна. Память, отведенная main(), во время вызова trouble()находится в стеке, так что m1
остается доступной внутри trouble().
Если адрес автоматического объекта сохраняется в указателе, время жизни которого больше, чем самого объекта, такой указатель называют висячим. Работа с ним – это серьезная ошибка, поскольку содержимое адресуемой области памяти непредсказуемо. Если комбинация бит по этому адресу оказывается в какой-то степени допустимой (не приводит к нарушению защиты памяти), то программа будет выполняться, но результаты ее будут неправильными.
Безопасное связывание A
При использовании перегрузки складывается впечатление, что в программе можно иметь несколько одноименных функций с разными списками параметров. Однако это лексическое удобство существует только на уровне исходного текста. В большинстве систем компиляции программы, обрабатывающие этот текст для получения исполняемого кода, требуют, чтобы все имена были различны. Редакторы связей, как правило, разрешают внешние ссылки лексически. Если такой редактор встречает имя print два или более раз, он не может различить их путем анализа типов (к этому моменту информация о типах обычно уже потеряна). Поэтому он просто печатает сообщение о повторно определенном символе print и завершает работу.Чтобы разрешить эту проблему, имя функции вместе с ее списком параметров декорируется так, чтобы получилось уникальное внутреннее имя. Вызываемые после компилятора программы видят только это внутреннее имя. Как именно производится такое преобразование имен, зависит от реализации. Общая идея заключается в том, чтобы представить число и типы параметров в виде строки символов и дописать ее к имени функции.
Как было сказано в разделе 8.2, такое кодирование гарантирует, в частности, что два объявления одноименных функций с разными списками параметров, находящиеся в разных файлах, не воспринимаются редактором связей как объявления одной и той же функции. Поскольку этот способ помогает различить перегруженные функции на фазе редактирования связей, мы говорим о безопасном связывании.
Декорирование имен не применяется к функциям, объявленным с помощью директивы extern "C", так как лишь одна из множества перегруженных функций может быть написана на чистом С. Две функции с различными списками параметров, объявленные как extern "C", редактор связей воспринимает как один и тот же символ.
Упражнение 9.1
Зачем может понадобиться объявлять перегруженные функции?
Упражнение 9.2
Как нужно объявить перегруженные варианты функции error(), чтобы были корректны следующие вызовы:
int index; int upperBound; char selectVal; // ... error( "Array out of bounds: ", index, upperBound ); error( "Division by zero" ); |
error( "Invalid selection", selectVal );
Упражнение 9.3
Объясните, к какому эффекту приводит второе объявление в каждом из приведенных примеров:
(a) int calc( int, int ); int calc( const int, const int ); (b) int get(); double get(); (c) int *reset( int * ); double *reset( double * ): (d) extern "C" int compute( int *, int ); |
Упражнение 9.4
Какая из следующих инициализаций приводит к ошибке? Почему?
(a) void reset( int * ); void (*pf)( void * ) = reset; (b) int calc( int, int ); int (*pf1)( int, int ) = calc; (c) extern "C" int compute( int *, int ); int (*pf3)( int*, int ) = compute; |
Безымянные пространства имен
Может возникнуть необходимость определить объект, функцию, класс или любую другую сущность так, чтобы она была видимой только в небольшом участке программы. Это еще один способ решения проблемы засорения глобального пространства имен. Поскольку мы уверены, что эта сущность используется ограниченно, можно не тратить время на выдумывание уникального имени. Если мы объявляем объект внутри функции или блока, его имя видимо только в этом блоке. А как сделать некоторую сущность доступной нескольким функциям, но не всей программе?Предположим, мы хотим реализовать набор функций для сортировки вектора типа double:
// ----- SortLib.h ----- void quickSort( double *, double * ); void bubbleSort( double *, double * ); void mergeSort( double *, double * ); |
void heapSort( double *, double * );
Все они используют одну и ту же функцию swap() для того, чтобы менять местами элементы вектора. Однако она не должна быть видна во всей программе, поскольку нужна только четырем названным функциям. Локализуем ее в файле SortLib.C. Приведенный код не дает желаемого результата. Как вы думаете, почему?
// ----- SortLib.C ----- void swap( double *dl, double *d2 ) { /* ... */ } // только эти функции используют swap() void quickSort( double *d1, double *d2 ) { /* ... */ } void bubbleSort( double *d1, double *d2 ) { /* ... */ } void mergeSort( double *d1, double *d2 ) { /* ... */ } |
void heapSort( double *d1, double *d2 ) { /* ... */ }
Хотя функция swap()
определена в файле SortLib.C и не появляется в заголовочном файле SortLib.h, где содержится описание интерфейса библиотеки сортировки, она объявлена в глобальной области видимости. Следовательно, это имя является глобальным, при этом сохраняется возможность конфликта с другими именами.
Язык С++ предоставляет возможность использования безымянного пространства имен для объявления сущности, локальной по отношению к файлу. Определение такого пространства начинается ключевым словом namespace. Очевидно, что никакого имени за этим словом нет, а сразу же идет блок в фигурных скобках, содержащий различные объявления. Например:
// ----- SortLib.C ----- namespace { void swap( double *dl, double *d2 ) { /* ... */ } } |
Функция swap()
видна только в файле SortLib.C. Если в другом файле в безымянном пространстве имен содержится определение swap(), то это другая функция. Наличие двух функций swap() не является ошибкой, поскольку они различны. Безымянные пространства имен отличаются от прочих: определение такого пространства локально для одного файла и не может размещаться в нескольких.
Имя swap()
может употребляться в неквалифицированной форме в файле SortLib.C
после определения безымянного пространства. Оператор разрешения области видимости для ссылки на его члены не нужен.
void quickSort( double *d1, double *d2 ) { // ... double* elem = d1; // ... // ссылка на член безымянного пространства имен swap() swap( d1, elem ); // ... |
Члены безымянного пространства имен относятся к сущностям программы. Поэтому функция swap()
может быть вызвана во время выполнения. Однако имена этих членов видны только внутри одного файла.
До того как в стандарте С++ появилось понятие пространства имен, наиболее удачным решением проблемы локализации было использование ключевого слова static, унаследованного из С. Член безымянного пространства имеет свойства, аналогичные глобальной сущности, объявленной как static. В языке С такая сущность невидима вне файла, в котором объявлена. Например, текст из SortLib.C
можно переписать на С, сохранив свойства swap():
// SortLib.C // swap() невидима для других файлов программы static void swap( double *d1, double *d2 ) { /* ... */ } |
Во многих программах на С++ используются объявления с ключевым словом static. Предполагается, что они должны быть заменены безымянными пространствами имен по мере того, как все большее число компиляторов начнет поддерживать это понятие.
Упражнение 8.11
Зачем нужно определять собственное пространство имен в программе?
Упражнение 8.12
Имеется следующее объявление operator*(), члена вложенного пространства имен cplusplus_primer::MatrixLib:
namespace cplusplus_primer { namespace MatrixLib { class matrix { /*...*/ }; matrix operator* ( const matrix &, const matrix & ); // ... } |
Как определить эту функцию в глобальной области видимости? Напишите только прототип.
Упражнение 8.13
Объясните, зачем нужны безымянные пространства имен.
Библиотека iostream
Частью стандартной библиотеки C++ является библиотека iostream– объектно-ориентированная иерархия классов, где используется и множественное, и виртуальное наследование. В ней реализована поддержка для файлового ввода/вывода данных встроенных типов. Кроме того, разработчики классов могут расширять эту библиотеку для чтения и записи новых типов данных.Для использования библиотеки iostream в программе необходимо включить заголовочный файл
#include
Операции ввода/вывода выполняются с помощью классов istream (потоковый ввод) и ostream
(потоковый вывод). Третий класс, iostream, является производным от них и поддерживает двунаправленный ввод/вывод. Для удобства в библиотеке определены три стандартных объекта-потока:
· cin – объект класса istream, соответствующий стандартному вводу. В общем случае он позволяет читать данные с терминала пользователя;
· cout – объект класса ostream, соответствующий стандартному выводу. В общем случае он позволяет выводить данные на терминал пользователя;
· cerr – объект класса ostream, соответствующий стандартному выводу для ошибок. В этот поток мы направляем сообщения об ошибках программы.
Вывод осуществляется, как правило, с помощью перегруженного оператора сдвига влево (<<), а ввод – с помощью оператора сдвига вправо (>>):
#include #include int main() { string in_string; // вывести литерал на терминал пользователя cout << "Введите свое имя, пожалуйста: "; // прочитать ответ пользователя в in_string cin >> in_string; if ( in_string.empty() ) // вывести сообщение об ошибке на терминал пользователя cerr << "ошибка: введенная строка пуста!\n"; else cout << "Привет, " << in_string << "!\n";
| |
}
Назначение операторов легче запомнить, если считать, что каждый “указывает” в сторону перемещения данных. Например,
>> x
перемещает данные в x, а
<< x
перемещает данные из x. (В разделе 20.1 мы покажем, как библиотека iostream
поддерживает ввод данных, а в разделе 20.5 – как расширить ее для ввода данных новых типов. Аналогично раздел 20.2 посвящен поддержке вывода, а раздел 20.4 – расширению для вывода данных определенных пользователем типов.)
Помимо чтения с терминала и записи на него, библиотека iostream поддерживает чтение и запись в файлы. Для этого предназначены следующие классы:
· ifstream, производный от istream, связывает ввод программы с файлом;
· ofstream, производный от ostream, связывает вывод программы с файлом;
· fstream, производный от iostream, связывает как ввод, так и вывод программы с файлом.
Чтобы использовать часть библиотеки iostream, связанную с файловым вводом/выводом, необходимо включить в программу заголовочный файл
#include
(Файл fstream уже включает iostream, так что включать оба файла необязательно.) Файловый ввод/вывод поддерживается теми же операторами:
#include #include #include #include int main() { string ifile; cout << "Введите имя файла для сортировки: "; cin >> ifile; // сконструировать объект класса ifstream для ввода из файла ifstream infile( ifile.c_str() ); if ( ! infile ) { cerr << "ошибка: не могу открыть входной файл: " << ifile << endl; return -1; } string ofile = ifile + ".sort"; // сконструировать объект класса ofstream для вывода в файл ofstream outfile( ofile.c_str() ); if ( ! outfile) { cerr << "ошибка: не могу открыть выходной файл: " << ofile << endl; return -2; } string buffer; vector< string, allocator > text; int cnt = 1; while ( infile >> buffer ) { text.push_back( buffer ); cout << buffer << (cnt++ % 8 ? " " : "\n" ); } sort( text.begin(), text.end() ); // выводим отсортированное множество слов в файл vector< string >::iterator iter = text.begin(); for ( cnt = 1; iter != text.end(); ++iter, ++cnt ) outfile << *iter << (cnt % 8 ? " " : "\n" ); return 0; |
}
Вот пример сеанса работы с этой программой. Нас просят ввести файл для сортировки. Мы набираем alice_emma
(набранные на клавиатуре символы напечатаны полужирным шрифтом). Затем программа направляет на стандартный вывод все, что прочитала из файла:
Введите имя файла для сортировки: alice_emma
Alice Emma has long flowing red hair. Her
Daddy says when the wind blows through her
hair, it looks almost alive, like a fiery
bird in flight. A beautiful fiery bird, he
tells her, magical but untamed. "Daddy, shush, there
is no such creature," she tells him, at
the same time wanting him to tell her
more. Shyly, she asks, "I mean, Daddy, is
there?"
Далее программа выводит в файл outfile
отсортированную последовательность строк. Конечно, на порядок слов влияют знаки препинания; в следующем разделе мы это исправим:
"Daddy, "I A Alice Daddy Daddy, Emma Her
Shyly, a alive, almost asks, at beautiful bird
bird, blows but creature," fiery fiery flight. flowing
hair, hair. has he her her her, him
him, in is is it like long looks
magical mean, more. no red same says she
she shush, such tell tells tells the the
there there?" through time to untamed. wanting when
wind
(В разделе 20.6 мы познакомимся с файловым вводом/выводом более подробно.)
Библиотека iostream
поддерживает также ввод/вывод в область памяти, при этом поток связывается со строкой в памяти программы. С помощью потоковых операторов ввода/вывода мы можем записывать данные в эту строку и читать их оттуда. Объект для строкового ввода/вывода определяется как экземпляр одного из следующих классов:
· istringstream, производный от istream, читает из строки;
· ostringstream, производный от ostream, пишет в строку;
· stringstream, производный от iostream, выполняет как чтение, так и запись.
Для использования любого из этих классов в программу нужно включить заголовочный файл
#include
( Файл sstream уже включает iostream, так что включать оба файла необязательно.) В следующем фрагменте объект класса ostringstream
используется для форматирования сообщения об ошибке, которое возвращается вызывающей программе.
#include string program_name( "our_program" ); string version( 0.01 ); // ... string mumble( int *array, int size ) { if ( ! array ) { ostringstream out_message; out_message << "ошибка: " << program_name << "--" << version << ": " << __FILE__ << ": " << __LINE__ << " -- указатель равен 0; " << " а должен адресовать массив.\n"; // возвращаем строку, в которой находится сообщение return out_message.str(); } // ... |
(В разделе 20.8 мы познакомимся со строковым вводом/выводом более подробно.)
Потоки ввода/вывода поддерживают два предопределенных типа: char и wchar_t. В этой главе мы расскажем только о чтении и записи в потоки данных типа char. Помимо них, в библиотеке iostream
имеется набор классов и объектов для работы с типом wchar_t. Они отличаются от соответствующих классов, использующих тип char, наличием префикса ‘w’. Так, объект стандартного ввода называется wcin, стандартного вывода – wcout, стандартного вывода для ошибок – wcerr. Но набор заголовочных файлов для char и wchar_t
один и тот же.
Классы для ввода/вывода данных типа wchar_t называются wostream, wistream, wiostream, для файлового ввода/вывода – wofstream, wifstream, wfstream, а для строкового – wostringstream, wistringstream, wstringstream.
Битовое поле– член, экономящий память
Для хранения заданного числа битов можно объявить член класса специального вида, называемый битовым полем. Он должен иметь целый тип данных, со знаком или без знака:class File { // ... unsigned int modified : 1; // битовое поле |
};
После идентификатора битового поля следует двоеточие, а за ним – константное выражение, задающее число битов. К примеру, modified – это поле из одного бита.
Битовые поля, определенные в теле класса подряд, по возможности упаковываются в соседние биты одного целого числа, делая хранение объекта более компактным. Так, в следующем объявлении пять битовых полей будут содержаться в одном числе типа unsigned int, ассоциированном с первым полем mode:
typedef unsigned int Bit; class File { public: Bit mode: 2; Bit modified: 1; Bit prot_owner: 3; Bit prot_group: 3; Bit prot_world: 3; // ... |
};
Доступ к битовому полю осуществляется так же, как к прочим членам класса. Скажем, к битовому полю, являющемуся закрытым членом класса, можно обратиться лишь из функций-членов и друзей этого класса:
void File::write() { modified = 1; // ... } void File::close() { if ( modified ) // ... сохранить содержимое |
}
Вот простой пример использования битового поля длиной больше 1 (примененные здесь побитовые операции рассматривались в разделе 4.11):
enum { READ = 01, WRITE = 02 }; // режимы открытия файла int main() { File myFile; myFile.mode |= READ; if ( myFile.mode & READ ) cout << "myFile.mode is set to READ\n"; |
}
Обычно для проверки значения битового поля-члена определяются встроенные функции-члены. Допустим, в классе File
можно ввести члены isRead() и isWrite():
inline int File::isRead() { return mode & READ; } inline int File::isWrite() { return mode & WRITE; } |
if ( myFile.isRead() ) /* ... */
С помощью таких функций-членов битовые поля можно сделать закрытыми членами класса File.
К битовому полю нельзя применять оператор взятия адреса (&), поэтому не может быть и указателя на подобные поля-члены. Кроме того, полю запрещено быть статическим членом.
В стандартной библиотеке C++ имеется шаблон класса bitset, который облегчает манипуляции с битовыми множествами. Мы рекомендуем использовать его вместо битовых полей. (Шаблон класса bitset и определенные в нем операции рассматривались в разделе 4.12.)
Упражнение 13.17
Перепишите примеры из этого подраздела так, чтобы в классе File вместо объявления и прямого манипулирования битовыми полями использовался класс bitset и его операторы.
Частичные специализации шаблонов классов A
Если у шаблона класса есть несколько параметров, то можно специализировать его только для одного или нескольких аргументов, оставляя другие неспециализированными. Иными словами, допустимо написать шаблон, соответствующий общему во всем, кроме тех параметров, вместо которых подставлены фактические типы или значения. Такой механизм носит название частичной специализации шаблона класса. Она может понадобиться при определении реализации, более подходящей для конкретного набора аргументов.Рассмотрим шаблон класса Screen, введенный в разделе 16.2. Частичная специализации Screen
дает более эффективную реализацию для экранов с 80 столбцами:
};
Частичная специализация шаблона класса – это шаблон, и ее определение похоже на определение шаблона. Оно начинается с ключевого слова template, за которым следует список параметров, заключенный в угловые скобки. Список параметров здесь отличается от соответствующего списка параметров общего шаблона. Для частичной специализации шаблона Screen
есть только один параметр-константа hi, поскольку значение второго аргумента равно 80, т.е. в данном списке представлены только те параметры, для которых фактические аргументы еще неизвестны.
Имя частичной специализации совпадает с именем того общего шаблона, которому она соответствует, в нашем случае Screen. Однако за ее именем всегда следует список аргументов. В примере выше этот список выглядит как
дает более эффективную реализацию для экранов с 80 столбцами:
template class Screen { // ... }; // частичная специализация шаблона класса Screen template class Screen {public: Screen(); // ... private: string _screen; string::size_type _cursor; short _height; // для экранов с 80 колонками используются специальные алгоритмы
| |
};
Частичная специализация шаблона класса – это шаблон, и ее определение похоже на определение шаблона. Оно начинается с ключевого слова template, за которым следует список параметров, заключенный в угловые скобки. Список параметров здесь отличается от соответствующего списка параметров общего шаблона. Для частичной специализации шаблона Screen
есть только один параметр-константа hi, поскольку значение второго аргумента равно 80, т.е. в данном списке представлены только те параметры, для которых фактические аргументы еще неизвестны.
Имя частичной специализации совпадает с именем того общего шаблона, которому она соответствует, в нашем случае Screen. Однако за ее именем всегда следует список аргументов. В примере выше этот список выглядит как
. Поскольку значение аргумента для первого параметра шаблона неизвестно, то на этом месте в списке стоит имя параметра шаблона; вторым же аргументом является значение 80, которым частично специализирован шаблон.
Частичная специализация шаблона класса неявно конкретизируется при использовании в программе. В следующем примере частичная специализация конкретизируется аргументом шаблона 24
вместо hi:
Screen<24,80> hp2621;
Обратите внимание, что экземпляр Screen<24,80>
может быть конкретизирован не только из частично специализированного, но и из общего шаблона. Почему же тогда компилятор остановился именно на частичной специализации? Если для шаблона класса объявлены частичные специализации, компилятор выбирает то определение, которое является наиболее специализированным для заданных аргументов. Если же ни одно из них не подходит, используется общее определение шаблона. Например, при конкретизации экземпляра Screen<40,132> соответствующей аргументам шаблона специализации нет. Наш вариант применяется только для конкретизации типа Screen с 80 колонками.
Определение частичной специализации не связано с определением общего шаблона. У него может быть совершенно другой набор членов, а также собственные определения функций-членов, статических членов и вложенных типов. Содержащиеся в общем шаблоне определения членов никогда не употребляются для конкретизации членов его частичной специализации. Например, для частичной специализации ScreenЧастичная специализация шаблона класса неявно конкретизируется при использовании в программе. В следующем примере частичная специализация конкретизируется аргументом шаблона 24
вместо hi:
Screen<24,80> hp2621;
Обратите внимание, что экземпляр Screen<24,80>
может быть конкретизирован не только из частично специализированного, но и из общего шаблона. Почему же тогда компилятор остановился именно на частичной специализации? Если для шаблона класса объявлены частичные специализации, компилятор выбирает то определение, которое является наиболее специализированным для заданных аргументов. Если же ни одно из них не подходит, используется общее определение шаблона. Например, при конкретизации экземпляра Screen<40,132> соответствующей аргументам шаблона специализации нет. Наш вариант применяется только для конкретизации типа Screen с 80 колонками.
Определение частичной специализации не связано с определением общего шаблона. У него может быть совершенно другой набор членов, а также собственные определения функций-членов, статических членов и вложенных типов. Содержащиеся в общем шаблоне определения членов никогда не употребляются для конкретизации членов его частичной специализации. Например, для частичной специализации Screen
должен быть определен свой конструктор:
{ }
Если для конкретизации некоторого класса применяется частичная специализация, то определение конструктора из общего шаблона не используется даже тогда, когда определение конструктора Screenдолжен быть определен свой конструктор:
// конструктор для частичной специализации Screen template Screen ::Screen() : _height( hi ), _cursor( 0 ),_screen( hi * 80, bk ) |
Если для конкретизации некоторого класса применяется частичная специализация, то определение конструктора из общего шаблона не используется даже тогда, когда определение конструктора Screen
отсутствует.
.
accumulate(), partial_sum(), inner_product(), adjacent_difference()
Чтобы добиться этого, определим абстрактный тип класса Query. При этом мы не будем программировать разные типы пользовательских запросов, а лишь абстрактные операции, применимые к ним:
}
Такое определение позволяет добавлять неограниченное число типов запросов без необходимости изменять или даже перекомпилировать ядро системы, но при условии, что открытый интерфейс нашего абстрактного базового класса Query
достаточен для поддержки новых запросов.
Проектируя открытый интерфейс Query, мы определим множество операций, достаточное для поддержки всех существующих и будущих типов запросов, хотя на практике нам вряд ли удастся это гарантировать. Предоставление общего интерфейса для тех запросов, о которых мы уже знаем, – вполне реальная задача, но любое заявление, претендующее на более широкую поддержку, следует рассматривать с долей скептицизма.
Поскольку Query – абстрактный класс, объекты которого в приложении не создаются, то никакой разумной реализации виртуальных функций в нем самом мы предложить не можем. Это лишь названия, которые должны быть замещены в производных классах. Напрямую вызывать их мы не будем.
Язык обладает синтаксической конструкцией, обозначающей, что некоторая виртуальная функция предоставляет интерфейс, который должен быть замещен в производных подтипах, но вызываться непосредственно не может. Это чисто виртуальные функции. Объявляются они следующим образом:
};
Заметьте, что за объявлением функции следует присваивание нуля.
Класс, содержащий (или наследующий) одну или несколько таких функций, распознается компилятором как абстрактный базовый класс. Попытка создать независимый объект абстрактного класса приводит к ошибке компиляции. (Ошибкой является также вызов чисто виртуальной функции с помощью механизма виртуализации.) Например:
Query *pq2 = new Query;
Абстрактный базовый класс может существовать только как подобъект в составе объекта некоторого производного от него класса. Это именно та семантика, которая нужна нам для базового Query.
Как получить одну строку текста? Стандартная библиотека предоставляет для этого функцию getline():
getline( istream &is, string str, char delimiter );
getline()берет из входного потока все символы, включая пробелы, и помещает их в объект типа string, до тех пор пока не встретится символ delimiter, не будет достигнут конец файла или количество полученных символов не станет равным величине, возвращаемой функцией-членом max_size()класса string.
Мы будем помещать каждую такую строку в вектор.
Мы вынесли код, читающий файл, в функцию, названную retrieve_text(). В объекте типа pair
дополнительно сохраняется размер и номер самой длинной строки. (Полный текст программы приводится в разделе 6.14.)
Вот реализация функции ввода файла:[15]
<
}
Вот как выглядит вывод программы (размер страницы книги недостаточен, чтобы расположить напечатанные строки во всю длину, поэтому мы сделали в тексте отступы, показывающие, где реально заканчивалась строка):
please enter file name: a1ice_emma
line read: Alice Emma has long flowing red hair. Her Daddy says
line read: when the wind blows through her hair, it looks
almost alive,
line read: like a fiery bird in flight. A beautiful fiery bird,
he tells her,
line read: magical but untamed. "Daddy, shush, there is no such
thing, "
line read: she tells him, at the same time wanting him to tell
her more.
line read: Shyly, she asks, "I mean. Daddy, is there?"
number of lines: 6
maximum length: 66
longest line: like a fiery bird in flight. A beautiful fiery
bird, he tells her,
После того как все строки текста сохранены, нужно разбить их на слова. Сначала мы отбросим знаки препинания. Например, возьмем строку из части “Anna Livia Plurrabelle” романа “Finnegans Wake”.
"For every tale there's a telling,
and that's the he and she of it."
В приведенном фрагменте есть следующие знаки препинания:
"For
there's
telling,
that's
it."
А хотелось бы получить:
For
there
telling
that
it
Можно возразить, что
there's
должно превратиться в
there is
но мы-то движемся в другом направлении: следующий шаг – это отбрасывание семантически нейтральных слов, таких, как is, that, and, it и т.д. Так что для данной строчки из “Finnegans Wake” только два слова являются значимыми: tale и telling, и только по этим словам будет выполняться поиск. (Мы реализуем набор стоп-слов с помощью контейнерного типа set, который подробно рассматривается в следующем разделе.)
После удаления знаков препинания нам необходимо превратить все прописные буквы в строчные, чтобы избежать проблем с поиском в таких, например, строках:
Home is where the heart is.
A home is where they have to let you in.
Несомненно, запрос слова home
должен найти обе строки.
Мы должны также обеспечить минимальную поддержку учета словоформ: отбрасывать окончания слов, чтобы слова dog и dogs, love, loving и loved рассматривались системой как одинаковые.
В следующем разделе мы вернемся к описанию стандартного класса string и рассмотрим многочисленные операции над строками, которые он поддерживает, в контексте дальнейшей разработки нашей поисковой системы.
}
При первом использовании оператора равенства в main() вызывается перегруженный operator==(const char *)
класса String. Однако на второй инструкции if компилятор выдает сообщение об ошибке. В чем дело?
Перегруженный оператор, являющийся членом некоторого класса, применяется только тогда, когда левым операндом служит объект этого класса. Поскольку во втором случае левый операнд не принадлежит к классу String, компилятор пытается найти такой встроенный оператор, для которого левым операндом может быть C-строка, а правым – объект класса String. Разумеется, его не существует, поэтому компилятор говорит об ошибке.
Но можно же создать объект класса String из C-строки с помощью конструктора класса. Почему компилятор не выполнит неявно такое преобразование:
if ( String( "tulip" ) == flower ) //правильно: вызывается оператор-член
Причина в его неэффективности. Перегруженные операторы не требуют, чтобы оба операнда имели один и тот же тип. К примеру, в классе Text определяются следующие операторы равенства:
};
и выражение в main()
можно переписать так:
if ( Text( "tulip" ) == flower ) // вызывается Text::operator==()
Следовательно, чтобы найти подходящий для сравнения оператор равенства, компилятору придется просмотреть все определения классов в поисках конструктора, способного привести левый операнд к некоторому типу класса. Затем для каждого из таких типов нужно проверить все ассоциированные с ним перегруженные операторы равенства, чтобы понять, может ли хоть один из них выполнить сравнение. А после этого компилятор должен решить, какая из найденных комбинаций конструктора и оператора равенства (если таковые нашлись) лучше всего соответствует операнду в правой части! Если потребовать от компилятора выполнения всех этих действий, то время трансляции программ C++ резко возрастет. Вместо этого компилятор просматривает только перегруженные операторы, определенные как члены класса левого операнда (и его базовых классов, как мы покажем в главе 19).
Разрешается, однако, определять перегруженные операторы, не являющиеся членами класса. При анализе строки в main(), вызвавшей ошибку компиляции, подобные операторы принимались во внимание. Таким образом, сравнение, в котором C-строка стоит в левой части, можно сделать корректным, если заменить операторы равенства, являющиеся членами класса String, на операторы равенства, объявленные в области видимости пространства имен:
bool operator==( const String &, const char * );
Обратите внимание, что эти глобальные перегруженные операторы имеют на один параметр больше, чем операторы-члены. Если оператор является членом класса, то первым параметром неявно передается указатель this. То есть для операторов-членов выражение
flower == "lily"
переписывается компилятором в виде:
flower.operator==( "lily" )
и на левый операнд flower в определении перегруженного оператора-члена можно сослаться с помощью this. (Указатель this
введен в разделе 13.4.) В случае глобального перегруженного оператора параметр, представляющий левый операнд, должен быть задан явно.
Тогда выражение
flower == "lily"
вызывает оператор
bool operator==( const String &, const char * );
Непонятно, какой оператор вызывается для второго случая использования оператора равенства:
"tulip" == flower
Мы ведь не определили такой перегруженный оператор:
bool operator==( const char *, const String & );
Но это необязательно. Когда перегруженный оператор является функцией в пространстве имен, то как для первого, так и для второго его параметра (для левого и правого операндов) рассматриваются возможные преобразования, т.е. компилятор интерпретирует второе использование оператора равенства как
operator==( String("tulip"), flower );
и вызывает для выполнения сравнения следующий перегруженный оператор:
bool operator==( const String &, const String & );
Но тогда зачем мы предоставили второй перегруженный оператор:
bool operator==( const String &, const char * );
Преобразование типа из C-строки в класс String
может быть применено и к правому операнду. Функция main()
будет компилироваться без ошибок, если просто определить в пространстве имен перегруженный оператор, принимающий два операнда String:
bool operator==( const String &, const String & );
Предоставлять ли только этот оператор или еще два:
bool operator==( const String &, const char * );
зависит от того, насколько велики затраты на преобразование из C-строки в String во время выполнения, то есть от “стоимости” дополнительных вызовов конструктора в программах, пользующихся нашим классом String. Если оператор равенства будет часто использоваться для сравнения C-строк и объектов , то лучше предоставить все три варианта. (Мы вернемся к вопросу эффективности в разделе, посвященном друзьям.
Подробнее о приведении к типу класса с помощью конструкторов мы расскажем в разделе 15.9; в разделе 15.10 речь пойдет о разрешении перегрузки функций с помощью описанных преобразований, а в разделе 15.12 – о разрешении перегрузки операторов.)
Итак, на основе чего принимается решение, делать ли оператор членом класса или членом пространства имен? В некоторых случаях у программиста просто нет выбора:
· если перегруженный оператор является членом класса, то он вызывается лишь при условии, что левым операндом служит член этого класса. Если же левый операнд имеет другой тип, оператор обязан быть членом пространства имен;
· язык требует, чтобы операторы присваивания ("="), взятия индекса ("[]"), вызова ("()") и доступа к членам по стрелке ("->") были определены как члены класса. В противном случае выдается сообщение об ошибке компиляции:
char& operator[]( String &, int ix );
(Подробнее оператор присваивания рассматривается в разделе 15.3, взятия индекса – в разделе 15.4, вызова – в разделе 15.5, а оператор доступа к члену по стрелке – в разделе 15.6.)
В остальных случаях решение принимает проектировщик класса. Симметричные операторы, например оператор равенства, лучше определять в пространстве имен, если членом класса может быть любой операнд (как в String).
Прежде чем закончить этот подраздел, определим операторы равенства для класса String в пространстве имен:
}
char delimiter;
Переменная, как и литерал, имеет определенный тип и хранит свое значение в некоторой области памяти. Адресуемость – вот чего не хватает литералу. С переменной ассоциируются две величины:
· собственно значение, или r-значение (от read value – значение для чтения), которое хранится в этой области памяти и присуще как переменной, так и литералу;
· значение адреса области памяти, ассоциированной с переменной, или l-значение (от location value – значение местоположения) – место, где хранится r-значение; присуще только объекту.
В выражении
ch = ch - '0';
переменная ch
находится и слева и справа от символа операции присваивания. Справа расположено значение для чтения (ch и символьный литерал '0'): ассоциированные с переменной данные считываются из соответствующей области памяти. Слева – значение местоположения: в область памяти, соотнесенную с переменной ch, помещается результат вычитания. В общем случае левый операнд операции присваивания должен быть l-значением. Мы не можем написать следующие выражения:
salary + salary * 0.10 = new_salary;
Оператор определения переменной выделяет для нее память. Поскольку объект имеет только одну ассоциированную с ним область памяти, такой оператор может встретиться в программе только один раз. Если же переменная, определенная в одном исходном файле, должна быть использована в другом, появляются проблемы. Например:
// определяет объект fileName
string fileName;
// ... присвоить fileName значение
// файл module1.C
// использует объект fileName
// увы, не компилируется:
// fileName не определен в module1.C
ifstream input_file( fileName );
С++ требует, чтобы объект был известен до первого обращения к нему. Это вызвано необходимостью гарантировать правильность использования объекта в соответствии с его типом. В нашем примере модуль module1.C
вызовет ошибку компиляции, поскольку переменная fileName не определена в нем. Чтобы избежать этой ошибки, мы должны сообщить компилятору об уже определенной переменной fileName. Это делается с помощью инструкции объявления
переменной:
// использует объект fileName
// fileName объявляется, то есть программа получает
// информацию об этом объекте без вторичного его определения
extern string fileName;
ifstream input_file( fileName )
Объявление переменной сообщает компилятору, что объект с данным именем, имеющий данный тип, определен где-то в программе. Память под переменную при ее объявлении не отводится. (Ключевое слово extern рассматривается в разделе 8.2.)
Программа может содержать сколько угодно объявлений одной и той же переменной, но определить ее можно только один раз. Такие объявления удобно помещать в заголовочные файлы, включая их в те модули, которые этого требуют. Так мы можем хранить информацию об объектах в одном месте и обеспечить удобство ее модификации в случае надобности. (Более подробно о заголовочных файлах мы поговорим в разделе 8.2.)
}
Результатом вычисления выражения 3.14159
станет 3.14159
типа double, выражения "melancholia" – адрес первого элемента строки типа const char*. Значение выражения upperBound – это значение объекта upperBound, а его типом будет тип самого объекта.
Более общим случаем выражения является один или более операндов и некоторая операция, применяемая к ним:
first_name + " " + 1ast_name
Операции обозначаются соответствующими знаками. В первом примере сложение применяется к salary и raise. Во втором выражении size
делится на 2. Частное используется как индекс для массива ivec. Получившийся в результате операции взятия индекса элемент массива умножается на delta. В третьем примере два строковых объекта конкатенируются между собой и со строковым литералом, создавая новый строковый объект.
Операции, применяемые к одному операнду, называются унарными
(например,
взятие адреса (&) и разыменование (*)), а применяемые к двум операндам – бинарными. Один и тот же символ может обозначать разные операции в зависимости от того, унарна она или бинарна. Так, в выражении
*ptr
* представляет собой унарную операцию разыменования. Значением этого выражения является значение объекта, адрес которого содержится в ptr. Если же написать:
var1 * var2
то звездочка будет обозначать бинарную операцию умножения.
Результатом вычисления выражения всегда, если не оговорено противное, является r-значение. Тип результата арифметического выражения определяется типами операндов. Если операнды имеют разные типы, производится преобразование типов в соответствии с предопределенным набором правил. (Мы детально рассмотрим эти правила в разделе 4.14.)
Выражение может являться составным, то есть объединять в себе несколько подвыражений. Вот, например, выражение, проверяющее на неравенство нулю указатель и объект, на который он указывает (если он на что-то указывает)[7]:
ptr != 0 && *ptr != 0
Выражение состоит из трех подвыражений: проверку указателя ptr, разыменования ptr и проверку результата разыменования. Если ptr определен как
int *ptr = &ival;
то результатом разыменования будет 1024 и оба сравнения дадут истину. Результатом всего выражения также будет истина (оператор &&
обозначает логическое И).
Если посмотреть на этот пример внимательно, можно заметить, что порядок выполнения операций очень важен. Скажем, если бы операция разыменования ptr
производилась до его сравнения с 0, в случае нулевого значения ptr это скорее всего вызвало бы крах программы. В случае операции И порядок действий строго определен: сначала оценивается левый операнд, и если его значение равно false, правый операнд не вычисляется вовсе. Порядок выполнения операций определяется их приоритетами, не всегда очевидными, что вызывает у начинающих программистов на С и С++ множество ошибок. Приоритеты будут приведены в разделе 4.13, а пока мы расскажем обо всех операциях, определенных в С++, начиная с наиболее привычных.
могут быть следующие данные-члены:
};
Поскольку мы решили использовать строки для внутреннего представления объекта класса Screen, то член _screen
имеет тип string. Член _cursor– это смещение в строке, он применяется для указания текущей позиции на экране. Для него использован переносимый тип string::size_type.
(Тип size_type
рассматривался в разделе 6.8.)
Необязательно объявлять два члена типа short по отдельности. Вот объявление класса Screen, эквивалентное приведенному выше:
};
Член класса может иметь любой тип:
};
Описанные данные-члены называются нестатическими. Класс может иметь также и статические
данные-члены. (У них есть особые свойства, которые мы рассмотрим в разделе 13.5.)
Объявления данных-членов очень похожи на объявления переменных в области видимости блока или пространства имен. Однако их, за исключением статических членов, нельзя явно инициализировать в теле класса:
};
Данные-члены класса инициализируются с помощью конструктора класса. (Мы рассказывали о конструкторах в разделе 2.3; более подробно они рассматриваются в главе 14.)
выделяется память для массива символов и присваивается уникальный номер счету. Можно также представить ситуацию, когда нужно получить монопольный доступ к разделяемой памяти или к критической секции потока. Для этого необходима симметричная операция, обеспечивающая автоматическое освобождение памяти или возврат ресурса по завершении времени жизни объекта, – деструктор. Деструктор – это специальная определяемая пользователем функция-член, которая автоматически вызывается, когда объект выходит из области видимости или когда к указателю на объект применяется операция delete. Имя этой функции образовано из имени класса с предшествующим символом “тильда” (~). Деструктор не возвращает значения и не принимает никаких параметров, а следовательно, не может быть перегружен. Хотя разрешается определять несколько таких функций-членов, лишь одна из них будет применяться ко всем объектам класса. Вот, например, деструктор для нашего класса Account:
}
Обратите внимание, что в нашем деструкторе не сбрасываются значения членов:
}
Делать это необязательно, поскольку отведенная под члены объекта память все равно будет освобождена. Рассмотрим следующий класс:
<
};
Конструктор здесь необходим для инициализации членов, представляющих координаты точки. Нужен ли деструктор? Нет. Для объекта класса Point3d не требуется освобождать ресурсы: память выделяется и освобождается компилятором автоматически в начале и в конце его жизни.
В общем случае, если члены класса имеют простые значения, скажем, координаты точки, то деструктор не нужен. Не для каждого класса необходим деструктор, даже если у него есть один или более конструкторов. Основной целью деструктора является освобождения ресурсов, выделенных либо в конструкторе, либо во время жизни объекта, например освобождение замка или памяти, выделенной оператором new.
Но функции деструктора не ограничены только освобождением ресурсов. Он может реализовывать любую операцию, которая по замыслу проектировщика класса должна быть выполнена сразу по окончании использования объекта. Так, широко распространенным приемом для измерения производительности программы является определение класса Timer, в конструкторе которого запускается та или иная форма программного таймера. Деструктор останавливает таймер и выводит результаты замеров. Объект данного класса можно условно определять в критических участках программы, которые мы хотим профилировать, таким образом:
}
Чтобы убедиться в том, что мы понимаем поведение деструктора (да и конструктора тоже), разберем следующий пример:
<
(15) }
Сколько здесь вызывается конструкторов? Четыре: один для глобального объекта global в строке (2); по одному для каждого из локальных объектов local и local_too в строках (5) и (10) соответственно, и один для объекта, распределенного в хипе, в строке (11). Ни объявление ссылки loc_ref на объект в строке (6), ни объявление указателя pact в строке (7) не приводят к вызову конструктора. Ссылка – это псевдоним для уже сконструированного объекта, в данном случае для global. Указатель также лишь адресует объект, созданный ранее (в данном случае распределенный в хипе, строка (11)), или не адресует никакого объекта (строка (7)).
Аналогично вызываются четыре деструктора: для глобального объекта global, объявленного в строке (2), для двух локальных объектов и для объекта в хипе при вызове delete в строке (14). Однако в программе нет инструкции, с которой можно связать вызов деструктора. Компилятор просто вставляет эти вызовы за последним использованием объекта, но перед закрытием соответствующей области видимости.
Конструкторы и деструкторы глобальных объектов вызываются на стадиях инициализации и завершения выполнения программы. Хотя такие объекты нормально ведут себя при использовании в том файле, где они определены, но их применение в ситуации, когда производятся ссылки через границы файлов, становится в C++ серьезной проблемой.4
Деструктор не вызывается, когда из области видимости выходит ссылка или указатель на объект (сам объект при этом остается).
С++ с помощью внутренних механизмов препятствует применению оператора delete к указателю, не адресующему никакого объекта, так что соответствующие проверки кода необязательны:
if (pact != 0 ) delete pact;
Всякий раз, когда внутри функции этот оператор применяется к отдельному объекту, размещенному в хипе, лучше использовать объект класса auto_ptr, а не обычный указатель (см. обсуждение класса auto_ptr в разделе 8.4). Это особенно важно потому, что пропущенный вызов delete
(скажем, в случае, когда возбуждается исключение) ведет не только к утечке памяти, но и к пропуску вызова деструктора. Ниже приводится пример программы, переписанной с использованием auto_ptr (она слегка модифицирована, так как объект класса auto_ptr
может быть явно переустановлен для адресации другого объекта только присваиванием его другому auto_ptr):
}
NameQuery nq( "hyperion" );
то порядок вызова деструкторов следующий: сначала деструктор NameQuery, затем деструктор string для члена _name и наконец деструктор базового класса. В общем случае эта последовательность противоположна порядку вызова конструкторов.
Вот деструкторы нашего базового Query и производных от него (все они объявлены открытыми членами соответствующих классов):
~AndQuery(){ delete _lop; delete _rop; }
Отметим два аспекта:
· мы не предоставляем явного деструктора NameQuery, потому что никаких специальных действий по очистке его объекта предпринимать не нужно. Деструкторы базового класса и класса string для члена _name
вызываются автоматически;
· в деструкторах производных классов оператор delete
применяется к указателю типа Query*. Чтобы вызвать не деструктор Query, а деструктор класса того объекта, который фактически адресуется этим указателем, мы должны объявить деструктор базового Query
виртуальным. (Более подробно о виртуальных функциях вообще и о виртуальных деструкторах в частности мы поговорим в следующем разделе.)
В нашей реализации неявно подразумевалось, что память для операндов, указатели на которые имеются в объектах классов NotQuery, OrQuery и AndQuery, выделена из хипа. Именно поэтому в деструкторах мы применяли к этим указателям оператор delete. Но язык не позволяет обеспечить истинность такого предположения, так как в нем нет различий между адресами в хипе и вне его. С этой точки зрения наша реализация не застрахована от ошибок.
В разделе 17. 7 мы инкапсулируем выделение памяти и конструирование объектов иерархии Query в управляющий класс UserQuery. Это гарантирует выполнение нашего предположения. На уровне программы в целом следует перегрузить операторы new и delete для классов иерархии. Например, можно поступить следующим образом. Оператор new устанавливает в объекте флажок, говорящий, что память для него выделена из хипа. Перегруженный оператор delete
проверяет этот флажок: если он есть, то память освобождается с помощью стандартного оператора delete.
Упражнение 17.7
Идентифицируйте конструкторы и деструкторы базового и производных классов для той иерархии, которую вы выбрали в упражнении 17.2 (раздел 17.1).
Упражнение 17.8
Измените реализацию класса OrQuery
так, чтобы он был производным от BinaryQuery.
Упражнение 17.9
Найдите ошибку в следующем определении класса:
};
Упражнение 17.10
Дано определение базового класса:
};
Что неправильно в следующих фрагментах:
};
};
};
};
Упражнение 17.11
В первоначальном определении языка C++ порядок следования инициализаторов в списке инициализации членов определял порядок вызова конструкторов. Принцип, который действует сейчас, был принят в 1986 году. Как вы думаете, почему была изменена исходная спецификация?
1. Установить множество функций-кандидатов для разрешения данного вызова, а также свойства списка фактических аргументов.
2. Отобрать из множества кандидатов устоявшие функции – те, которые могут быть вызваны с данным списком фактических аргументов при учете их числа и типов.
3. Выбрать функцию, лучше всего соответствующую вызову, подвергнув ранжированию преобразования, которые необходимо применить к фактическим аргументам, чтобы привести их в соответствие с формальными параметрами устоявшей функции.
Теперь мы готовы к тому, чтобы изучить эти шаги более детально.
Третий вид объектов позволяет программисту полностью управлять выделением и освобождением памяти. Такие объекты называют динамически
размещаемыми
или, для краткости, просто динамическими. Динамический объект “живет” в пуле свободной памяти, называемой хипом. Программист создает его с помощью оператора new, а уничтожает с помощью оператора delete. Динамически размещаться может как единичный объект, так и массив объектов. Размер массива, размещаемого в хипе, разрешается задавать во время выполнения.
В этом разделе, посвященном динамическим объектам, мы рассмотрим три формы оператора new: для размещения единичного объекта, для размещения массива и третью форму, называемую оператором размещения new (placement new expression). Когда хип исчерпан, этот оператор возбуждает исключение. (Разговор об исключениях будет продолжен в главе 11. В главе 15 мы расскажем об операторах new и delete
применительно к классам.)
состоит их ключевого слова new, за которым следует спецификатор типа. Этот спецификатор может относиться к встроенным типам или к типам классов. Например:
new int;
размещает в хипе один объект типа int. Аналогично в результате выполнения инструкции
new iStack;
там появится один объект класса iStack.
Сам по себе оператор new не слишком полезен. Как можно реально воспользоваться созданным объектом? Одним из аспектов работы с памятью из хипа является то, что размещаемые в ней объекты не имеют имени. Оператор new
возвращает не сам объект, а указатель на него. Все манипуляции с этим объектом производятся косвенно через указатели:
int *pi = new int;
Здесь оператор new
создает один объект типа int, на который ссылается указатель pi. Выделение памяти из хипа во время выполнения программы называется динамическим выделением. Мы говорим, что память, адресуемая указателем pi, выделена динамически.
Второй аспект, относящийся к использованию хипа, состоит в том, что эта память не инициализируется. Она содержит “мусор”, оставшийся после предыдущей работы. Проверка условия:
if ( *pi == 0 )
вероятно, даст false, поскольку объект, на который указывает pi, содержит случайную последовательность битов. Следовательно, объекты, создаваемые с помощью оператора new, рекомендуется инициализировать. Программист может инициализировать объект типа int из предыдущего примера следующим образом:
int *pi = new int( 0 );
Константа в скобках задает начальное значение для создаваемого объекта; теперь pi
ссылается на объект типа int, имеющий значение 0. Выражение в скобках называется инициализатором. Это может быть любое выражение (не обязательно константа), возвращающее значение, приводимое к типу int.
Оператор new
выполняет следующую последовательность действий: выделяет из хипа память для объекта, затем инициализирует его значением, стоящим в скобках. Для выделения памяти вызывается библиотечная функция new(). Предыдущий оператор приблизительно эквивалентен следующей последовательности инструкций:
int *pi = &ival; // указатель ссылается на этот объект
не считая, конечно, того, что объект, адресуемый pi, создается библиотечной функцией new() и размещается в хипе. Аналогично
iStack *ps = new iStack( 512 );
создает объект типа iStack на 512 элементов. В случае объекта класса значение или значения в скобках передаются соответствующему конструктору, который вызывается в случае успешного выделения памяти. (Динамическое создание объектов классов более подробно рассматривается в разделе 15.8. Оставшаяся часть данного раздела посвящена созданию объектов встроенных типов.)
Описанные операторы new
могут вызывать одну проблему: хип, к сожалению, является конечным ресурсом, и в некоторой точке выполнения программы мы можем исчерпать его. Если функция new() не может выделить затребованного количества памяти, она возбуждает исключение bad_alloc.
(Обработка исключений рассматривается в главе 11.)
Время жизни объекта, на который указывает pi, заканчивается при освобождении памяти, где этот объект размещен. Это происходит, когда pi
передается оператору delete. Например,
delete pi;
освобождает память, на которую ссылается pi, завершая время жизни объекта типа int. Программист управляет окончанием жизни объекта, используя оператор delete в нужном месте программы. Этот оператор вызывает библиотечную функцию delete(), которая возвращает выделенную память в хип. Поскольку хип конечен, очень важно возвращать ее своевременно.
Глядя на предыдущий пример, вы можете спросить: а что случится, если значение pi по какой-либо причине было нулевым? Не следует ли переписать этот код таким образом:
delete pi;
Нет. Язык С++ гарантирует, что оператор delete не будет вызывать функцию delete() в случае нулевого операнда. Следовательно, проверка на 0
необязательна. (Если вы явно добавите такую проверку, в большинстве реализаций она фактически будет выполнена дважды.)
Важно понимать разницу между временем жизни указателя pi и объекта, который он адресует. Сам объект pi является глобальным и объявлен в глобальной области видимости. Следовательно, память под него выделяется до выполнения программы и сохраняется за ним до ее завершения. Совсем не так определяется время жизни адресуемого указателем pi
объекта, который создается с помощью оператора new во время выполнения. Область памяти, на которую указывает pi, выделена динамически, следовательно, pi является указателем на динамически размещенный объект типа int. Когда в программе встретится оператор delete, эта память будет освобождена. Однако память, отведенная самому указателю pi, не освобождается, а ее содержимое не изменяется. После выполнения delete
объект pi
становится висячим указателем, то есть ссылается на область памяти, не принадлежащую программе. Такой указатель служит источником трудно обнаруживаемых ошибок, поэтому сразу после уничтожения объекта ему полезно присвоить 0, обозначив таким образом, что указатель больше ни на что не ссылается.
Оператор delete
может использоваться только по отношению к указателю, который содержит адрес области памяти, выделенной в результате выполнения оператора new. Попытка применить delete к указателю, не ссылающемуся на такую память, приведет к непредсказуемому поведению программы. Однако, как было сказано выше, этот оператор можно применять к нулевому указателю.
Ниже приведены примеры опасных и безопасных операторов delete:
}
Вот три основные ошибки, связанные с динамическим выделением памяти:
· не освободить выделенную память. В таком случае память не возвращается в хип. Эта ошибка получила название утечки
памяти;
· дважды применить оператор delete к одной и той же области памяти. Такое бывает, когда два указателя получают адрес одного и того же динамически размещенного объекта. В результате подобной ошибки мы вполне можем удалить нужный объект. Действительно, память, освобожденная с помощью одного из адресующих ее указателей, возвращается в хип и затем выделяется под другой объект. Затем оператор delete применяется ко второму указателю, адресовавшему старый объект, а удаляется при этом новый;
· изменять объект после его удаления. Такое часто случается, поскольку указатель, к которому применяется оператор delete, не обнуляется.
Эти ошибки при работе с динамически выделяемой памятью гораздо легче допустить, нежели обнаружить и исправить. Для того чтобы помочь программисту, стандартная библиотека С++ представляет класс auto_ptr. Мы рассмотрим его в следующем подразделе. После этого мы покажем, как динамически размещать и уничтожать массивы, используя вторую форму операторов new и delete.
const int *pci = new const int(1024);
Константный динамический объект имеет несколько особенностей. Во-первых, он должен быть инициализирован, иначе компилятор сигнализирует об ошибке (кроме случая, когда объект принадлежит к типу класса, имеющего конструктор по умолчанию; в такой ситуации инициализатор можно опустить).
Во-вторых, указатель, возвращаемый выражением new, должен адресовать константу. В предыдущем примере pci
служит указателем на const int.
Константность динамически созданного объекта подразумевает, что значение, полученное при инициализации, в дальнейшем не может быть изменено. Но поскольку объект динамический, временем его жизни управляет оператор delete. Например:
delete pci;
Хотя операнд оператора delete
имеет тип указателя на const int, эта инструкция является корректной и освобождает область памяти, на которую ссылается pci.
Невозможно создать динамический массив константных элементов встроенного типа потому, что, как мы отмечали выше, элементы такого массива нельзя проинициализировать в операторе new. Следующая инструкция приводит к ошибке компиляции:
const int *pci = new const int[100]; // ошибка
может выделить из хипа память для размещения массива. В этом случае после спецификатора типа в квадратных скобках указывается размер массива. Он может быть задан сколь угодно сложным выражением. new
возвращает указатель на первый элемент массива. Например:
int (*pia2)[ 1024 ] = new int[ 4 ][ 1024 ];
pi содержит адрес единственного элемента типа int, инициализированного значением 1024; pia – адрес первого элемента массива из 1024
элементов; pia2 – адрес начала массива, содержащего четыре массива по 1024
элемента, т.е. pia2
адресует 4096
элементов.
В общем случае массив, размещаемый в хипе, не может быть инициализирован. (В разделе 15.8 мы покажем, как с помощью конструктора по умолчанию присвоить начальное значение динамическому массиву объектов типа класса.) Задавать инициализатор при выделении оператором new памяти под массив не разрешается. Массиву элементов встроенного типа, размещенному в хипе, начальные значения присваиваются с помощью цикла for:
pia[ index ] = 0;
Основное преимущество динамического массива состоит в том, что количество элементов в его первом измерении не обязано быть константой, т.е. может не быть известным во время компиляции. Для массивов, определяемых в локальной или глобальной области видимости, это не так: здесь размер задавать необходимо.
Например, если указатель в ходе выполнения программы ссылается на разные C-строки, то область памяти под текущую строку обычно выделяется динамически и ее размер определяется в зависимости от длины строки. Как правило, это более эффективно, чем создавать массив фиксированного размера, способный вместить самую длинную строку: ведь все остальные строки могут быть значительно короче. Более того, программа может аварийно завершиться, если длина хотя бы одной из строк превысит отведенный лимит.
Оператор new
допустимо использовать для задания первого измерения массива с помощью значения, вычисляемого во время выполнения. Предположим, у нас есть следующие C-строки:
"specify a function return type!";
Размер создаваемого с помощью оператора new
массива может быть задан значением, вычисляемым во время выполнения:
strcpy( strl, errorTxt );
dimension
разрешается заменить выражением:
char *strl = new char[ str1en( errorTxt ) + 1 ];
Единица, прибавляемая к значению, которое возвращает strlen(), необходима для учета завершающего нулевого символа в C-строке. Отсутствие этой единицы – весьма распространенная ошибка, которую достаточно трудно обнаружить, поскольку она проявляет себя косвенно: происходит затирание какой-либо другой области программы. Почему? Большинство функций, которые обрабатывают массивы, представляющие собой С-строки символов, пробегают по элементам, пока не встретят завершающий нуль.
Если в конце строки нуля нет, то возможно чтение или запись в случайную область памяти. Избежать подобных проблем позволяет класс string из стандартной библиотеки С++.
Отметим, что только первое измерение массива, создаваемого с помощью оператора new, может быть задано значением, вычисляемым во время выполнения. Остальные измерения должны задаваться константами, известными во время компиляции. Например:
<
int **pia4 = new int[ 4 ][ getDim() ];
Оператор delete для уничтожения массива имеет следующую форму:
delete[] str1;
Пустые квадратные скобки необходимы. Они говорят компилятору, что указатель адресует массив, а не единичный элемент. Поскольку тип str1 – указатель на char, без этих скобок компилятор не поймет, что удалять следует целый массив.
Отсутствие скобок не является синтаксической ошибкой, но правильность выполнения программы не гарантируется (это особенно справедливо для массивов, которые содержат объекты классов, имеющих деструкторы, как это будет показано в разделе 14.4).
Чтобы избежать проблем, связанных с управлением динамически выделяемой памятью для массивов, рекомендуется пользоваться контейнерными типами из стандартной библиотеки, такими, как vector, list или string. Они управляют памятью автоматически. (Тип string был представлен в разделе 3.4, тип vector – в разделе 3.10. Подробное описание контейнерных типов см. в главе 6.)
В С++ объекты могут быть размещены либо статически – во время компиляции, либо динамически – во время выполнения программы, путем вызова функций из стандартной библиотеки. Основная разница в использовании этих методов – в их эффективности и гибкости. Статическое размещение более эффективно, так как выделение памяти происходит до
выполнения программы, однако оно гораздо менее гибко, потому что мы должны заранее знать тип и размер размещаемого объекта. К примеру, совсем не просто разместить содержимое некоторого текстового файла в статическом массиве строк: нам нужно заранее знать его размер. Задачи, в которых нужно хранить и обрабатывать заранее неизвестное число элементов, обычно требуют динамического выделения памяти.
До сих пор во всех наших примерах использовалось статическое выделение памяти. Скажем, определение переменной ival
int ival = 1024;
заставляет компилятор выделить в памяти область, достаточную для хранения переменной типа int, связать с этой областью имя ival и поместить туда значение 1024. Все это делается на этапе компиляции, до выполнения программы.
С объектом ival
ассоциируются две величины: собственно значение переменной, 1024 в данном случае, и адрес той области памяти, где хранится это значение. Мы можем обращаться к любой из этих двух величин. Когда мы пишем:
int ival2 = ival + 1;
то обращаемся к значению, содержащемуся в переменной ival: прибавляем к нему 1 и инициализируем переменную ival2 этим новым значением, 1025. Каким же образом обратиться к адресу, по которому размещена переменная?
С++ имеет встроенный тип “указатель”, который используется для хранения адресов объектов. Чтобы объявить указатель, содержащий адрес переменной ival, мы должны написать:
int *pint; // указатель на объект типа int
int *pint = new int(1024);
Здесь оператор new
выделяет память под безымянный объект типа int, инициализирует его значением 1024 и возвращает адрес созданного объекта. Этот адрес используется для инициализации указателя pint. Все действия над таким безымянным объектом производятся путем разыменовывания данного указателя, т.к. явно манипулировать динамическим объектом невозможно.
Вторая форма оператора new
выделяет память под массив заданного размера, состоящий из элементов определенного типа:
int *pia = new int[4];
В этом примере память выделяется под массив из четырех элементов типа int. К сожалению, данная форма оператора new не позволяет инициализировать элементы массива.
Некоторую путаницу вносит то, что обе формы оператора new возвращают одинаковый указатель, в нашем примере это указатель на целое. И pint, и pia
объявлены совершенно одинаково, однако pint указывает на единственный объект типа int, а pia – на первый элемент массива из четырех объектов типа int.
Когда динамический объект больше не нужен, мы должны явным образом освободить отведенную под него память. Это делается с помощью оператора delete, имеющего, как и new, две формы – для единичного объекта и для массива:
delete[] pia;
Что случится, если мы забудем освободить выделенную память? Память будет расходоваться впустую, она окажется неиспользуемой, однако возвратить ее системе нельзя, поскольку у нас нет указателя на нее. Такое явление получило специальное название утечка памяти. В конце концов программа аварийно завершится из-за нехватки памяти (если, конечно, она будет работать достаточно долго). Небольшая утечка трудно поддается обнаружению, но существуют утилиты, помогающие это сделать.
Наш сжатый обзор динамического выделения памяти и использования указателей, наверное, больше породил вопросов, чем дал ответов. В разделе 8.4 затронутые проблемы будут освещены во всех подробностях. Однако мы не могли обойтись без этого отступления, так как класс Array, который мы собираемся спроектировать в последующих разделах, основан на использовании динамически выделяемой памяти.
Упражнение 2.3
Объясните разницу между четырьмя объектами:
(d) int *pi3 = new int[1024];
Упражнение 2.4
Что делает следующий фрагмент кода? В чем состоит логическая ошибка? (Отметим, что операция взятия индекса ([]) правильно применена к указателю pia. Объяснение этому факту можно найти в разделе 3.9.2.)
delete[] pia;
можно использовать в программе на C++ для того, чтобы указать, что некоторый объект находится в части, написанной на языке C. Как эта директива влияет на объявления перегруженных функций? Могут ли в одном и том же множестве находиться функции, написанные как на C++, так и на C?
В директиве связывания разрешается задать только одну из множества перегруженных функций. Например, следующая программа некорректна:
extern "C" void print( int );
Приведенный ниже пример перегруженной функции calc() иллюстрирует типичное применение директивы extern "C":
extern BigNum calc( const BigNum& );
Написанная на C функция calc()
может быть вызвана как из C, так и из программы на C++. Остальные две функции принимают в качестве параметра класс и, следовательно, их допустимо использовать только в программе на C++. Порядок следования объявлений несуществен.
Директива связывания не имеет значения при решении, какую функцию вызывать; важны только типы параметров. Выбирается та функция, которая лучше всего соответствует типам переданных аргументов:
}
Показать, что функция написана на другом языке, можно с помощью директивы связывания в форме простой либо составной инструкции:
}
Первая форма такой директивы состоит из ключевого слова extern, за которым следует строковый литерал, а за ним – “обычное” объявление функции. Хотя функция написана на другом языке, проверка типов вызова выполняется полностью. Несколько объявлений функций могут быть помещены в фигурные скобки составной инструкции директивы связывания – второй формы этой директивы. Скобки отмечают те объявления, к которым она относится, не ограничивая их видимости, как в случае обычной составной инструкции. Составная инструкция extern "C" в предыдущем примере говорит только о том, что функции printf() и scanf()
написаны на языке С. Во всех остальных отношениях эти объявления работают точно так же, как если бы они были расположены вне инструкции.
Если в фигурные скобки составной директивы связывания помещается директива препроцессора #include, все объявленные во включаемом заголовочном файле функции рассматриваются как написанные на языке, указанном в этой директиве. В предыдущем примере все функции из заголовочного файла cmath написаны на языке С.
Директива связывания не может появиться внутри тела функции. Следующий фрагмент кода вызывает ошибку компиляции:
<
}
Если мы переместим директиву так, чтобы она оказалась вне тела main(), программа откомпилируется правильно:
}
Однако более подходящее место для директивы связывания – заголовочный файл, где находится объявление функции, описывающее ее интерфейс.
Как сделать С++ функцию доступной для программы на С? Директива extern "C"
поможет и в этом:
extern "C" double calc( double dparm ) { /* ... */ }
Если в одном файле имеется несколько объявлений функции, то директива связывания может быть указана при каждом из них или только при первом – в этом случае она распространяется и на все последующие объявления. Например:
double calc( double dparm ) { // ... }
В данном разделе мы видели примеры директивы связывания extern "C" только для языка С. Это единственный внешний язык, поддержку которого гарантирует стандарт С++. Конкретная реализация может поддерживать связь и с другими языками. Например, extern "Ada" для функций, написанных на языке Ada; extern "FORTRAN" для языка FORTRAN и т.д. Мы описали один из случаев использования ключевого слова extern в С++. В разделе 8.2 мы покажем, что это слово имеет и другое назначение в объявлениях функций и объектов.
Упражнение 7.14
exit(), printf(), malloc(), strcpy() и strlen() являются функциями из библиотеки С. Модифицируйте приведенную ниже С-программу так, чтобы она компилировалась и связывалась в С++.
}
позволяет задать синоним для встроенного либо пользовательского типа данных. Например:
typedef int *Pint;
Имена, определенные с помощью директивы typedef, можно использовать точно так же, как спецификаторы типов:
Pint table [ 10 ];
Эта директива начинается с ключевого слова typedef, за которым идет спецификатор типа, и заканчивается идентификатором, который становится синонимом для указанного типа.
Для чего используются имена, определенные с помощью директивы typedef? Применяя мнемонические имена для типов данных, можно сделать программу более легкой для восприятия. Кроме того, принято употреблять такие имена для сложных составных типов, в противном случае воспринимаемых с трудом (см. пример в разделе 3.14), для объявления указателей на функции и функции-члены класса (см. раздел 13.6).
Ниже приводится пример вопроса, на который почти все дают неверный ответ. Ошибка вызвана непониманием директивы typedef как простой текстовой макроподстановки. Дано определение:
typedef char *cstring;
Каков тип переменной cstr в следующем объявлении:
extern const cstring cstr;
Ответ, который кажется очевидным:
const char *cstr
Однако это неверно. Спецификатор const
относится к cstr, поэтому правильный ответ – константный указатель на char:
char *const
cstr;
(в современных компиляторах препроцессор обычно является частью самого компилятора).
Директива #include
включает в программу содержимое указанного файла. Имя файла может быть указано двумя способами:
#include "my_file.h"
Если имя файла заключено в угловые скобки (<>), считается, что нам нужен некий стандартный заголовочный файл, и компилятор ищет этот файл в предопределенных местах. (Способ определения этих мест сильно различается для разных платформ и реализаций.) Двойные кавычки означают, что заголовочный файл – пользовательский, и его поиск начинается с того каталога, где находится исходный текст программы.
Заголовочный файл также может содержать директивы #include. Поэтому иногда трудно понять, какие же конкретно заголовочные файлы включены в данный исходный текст, и некоторые заголовочные файлы могут оказаться включенными несколько раз. Избежать этого позволяют условные директивы препроцессора. Рассмотрим пример:
#ifndef BOOKSTORE_H
#endif
Условная директива
#ifndef проверяет, не было ли значение BOOKSTORE_H
определено ранее. (BOOKSTORE_H – это константа препроцессора; такие константы принято писать заглавными буквами.) Препроцессор обрабатывает следующие строки вплоть до директивы #endif. В противном случае он пропускает строки от #ifndef до # endif.
Директива
#define BOOKSTORE_H
определяет константу препроцессора BOOKSTORE_H. Поместив эту директиву непосредственно после директивы #ifndef, мы можем гарантировать, что содержательная часть заголовочного файла bookstore.h
будет включена в исходный текст только один раз, сколько бы раз ни включался в текст сам этот файл.
Другим распространенным примером применения условных директив препроцессора является включение в текст программы отладочной информации. Например:
}
Если константа DEBUG не определена, результирующий текст программы будет выглядеть так:
{
string word;
vector text;
while ( cin >> word )
{
text.push_back(word);
}
// ...
}
В противном случае мы получим:
{
cout << "Начало выполнения main()\n";
string word;
vector text;
while ( cin >> word )
{
cout << "Прочитано слово: " << word << "\n";
text.push_back(word);
}
// ...
}
Константа препроцессора может быть определена в командной строке при вызове компилятора с помощью опции -D (в различных реализациях эта опция может называться по-разному). Для UNIX-систем вызов компилятора с определением препроцессорной константы DEBUG
выглядит следующим образом:
$ CC -DDEBUG main.C
Есть константы, которые автоматически определяются компилятором. Например, мы можем узнать, компилируем ли мы С++ или С программу. Для С++ программы автоматически определяется константа __cplusplus
(два подчеркивания). Для стандартного С определяется __STDC__. Естественно, обе константы не могут быть определены одновременно. Пример:
int main(int,int);
Другими полезными предопределенными константами (в данном случае лучше сказать переменными) препроцессора являются __LINE__ и __FILE__. Переменная __LINE__
содержит номер текущей компилируемой строки, а __FILE__ – имя компилируемого файла. Вот пример их использования:
cerr << "Ошибка. Файл: " << __FILE__
<< " Строка: " << __LINE__
<< "element_count не может быть 0";
Две константы __DATE__ и __TIME__
содержат дату и время компиляции.
Стандартная библиотека С предоставляет полезный макрос assert(), который проверяет некоторое условие и в случае, если оно не выполняется, выдает диагностическое сообщение и аварийно завершает программу. Мы будем часто пользоваться этим полезным макросом в последующих примерах программ. Для его применения следует включить в программу директиву
#include
assert.h – это заголовочный файл стандартной библиотеки С. Программа на C++ может ссылаться на заголовочный файл как по его имени, принятому в C, так и по имени, принятому в C++. В стандартной библиотеке С++ этот файл носит имя cassert. Имя заголовочного файла в библиотеке С++ отличается от имени соответствующего файла для С отсутствием расширения .h и подставленной спереди буквой c
(выше уже упоминалось, что в заголовочных файлах для C++ расширения не употребляются, поскольку они могут зависеть от реализации).
Эффект от использования директивы препроцессора #include зависит от типа заголовочного файла. Инструкция
#include
включает в текст программы содержимое файла cassert. Но поскольку все имена, используемые в стандартной библиотеке С++, определены в пространстве std, имя assert()
будет невидимо до тех пор, пока мы явно не сделаем его видимым с помощью следующей using-директивы:
using namespace std;
Если же мы включаем в программу заголовочный файл для библиотеки С
#include
то надобность в using-директиве отпадает: имя assert() будет видно и так[2]. (Пространства имен используются разработчиками библиотек для предотвращения засорения глобального пространства имен. В разделе 8.5 эта тема рассматривается более подробно.)
принимает в качестве параметров два итератора, ограничивающих удаляемую подстроку. Например, превратим
string name( "AnnaLiviaPlurabelle" );
в строку "Annabelle":
name.begin()+endPos );
Символ, на который указывает второй итератор, не входит в удаляемую подстроку.
Для третьей формы параметром является только один итератор; эта форма удаляет все символы, начиная с указанной позиции до конца строки. Например:
name.erase( name. begin()+4 );
оставляет строку "Anna".
Функция-член insert()
позволяет вставить в заданную позицию строки другую строку или символ. Общая форма выглядит так:
string_object.insert( position, new_string );
position
обозначает позицию, перед которой производится вставка. new_string
может быть объектом класса string, C-строкой или символом:
string_object.insert( pos+1, 's' );
Можно выделить для вставки подстроку из new_string:
)
string_object
получает значение "Mississippi Belle". Если мы хотим вставить все символы new_string, начиная с pos, последний параметр нужно опустить.
Пусть есть две строки:
string s2( "Annabelle" );
Как получить третью строку со значением "Miss Anna"?
Можно использовать функции-члены assign() и append():
s3.assign ( s1, 4 );
s3 теперь содержит значение "Miss".
s3 += ' ';
Теперь s3 содержит "Miss ".
s3.append(s2,4);
s3 получила значение "Miss Anna". То же самое можно сделать короче:
s3.assign(s1,4).append(' ').append(s2,4);
Другая форма функции-члена assign()
имеет три параметра: второй обозначает позицию начала, а третий – длину. Позиции нумеруются с 0. Вот как, скажем, извлечь "belle" из "Annabelle":
beauty.assign( s2, 4, 5 );
Вместо этих параметров мы можем использовать пару итераторов:
beauty.assign( s2, s2.begin()+4, s2.end() );
В следующем примере две строки содержат названия текущего проекта и проекта, находящегося в отложенном состоянии. Они должны периодически обмениваться значениями, поскольку работа идет то над одним, то над другим. Например:
string pending_project( "Fantasia 2000, Firebird segment" );
Функция-член swap()
позволяет обменять значения двух строк с помощью вызова
current_project.swap( pending_project );
Для строки
string first_novel( "V" );
операция взятия индекса
char ch = first_novel[ 1 ];
возвратит неопределенное значение: длина строки first_novel равна 1, и единственное правильное значение индекса – 0. Такая операция взятия индекса не обеспечивает проверку правильности параметра, но мы всегда можем сделать это сами с помощью функции-члена size():
<
}
Там, где это невозможно или нежелательно, например:
}
следует воспользоваться функцией at(), которая делает то же, что и операция взятия индекса, но с проверкой. Если индекс выходит за границу, возбуждается исключение out_of_range:
}
Строки можно сравнивать лексикографически. Например:
string cplus_program_crash( "abort" );
Строка cobol_program_crash
лексикографически меньше, чем cplus_program_crash: сопоставление производится по первому отличающемуся символу, а буква e в латинском алфавите идет раньше, чем o. Операция сравнения выполняется функцией-членом compare(). Вызов
sl.compare( s2 );
возвращает одно из трех значений:
· если s1
больше, чем s2, то положительное;
· если s1
меньше, чем s2, то отрицательное;
· если s1
равно s2, то 0.
Например,
cobol_program_crash.compare( cplus_program_crash );
вернет отрицательное значение, а
cplus_program_crash.compare( cobol_program_crash );
положительное. Перегруженные операции сравнения (<, >, !=, ==, <=, >=) являются более компактной записью функции compare().
Шесть вариантов функции-члена compare() позволяют выделить сравниваемые подстроки в одном или обоих операндах. (Примеры вызовов приводились в предыдущем разделе.)
Функция-член replace()
дает десять способов заменить одну подстроку на другую (их длины не обязаны совпадать). В двух основных формах replace() первые два аргумента задают заменяемую подстроку: в первом варианте в виде начальной позиции и длины, во втором – в виде пары итераторов на ее начало и конец. Вот пример первого варианта:
sentence.repiace( position, length, "Abstract Data Type" );
position
представляет собой начальную позицию, а length – длину заменяемой подстроки. Третий аргумент является подставляемой строкой. Его можно задать несколькими способами. Допустим, как объект string:
sentence.replace( position, length, new_str );
Следующий пример иллюстрирует выделение подстроки в new_str:
sentence.replace( position, 1, new_str, posA, 9 );
Еще один вариант позволяет заменить подстроку на один символ, повторенный заданное количество раз:
hmm.repiace( position, 4, 'x', 4 );
В данном примере используется указатель на символьный массив и длина вставляемой подстроки:
ahhem.replace(0, 3, lang+index[1], index[2]-index[1]);
А здесь мы используем пару итераторов:
<
sentence.repiace( start, start+3, "Abstract Data Type" );
Оставшиеся четыре варианта допускают задание заменяющей строки как объекта типа string, символа, повторяющегося N раз, пары итераторов и C-строки.
Вот и все, что мы хотели сказать об операциях со строками. Для более полной информации обращайтесь к определению стандарта С++ [ISO-C++97].
Упражнение 6.18
Напишите программу, которая с помощью функций-членов assign() и append() из строк
string quote2( "The child "is father of the man" );
составит предложение
"The child is in the dooryard"
Упражнение 6.19
Напишите функцию:
int length );
которая в строке
string generic1( "Dear Ms Daisy:" );
заменяет Daisy и Ms
(миссис). Вместо Daisy
подставляется параметр lastname, а вместо Ms
подстрока
string generic2( "MrsMsMissPeople" );
длины length, начинающаяся с pos.
Например, вызов
generate_salutation( generici, lastName, generic2, 5, 4 );
вернет строку:
Dear Miss AnnaP:
читает по одному байту, а функция getline() читает строку, завершающуюся либо символом перехода на новую строку, либо каким-то иным символом, определяемым пользователем. У функции-члена get() есть три формы:
·
get(char& ch) читает из входного потока один символ (в том числе и пустой) и помещает его в ch. Она возвращает объект iostream, для которого была вызвана. Например, следующая программа собирает статистику о входном потоке, а затем копирует входной поток в выходной:
}
Функция-член put()
класса ostream
дает альтернативный метод вывода символа в выходной поток: put()
принимает аргумент типа char и возвращает объект класса ostream, для которого была вызвана.
После компиляции и запуска программа печатает следующий результат:
Alice Emma has long flowing red hair. Her Daddy says
when the wind blows through her hair, it looks almost alive,
like a fiery bird in flight. A beautiful fiery bird, he tells her,
magical but untamed. "Daddy, shush, there is no such creature,"
she tells him, at the same time wanting him to tell her more.
Shyly, she asks, "I mean, Daddy, is there?"
наша статистика:
пробелов: 59 символов новой строки: 6 табуляций: 0
точек: 4 запятых: 12
· вторая форма get()
также читает из входного потока по одному символу, но возвращает не поток istream, а значение прочитанного символа. Тип возвращаемого значения равен int, а не char, поскольку необходимо возвращать еще и признак конца файла, который обычно равен -1, чтобы отличаться от кодов реальных символов. Для проверки на конец файла мы сравниваем полученное значение с константой EOF, определенной в заголовочном файле iostream. Переменная, в которой сохраняется значение, возвращенное get(), должна быть объявлена как int, чтобы в ней можно было представить не только код любого символа, но и EOF:
}
При использовании любой из этих форм get() для чтения данной последовательности нужно семь итераций:
a b c
d
Читаются следующие символы: ('a', пробел, 'b', пробел, 'c', символ новой строки, 'd'). На восьмой итерации читается EOF. Оператор ввода (>>) по умолчанию пропускает пустые символы, поэтому на ту же последовательность потребуется четыре итерации, на которых возвращаются символы: 'a', 'b', 'c', 'd'. А вот следующая форма get()
может прочесть всю последовательность всего за две итерации;
· сигнатура третьей формы get()
такова:
get(char *sink, streamsize size, char delimiter='\n')
sink – это массив, в который помещаются символы. size – это максимальное число символов, читаемых из потока istream. delimiter – это символ-ограничитель, при обнаружении которого чтение прекращается. Сам ограничитель не читается, а оставляется в потоке и будет прочитан следующим. Программисты часто забывают удалить его из потока перед вторым обращением к get(). Чтобы избежать этой ошибки, в показанной ниже программе мы воспользовались функцией-членом ignore()
класса istream. По умолчанию ограничителем является символ новой строки.
Символы читаются из потока, пока одно из следующих условий не окажется истинным. Как только это случится, в очередную позицию массива помещается двоичный нуль.
· прочитано size-1
символов;
· встретился конец файла;
· встретился символ-ограничитель (еще раз напомним, что он остается в потоке и будет считан следующим).
Эта форма get()
возвращает объект istream, для которого была вызвана (функция-член gcount() позволяет узнать количество прочитанных символов). Вот простой пример ее применения:
}
Если на вход этой программы подать текст о юной Алисе Эмме, то результат будет выглядеть так:
фактически прочитано символов: 52
фактически прочитано символов: 60
фактически прочитано символов: 66
фактически прочитано символов: 63
фактически прочитано символов: 61
фактически прочитано символов: 43
Чтобы еще раз протестировать поведение программы, мы создали строку, содержащую больше max_line
символов, и поместили ее в начало текста. Получили:
фактически прочитано символов: 1023
фактически прочитано символов: 528
фактически прочитано символов: 52
фактически прочитано символов: 60
фактически прочитано символов: 66
фактически прочитано символов: 63
фактически прочитано символов: 61
фактически прочитано символов: 43
По умолчанию ignore()
читает и удаляет один символ из потока, для которого вызвана, но можно и явно задать ограничитель и количество пропускаемых символов. В общем виде ее сигнатура такова:
ignore( streamsize length = 1, int delim = traits::eof )
ignore()
читает и отбрасывает length
символов из потока или все символы до ограничителя включительно или до конца файла и возвращает объект istream, для которого вызвана.
Мы рекомендуем пользоваться функцией getline(), а не get(), поскольку она автоматически удаляет ограничитель из потока. Сигнатура getline()
такая же, как у get() с тремя аргументами (и возвращает она тоже объект istream, для которого вызвана):
getline(char *sink, streamsize size, char delimiter='\n')
Поскольку и getline(), и get() с тремя аргументами могут читать size символов или меньше, то часто нужно “спросить” у объекта istream, сколько символов было фактически прочитано. Это позволяет сделать функция-член gcount(): она возвращает число символов, прочитанных при последнем обращении к get() или getline().
Функция-член write()
класса ostream
дает альтернативный метод вывода массива символов. Вместо того чтобы выводить символы до завершающего нуля, она выводит указанное число символов, включая и внутренние нули, если таковые имеются. Вот ее сигнатура:
write( const char *sink, streamsize length )
Здесь length
определяет, сколько символов выводить. write()
возвращает объект класса ostream, для которого она вызвана.
Парной для функции write() из класса ostream
является функция read() из класса istream с такой сигнатурой:
read( char* addr, streamsize size )
read()
читает size
соседних байт из входного потока и помещает их, начиная с адреса addr. Функция gcount()
возвращает число байт, прочитанных при последнем обращении к read(). В свою очередь read() возвращает объект класса istream, для которого она вызвана. Вот пример использования getline(), gcount() и write():
}
Когда на вход было подано несколько фраз из романа Германа Мелвилла “Моби Дик”, программа напечатала следующее:
Строка #1 Прочитано символов: 45
Call me Ishmael. Some years ago, never mind
Строка #2 Прочитано символов: 46
how long precisely, having little or no money
Строка #3 Прочитано символов: 48
in my purse, and nothing particular to interest
Строка #4 Прочитано символов: 51
me on shore, I thought I would sail about a little
Строка #5 Прочитано символов: 47
and see the watery part of the world. It is a
Строка #6 Прочитано символов: 43
way I have of driving off the spleen, and
Строка #7 Прочитано символов: 28
regulating the circulation.
Всего прочитано строк: 7
Самая длинная строка: 51
Функция-член getline()
класса istream
поддерживает только ввод в массив символов. Однако в стандартной библиотеке есть обычная функция getline(), которая помещает символы в объект класса string:
getline( istream &is, string str, char delimiter );
Эта функция читает не более str::max_size()-1
символов. Если входная последовательность длиннее, то операция завершается неудачно и объект переводится в ошибочное состояние. В противном случае ввод прекращается, когда прочитан ограничитель (он удаляется из потока, но в строку не помещается) либо достигнут конец файла.
Вот еще три необходимые нам функции-члена класса istream:
peek();
Следующий фрагмент иллюстрирует использование некоторых из них:
}
Упражнение 20.4
Прочитайте из стандартного ввода следующую последовательность символов, включая все пустые, и скопируйте каждый символ на стандартный вывод (эхо-копирование):
a b c
d e
f
Упражнение 20.5
Прочитайте фразу “riverrun, from bend of bay to swerve of shore” сначала как последовательность из девяти строк, а затем как одну строку.
Упражнение 20.6
С помощью функций getline() и gcount()
прочитайте последовательность строк из стандартного ввода и найдите самую длинную (не забудьте, что строку, прочитанную за несколько обращений к getline(), нужно считать одной).
Внутри производного класса к членам, унаследованным из базового, можно обращаться напрямую, как к его собственным. (Глубина цепочки наследования не увеличивает затраты времени и не лимитирует доступ к ним.) Например:
}
Это касается и доступа к унаследованным функциям-членам базового класса: мы вызываем их так, как если бы они были членами производного – либо через его объект:
nq.display();
либо непосредственно из тела другой (или той же самой) функции-члена:
}
Однако прямой доступ из производного класса к членам базового запрещен, если имя последнего скрыто в производном классе:
};
В области видимости Shy
употребление неквалифицированного имени _mumble разрешается в пользу члена _mumble
класса Shy
(объекта string), даже если такое использование в данном контексте недопустимо:
}
Некоторые компиляторы помечают это как ошибку типизации. Для доступа к члену базового класса, имя которого скрыто в производном, необходимо квалифицировать имя члена базового класса именем самого этого класса с помощью оператора разрешения области видимости. Так выглядит правильная реализация функции-члена turn_eyes_down():
}
Функции-члены базового и производного классов не составляют множество перегруженных функций:
};
Вызов функции-члена базового класса из производного в этом случае приводит к ошибке компиляции:
simon.mumble( 2 );
Хотя к членам базового класса можно обращаться напрямую, они сохраняют область видимости класса, в котором определены. А чтобы функции перегружали друг друга, они должны находиться в одной и той же области видимости. Если бы это было не так, следующие два экземпляра невиртуальной функции-члена turn_aside()
<
};
привели бы к ошибке повторного определения, так как их сигнатуры одинаковы. Однако запись правильна, поскольку каждая функция находится в области видимости того класса, в котором определена.
А если нам действительно нужен набор перегруженных функций-членов базового и производного классов? Написать в производном классе небольшую встроенную заглушку для вызова экземпляра из базового? Это возможно:
};
Но в стандартном C++ тот же результат достигается посредством using-объявления:
};
По сути дела, using-объявление вводит каждый именованный член базового класса в область видимости производного. Поэтому такой член теперь входит в множество перегруженных функций, ассоциированных с именем функции-члена производного класса. (В ее using-объявлении нельзя указать список параметров, только имя. Это означает, что если некоторая функция уже перегружена в базовом классе, то в область видимости производного класса попадут все перегруженные экземпляры и, следовательно, добавить только одну из них невозможно.)
Обратим внимание на степень доступности защищенных членов базового класса. Когда мы пишем:
};
то имеем в виду, что класс, производный от Query, может напрямую обратиться к члену _loc, тогда как во всей остальной программе для этого необходимо пользоваться открытой функцией доступа. Однако объект производного класса имеет доступ только к защищенному члену _loc входящего в него
подобъекта, относящегося к базовому классу. Объект производного класса неспособен обратиться к защищенным членам другого независимого объекта базового класса:
}
У объекта NameQuery
есть доступ к защищенным членам только одного объекта Query – подобъекта самого себя. Прямое обращение к ним из производного класса осуществляется через неявный указатель this (см. раздел 13.4). Первая реакция на ошибку компиляции – переписать функцию compare() с использованием открытой функции-члена location():
}
Однако проблема заключается в неправильном проектировании. Поскольку _loc – это член базового класса Query, то место compare()
среди членов базового, а не производного класса. Во многих случаях подобные проблемы могут быть решены путем переноса некоторой операции в тот класс, где находится недоступный член, как в приведенном примере.
Этот вид ограничения доступа не распространяется на доступ изнутри класса к другим объектам того же класса:
}
Производный класс может напрямую обращаться к защищенным членам базового в других объектах того же класса, что и он сам, равно как и к защищенным и закрытым членам других объектов своего класса.
Рассмотрим инициализацию указателя на базовый Query адресом объекта производного NameQuery:
Query *pb = new NameQuery( "sprite" );
При вызове виртуальной функции, определенной в базовом классе Query, например:
pb->eval(); // вызывается NameQuery::eval()
вызывается функция из NameQuery. За исключением вызова виртуальной функции, объявленной в Query и переопределенной в NameQuery, другого способа напрямую добраться до членов класса NameQuery
через указатель pb не существует:
(a) если в Query и NameQuery
объявлены некоторые невиртуальные функции-члены с одинаковым именем, то через pb
всегда вызывается экземпляр из Query;
(b) если в Query и NameQuery
объявлены одноименные члены, то через pb обращение происходит к члену класса Query;
(c) если в NameQuery
имеется виртуальная функция, отсутствующая в Query, скажем suffix(), то попытка вызвать ее через pb приводит к ошибке компиляции:
pb->suffix();
· Обращение к члену или невиртуальной функции-члену класса NameQuery
через pb
тоже вызывает ошибку компиляции:
pb->_name;
Квалификация имени члена в этом случае не помогает:
pb->NameQuery::_name;
В C++ с помощью указателя на базовый класс можно работать только с данными и функциями-членами, включая виртуальные, которые объявлены (или унаследованы) в самом этом классе, независимо от того, какой фактический объект адресуется указателем. Объявление функции-члена виртуальной откладывает решение вопроса о том, какой экземпляр функции вызвать, до выяснения (во время выполнения программы) фактического типа объекта, адресуемого pb.
Такой подход может показаться недостаточно гибким, но у него есть два весомых преимущества:
· поиск виртуальной функции-члена во время выполнения никогда не закончится неудачно из-за того, что фактический тип класса не существует. В таком случае программа просто не смогла бы откомпилироваться;
· механизм виртуализации можно оптимизировать. Часто вызов такой функции оказывается не дороже, чем косвенный вызов функции по указателю (детально этот вопрос рассмотрен в [LIPPMAN96a]).
В базовом классе Query
определен статический член _text_file:
static vector *_text_file;
Создается ли при порождении класса NameQuery
второй экземпляр _text_file, уникальный именно для него? Нет. Все объекты производного класса ссылаются на тот же самый, единственный разделяемый статический член. Сколько бы ни было производных классов, существует лишь один экземпляр _text_file. Можно обратиться к нему через объект производного класса с помощью синтаксиса доступа:
nameQueryObject._text_file; // правильно
Наконец, если производный класс хочет получить доступ к закрытым членам своего базового класса напрямую, то он должен быть объявлен другом базового:
};
Теперь объект NameQuery
может обращаться не только к закрытым членам своего подобъекта, соответствующего базовому классу, но и к закрытым и защищенным членам любых объектов Query.
А если мы произведем от NameQuery
класс StringQuery? Он будет поддерживать сокращенную форму запроса AndQuery, и вместо
beautiful && fiery && bird
можно будет написать:
"beautiful fiery bird"
Унаследует ли StringQuery от класса NameQuery
дружественные отношения с Query? Нет. Отношение дружественности не наследуется. Производный класс не становится другом класса, который объявил своим другом один из базовых. Если производному классу требуется стать другом одного или более классов, то эти классы должны предоставить ему соответствующие права явно. Например, у класса StringQuery нет никаких специальных прав доступа по отношению к Query. Если расширенный доступ необходим, то Query должен разрешить его явно.
Упражнение 17.6
Даны следующие определения базового и производных классов:
};
Исправьте ошибки в каждом из следующих фрагментов кода:
(b) void Derived::foobar() { _bar = 1024; }
{ return _foo_bar == pb->_foo_bar; }
· в определении функции-члена могут быть обращения к любым членам класса, открытым или закрытым, и это не нарушает ограничений доступа;
· когда функция-член обращается к членам класса, операторы доступа “точка” и “стрелка” не необходимы.
Например:
}
Хотя _screen, _height, _width и _cursor
являются закрытыми членами класса Screen, функция-член copy()
работает с ними напрямую. Если при обращении к члену отсутствует оператор доступа, то считается, что речь идет о члене того класса, для которого функция-член вызвана. Если вызвать copy() следующим образом:
}
то параметр sobj
внутри определения copy()
соотносится с объектом s1 из функции main(). Функция-член copy() вызвана для объекта s2, стоящего перед оператором “точка”. Для такого вызова члены _screen, _height, _width и _cursor, при обращении к которым внутри определения этой функции нет оператора доступа, – это члены объекта s2. В следующем разделе мы рассмотрим доступ к членам класса внутри определения функции-члена более подробно и, в частности, покажем, как для поддержки такого доступа применяется указатель this.
показал, что для его объектов всегда задается размер экрана 80 ´ 24. В таком случае было бы желательно заменить внутреннее представление экрана менее гибким, но более эффективным:
};
Прежняя реализация функций-членов (то, как они манипулируют данными-членами класса) больше не годится, ее нужно переписать. Но это не означает, что должен измениться и интерфейс функций-членов (список формальных параметров и тип возвращаемого значения).
Если бы данные-члены класса Screen
были открыты и доступны любой функции внутри программы, как отразилось бы на пользователях изменение внутреннего представления этого класса?
·
все функции, которые напрямую обращались к данным-членам старого представления, перестали бы работать. Следовательно, пришлось бы отыскивать и изменять соответствующие части кода;
· так как интерфейс не изменился, то коды, манипулировавшие объектами класса Screen только через функции-члены, не пришлось бы модифицировать. Но поскольку сами функции-члены все же изменились, программу пришлось бы откомпилировать заново.
Сокрытие информации – это формальный механизм, предотвращающий прямой доступ к внутреннему представлению типа класса из функций программы. Ограничение доступа к членам задается с помощью секций тела класса, помеченных ключевыми словами public, private и protected – спецификаторами доступа. Члены, объявленные в секции public, называются открытыми, а объявленные в секциях private и protected
соответственно закрытыми или защищенными.
· открытый член
доступен из любого места программы. Класс, скрывающий информацию, оставляет открытыми только функции-члены, определяющие операции, с помощью которых внешняя программа может манипулировать его объектами;
· закрытый член
доступен только функциям-членам и друзьям
класса. Класс, который хочет скрыть информацию, объявляет свои данные-члены закрытыми;
· защищенный член
ведет себя как открытый по отношению к производному классу и как закрытый по отношению к остальной части программы. (В главе 2 мы видели пример использования защищенных членов в классе IntArray. Детально они рассматриваются в главе 17, где вводится понятие наследования.)
В следующем определении класса Screen
указаны секции public и private:
};
Согласно принятому соглашению, сначала объявляются открытые члены класса. (Обсуждение того, почему в старых программах C++ сначала шли закрытые члены и почему этот стиль еще кое-где сохранился, см. в книге [LIPPMAN96a].) В теле класса может быть несколько секций public, protected и private. Каждая секция продолжается либо до метки следующей секции, либо до закрывающей фигурной скобки. Если спецификатор доступа не указан, то секция, непосредственно следующая за открывающей скобкой, по умолчанию считается private.
позволяет классу разрешать доступ к своим неоткрытым членам.
Объявление друга начинается с ключевого слова friend и может встречаться только внутри определения класса. Так как друзья не являются членами класса, то не имеет значения, в какой секции они объявлены. В примере ниже мы сгруппировали все подобные объявления сразу после заголовка класса:
};
Операторы ввода и вывода теперь могут напрямую обращаться к закрытым членам класса Screen. Простая реализация оператора вывода выглядит следующим образом:
}
Другом может быть функция из пространства имен, функция-член другого класса или даже целый класс. В последнем случае всем его функциям-членам предоставляется доступ к неоткрытым членам класса, объявляющего дружественные отношения. (В разделе 15.2 друзья обсуждаются более подробно.)
Рассмотрим еще раз перегруженные операторы равенства для класса String, определенные в области видимости пространства имен. Оператор равенства для двух объектов String
выглядит следующим образом:
}
Сравните это определение с определением того же оператора как функции-члена:
}
Нам пришлось модифицировать способ обращения к закрытым членам класса String. Поскольку новый оператор равенства– это глобальная функция, а не функция-член, у него нет доступа к закрытым членам класса String. Для получения размера объекта String и лежащей в его основе C-строки символов используются функции-члены size() и c_str().
Альтернативной реализацией является объявление глобальных операторов равенства друзьями класса String. Если функция или оператор объявлены таким образом, им предоставляется доступ к неоткрытым членам.
Объявление друга (оно начинается с ключевого слова friend) встречается только внутри определения класса. Поскольку друзья не являются членами класса, объявляющего дружественные отношения, то безразлично, в какой из секций – public, private или protected – они объявлены. В примере ниже мы решили поместить все подобные объявления сразу после заголовка класса:
};
В этих трех строчках три перегруженных оператора сравнения, принадлежащие глобальной области видимости, объявляются друзьями класса String, а следовательно, в их определениях можно напрямую обращаться к закрытым членам данного класса:
На третьем шаге этой процедуры выбирается наилучшее соответствие. При этом ранжируются преобразования типов фактических аргументов в типы формальных параметров функции. Если аргументы и параметры имеют тип класса, то в множество возможных преобразований следует включать и последовательности определенных пользователем преобразований, также подвергая их ранжированию.
В этом разделе мы детально рассмотрим, как фактические аргументы и формальные параметры типа класса влияют на отбор функций-кандидатов и как последовательности определенных пользователем преобразований сказываются на выборе наилучшей из устоявших функции.
}
Проблема в том, что у ссылки vec
есть спецификатор const, а мы пытаемся связать с ней итератор без такого спецификатора. Если бы это было разрешено, то ничто не помешало бы нам модифицировать с помощью этого итератора элементы вектора. Для предотвращения подобной ситуации язык требует, чтобы итератор, связанный с const-вектором, был константным. Мы можем сделать это следующим образом:
vector< type>::const_iterator iter = vec.begin();
Требование, чтобы с const-контейнером был связан только константный итератор, аналогично требованию о том, чтобы const-массив адресовался только константным указателем. В обоих случаях это вызвано необходимостью гарантировать, что содержимое const-контейнера не будет изменено.
Операции begin() и end()
перегружены и возвращают константный или неконстантный итератор в зависимости от наличия спецификатора const в объявлении контейнера. Если дана такая пара объявлений:
const vector< int > vec1;
то при обращениях к begin() и end() для vec0
будет возвращен неконстантный, а для vec1 – константный итератор:
vector< int >::const_iterator iter1 = vec1.begin();
Разумеется, присваивание константному итератору неконстантного разрешено всегда. Например:
vector< int >::const_iterator iter2 = vec0.begin();
поддерживает и файловый ввод/вывод. Все операции, применимые в стандартному вводу и выводу, могут быть также применены к файлам. Чтобы использовать файл для ввода или вывода, мы должны включить еще один заголовочный файл:
#include
Перед тем как открыть файл для вывода, необходимо объявить объект типа ofstream:
ofstream outfile("name-of-file");
Проверить, удалось ли нам
открыть файл, можно следующим образом:
cerr << "Ошибка открытия файла.\n"
Так же открывается файл и для ввода, только он имеет тип ifstream:
if ( ! infile ) // false, если файл не открыт
cerr << "Ошибка открытия файла.\n"
Ниже приводится текст простой программы, которая читает файл с именем in_file и выводит все прочитанные из этого файла слова, разделяя их пробелом, в другой файл, названный out_file.
string word;
while ( infile >> word )
outfile << word << ' ';
return 0;
}
В главе 20 библиотека ввода/вывода будет рассмотрена подробно. А в следующих разделах мы увидим, как можно создавать новые типы данных, используя механизм классов и шаблонов.
такой набор состоит из следующих объявленных в нем функций-членов:
};
Хотя у любого объекта класса есть собственная копия всех данных-членов, каждая функция-член существует в единственном экземпляре:
groupScreen.home();
При вызове функции home() для объекта myScreen
происходит обращение к его члену _cursor. Когда же эта функция вызывается для объекта groupScreen, то она обращается к члену _cursor именно этого объекта, причем сама функция home() одна и та же. Как же может одна функция-член обращаться к данным-членам разных объектов? Для этого применяется указатель this, рассматриваемый в следующем разделе.
является встроенным, так как определен внутри определения шаблона класса:
};
При определении функции-члена шаблона вне определения самого шаблона следует применять специальный синтаксис для обозначения того, членом какого именно шаблона является функция. Определению функции-члена должно предшествовать ключевое слово template, за которым следуют параметры шаблона. Так, конструктор Queue
можно определить следующим образом:
Queue( ) { front = back = 0; }
За первым вхождением Queue
(перед оператором ::) следует список параметров, показывающий, какому шаблону принадлежит данная функция-член. Второе вхождение Queue в определение конструктора (после оператора ::) содержит имя функции-члена, за которым может следовать список параметров шаблона, хотя это и необязательно. После имени функции идет ее определение;. в нем могут быть ссылки на параметр шаблона Type всюду, где в определении обычной функции использовалось бы имя типа.
Функция-член шаблона класса сама является шаблоном. Стандарт C++ требует, чтобы она конкретизировалась только при вызове либо при взятии ее адреса. (Некоторые более старые компиляторы конкретизируют такие функции одновременно с конкретизацией самого шаблона класса.) При конкретизации функции-члена используется тип того объекта, для которого функция вызвана:
Queue qs;
Объект qs
имеет тип Queue. При инициализации объекта этого класса вызывается конструктор Queue. В данном случае аргументом, которым конкретизируется функция-член (конструктор), будет string.
Функция-член шаблона конкретизируется только при реальном использовании в программе (т.е. при вызове или взятии ее адреса). От того, в какой именно момент конкретизируется функция-член, зависит разрешение имен в ее определении (см. раздел 16.11) и объявление ее специализации (см. раздел 16.9).
};
Деструктор, а также функции-члены remove() и add()
определены не в теле шаблона, а вне его. Деструктор Queue
опустошает очередь:
}
Функция-член Queue::add()
помещает новый элемент в конец очереди:
}
Функция-член Queue::remove()
возвращает значение элемента, находящегося в начале очереди, и удаляет сам элемент.
}
Мы поместили определения функций-членов в заголовочный файл Queue.h, включив его в каждый файл, где возможны конкретизации функций. (Обоснование этого решения, а также рассмотрение более общих вопросов, касающихся модели компиляции шаблонов, мы отложим до раздела 16.8.)
В следующей программе иллюстрируется использование и конкретизация функции-члена шаблона Queue:
}
После компиляции и запуска программа выводит следующую строку:
!! queue executed ok
Упражнение 16.5
Используя шаблон класса Screen, определенный в разделе 16.2, реализуйте функции-члены Screen
(см. разделы 13.3, 13.4 и 13.6) в виде функций-членов шаблона.
blank = '\n'; // ошибка
Однако объект класса, как правило, не модифицируется программой напрямую. Вместо этого вызывается та или иная открытая функция-член. Чтобы не было “покушений” на константность объекта, компилятор должен различать безопасные (те, которые не изменяют объект) и небезопасные (те, которые пытаются это сделать) функции-члены:
blankScreen.set( '*' ); // ошибка: модифицирует объект класса
Проектировщик класса может указать, какие функции-члены не модифицируют объект, объявив их константными с помощью спецификатора const:
};
Для класса, объявленного как const, могут быть вызваны только те функции-члены, которые также объявлены со спецификатором const. Ключевое слово const
помещается между списком параметров и телом функции-члена. Для константной функции-члена, определенной вне тела класса, это слово должно присутствовать как в объявлении, так и в определении:
}
Запрещено объявлять константную функцию-член, которая модифицирует члены класса. Например, в следующем упрощенном определении:
};
определение функции-члена ok()
корректно, так как она не изменяет значения _cursor. В определении же error()
значение _cursor
изменяется, поэтому такая функция-член не может быть объявлена константной и компилятор выдает сообщение об ошибке:
error: cannot modify a data member within a const member function
ошибка: не могу модифицировать данные-члены внутри константной функции-члена
Если класс будет интенсивно использоваться, лучше объявить его функции-члены, не модифицирующие данных, константными. Однако наличие спецификатора const в объявлении функции-члена не предотвращает все возможные изменения. Такое объявление гарантирует лишь, что функции-члены не смогут изменять данные-члены, но если класс содержит указатели, то адресуемые ими объекты могут быть модифицированы константной функцией, не вызывая ошибки компиляции. Это часто приводит в недоумение начинающих программистов. Например:
}
Модифицировать _text
нельзя, но это объект типа char*, и символы, на которые он указывает, можно изменить внутри константной функции-члена класса Text. Функция-член bad()
демонстрирует плохой стиль программирования. Константность функции-члена не гарантирует, что объекты внутри класса останутся неизменными после ее вызова, причем компилятор не поможет обнаружить такую ситуацию.
Константную функцию-член можно перегружать неконстантной функцией с тем же списком параметров:
};
В этом случае наличие спецификатора const у объекта класса определяет, какая из двух функций будет вызвана:
}
Хотя конструкторы и деструкторы не являются константными функциями-членами, они все же могут вызываться для константных объектов. Объект становится константным после того, как конструктор проинициализирует его, и перестает быть таковым, как только вызывается деструктор. Таким образом, объект со спецификатором const
трактуется как константный с момента завершения работы конструктора и до вызова деструктора.
Функцию-член можно также объявить со спецификатором volatile (он был введен в разделе 3.13). Объект класса объявляется как volatile, если его значение изменяется способом, который не обнаруживается компилятором (например, если это структура данных, представляющая порт ввода/вывода). Для таких объектов вызываются только функции-члены с тем же спецификатором, конструкторы и деструкторы:
char Screen::poll() volatile { ... }
Функции-члены класса объявляются в его теле. Это объявление выглядит точно так же, как объявление функции в области видимости пространства имен. (Напомним, что глобальная область видимости – это тоже область видимости пространства имен. Глобальные функции рассматривались в разделе 8.2, а пространства имен – в разделе 8.5.) Например:
};
Определение функции-члена также можно поместить внутрь тела класса:
};
home() перемещает курсор в левый верхний угол экрана; get()
возвращает символ, находящийся в текущей позиции курсора.
Функции-члены отличаются от обычных функций следующим:
· функция-член объявлена в области видимости своего класса, следовательно, ее имя не видно за пределами этой области. К функции-члену можно обратиться с помощью одного из операторов доступа к членам – точки (.) или стрелки (->):
myScreen.home();
(в разделе 13.9 область видимости класса обсуждается более детально);
· функции-члены имеют право доступа как к открытым, так и к закрытым членам класса, тогда как обычным функциям доступны лишь открытые. Конечно, функции-члены одного класса, как правило, не имеют доступа к данным-членам другого класса.
Функция-член может быть перегруженной (перегруженные функции рассматриваются в главе 9). Однако она способна перегружать лишь другую функцию-член своего класса. По отношению к функциям, объявленным в других классах или пространствах имен, функция-член находится в отдельной области видимости и, следовательно, не может перегружать их. Например, объявление get(int, int) перегружает лишь get() из того же класса Screen:
};
(Подробнее мы остановимся на функциях-членах класса в разделе 13.3.)
calc(t)
встречается в области видимости класса (например, внутри функции-члена), то первая часть множества кандидатов, описанного в предыдущем подразделе (т.е. множество, включающее объявления функций, видимых в точке вызова), может содержать не только функции-члены класса. Для построения такого множества применяется разрешение имени. (Эта тема детально разбиралась в разделах 13.9 – 13.12.)
Рассмотрим пример:
}
Как отмечалось в разделе 13.11, квалификаторы NS::myClass:: просматриваются в обратном порядке: сначала поиск видимого объявления для имени, использованного в определении функции-члена mf(), ведется в классе myClass, а затем – в пространстве имен NS. Рассмотрим первый вызов:
h( 'a' );
При разрешении имени h() в определении функции-члена mf() сначала просматриваются функции-члены myClass. Поскольку функции-члена с таким именем в области видимости этого класса нет, то далее поиск идет в пространстве имен NS. Функции h()нет и там, поэтому мы переходим в глобальную область видимости. Результат – глобальная функция h(char), единственная функция-кандидат, видимая в точке вызова.
Как только найдено подходящее объявление, поиск прекращается. Следовательно, множество содержит только те функции, объявления которых находятся в областях видимости, где разрешение имени завершилось успешно. Это можно наблюдать на примере построения множества кандидатов для вызова
k( 4 );
Сначала поиск ведется в области видимости класса myClass. При этом найдены две функции-члена k(int) и k(char*). Поскольку множество кандидатов содержит лишь функции, объявленные в той области, где разрешение успешно завершилось, то пространство имен NS не просматривается и функция k(double) в данное множество не включается.
Если обнаруживается, что вызов неоднозначен, поскольку в множестве нет наиболее подходящей функции, то компилятор выдает сообщение об ошибке. Поиск кандидатов, лучше соответствующих фактическим аргументам, в объемлющих областях видимости не производится.
·
объявление функции видимо в точке вызова. В следующем примере
}
все четыре функции f()
удовлетворяют этому условию. Поэтому множество кандидатов содержит четыре элемента;
· если тип фактического аргумента объявлен внутри некоторого пространства имен, то функции-члены этого пространства, имеющие то же имя, что и вызванная функция, добавляются в множество кандидатов:
}
Таким образом, совокупность кандидатов является объединением множества функций, видимых в точке вызова, и множества функций, объявленных в том же пространстве имен, к которому принадлежат типы фактических аргументов.
При идентификации множества перегруженных функций, видимых в точке вызова, применимы уже рассмотренные ранее правила.
Функция, объявленная во вложенной области видимости, скрывает, а не перегружает одноименную функцию во внешней области. В такой ситуации кандидатами будут только функции из во вложенной области, т.е. такие, которые не скрыты при вызове. В следующем примере функциями-кандидатами, видимыми в точке вызова, являются format(double) и format(char*):
Функцией-кандидатом называется функция с тем же именем, что и вызванная. Предположим, что имеется такой вызов:
add( si, 566 );
Функция-кандидат должна иметь имя add. Какие из объявлений add()
принимаются во внимание? Те, которые видимы в точке вызова.
Например, обе функции add(), объявленные в глобальной области видимости, будут кандидатами для следующего вызова:
}
Рассмотрение функций, чьи объявления видны в точке вызова, производится не только для вызовов с аргументами типа класса. Однако для них поиск объявлений проводится еще в двух областях видимости:
· если фактический аргумент – это объект типа класса, указатель или ссылка на тип класса либо указатель на член класса и этот тип объявлен в пользовательском пространстве имен, то к множеству функций-кандидатов добавляются функции, объявленные в этом же пространстве и имеющие то же имя, что и вызванная:
}
Аргумент si
имеет тип SmallInt, т.е. тип класса, объявленного в пространстве имен NS. Поэтому к множеству функций-кандидатов добавляется add(const String &, const String &), объявленная в этом пространстве имен;
· если фактический аргумент – это объект типа класса, указатель или ссылка на класс либо указатель на член класса и у этого класса есть друзья, имеющие то же имя, что и вызванная функция, то они добавляются к множеству функций-кандидатов:
<
Наследование влияет на первый шаг процедуры разрешения перегрузки функции – формирование множества кандидатов для данного вызова, причем это влияние может быть различным в зависимости от того, рассматривается ли вызов обычной функции вида
func( args );
или функции-члена с помощью операторов доступа “точка” или “стрелка”:
pointer->memfunc( args );
В данном разделе мы изучим оба случая.
Если аргумент обычной функции имеет тип класса, ссылки или указателя на тип класса, и класс определен в пространстве имен, то кандидатами будут все одноименные функции, объявленные в этом пространстве, даже если они невидимы в точке вызова (подробнее об этом говорилось в разделе 15.10). Если аргумент при наследовании имеет тип класса, ссылки или указателя на тип класса, и у этого класса есть базовые, то в множество кандидатов добавляются также функции, объявленные в тех пространствах имен, где определены базовые классы. Например:
}
Аргумент baloo
имеет тип класса Bear. Кандидатами для вызова display() будут не только функции, объявления которых видимы в точке ее вызова, но также и те, что объявлены в пространствах имен, в которых объявлены класс Bear и его базовый класс ZooAnimal. Поэтому в множество кандидатов добавляется функция display(const ZooAnimal&), объявленная в пространстве имен NS.
Если аргумент имеет тип класса и в определении этого класса объявлены функции-друзья с тем же именем, что и вызванная функция, то эти друзья также будут кандидатами, даже если их объявления не видны в точке вызова (см. раздел 15.10). Если аргумент при наследовании имеет тип класса, у которого есть базовые, то в множество кандидатов добавляются одноименные функции-друзья каждого из них. Предположим, что в предыдущем примере display() объявлена как функция-друг ZooAnimal:
Рассмотрим два вида вызовов функции-члена:
pmc->mf( arg );
где mc – выражение типа myClass, а pmc – выражение типа “указатель на тип myClass”. Множество кандидатов для обоих вызовов составлено из функций, найденных в области видимости класса myClass при поиске объявления mf().
Аналогично для вызова функции вида
myClass::mf( arg );
множество кандидатов также состоит из функций, найденных в области видимости класса myClass при поиске объявления mf(). Например:
}
Кандидатами для вызова функции в main()
являются все три функции-члена mf(), объявленные в myClass:
static void mf( int* );
Если бы в myClass не было объявлено ни одной функции-члена с именем mf(), то множество кандидатов оказалось бы пустым. (На самом деле рассматривались бы также и функции из базовых классов. О том, как они попадают в это множество, мы поговорим в разделе 19.3.) Если для вызова функции не оказывается кандидатов, компилятор выдает сообщение об ошибке.
prog -d -o of lie dataO
Фактические параметры являются аргументами функции main() и могут быть получены из массива C-строк с именем argv; мы покажем, как их использовать.
Во всех предыдущих примерах определение main()
содержало пустой список:
int main() { ... }
Развернутая сигнатура main()
позволяет получить доступ к параметрам, которые были заданы пользователем в командной строке:
int main( int argc, char *argv[] ){...}
argc
содержит их количество, а argv – C-строки, представляющие собой отдельные значения (в командной строке они разделяются пробелами). Скажем, при запуске команды
prog -d -o ofile data0
argc
получает значение 5, а argv
включает следующие строки:
argv[ 0 ] = "prog";
argv[ 1 ] = "-d";
argv[ 2 ] = "-o";
argv[ 3 ] = "ofile";
argv[ 4 ] = "dataO";
В argv[0]
всегда входит имя команды (программы). Элементы с индексами от 1 до argc-1
служат параметрами.
Посмотрим, как можно извлечь и использовать значения, помещенные в argv. Пусть программа из нашего примера вызывается таким образом:
[ file_name [file_name [ ... ]]]
Параметры в квадратных скобках являются необязательными. Вот, например, запуск программы с их минимальным количеством – одним лишь именем файла:
prog chap1.doc
Но можно запускать и так:
prog -l 512 -d chap4.doc
При разборе параметров командной строки выполняются следующие основные шаги:
1. По очереди извлечь каждый параметр из argv. Мы используем для этого цикл for с начальным индексом 1
(пропуская, таким образом, имя программы):
}
2. Определить тип параметра. Если строка начинается с дефиса (-), это одна из опций { h, d, v, l, o}. В противном случае это может быть либо значение, ассоциированное с опцией (максимальный размер для -l, имя выходного файла для -o), либо имя входного файла. Чтобы определить, начинается ли строка с дефиса, используем инструкцию switch:
}
Реализуем обработку двух случаев пункта 2.
Если строка начинается с дефиса, мы используем switch по следующему символу для определения конкретной опции. Вот общая схема этой части программы:
}
Опция -d
задает необходимость отладки. Ее обработка заключается в присваивании переменной с объявлением
bool debug_on = false;
значения true:
break;
В нашу программу может входить код следующего вида:
display_state_elements( obj );
Опция -v
выводит номер версии программы и завершает исполнение:
return 0;
Опция -h
запрашивает информацию о синтаксисе запуска и завершает исполнение. Вывод сообщения и выход из программы выполняется функцией usage():
usage();
Опция -o
сигнализирует о том, что следующая строка содержит имя выходного файла. Аналогично опция -l
говорит, что за ней указан максимальный размер. Как нам обработать эти ситуации?
Если в строке параметра нет дефиса, возможны три варианта: параметр содержит имя выходного файла, максимальный размер или имя входного файла. Чтобы различать эти случаи, присвоим true
переменным, отражающим внутреннее состояние:
bool limit_on = false;
Вот обработка опций -l и -o в нашей инструкции switch:
break;
Встретив строку, не начинающуюся с дефиса, мы с помощью переменных состояния можем узнать ее содержание:
}
Если аргумент является именем выходного файла, сохраним это имя и выключим ofile_on:
}
Если аргумент задает максимальный размер, мы должны преобразовать строку встроенного типа в представляемое ею число. Сделаем это с помощью стандартной функции atoi(), которая принимает строку в качестве аргумента и возвращает int
(также существует функция atof(), возвращающая double). Для использования atoi()
включим заголовочный файл ctype.h. Нужно проверить, что значение максимального размера неотрицательно и выключить limit_on:
}
Если обе переменных состояния равны false, у нас есть имя входного файла. Сохраним его в векторе строк:
<
file_names.push_back( string( pchar ));
При обработке параметров командной строки важен способ реакции на неверные опции. Мы решили, что задание отрицательной величины в качестве максимального размера будет фатальной ошибкой. Это приемлемо или нет в зависимости от ситуации. Также можно распознать эту ситуацию как ошибочную, выдать предупреждение и использовать ноль или какое-либо другое значение по умолчанию.
Слабость нашей реализации становится понятной, если пользователь небрежно относится к пробелам, разделяющим параметры. Скажем, ни одна из следующих двух строк не будет обработана:
prog -oout_file dataOl
(Оба случая мы оставим для упражнений в конце раздела.)
Вот полный текст нашей программы. (Мы добавили инструкции печати для трассировки выполнения.)
}
a.out -d -l 1024 -o test_7_8 chapter7.doc chapters.doc
Вот трассировка обработки параметров командной строки:
демонстрация обработки параметров в командной строке:
argc: 8
argv[ 1 ]: -d
встретился '-'
встретилась -d: отладочная печать включена
argv[ 2 ]: -l
встретился '-'
встретилась -l: ограничение ресурса
argv[ 3 ]: 1024
default: параметр без дефиса: 1024
argv[ 4 ]: -o
встретился '-'
встретилась -o: выходной файл
argv[ 5 ]: test_7_8
default: параметр без дефиса: test_7_8
argv[ 6 ]: chapter7.doc
default: параметр без дефиса: chapter7.doc
argv[ 7 ]: chapter8.doc
default: параметр без дефиса: chapter8.doc
Заданное пользователем значение limit: 1024
Заданный пользователем выходной файл: test_7_8
Файлы, подлежащий(е) обработке:
chapter7.doc
chapter8.doc
Для того чтобы глобальную функцию можно было вызвать или взять ее адрес, она должна иметь определение. Любой глобальный объект, используемый в программе, должен быть определен, причем только один раз. Встроенные функции могут определяться несколько раз, если только все определения совпадают. Такое требование единственности или точного совпадения получило название правила одного определения (ПОО). В этом разделе мы покажем, как следует вводить глобальные объекты и функции в программе, чтобы ПОО соблюдалось.
В компьютере сцена представляется графом сцены, который содержит информацию о геометрии (трехмерные модели), один или более источников освещения (иначе сцена будет погружена во тьму), камеру (без нее мы не можем смотреть на сцену) и несколько трансформационных узлов, с помощью которых позиционируются элементы.
Процесс применения источников освещения и камеры к геометрической модели для получения двумерного изображения, отображаемого на дисплее, называется рендерингом. В алгоритме рендеринга учитываются два основных аспекта: природа источника освещения сцены и свойства материалов поверхностей объектов, такие, как цвет, шероховатость и прозрачность. Ясно, что перышки на белоснежных крыльях феи выглядят совершенно не так, как капающие из ее глаз слезы, хотя те и другие освещены одним и тем же серебристым светом.
Добавление объектов к сцене, их перемещение, игра с источниками освещения и геометрией– работа компьютерного художника. Наша задача – предоставить интерактивную поддержку для манипуляций с графом сцены на экране. Предположим, что в текущей версии своего инструмента мы решили воспользоваться каркасом приложений Open Inventor для C++ (см. [WERNECKE94]), но с помощью подтипизации расширили его, создав собственные абстракции нужных нам классов. Например, Open Inventor располагает тремя встроенными источниками освещения, производными от абстрактного базового класса SoLight:
class SoDirectionalLight : public SoLight { ... }
Префикс So
служит для того, чтобы дать уникальные имена сущностям, которые в области компьютерной графики весьма распространены (данный каркас приложений проектировался еще до появления пространств имен). Точечный источник (point light) – это источник света, излучающий, как солнце, во всех направлениях. Направленный источник (directional light) – источник света, излучающий в одном направлении. Прожектор
(spotlight) – источник, испускающий узконаправленный конический пучок, как обычный театральный прожектор.
По умолчанию Open Inventor осуществляет рендеринг графа сцены на экране с помощью библиотеки OpenGL (см. [NEIDER93]). Для интерактивного отображения этого достаточно, но почти все изображения, сгенерированные для киноиндустрии, сделаны с помощью средства RenderMan (см. [UPSTILL90]). Чтобы добавить поддержку такого алгоритма рендеринга мы, в частности, должны реализовать собственные специальные подтипы источников освещения:
class RiDirectionalLight : public SoDirectionalLight { ... }
Новые подтипы содержат дополнительную информацию, необходимую для рендеринга с помощью RenderMan. При этом базовые классы Open Inventor по-прежнему позволяют выполнять рендеринг с помощью OpenGL. Неприятности начинаются, когда возникает необходимость расширить поддержку теней.
В RenderMan направленный источник и прожектор поддерживают отбрасывание тени (поэтому мы называем их источниками освещения, дающими тень, – SCLS), а точечный – нет. Общий алгоритм требует, чтобы мы обошли все источники освещения на сцене и составили карту теней для каждого включенного SCLS. Проблема в том, что источники освещения хранятся в графе сцены как полиморфные объекты класса SoLight. Хотя мы можем инкапсулировать общие данные и необходимые операции в класс SCLS, непонятно, как включить его в существующую иерархию классов Open Inventor.
В поддереве с корнем SoLight в иерархии Open Inventor нет такого класса, из которого можно было бы произвести с помощью одиночного наследования класс SCLS так, чтобы в дальнейшем уже от него произвести SdRiSpotLight и SdRiDirectionalLight. Если не пользоваться множественным наследованием, лучшее, что можно сделать, – это сравнить член класса SCLS с каждым возможным типом SCLS-источника и вызвать соответствующую операцию:
<
// и так далее
(Оператор dynamic_cast – это часть механизма идентификации типов во время выполнения (RTTI). Он позволяет опросить тип объекта, адресованного полиморфным указателем или ссылкой. Подробно RTTI будет обсуждаться в главе 19.)
Пользуясь множественным наследованием, мы можем инкапсулировать подтипы SCLS, защитив наш код от изменений при добавлении или удалении источника освещения (см. рис. 18.1).
SoNode SCLS
SoLight
SoPointLight SoSpotLight SoDirectionalLight
RPointLight RSpotLight RDirectionalLight
Рис. 18.1. Множественное наследование источников освещения
pscls->cast_shadow_map();
Это решение несовершенно. Если бы у нас был доступ к исходным текстам Open Inventor, то можно было бы избежать множественного наследования, добавив к SoLight
член-указатель на SCLS и поддержку операции cast_shadow_map():
plight-> cast_shadow_map();
Самое распространенное приложение, где используется множественное (и виртуальное) наследование, – это потоковая библиотека ввода/вывода в стандартном C++. Два основных видимых пользователю класса этой библиотеки – istream
(для ввода) и ostream
(для вывода). В число их общих атрибутов входят:
· информация о форматировании (представляется ли целое число в десятичной, восьмеричной или шестнадцатеричной системе счисления, число с плавающей точкой – в нотации с фиксированной точкой или в научной нотации и т.д.);
· информация о состоянии (находится ли потоковый объект в нормальном или ошибочном состоянии и т.д.);
· информация о параметрах локализации (отображается ли в начале даты день или месяц и т.д.);
· буфер, где хранятся данные, которые нужно прочитать или записать.
Эти общие атрибуты вынесены в абстрактный базовый класс ios, для которого istream и ostream
являются производными.
Класс iostream – наш второй пример множественного наследования. Он предоставляет поддержку для чтения и записи в один и тот же файл; его предками являются классы istream и ostream. К сожалению, по умолчанию он также унаследует два различных экземпляра базового класса ios, а нам это не нужно.
Виртуальное наследование решает проблему наследования нескольких экземпляров базового класса, когда нужен только один разделяемый экземпляр. Упрощенная иерархия iostream
изображена на рис. 18.2.
ios
istream ostream
ifstream iostream ofstream
fstream
Рис. 18.2. Иерархия виртуального наследования iostream (упрощенная)
Еще один реальный пример виртуального и множественного наследования дают распределенные объектные вычисления. Подробное рассмотрение этой темы см. в серии статей Дугласа Шмидта (Douglas Schmidt) и Стива Виноски (Steve Vinoski) в [LIPPMAN96b].
В данной главе мы рассмотрим использование и поведение механизмов виртуального и множественного наследования. В другой нашей книге, “Inside the C++ Object Model”, описаны более сложные вопросы производительности и дизайна этого аспекта языка.
Для последующего обсуждения мы выбрали иерархию животных в зоопарке. Наши животные существуют на разных уровнях абстракции. Есть, конечно, особи, имеющие свои имена: Линь-Линь, Маугли или Балу. Каждое животное принадлежит к какому-то виду; скажем, Линь-Линь – это гигантская панда.
Виды в свою очередь входят в семейства. Так, гигантская панда – член семейства медведей, хотя, как мы увидим в разделе 18.5, по этому поводу в зоологии долго велись бурные дискуссии. Каждое семейство – член животного мира, в нашем случае ограниченного территорией зоопарка.
На каждом уровне абстракции имеются данные и операции, необходимые для поддержки все более и более широкого круга пользователей. Например, абстрактный класс ZooAnimal
хранит информацию, общую для всех животных в зоопарке, и предоставляет открытый интерфейс для всех возможных запросов.
Помимо классов, описывающих животных, есть и вспомогательные классы, инкапсулирующие различные абстракции иного рода, например “животные, находящиеся под угрозой вымирания”. Наша реализация класса Panda множественно наследует от Bear
(медведь) и Endangered
(вымирающие).
Когда используется наследование, у класса оказывается множество разработчиков. Во-первых, тот, кто предоставил реализацию базового класса (и, возможно, некоторых производных от него), а во-вторых, те, кто разрабатывал производные классы на различных уровнях иерархии. Этот род деятельности тоже относится к проектированию. Разработчик подтипа часто (хотя и не всегда) должен иметь доступ к реализации базового класса. Чтобы разрешить такой вид доступа, но все же предотвратить неограниченный доступ к деталям реализации класса, вводится дополнительный уровень доступа– protected (защищенный). Данные и функции-члены, помещенные в секцию protected
некоторого класса, остаются недоступными вызывающей программе, но обращение к ним из производных классов разрешено. (Все находящееся в секции private
базового класса доступно только ему, но не производным.)
Критерии помещения того или иного члена в секцию public одинаковы как для объектного, так и для объектно-ориентированного проектирования. Меняется только точка зрения на то, следует ли объявлять член закрытым или защищенным. Член базового класса объявляется закрытым, если мы не хотим, чтобы производные классы имели к нему прямой доступ; и защищенным, если его семантика такова, что для эффективной реализации производного класса может потребоваться прямой доступ к нему. При проектировании класса, который предполагается использовать в качестве базового, надо также принимать во внимание особенности функций, зависящих от типа, – виртуальных функций в иерархии классов.
На следующем шаге проектирования иерархии классов Query следует ответить на такие вопросы:
(a) Какие операции следует предоставить в открытом интерфейсе иерархии классов Query?
(b) Какие из них следует объявить виртуальными?
(c) Какие дополнительные операции могут потребоваться производным классам?
(d) Какие данные-члены следует объявить в нашем абстрактном базовом классе Query?
(e) Какие данные-члены могут потребоваться производным классам?
К сожалению, однозначно ответить на эти вопросы невозможно. Как мы увидим, процесс объектно-ориентированного проектирования по своей природе итеративен, эволюционирующая иерархия классов требует и добавлений, и модификаций. В оставшейся части этого раздела мы будем постепенно уточнять иерархию классов Query.
· оператор dynamic_cast
поддерживает преобразования типов во время выполнения, обеспечивая безопасную навигацию по иерархии классов. Он позволяет трансформировать указатель на базовый класс в указатель на производный от него, а также преобразовать l-значение, ссылающееся на базовый класс, в ссылку на производный, но только в том случае, если это завершится успешно;
· оператор typeid
позволяет получить фактический производный тип объекта, адресованного указателем или ссылкой.
Однако для получения информации о типе производного класса операнд любого из операторов dynamic_cast или typeid
должен иметь тип класса, в котором есть хотя бы одна виртуальная функция. Таким образом, операторы RTTI – это события времени выполнения для классов с виртуальными функциями и события времени компиляции для всех остальных типов. В данном разделе мы более подробно познакомимся с их возможностями.
Использование RTTI оказывается необходимым при реализации таких приложений, как отладчики или объектные базы данных, когда тип объектов, которыми манипулирует программа, становится известен только во время выполнения путем исследования RTTI-информации, хранящейся вместе с типами объектов. Однако лучше пользоваться статической системой типов C++, поскольку она безопаснее и эффективнее.
Корневой класс исключения в стандартной иерархии называется exception. Он определен в стандартном заголовочном файле и является базовым для всех исключений, возбуждаемых функциями из стандартной библиотеки. Класс exception
имеет следующий интерфейс:
}
Как и всякий другой класс из стандартной библиотеки C++, exception помещен в пространство имен std, чтобы не засорять глобальное пространство имен программы.
Первые четыре функции-члена в определении класса – это конструктор по умолчанию, копирующий конструктор, копирующий оператор присваивания и деструктор. Поскольку все они открыты, любая программа может свободно создавать и копировать объекты-исключения, а также присваивать им значения. Деструктор объявлен виртуальным, чтобы сделать возможным дальнейшее наследование классу exception.
Самой интересной в этом списке является виртуальная функция what(), которая возвращает C-строку с текстовым описанием возбужденного исключения. Классы, производные от exception, могут заместить what()
собственной версией, которая лучше характеризует объект-исключение.
Отметим, что все функции в определении класса exception имеют пустую спецификацию throw(), т.е. не возбуждают никаких исключений. Программа может манипулировать объектами-исключениями (к примеру, внутри catch-обработчиков типа exception), не опасаясь, что функции создания, копирования и уничтожения этих объектов возбудят исключения.
Помимо корневого exception, в стандартной библиотеке есть и другие классы, которые допустимо использовать в программе для извещения об ошибках, обычно подразделяемых на две больших категории: логические ошибки и ошибки времени выполнения.
Логические ошибки обусловлены нарушением внутренней логики программы, например логических предусловий или инвариантов класса. Предполагается, что их можно найти и предотвратить еще до начала выполнения программы. В стандартной библиотеке определены следующие такие ошибки:
}
Функция может возбудить исключение invalid_argument, если получит аргумент с некорректным значением; в конкретной ситуации, когда значение аргумента выходит за пределы допустимого диапазона, разрешается возбудить исключение out_of_range, а length_error
используется для оповещения о попытке создать объект, длина которого превышает максимально возможную.
Ошибки времени выполнения, напротив, вызваны событием, с самой программой не связанным. Предполагается, что их нельзя обнаружить, пока программа не начала работать. В стандартной библиотеке определены следующие такие ошибки:
<
Упражнение 19.5
Какие исключения могут возбуждать следующие функции:
(c) char manip( string ) throw( );
Упражнение 19.6
Объясните, как механизм обработки исключений в C++ поддерживает технику программирования “захват ресурса – это инициализация; освобождение ресурса – это уничтожение”.
Упражнение 19.7
Исправьте ошибку в списке catch-обработчиков для данного try-блока:
}
Упражнение 19.8
Дана программа на C++:
}
Модифицируйте main()
так, чтобы она перехватывала все исключения, возбуждаемые функциями стандартной библиотеки. Обработчики должны печатать сообщение об ошибке, ассоциированное с исключением, а затем вызывать функцию abort() (она определена в заголовочном файле) для завершения main().
}
Функция может возбудить исключение range_error, чтобы сообщить об ошибке во внутренних вычислениях. Исключение overflow_error
говорит об ошибке арифметического переполнения, а underflow_error – о потере значимости.
Класс exception
является базовым и для класса исключения bad_alloc, которое возбуждает оператор new(), когда ему не удается выделить запрошенный объем памяти (см. раздел 8.4), и для класса исключения bad_cast, возбуждаемого в ситуации, когда ссылочный вариант оператора dynamic_cast не может быть выполнен (см. раздел 19.1).
Переопределим оператор operator[] в шаблоне Array из раздела 16.12 так, чтобы он возбуждал исключение типа range_error, если индекс массива Array
выходит за границы:
};
Для использования предопределенных классов исключений в программу необходимо включить заголовочный файл. Описание возбужденного исключения содержится в объекте eObj
типа string. Эту информацию можно извлечь в обработчике с помощью функции-члена what():
}
В данной реализации выход индекса за пределы массива в функции try_array() приводит к тому, что оператор взятия индекса operator[]()
класса Array
возбуждает исключение типа out_of_range, которое перехватывается в main().
Таблица 15.1. Перегружаемые операторы
Проектировщик класса не вправе объявить перегруженным оператор с другим именем. Так, при попытке объявить оператор ** для возведения в степень компилятор выдаст сообщение об ошибке.
Следующие четыре оператора языка C++ не могут быть перегружены:
:: .* . ?:
Предопределенное назначение оператора нельзя изменить для встроенных типов. Например, не разрешается переопределить встроенный оператор сложения целых чисел так, чтобы он проверял результат на переполнение.
int operator+( int, int );
Нельзя также определять дополнительные операторы для встроенных типов данных, например добавить к множеству встроенных операций operator+ для сложения двух массивов.
Перегруженный оператор определяется исключительно для операндов типа класса или перечисления и может быть объявлен только как член класса или пространства имен, принимая хотя бы один параметр типа класса или перечисления (переданный по значению или по ссылке).
Предопределенные приоритеты операторов (см. раздел 4.13) изменить нельзя. Независимо от типа класса и реализации оператора в инструкции
x == y + z;
всегда сначала выполняется operator+, а затем operator==; однако помощью скобок порядок можно изменить.
Предопределенная арность операторов также должна быть сохранена. К примеру, унарный логический оператор НЕ нельзя определить как бинарный оператор для двух объектов класса String. Следующая реализация некорректна и приведет к ошибке компиляции:
}
Для встроенных типов четыре предопределенных оператора ("+", "-", "*" и "&") используются либо как унарные, либо как бинарные. В любом из этих качеств они могут быть перегружены.
Для всех перегруженных операторов, за исключением operator(), недопустимы аргументы по умолчанию.
неудобно.
Некоторые слова являются ключевыми в С++ и не могут быть использованы в качестве идентификаторов; в таблице 3.1 приведен их полный список.
Таблица 3.1. Ключевые слова C++
Чтобы текст вашей программы был более понятным, мы рекомендуем придерживаться общепринятых соглашений об именах объектов:
· имя переменной обычно пишется строчными буквами, например index
(для сравнения: Index – это имя типа, а INDEX – константа, определенная с помощью директивы препроцессора #define);
· идентификатор должен нести какой-либо смысл, поясняя назначение объекта в программе, например: birth_date или salary;
если такое имя состоит из нескольких слов, как, например, birth_date, то принято либо разделять слова символом подчеркивания (birth_date), либо писать каждое следующее слово с большой буквы (birthDate). Замечено, что программисты, привыкшие к ОбъектноОриентированномуПодходу предпочитают выделять слова заглавными буквами, в то время как те_кто_много_писал_на_С используют символ подчеркивания. Какой из двух способов лучше – вопрос вкуса.
заменить C-строку на тип класса string? Как это повлияет на почленную инициализацию по умолчанию? Как надо будет изменить явный копирующий конструктор? Мы ответим на эти вопросы в данном подразделе.
При почленной инициализации по умолчанию исследуется каждый член. Если он принадлежит к встроенному или составному типу, то такая инициализация применяется непосредственно. Например, в первоначальном определении класса Account
член _name
инициализируется непосредственно, так как это указатель:
newAcct._name = oldAcct._name;
Члены, являющиеся объектами классов, обрабатываются по-другому. В инструкции
Account newAcct( oldAcct );
оба объекта распознаются как экземпляры Account. Если у этого класса есть явный копирующий конструктор, то он и применяется для задания начального значения, в противном случае выполняется почленная инициализация по умолчанию.
Таким образом, если обнаруживается член-объект класса, то описанный выше процесс применяется рекурсивно. У класса есть явный копирующий конструктор? Если да, вызвать его для задания начального значения члена-объекта класса. Иначе применить к этому члену почленную инициализацию по умолчанию. Если все члены этого класса принадлежат к встроенным или составным типам, то каждый инициализируется непосредственно и процесс на этом завершается. Если же некоторые члены сами являются объектами классов, то алгоритм применяется к ним рекурсивно, пока не останется ничего, кроме встроенных и составных типов.
В нашем примере у класса string
есть явный копирующий конструктор, поэтому _name инициализируется с помощью его вызова. Копирующий конструктор по умолчанию для класса Account
выглядит следующим образом (хотя явно он не определен):
}
Теперь почленная инициализация по умолчанию для класса Account корректно обрабатывает выделение и освобождение памяти для _name, но все еще неверно копирует номер счета, поэтому приходится кодировать явный копирующий конструктор. Однако приведенный ниже фрагмент не совсем правилен. Можете ли вы сказать, почему?
}
Эта реализация ошибочна, поскольку в ней не различаются инициализация и присваивание. В результате вместо вызова копирующего конструктора string мы вызываем конструктор string по умолчанию на фазе неявной инициализации и копирующий оператор присваивания string – в теле конструктора. Исправить это несложно:
}
Самое главное – понять, что такое исправление необходимо. (Обе реализации приводят к тому, что в _name
копируется значение из rhs._name, но в первой одна и та же работа выполняется дважды.) Общее эвристическое правило состоит в том, чтобы по возможности инициализировать все члены-объекты классов в списке инициализации членов.
Упражнение 14.13
Для какого определения класса скорее всего понадобится копирующий конструктор?
1. Представление Point3w, содержащее четыре числа с плавающей точкой.
2. Класс matrix, в котором память для хранения матрицы выделяется динамически в конструкторе и освобождается в деструкторе.
3. Класс payroll
(платежная ведомость), где каждому объекту приписывается уникальный идентификатор.
4. Класс word
(слово), содержащий объект класса string и вектор, в котором хранятся пары (номер строки, смещение в строке).
Упражнение 14.14
Реализуйте для каждого из данных классов копирующий конструктор, конструктор по умолчанию и деструктор.
};
};
};
};
Упражнение 14.15
Нужен ли копирующий конструктор для того класса, который вы выбрали в упражнении 14.3 из раздела 14.2? Если нет, объясните почему. Если да, реализуйте его.
Упражнение 14.16
Идентифицируйте в следующем фрагменте программы все места, где происходит почленная инициализация:
}
lexicoCompare;
получается указатель типа
int (*)( const string &, const string & );
Применение оператора взятия адреса к имени функции также дает указатель того же типа, например lexicoCompare и &lexicoCompare. Указатель на функцию инициализируется следующим образом:
int (*pfi2)( const string &, const string & ) = &lexicoCompare;
Ему можно присвоить значение:
pfi2 = pfi;
Инициализация и присваивание корректны только тогда, когда список параметров и тип значения, которое возвращает функция, адресованная указателем в левой части операции присваивания, в точности соответствуют списку параметров и типу значения, возвращаемого функцией или указателем в правой части. В противном случае выдается сообщение об ошибке компиляции. Никаких неявных преобразований типов для указателей на функции не производится. Например:
}
Такой указатель можно инициализировать нулем или присвоить ему нулевое значение, в этом случае он не адресует функцию.
};
Чтобы безопасно пользоваться объектом класса, необходимо правильно инициализировать его члены. Однако смысл этого действия для разных классов различен. Например, может ли ival
содержать отрицательное значение или нуль? Каковы правильные начальные значения обоих членов класса? Мы не ответим на эти вопросы, не понимая абстракции, представляемой классом. Если с его помощью описываются служащие компании, то ptr, вероятно, указывает на фамилию служащего, а ival – его уникальный номер. Тогда отрицательное или нулевое значения ошибочны. Если же класс представляет текущую температуру в городе, то допустимы любые значения ival. Возможно также, что класс Data представляет строку со счетчиком ссылок: в таком случае ival
содержит текущее число ссылок на строку по адресу ptr. При такой абстракции ival
инициализируется значением 1; как только значение становится равным 0, объект класса уничтожается.
Мнемонические имена класса и обоих его членов сделали бы, конечно, его назначение более понятным для читателя программы, но не дали бы никакой дополнительной информации компилятору. Чтобы компилятор понимал наши намерения, мы должны предоставить одну или несколько перегруженных функций инициализации – конструкторов. Подходящий конструктор выбирается в зависимости от множества начальных значений, указанных при определении объекта. Например, любая из приведенных ниже инструкций представляет корректную инициализацию объекта класса Data:
Data dat04;
Бывают ситуации (как в случае с dat04), когда нам нужен объект класса, но его начальные значения мы еще не знаем. Возможно, они станут известны позже. Однако начальное значение задать необходимо, хотя бы такое, которое показывает, что разумное начальное значение еще не присвоено. Другими словами, инициализация объекта иногда сводится к тому, чтобы показать, что он еще не
инициализирован. Большинство классов предоставляют специальный конструктор по умолчанию, для которого не требуется задавать начальных значений. Как правило, он инициализирует объект таким образом, чтобы позже можно было понять, что реальной инициализации еще не проводилось.
Обязан ли наш класс Data
иметь конструктор? Нет, поскольку все его члены открыты. Унаследованный из языка C механизм поддерживает явную инициализацию, аналогичную используемой при инициализации массивов:
}
Значения присваиваются позиционно, на основе порядка, в котором объявляются данные-члены. Следующий пример приводит к ошибке компиляции, так как ival
объявлен перед ptr:
Data.local2 - { "Anna Livia Plurabelle", 1024 };
Явная инициализация имеет два основных недостатка. Во-первых, она может быть применена лишь для объектов классов, все члены которых открыты (т.е. эта инициализация не поддерживает инкапсуляции данных и абстрактных типов – их не было в языке C, откуда она заимствована). А во-вторых, такая форма требует вмешательства программиста, что увеличивает вероятность появления ошибок (забыл включить список инициализации или перепутал порядок следования инициализаторов в нем).
Так нужно ли применять явную инициализацию вместо конструкторов? Да. Для некоторых приложений более эффективно использовать список для инициализации больших структур постоянными значениями. К примеру, мы можем таким образом построить палитру цветов или включить в текст программы фиксированные координаты вершин и значения в узлах сложной геометрической модели. В подобных случаях инициализация выполняется во время загрузки, что сокращает затраты времени на запуск конструктора, даже если он определен как встроенный. Это особенно удобно при работе с глобальными объектами1[O.A.4] .
Однако в общем случае предпочтительным методом инициализации является конструктор, который гарантированно будет вызван компилятором для каждого объекта до его первого использования. В следующем разделе мы познакомимся с конструкторами детально.
Чтобы свести инициализацию к одному шагу, программист должен вмешаться и поддержать следующую семантику: задать начальные значения для всех или некоторых элементов массива и гарантировать применение конструктора по умолчанию для тех элементов, начальные значения которых не заданы. Ниже приведено одно из возможных программных решений, где используется оператор размещения new:
<
}
Необходимо заранее выделить блок памяти, достаточный для хранения запрошенного массива, как массив байт, чтобы избежать применения к каждому элементу конструктора по умолчанию. Это делается в такой инструкции:
char *p = new char[sizeof(Account)*elems];
Далее программа в цикле обходит этот блок, присваивая на каждой итерации переменной p
адрес следующего элемента и вызывая либо конструктор с двумя параметрами, если задана пара начальных значений, либо конструктор по умолчанию:
}
В разделе 14.3 говорилось, что оператор размещения new
позволяет применить конструктор класса к уже выделенной области памяти. В данном случае мы используем new для поочередного применения конструктора класса Account к каждому из выделенных элементов массива. Поскольку при создании инициализированного массива мы подменили стандартный механизм выделения памяти, то должны сами позаботиться о ее освобождении. Оператор delete
работать не будет:
delete [] ps;
Почему? Потому что ps (мы предполагаем, что эта переменная была инициализирована вызовом init_heap_array()) указывает на блок памяти, полученный не с помощью стандартного оператора new, поэтому число элементов в массиве компилятору неизвестно. Так что всю работу придется сделать самим:
}
Если в функции инициализации мы пользовались арифметическими операциями над указателями для доступа к элементам:
new( p+offset*ix ) Account;
то здесь мы обращаемся к ним, задавая индекс в массиве ps:
ps[ix].Account::~Account();
Хотя и ps, и p адресуют одну и ту же область памяти, ps объявлен как указатель на объект класса Account, а p – как указатель на char. Индексирование p
дало бы ix-й байт, а не ix-й объект класса Account. Поскольку с p
ассоциирован не тот тип, что нужно, арифметические операции над указателями приходится программировать самостоятельно.
Мы объявляем обе функции статическими членами класса:
typedef pair value_pair;
};
По умолчанию как инициализация, так и присваивание одного объекта класса другому выполняются почленно, т.е. путем последовательного копирования всех членов. Хотя этого обычно достаточно, при некоторых обстоятельствах такая семантика оказывается неадекватной. Тогда проектировщик класса должен предоставить специальный копирующий конструктор и копирующий оператор присваивания. Самое сложное в поддержке этих функций-членов – понять, что они должны быть написаны.
int ival;
рассматривается как инструкция объявления (хотя в данном случае более правильно было бы сказать определения). Ее можно использовать в любом месте программы, где разрешено употреблять инструкции. В следующем примере объявления помечены комментарием //#n, где n– порядковый номер.
}
Программа содержит семь инструкций объявления и восемь определений объектов. Объявления действуют локально; переменная объявляется непосредственно перед первым использованием объекта.
В 70-е годы философия программирования уделяла особое внимание тому, чтобы определения всех объектов находились в начале программы или блока, перед исполняемыми инструкциями. (В С, например, определение переменной не является инструкцией и обязано располагаться в начале блока.) В некотором смысле это была реакция на идиому использования переменных без предварительного объявления, чреватую ошибками. Такую идиому поддерживал, например, FORTRAN.
Поскольку в С++ объявление является обычной инструкцией, ему разрешено появляться в любом месте программы, где допустимо употребление инструкции, что дает возможность использовать локальные объявления.
Необходимо ли это? Для встроенных типов данных применение локальных объявлений является скорее вопросом вкуса. Язык их поощряет , разрешая объявлять переменные внутри условных частей инструкций if, if-else, switch, while, for. Те программисты, которые любят этот стиль, верят, что таким образом делают свои программы более понятными.
Локальные объявления становятся необходимостью, когда мы используем объекты классов, имеющие конструкторы и деструкторы. Если мы помещаем все объявления в начало блока или функции, происходят две неприятные вещи:
· конструкторы всех объектов вызываются перед исполнением первой инструкции блока. Применение локальных объявлений позволяет “размазать” расходы на инициализацию по всему блоку;
· что более важно, блок или функция могут завершиться до того, как будут действительно использованы все объявленные в начале объекты. Скажем, наша программа из предыдущего примера имеет два аварийных выхода: при вводе пользователем пустого имени файла и при
невозможности открыть файл с заданным именем. При этом последующие инструкции функции уже не выполняются. Если бы объекты inBuf и next были объявлены в начале блока, конструкторы и деструкторы этих объектов в случае ненормального завершения функции вызывались бы совершенно напрасно.
Инструкция объявления может состоять из одного или более определений. Например, в нашей программе мы определяем два итератора вектора в одной инструкции:
lend = text.end();
Эквивалентная пара, определяющая по одному объекту, выглядит так:
vector::iterator lend = text.end();
Хотя определение одного или нескольких объектов в одном предложении является скорее вопросом вкуса, в некоторых случаях – например, при одновременном определении объектов, указателей и ссылок – это может спровоцировать появление ошибок. Скажем, в следующей инструкции не совсем ясно, действительно ли программист хотел определить указатель и объект или просто забыл поставить звездочку перед вторым идентификатором (используемые имена переменных наводят на второе предположение):
string *ptrl, ptr2;
Эквивалентная пара инструкций не позволит допустить такую ошибку:
string *ptr2;
В наших примерах мы обычно группируем определения объектов в инструкции по сходству употребления. Например, в следующей паре
int charCnt=0, wordCnt=0;
первая инструкция объявляет пять очень похожих по назначению объектов – счетчиков пяти гласных латинского алфавита. Счетчики для подсчета символов и слов определяются во второй инструкции. Хотя такой подход нам кажется естественным и удобным, нет никаких причин считать его хоть чем-то лучше других.
Упражнение 5.1
Представьте себе, что вы являетесь руководителем программного проекта и хотите, чтобы применение инструкций объявления было унифицировано. Сформулируйте правила использования объявлений объектов для вашего проекта.
Упражнение 5.2
Представьте себе, что вы только что присоединились к проекту из предыдущего упражнения. Вы совершенно не согласны не только с конкретными правилами использования инструкций объявления, но и вообще с навязыванием каких-либо правил для этого. Объясните свою позицию.
обеспечивают условное выполнение. Повторение обеспечивается инструкциями цикла while, do-while и for.
останавливает циклы for, while, do while и блока switch. Выполнение программы продолжается с инструкции, следующей за закрывающей фигурной скобкой цикла или блока. Например, данная функция ищет в массиве целых чисел определенное значение. Если это значение найдено, функция сообщает его индекс, в противном случае она возвращает -1. Вот как выглядит реализация функции:
}
В этом примере break
прекращает выполнение цикла for и передает управление инструкции, следующей за этим циклом, – в нашем случае return. Заметим, что break
выводит из блока, относящегося к инструкции for, а не if, хотя является частью составной инструкции, соответствующей if. Использование break
внутри блока if, не входящего в цикл или в switch, является синтаксической ошибкой:
}
Если эта инструкция используется внутри вложенных циклов или инструкций switch, она завершает выполнение того внутреннего блока, в котором находится. Цикл или switch, включающий тот цикл или switch, из которого мы вышли с помощью break, продолжает выполняться. Например:
}
Инструкция break, помеченная // #1, завершает выполнение цикла for внутри ветви case '-'
блока switch, но не сам switch. Аналогично break // #2
завершает выполнение блока switch, но не цикла while, в который тот входит.
Цикл for
обычно используется для обработки структур данных, имеющих фиксированную длину, таких, как массив или вектор:
}
Синтаксис цикла for
следующий:
инструкция
инструкция-инициализации
может быть либо выражением, либо инструкцией объявления. Обычно она используется для инициализации переменной значением, которое увеличивается в ходе выполнения цикла. Если такая инициализация не нужна или выполняется где-то в другом месте, эту инструкцию можно заменить пустой (см. второй из приведенных ниже примеров). Вот примеры правильного использования инструкции-инициализации:
for ( char *ptr = getStr(); ...
условие служит для управления циклом. Пока условие при вычислении дает true, инструкция
продолжает выполняться. Выполняемая в цикле инструкция
может быть как простой, так и составной. Если же самое первое вычисление условия дает false, инструкция не выполняется ни разу. Правильные условия
можно записать так:
(... char ch = getNextChar(); ... )
Выражение вычисляется после выполнения инструкции на каждой итерации цикла. Обычно его используют для модификации переменной, инициализированной в инструкции-инициализации. Если самое первое вычисление условия дает false, выражение не выполняется ни разу. Правильные выражения выглядят таким образом:
( ... ...; ) // пустое выражение
Для приведенного ниже цикла for
}
порядок вычислений будет следующим:
1. инструкция-инициализации
выполняется один раз перед началом цикла. В данном примере объявляется переменная ix, которая инициализируется значением 0.
2. Вычисляется условие. Если оно равно true, выполняется составная инструкция тела цикла. В нашем примере, пока ix меньше sz, значение ix
присваивается элементам ivec[ix] и ia[ix]. Когда значением условия
станет false, выполнение цикла прекратится. Если самое первое вычисление условия
даст false, составная инструкция
выполняться не будет.
3. Вычисляется выражение. Как правило, его используют для модификации переменной, фигурирующей в инструкции-инициализации и проверяемой в условии. В нашем примере ix увеличивается на 1.
Эти три шага представляют собой полную итерацию цикла for. Теперь шаги 2 и 3 будут повторяться до тех пор, пока условие не станет равным false, т.е. ix
окажется равным или большим sz.
В инструкции-инициализации можно определить несколько объектов, однако все они должны быть одного типа, так как инструкция объявления допускается только одна:
// ...
Объявление объекта в условии гораздо труднее правильно использовать: такое объявление должно хотя бы раз дать значение false, иначе выполнение цикла никогда не прекратится. Вот пример, хотя и несколько надуманный:
}
Видимость всех объектов, определенных внутри круглых скобок инструкции for, ограничена телом цикла. Например, проверка iter после цикла вызовет ошибку компиляции[8]:[O.A.2]
// ...
Упражнение 5.8
Допущены ли ошибки в нижеследующих циклах for? Если да, то какие?
}
// ...
Упражнение 5.9
Представьте, что вам поручено придумать общий стиль использования цикла for в вашем проекте. Объясните и проиллюстрируйте примерами правила использования каждой из трех частей цикла.
Упражнение 5.10
Дано объявление функции:
const vector &v2 );
Напишите тело функции, определяющей равенство двух векторов. Для векторов разной длины сравнивайте только то количество элементов, которое соответствует меньшему из двух. Например, векторы (0,1,1,2) и (0,1,1,2,3,5,8) считаются равными. Длину векторов можно узнать с помощью функций v1.size() и v2.size().
завершает текущую итерацию цикла и передает управление на вычисление условия, после чего цикл может продолжиться. В отличие от инструкции break, завершающей выполнение всего цикла, инструкция continue
завершает выполнение только текущей итерации. Например, следующий фрагмент программы читает из входного потока по одному слову. Если слово начинается с символа подчеркивания, оно обрабатывается, в противном случае программа переходит к новому слову.
}
Инструкция continue
может быть использована только внутри цикла.
}
Проблема заключается в том, что условие вычисляется в теле цикла. for и while требуют, чтобы значение условия равнялось true до первого вхождения в цикл, иначе тело не выполнится ни разу. Это означает, что мы должны обеспечить такое условие до начала работы цикла. Альтернативой может служить использование do while, гарантирующего выполнение тела цикла хотя бы один раз. Синтаксис цикла do while
таков:
while ( условие );
инструкция
выполняется до первой проверки условия. Если вычисление условия
дает false, цикл останавливается. Вот как выглядит предыдущий пример с использованием цикла do while:
} while doMore();
В отличие от остальных инструкций циклов, do while не разрешает объявлять объекты в своей части условия. Мы не можем написать:
} while ( int foo = get_foo() ) // ошибка
потому что до условной части инструкции do while мы дойдем только после первого выполнения тела цикла.
Упражнение 5.14
Какие ошибки допущены в следующих циклах do while:
(a)
// ...
Упражнение 5.15
Напишите небольшую программу, которая запрашивает у пользователя две строки и печатает результат лексикографического сравнения этих строк (строка считается меньшей, если идет раньше при сортировке по алфавиту). Пусть она повторяет эти действия, пока пользователь не даст команду закончить. Используйте тип string, сравнение строк и цикл do while.
обеспечивает безусловный переход к другой инструкции внутри той же функции, поэтому современная практика программирования выступает против ее применения.
Синтаксис goto
следующий:
goto метка;
где метка– определенный пользователем идентификатор. Метка ставится перед инструкцией, на которую можно перейти с помощью goto, и должна заканчиваться двоеточием. Нельзя ставить метку непосредственно перед закрывающей фигурной скобкой. Если же это необходимо, их следует разделить пустой инструкцией:
}
Переход через инструкцию объявления в том же блоке с помощью goto
невозможен. Например, данная функция вызывает ошибку компиляции:
}
Правильная реализация функции помещает объявление ix и использующие его инструкции во вложенный блок:
}
Причина такого ограничения та же, что и для объявлений внутри блока switch: компилятор должен гарантировать, что для объявленного объекта конструктор и деструктор либо выполняются вместе, либо ни один из них не выполняется. Это и достигается заключением объявления во вложенный блок.
Переход назад через объявление, однако, не считается ошибкой. Почему? Перепрыгнуть через инициализацию объекта нельзя, но проинициализировать один и тот же объект несколько раз вполне допустимо, хотя это может привести к снижению эффективности. Например:
}
Использование инструкции goto
резко критикуется во всех современных языках программирования. Ее применение приводит к тому, что ход выполнения программы становится трудно понять и, следовательно, такую программу трудно модифицировать. В большинстве случаев goto
можно заменить на инструкции if или циклы. Если вы все-таки решили использовать goto, не перескакивайте через большой фрагмент кода, чтобы можно было легко найти начало и конец вашего перехода.
обеспечивает выполнение или пропуск инструкции или блока в зависимости от условия. Ее синтаксис таков:
инструкция
условие
заключается в круглые скобки. Оно может быть выражением, как в этом примере:
if(a+b>c) { ... }
или инструкцией объявления с инициализацией:
if ( int ival = compute_value() ){...}
Область видимости объекта, объявленного в условной части, ограничивается ассоциированной с if
инструкцией или блоком. Например, такой код вызывает ошибку компиляции:
if ( ! ival ) ...
Попробуем для иллюстрации применения инструкции if реализовать функцию min(), возвращающую наименьший элемент вектора. Заодно наша функция будет подсчитывать число элементов, равных минимуму. Для каждого элемента вектора мы должны проделать следующее:
1. Сравнить элемент с текущим значением минимума.
2. Если элемент меньше, присвоить текущему минимуму значение элемента и сбросить счетчик в 1.
3. Если элемент равен текущему минимуму, увеличить счетчик на 1.
4. В противном случае ничего не делать.
5. После проверки последнего элемента вернуть значение минимума и счетчика.
Необходимо использовать две инструкции if:
if ( minVal == ivec[ i ] )...// одинаковые значения
Довольно часто программист забывает использовать фигурные скобки, если нужно выполнить несколько инструкций в зависимости от условия:
occurs = 1; // не относится к if!
Такую ошибку трудно увидеть, поскольку отступы в записи подразумевают, что и minVal=ivec[i], и occurs=1
входят в одну инструкцию if. На самом же деле инструкция
occurs = 1;
не является частью if и выполняется безусловно, всегда сбрасывая occurs в 1. Вот как должна быть составлена правильная if-инструкция (точное положение открывающей фигурной скобки является поводом для бесконечных споров):
}
Вторая инструкция if
выглядит так:
++occurs;
Заметим, что порядок следования инструкций в этом примере крайне важен. Если мы будем сравнивать minVal
именно в такой последовательности, наша функция всегда будет ошибаться на 1:
++occurs;
Выполнение второго сравнения не обязательно: один и тот же элемент не может одновременно быть и меньше и равен minVal. Поэтому появляется необходимость выбора одного из двух блоков в зависимости от условия, что реализуется инструкцией if-else, второй формой if-инструкции. Ее синтаксис выглядит таким образом:
инструкция2
инструкция1
выполняется, если условие истинно, иначе переходим к инструкция2. Например:
}
Здесь инструкция2
сама является if-инструкцией. Если minVal
меньше ivec[i], никаких действий не производится.
В следующем примере выполняется одна из трех инструкций:
++occurs;
Составные инструкции if-else
могут служить источником неоднозначного толкования, если частей else
больше, чем частей if. К какому из if
отнести данную часть else? (Эту проблему иногда называют проблемой висячего else). Например:
}
Судя по отступам, программист предполагает, что else относится к самому первому, внешнему if. Однако в С++ неоднозначность висячих else разрешается соотнесением их с последним встретившимся if. Таким образом, в действительности предыдущий фрагмент означает следующее:
}
Одним из способов разрешения данной проблемы является заключение внутреннего if в фигурные скобки:
}
В некоторых стилях программирования рекомендуется всегда употреблять фигурные скобки при использовании инструкций if-else, чтобы не допустить возможности неправильной интерпретации кода.
Вот первый вариант функции min(). Второй аргумент функции будет возвращать количество вхождений минимального значения в вектор. Для перебора элементов массива используется цикл for. Но мы допустили ошибку в логике программы. Сможете ли вы заметить ее?
}
Обычно функция возвращает только одно значение. Однако согласно нашей спецификации в точке вызова должно быть известно не только само минимальное значение, но и количество его вхождений в вектор. Для возврата второго значения мы использовали параметр типа
ссылка. (Параметры-ссылки рассматриваются в разделе 7.3.) Любое присваивание значения ссылке occurs
изменяет значение переменной, на которую она ссылается:
}
Альтернативой использованию параметра-ссылки является применение объекта класса pair, представленного в разделе 3.14. Функция min() могла бы возвращать два значения в одной паре:
}
К сожалению, и эта реализация содержит ошибку. Где же она? Правильно: мы инициализировали minVal
нулем, поэтому, если минимальный элемент вектора больше нуля, наша реализация вернет нулевое значение минимума и нулевое значение количества вхождений.
Программу можно изменить, инициализировав minVal первым элементом вектора:
int minVal = ivec[0];
Теперь функция работает правильно. Однако в ней выполняются некоторые лишние действия, снижающие ее эффективность.
// ...
Поскольку ix
инициализируется нулем, на первой итерации цикла значение первого элемента сравнивается с самим собой. Можно инициализировать ix
единицей и избежать ненужного выполнения первой итерации. Однако при оптимизации кода мы допустили другую ошибку (наверное, стоило все оставить как было!). Сможете ли вы ее обнаружить?
// ...
Если ivec[0]
окажется минимальным элементом, переменная occurs не получит значения 1. Конечно, исправить это очень просто, но сначала надо найти ошибку:
occurs = 1;
К сожалению, подобного рода недосмотры встречаются не так уж редко: программисты тоже люди и могут ошибаться. Важно понимать, что это неизбежно, и быть готовым тщательно тестировать и анализировать свои программы.
Вот окончательная версия функции min() и программа main(), проверяющая ее работу:
}
Результат работы программы:
Минимальное значение: 1 встречается: 5 раз.
В некоторых случаях вместо инструкции if-else можно использовать более краткое и выразительное условное выражение. Например, следующую реализацию функции min():
}
можно переписать так:
}
Длинные цепочки инструкций if-else, подобные приведенной ниже, трудны для восприятия и, таким образом, являются потенциальным источником ошибок.
++uCnt;
В качестве альтернативы таким цепочкам С++ предоставляет инструкцию switch. Это тема следующего раздела.
Упражнение 5.3
Исправьте ошибки в примерах:
else ival = 0;
Упражнение 5.4
Преобразуйте тип параметра occurs
функции min(), сделав его не ссылкой, а простым объектом. Запустите программу. Как изменилось ее поведение?
Для иллюстрации инструкции switch
рассмотрим следующую задачу. Нам надо подсчитать, сколько раз встречается каждая из гласных букв в указанном отрывке текста. (Общеизвестно, что буква e– наиболее часто встречающаяся гласная в английском языке.) Вот алгоритм программы:
1. Считывать по одному символу из входного потока, пока они не кончатся.
2. Сравнить каждый символ с набором гласных.
3. Если символ равен одной из гласных, прибавить 1 к ее счетчику.
4. Напечатать результат.
Написанная программа была запущена, в качестве контрольного текста использовался раздел из оригинала данной книги. Результаты подтвердили, что буква e действительно самая частая:
aCnt: 394
eCnt: 721
iCnt: 461
oCnt: 349
uCnt: 186
Инструкция switch
состоит из следующих частей:
· ключевого слова switch, за которым в круглых скобках идет выражение, являющееся условием:
switch( ch )
· набора меток case, состоящих из ключевого слова case и константного выражения, с которым сравнивается условие. В данном случае каждая метка представляет одну из гласных латинского алфавита:
case 'u':
· последовательности инструкций, соотносимых с метками case. В нашем примере с каждой меткой будет сопоставлена инструкция, увеличивающая значение соответствующего счетчика;
· необязательной метки default, которая является аналогом части else инструкции if-else. Инструкции, соответствующие этой метке, выполняются, если условие не отвечает ни одной из меток case. Например, мы можем подсчитать суммарное количество встретившихся символов, не являющихся гласными буквами:
++non_vowe1_cnt;
Константное выражение в метке case
должно принадлежать к целому типу, поэтому следующие строки ошибочны:
case ival: // не константа
Кроме того, две разные метки не могут иметь одинаковое значение.
Выражение условия в инструкции switch
может быть сколь угодно сложным, в том числе включать вызовы функций. Результат вычисления условия сравнивается с метками case, пока не будет найдено равное значение или не выяснится, что такого значения нет. Если метка обнаружена, выполнение будет продолжено с первой инструкции после нее, если же нет, то с первой инструкции после метки default (при ее наличии) или после всей составной инструкции switch.
В отличие от if-else
инструкции, следующие за найденной меткой, выполняются друг за другом, проходя все нижестоящие метки case и метку default. Об этом часто забывают. Например, данная реализация нашей программы выполняется совершенно не так, как хотелось бы:
<
}
Если значение ch
равно i, выполнение начинается с инструкции после case 'i' и iCnt
возрастет на 1. Однако следующие ниже инструкции, ++oCnt и ++uCnt, также выполняются, увеличивая значения и этих переменных. Если же переменная ch
равна a, изменятся все пять счетчиков.
Программист должен явно дать указание компьютеру прервать последовательное выполнение инструкций в определенном месте switch, вставив break. В абсолютном большинстве случаев за каждой метке case
должен следовать соответствующий break.
break
прерывает выполнение switch и передает управление инструкции, следующей за закрывающей фигурной скобкой, – в данном случае производится вывод. Вот как это должно выглядеть:
}
Если почему-либо нужно, чтобы одна из секций не заканчивалась инструкцией break, то желательно написать в этом месте разумный комментарий. Программа создается не только для машин, но и для людей, и необходимо сделать ее как можно более понятной для читателя. Программист, изучающий чужой текст, не должен
сомневаться, было ли нестандартное использование языка намеренным или ошибочным.
При каком условии программист может отказаться от инструкции break и позволить программе провалиться сквозь несколько меток case? Одним из таких случаев является необходимость выполнить одни и те же действия для двух или более меток. Это может понадобиться потому, что с case
всегда связано только одно значение. Предположим, мы не хотим подсчитывать, сколько раз встретилась каждая гласная в отдельности, нас интересует только суммарное количество всех встретившихся гласных. Это можно сделать так:
<
}
Некоторые программисты подчеркивают осознанность своих действий тем, что предпочитают в таком случае писать метки на одной строке:
}
В данной реализации все еще осталась одна проблема: как будут восприняты слова типа
UNIX
Наша программа не понимает заглавных букв, поэтому заглавные U и I не будут отнесены к гласным. Исправить ситуацию можно следующим образом:
}
Метка default
является аналогом части else инструкции if-else. Инструкции, соответствующие default, выполняются, если условие не отвечает ни одной из меток case. Например, добавим к нашей программе подсчет суммарного количества согласных:
<
}
isalpha() – функция стандартной библиотеки С; она возвращает true, если ее аргумент является буквой. isalpha() объявлена в заголовочном файле ctype.h. (Функции из ctype.h мы будем рассматривать в главе 6.)
Хотя оператор break
функционально не нужен после последней метки в инструкции switch, лучше его все-таки ставить. Причина проста: если мы впоследствии захотим добавить еще одну метку после case, то с большой вероятностью забудем вписать недостающий break.
Условная часть инструкции switch
может содержать объявление, как в следующем примере:
switch( int ival = get_response() )
ival
инициализируется значением, получаемым от get_response(), и это значение сравнивается со значениями меток case. Переменная ival видна внутри блока switch, но не вне его.
Помещать же инструкцию объявления внутри тела блока switch не разрешается. Данный фрагмент кода не будет пропущен компилятором:
break;
Если бы разрешалось объявлять переменную таким образом, то ее было бы видно во всем блоке switch, однако инициализируется она только в том случае, если выполнение прошло через данную метку case.
Мы можем употребить в этом месте составную инструкцию, тогда объявление переменной file_name
будет синтаксически правильным. Использование блока гарантирует, что объявленная переменная видна только внутри него, а в этом контексте она заведомо инициализирована. Вот как выглядит правильный текст:
break;
}
Упражнение 5.5
Модифицируйте программу из данного раздела так, чтобы она подсчитывала не только буквы, но и встретившиеся пробелы, символы табуляции и новой строки.
Упражнение 5.6
Модифицируйте программу из данного раздела так, чтобы она подсчитывала также количество встретившихся двухсимвольных последовательностей ff, fl и fi.
Упражнение 5.7
Найдите и исправьте ошибки в следующих примерах:
(a)
}
(b)
}
(c)
}
(d)
}
(e)
break;
}
следующий:
инструкция
Пока значением условия
является true, инструкция
выполняется в такой последовательности:
1.
Вычислить условие.
2. Выполнить инструкцию,
если условие
истинно.
3. Если самое первое вычисление условия
дает false, инструкция не выполняется.
Условием
может быть любое выражение:
while ( cin >> word ){ ... }
или объявление с инициализацией:
}
В последнем случае ptr
видим только в блоке, соответствующем инструкции while, как это было и для инструкций for и switch.
Вот пример цикла while, обходящего множество элементов, адресуемых двумя указателями:
}
Для того чтобы функция sumit()
выполнялась правильно, оба указателя должны адресовать элементы одного и того же массива (parray_end
может указывать на элемент, следующий за последним). В противном случае sumit() будет возвращать бессмысленную величину. Увы, С++ не гарантирует, что два указателя адресуют один и тот же массив. Как мы увидим в главе 12, стандартные универсальные алгоритмы реализованы подобным же образом, они принимают параметрами указатели на первый и последний элементы массива.
Упражнение 5.11
Какие ошибки допущены в следующих циклах while:
<
// ...
cout << "Слов не найдено\n";
Упражнение 5.12
while
обычно применяется для циклов, выполняющихся, пока некоторое условие истинно, например, читать следующее значение, пока не
будет достигнут конец файла. for обычно рассматривается как пошаговый цикл: индекс пробегает по определенному диапазону значений. Напишите по одному типичному примеру for и while, а затем измените их, используя цикл другого типа. Если бы вам нужно было выбрать для постоянной работы только один из этих типов, какой бы вы выбрали? Почему?
Упражнение 5.13
Напишите функцию, читающую последовательность строк из стандартного ввода до тех пор, пока одно и то же слово не встретится два раза подряд либо все слова не будут обработаны. Для чтения слов используйте while; при обнаружении повтора слова завершите цикл с помощью инструкции break. Если повторяющееся слово найдено, напечатайте его. В противном случае напечатайте сообщение о том, что слова не повторялись.
Возможности механизма обработки исключений становятся больше, если в качестве исключений использовать иерархии классов. В этом разделе мы расскажем, как писать программы, которые умеют возбуждать и обрабатывать исключения, принадлежащие таким иерархиям.
В нашем примере в библиотеке определен класс iStack и его функции-члены. Разумно предположить, что программист, кодировавший main(), где используется эта библиотека, не разрабатывал ее. Функции-члены класса iStack могут обнаружить, что операция pop()
вызвана, когда стек пуст, или что операция push() вызвана, когда стек полон; однако разработчик библиотеки ничего не знал о программе, пользующейся его функциями, так что не мог разрешить проблему локально. Не сумев обработать ошибку внутри функций-членов, мы решили возбуждать исключения, чтобы известить вызывающую программу.
Хотя C++ поддерживает исключения, следует применять и другие методы обработки ошибок (например, возврат кода ошибки) – там, где это более уместно. Однозначного ответа на вопрос: “Когда ошибку следует трактовать как исключение?” не существует. Ответственность за решение о том, что считать исключительной ситуацией, возлагается на разработчика. Исключения – это часть интерфейса библиотеки, и решение о том, какие исключения она возбуждает, – важный аспект ее дизайна. Если библиотека предназначена для использования в программах, которые не должны аварийно завершаться ни при каких обстоятельствах, то она обязана разбираться с аномалиями сама либо извещать о них вызывающую программу, передавая ей управление. Решение о том, какие ошибки следует обрабатывать как исключения, – трудная часть работы по проектированию библиотеки.
В нашем примере с классом iStack
вопрос, должна ли функция push() возбуждать исключение, если стек полон, является спорным. Альтернативная и, по мнению многих, лучшая реализация push() – локальное решение проблемы: увеличение размера стека при его заполнении. В конце концов, единственное ограничение – это объем доступной программе памяти. Наше решение о возбуждении исключения при попытке поместить значение в полный стек, по-видимому, непродуманно. Можно переделать функцию-член push(), чтобы она в такой ситуации наращивала стек:
}
Аналогично следует ли функции pop()
возбуждать исключение при попытке извлечь значение из пустого стека? Интересно отметить, что класс stack из стандартной библиотеки C++ (он рассматривался в главе 6) не возбуждает исключения в такой ситуации. Вместо этого постулируется, что поведение программы при попытке выполнения подобной операции не определено. Разрешить программе продолжать работу при обнаружении некорректного состояния признали возможным. Мы уже упоминали, что в разных библиотеках определены разные исключения. Не существует пригодного для всех случаев ответа на вопрос, что такое исключение.
Не все программы должны беспокоиться по поводу исключений, возбуждаемых библиотечными функциями. Хотя есть системы, для которых простой недопустим и которые, следовательно, должны обрабатывать все исключительные ситуации, не к каждой программе предъявляются такие требования. Обработка исключений предназначена в первую очередь для реализации отказоустойчивых систем. В этом случае решение о том, должна ли программа обрабатывать все исключения, возбуждаемые библиотеками, или может закончить выполнение аварийно, – это трудная часть процесса проектирования.
Еще один аспект проектирования программ заключается в том, что обработка исключений обычно структурирована. Как правило, программа строится из компонентов, и каждый компонент решает сам, какие исключения обрабатывать локально, а какие передавать на верхние уровни. Что мы понимаем под компонентом? Например, система анализа текстовых запросов, рассмотренная в главе 6, может быть разбита на три компонента, или слоя. Первый слой – это стандартная библиотека C++, которая обеспечивает базовые операции над строками, отображениями и т.д. Второй слой – это сама система анализа текстовых запросов, где определены такие функции, как string_caps() и suffix_text(), манипулирующие текстами и использующие стандартную библиотеку как основу. Третий слой – это программа, которая применяет нашу систему. Каждый компонент строится независимо и должен принимать решения о том, какие исключительные ситуации обрабатывать локально, а какие передавать на более высокий уровень.
Не все функции должны уметь обрабатывать исключения. Обычно try-блоки и ассоциированные с ними catch-обработчики применяются в функциях, являющихся точками входа в компонент. Catch-обработчики проектируются так, чтобы перехватывать те исключения, которые не должны попасть на верхние уровни программы. Для этого также используются спецификации исключений (см. раздел 11.4).
Мы расскажем о других аспектах проектирования программ, использующих исключения, в главе 19, после знакомства с классами и иерархиями классов.
12
class pushOnFull { ... };
В реальных программах на C++ типы классов, представляющих исключения, чаще всего организуются в группы, или иерархии. Как могла бы выглядеть вся иерархия для этих классов?
Мы можем определить базовый класс Excp, которому наследуют оба наши класса исключений. Он инкапсулирует данные и функции-члены, общие для обоих производных:
class pushOnFull : public Excp { ... };
Одной из операцией, которые предоставляет базовый класс, является вывод сообщения об ошибке. Эта возможность используется обоими классами, стоящими ниже в иерархии:
};
Иерархию классов исключений разрешается развивать и дальше. От Excp
можно произвести другие классы для более точного описания исключений, обнаруживаемых программой:
class pushOnFull : public stackExcp { ... };
class divideByZero : public mathExcp { ... };
Последующие уточнения позволяют более детально идентифицировать аномальные ситуации в работе программы. Дополнительные классы исключений организуются как слои. По мере углубления иерархии каждый новый слой описывает все более специфичные исключения. Например, первый, самый общий слой в приведенной выше иерархии представлен классом Excp. Второй специализирует Excp, выделяя из него два подкласса: stackExcp (для исключений при работе с нашим iStack) и mathExcp
(для исключений, возбуждаемых функциями из математической библиотеки). Третий, самый специализированный слой данной иерархии уточняет классы исключений: popOnEmpty и pushOnFull
определяют два вида исключений работы со стеком, а ZeroOp и divideByZero – два вида исключений математических операций.
В последующих разделах мы рассмотрим, как возбуждаются и обрабатываются исключения, представленные классами в нашей иерархии.
К счастью, существуют механизмы, облегчающие использование членов пространств имен в программах. Псевдонимы пространства имен, using-объявления и using-директивы помогают преодолеть неудобства работы с очень длинными именами.
С++ предоставляет стандартный способ реакции на исключения. Благодаря вынесению в отдельную часть программы кода, ответственного за проверку и обработку ошибок, значительно облегчается восприятие текста программы и сокращается ее размер. Единый синтаксис и стиль обработки исключений можно, тем не менее, приспособить к самым разнообразным нуждам и запросам.
Механизм исключений делится на две основные части:
точка программы, в которой произошло исключение. Определение того факта, что при выполнении возникла какая-либо ошибка, влечет за собой возбуждение
исключения. Для этого в С++ предусмотрен специальный оператор throw. Возбуждение исключения в случае невозможности открыть некоторый файл выглядит следующим образом:
}
Место программы, в котором исключение обрабатывается. При возбуждении исключения нормальное выполнение программы приостанавливается и управление передается обработчику исключения. Поиск нужного обработчика часто включает в себя раскрутку так называемого стека вызовов программы. После обработки исключения выполнение программы возобновляется, но не с того места, где произошло исключение, а с точки, следующей за обработчиком. Для определения обработчика исключения в С++ используется ключевое слово catch. Вот как может выглядеть обработчик для примера из предыдущего абзаца:
catch (string exceptionMsg) {
log_message (exceptionMsg);
return false;
}
Каждый catch-обработчик ассоциирован с исключениями, возникающими в блоке операторов, который непосредственно предшествует обработчику и помечен ключевым словом try. Одному try-блоку могут соответствовать несколько catch-предложений, каждое из которых относится к определенному виду исключений. Приведем пример:
{
int *pstats = new int [4];
try {
pstats[0] = sum_it (ia,size);
pstats[1] = min_val (ia,size);
pstats[2] = max_val (ia,size);
}
catch (string exceptionMsg) {
// код обработчика
}
catch (const statsException &statsExcp) {
// код обработчика
}
pstats [3] = pstats[0] / size;
do_something (pstats);
return pstats;
}
В данном примере в теле функции stats() три оператора заключены в try-блок, а четыре – нет. Из этих четырех операторов два способны возбудить исключения.
1) int *pstats = new int [4];
Выполнение оператора new
может окончиться неудачей. Стандартная библиотека С++ предусматривает возбуждение исключения bad_alloc в случае невозможности выделить нужное количество памяти. Поскольку в примере не предусмотрен обработчик исключения bad_alloc, при его возбуждении выполнение программы закончится аварийно.
2) do_something (pstats);
Мы не знаем реализации функции do_something(). Любая инструкция этой функции, или функции, вызванной из этой функции, или функции, вызванной из функции, вызванной этой функцией, и так далее, потенциально может возбудить исключение. Если в реализации функции do_something и вызываемых из нее предусмотрен обработчик такого исключения, то выполнение stats()
продолжится обычным образом. Если же такого обработчика нет, выполнение программы аварийно завершится.
Необходимо заметить, что, хотя оператор
pstats [3] = pstats[0] / size;
может привести к делению на ноль, в стандартной библиотеке не предусмотрен такой тип исключения.
Обратимся теперь к инструкциям, объединенным в try-блок. Если в одной из вызываемых в этом блоке функций – sum_it(), min_val() или max_val() –произойдет исключение, управление будет передано на обработчик,
следующий за try-блоком и перехватывающий именно это исключение. Ни инструкция, возбудившая исключение, ни следующие за ней инструкции в try-блоке выполнены не будут. Представим себе, что при вызове функции sum_it() возбуждено исключение:
throw string ("Ошибка: adump27832");
Выполнение функции sum_it()
прервется, операторы, следующие в try-блоке за вызовом этой функции, также не будут выполнены, и pstats[0] не будет инициализирована. Вместо этого возбуждается исключительное состояние и исследуются два catch-обработчика. В нашем случае выполняется catch с параметром типа string:
}
После выполнения управление будет передано инструкции, следующей за последним catch-обработчиком, относящимся к данному try-блоку. В нашем случае это
pstats [3] = pstats[0] / size;
(Конечно, обработчик сам может возбуждать исключения, в том числе – того же типа. В такой ситуации будет продолжено выполнение catch-предложений, определенных в программе, вызвавшей функцию stats().)
Вот пример:
// код обработчика
cerr << "stats(): исключение: "
<< exceptionMsg
<< endl;
delete [] pstats;
return 0;
}
В таком случае выполнение вернется в функцию, вызвавшую stats(). Будем считать, что разработчик программы предусмотрел проверку возвращаемого функцией stats()
значения и корректную реакцию на нулевое значение.
Функция stats()
умеет реагировать на два типа исключений: string и statsException. Исключение любого другого типа игнорируется, и управление передается в вызвавшую функцию, а если и в ней не найдется обработчика, – то в функцию более высокого уровня, и так до функции main().При отсутствии обработчика и там, программа аварийно завершится.
Возможно задание специального обработчика, который реагирует на любой тип исключения. Синтаксис его таков:
<
}
( Детально обработка исключительных ситуаций рассматривается в главах 11 и 19.)
Упражнение 2.18
Какие ошибочные ситуации могут возникнуть во время выполнения следующей функции:
int *alloc_and_init (string file_name)
{
ifstream infile (file_name)
int elem_cnt;
infile >> elem_cnt;
int *pi = allocate_array(elem_cnt);
int elem;
int index=0;
while (cin >> elem)
pi[index++] = elem;
sort_array(pi,elem_cnt);
register_data(pi);
return pi;
}
Упражнение 2.19
В предыдущем примере вызываемые функции allocate_array(), sort_array() и register_data()
могут возбуждать исключения типов noMem, int и string соответственно. Перепишите функцию alloc_and_init(), вставив соответствующие блоки try и catch для обработки этих исключений. Пусть обработчики просто выводят в cerr сообщение об ошибке.
Упражнение 2.20
Усовершенствуйте функцию alloc_and_init()
так, чтобы она сама возбуждала исключение в случае возникновения всех возможных ошибок (это могут быть исключения, относящиеся к вызываемым функциям allocate_array(), sort_array() и register_data() и какими-то еще операторами внутри функции alloc_and_init()). Пусть это исключение имеет тип string и строка, передаваемая обработчику, содержит описание ошибки.
1.
Создать копию каждого вектора.
2. Слить все векторы в один.
3. Отсортировать его в алфавитном порядке.
4. Удалить все дубликаты.
5. Снова отсортировать, но уже по длине слов.
6. Подсчитать число слов, длина которых больше шести знаков (предполагается, что длина – это некоторая мера сложности, по крайней мере, в терминах словаря).
7. Удалить семантически нейтральные слова (например, союзы and (и), if (если), or (или), but (но) и т.д.).
8. Напечатать получившийся вектор.
На первый взгляд, задача на целую главу. Но с помощью обобщенных алгоритмов мы решим ее в рамках одного подраздела.
Аргументом нашей функции является вектор из векторов строк. Мы принимаем указатель на него, проверяя, не является ли он нулевым:
}
Нужно создать один вектор, включающий все элементы исходных векторов. Это делается с помощью обобщенного алгоритма copy()
(для его использования необходимо включить заголовочные файлы algorithm и iterator):
<
}
Первыми двумя аргументами алгоритма copy() являются итераторы, ограничивающие диапазон подлежащих копированию элементов. Третий аргумент – это итератор, указывающий на место, куда надо копировать элементы. back_inserter
называется адаптером итератора; он позволяет вставлять элементы в конец вектора, переданного ему в качестве аргумента. (Подробнее мы рассмотрим адаптеры итераторов в разделе 12.4.).
Алгоритм unique()
удаляет из контейнера дубликаты, расположенные рядом. Если дана последовательность 01123211, то результатом будет 012321, а не 0123. Чтобы получить вторую последовательность, необходимо сначала отсортировать вектор с помощью алгоритма sort(); тогда из последовательности 01111223
получится 0123. (Хотя на самом деле получится 01231223.)
unique() не изменяет размер контейнера. Вместо этого каждый уникальный элемент помещается в очередную свободную позицию, начиная с первой. В нашем примере физический результат – это последовательность 01231223; остаток 1223 – это, так сказать, “отходы” алгоритма. unique() возвращает итератор, указывающий на начало этого остатка. Как правило, этот итератор затем передается алгоритму erase() для удаления ненужных элементов. (Поскольку встроенный массив не поддерживает операции erase(), то семейство алгоритмов unique() в меньшей степени подходит для работы с ним.) Вот соответствующий фрагмент функции:
}
Ниже приведен результат печати вектора texts, объединяющего два небольших текстовых файла, после применения sort(), но до применения unique():
a a a a alice alive almost
alternately ancient and and and and and and
and as asks at at beautiful becomes bird
bird blows blue bounded but by calling coat
daddy daddy daddy dark darkened darkening distant each
either emma eternity falls fear fiery fiery flight
flowing for grow hair hair has he heaven,
held her her her her him him home
houses i immeasurable immensity in in in in
inexpressibly is is is it it it its
journeying lands leave leave life like long looks
magical mean more night, no not not not
now now of of on one one one
passion puts quite red rises row same says
she she shush shyly sight sky so so
star star still stone such tell tells tells
that that the the the the the the
the there there thing through time to to
to to trees unravel untamed wanting watch what
when wind with with you you you you
your your
После применения unique() и последующего вызова erase()
вектор texts
выглядит следующим образом:
a alice alive almost alternately ancient
and as asks at beautiful becomes bird blows
blue bounded but by calling coat daddy dark
darkened darkening distant each either emma eternity falls
fear fiery flight flowing for grow hair has
he heaven, held her him home houses i
immeasurable immensity in inexpressibly is it its journeying
lands leave life like long looks magical mean
more night, no not now of on one
passion puts quite red rises row same says
she shush shyly sight sky so star still
stone such tell tells that the there thing
through time to trees unravel untamed wanting watch
what when wind with you your
Следующая наша задача – отсортировать строки по длине. Для этого мы воспользуемся не алгоритмом sort(), а алгоритмом stable_sort(), который сохраняет относительные положения равных элементов. В результате для элементов равной длины сохраняется алфавитный порядок. Для сортировки по длине мы применим собственную операцию сравнения “меньше”. Один из возможных способов таков:
<
}
Нужный результат при этом достигается, но эффективность существенно ниже, чем хотелось бы. less_than()
реализована в виде одной инструкции. Обычно она вызывается как встроенная (inline) функция. Но, передавая указатель на нее, мы не даем компилятору сделать ее встроенной. Способ, позволяющий добиться этого, –применение объекта-функции:
};
Объект-функция – это класс, в котором перегружен оператор вызова operator(). В теле этого оператора и реализуется логика функции, в данном случае сравнение “меньше”. Определение оператора вызова выглядит странно из-за двух пар скобок. Запись
operator()
говорит компилятору, что мы перегружаем оператор вызова. Вторая пара скобок
( const string & s1, const string & s2 )
задает передаваемые ему формальные параметры. Если сравнить это определение с предыдущим определением функции less_than(), мы увидим, что, за исключением замены less_than на operator(), они совпадают.
Объект-функция определяется так же, как обычный объект класса (правда, в данном случае нам не понадобился конструктор: нет членов, подлежащих инициализации):
LessThan lt;
Для вызова экземпляра перегруженного оператора мы применяем оператор вызова к нашему объекту класса, передавая необходимые аргументы. Например:
bool is_shakespeare_less = lt( st1, st2 );
Ниже показана исправленная функция process_vocab(), в которой алгоритму stable_sort()
передается безымянный объект-функция LessThan():
}
Внутри stable_sort()
перегруженный оператор вызова подставляется в текст программы как встроенная функция. (В качестве третьего аргумента stable_sort() может принимать как указатель на функцию less_than(), так и объект класса LessThan, поскольку аргументом является параметр-тип шаблона. Подробнее об объектах-функциях мы расскажем в разделе 12.3.)
Вот результат применения stable_sort() к вектору texts:
a i
as at by he in is it no
of on so to and but for has
her him its not now one red row
she sky the you asks bird blue coat
dark each emma fear grow hair held home
life like long mean more puts same says
star such tell that time what when wind
with your alice alive blows daddy falls fiery
lands leave looks quite rises shush shyly sight
still stone tells there thing trees watch almost
either flight houses night, ancient becomes bounded calling
distant flowing heaven, magical passion through unravel untamed
wanting darkened eternity beautiful darkening immensity journeying alternately
immeasurable inexpressibly
Подсчитать число слов, длина которых больше шести символов, можно с помощью обобщенного алгоритма count_if() и еще одного объекта-функции – GreaterThan. Этот объект чуть сложнее, так как позволяет пользователю задать размер, с которым производится сравнение. Мы сохраняем размер в члене класса и инициализируем его с помощью конструктора (по умолчанию – значением 6):
};
Использовать его можно так:
<
}
Этот фрагмент программы выводит такую строку:
Number of words greater than length six are 22
Алгоритм remove()
ведет себя аналогично unique(): он тоже не изменяет размер контейнера, а просто разделяет элементы на те, что следует оставить (копируя их по очереди в начало контейнера), и те, что следует удалить (перемещая их в конец контейнера). Вот как можно воспользоваться им для исключения из коллекции слов, которые мы не хотим сохранять:
}
Результат применения remove():
1 instances removed: and
0 instances removed: if
0 instances removed: or
1 instances removed: but
1 instances removed: the
Теперь нам нужно распечатать содержимое вектора. Можно обойти все элементы и вывести каждый по очереди, но, поскольку при этом обобщенные алгоритмы не используются, мы считаем такое решение неподходящим. Вместо этого проиллюстрируем работу алгоритма for_each() для вывода всех элементов вектора. for_each() применяет указатель на функцию или объект-функцию к каждому элементу контейнера из диапазона, ограниченного парой итераторов. В нашем случае объект-функция PrintElem копирует один элемент в стандартный вывод:
<
};
}
Вот и все. Мы получили законченную программу, для чего пришлось лишь последовательно записать обращения к нескольким обобщенным алгоритмам. Для удобства мы приводим ниже полный листинг вместе с функцией main() для ее тестирования (здесь используются специальные типы итераторов, которые будут обсуждаться только в разделе 12.4). Мы привели текст реально исполнявшегося кода, который не полностью удовлетворяет стандарту C++. В частности, в нашем распоряжении были лишь устаревшие реализации алгоритмов count() и count_if(), которые не возвращают результат, а требуют передачи дополнительного аргумента для вычисленного значения. Кроме того, библиотека iostream
отражает предшествующую принятию стандарта реализацию, в которой требуется заголовочный файл iostream.h.
};
<
}
Упражнение 12.2
Длина слова – не единственная и, вероятно, не лучшая мера трудности текста. Другой возможный критерий – это длина предложения. Напишите программу, которая читает текст из файла либо со стандартного ввода, строит вектор строк для каждого предложения и передает его алгоритму count(). Выведите предложения в порядке сложности. Любопытный способ сделать это – сохранить каждое предложение как одну большую строку во втором векторе строк, а затем передать этот вектор алгоритму sort()
вместе с объектом-функцией, который считает, что чем строка короче, тем она меньше. (Более подробно с описанием конкретного обобщенного алгоритма, а также с иллюстрацией его применения вы может ознакомиться в Приложении, где все алгоритмы перечислены в алфавитном порядке.)
Упражнение 12.3
Более надежную оценку уровня трудности текста дает анализ структурной сложности предложений. Пусть каждой запятой присваивается 1 балл, каждому двоеточию или точке с запятой – 2 балла, а каждому тире – 3 балла. Модифицируйте программу из упражнения 12.2 так, чтобы она подсчитывала сложность каждого предложения. Воспользуйтесь алгоритмом count_if() для нахождения каждого из знаков препинания в векторе предложений. Выведите предложения в порядке сложности.
class Cplusplus_Primer_Third_Edition_Array { ... };
Конечно, это тоже не гарантирует уникальность имени, но с большой вероятностью избавит пользователя от данной проблемы. Как, однако, неудобно пользоваться столь длинными именами!
Стандарт С++ предлагает для решения проблемы совпадения имен механизм, называемый пространством имен. Каждый производитель программного обеспечения может заключить свои классы, функции и другие объекты в свое собственное пространство имен. Вот как выглядит, например, объявление нашего класса Array:
}
Ключевое слово namespace
задает пространство имен, определяющее видимость нашего класса и названное в данном случае Cplusplus_Primer_3E. Предположим, что у нас есть классы от других разработчиков, помещенные в другие пространства имен:
namespace IBM_Canada_Laboratory {
template class Array { ... };
class Matrix { ... };
}
namespace Disney_Feature_Animation {
class Point { ... };
template class Array { ... };
}
По умолчанию в программе видны объекты, объявленные без явного указания пространства имен; они относятся к глобальному пространству имен. Для того чтобы обратиться к объекту из другого пространства, нужно использовать его квалифицированное имя, которое состоит из идентификатора пространства имен и идентификатора объекта, разделенных оператором разрешения области видимости (::). Вот как выглядят обращения к объектам приведенных выше примеров:
IBM_Canada_Laboratory::Matrix mat;
Disney_Feature_Animation::Point origin(5000,5000);
Для удобства использования можно назначать псевдонимы
пространствам имен. Псевдоним выбирают коротким и легким для запоминания. Например:
// псевдонимы
namespace LIB = IBM_Canada_Laboratory;
namespace DFA = Disney_Feature_Animation;
int main()
{
LIB::Array ia(1024);
}
Псевдонимы употребляются и для того, чтобы скрыть использование пространств имен. Заменив псевдоним, мы можем сменить набор задействованных функций и классов, причем во всем остальном код программы останется таким же. Исправив только одну строчку в приведенном выше примере, мы получим определение уже совсем другого массива:
namespace LIB = Cplusplus_Primer_3E;
int main()
{
LIB::Array ia(1024);
}
Конечно, чтобы это стало возможным, необходимо точное совпадение интерфейсов классов и функций, объявленных в этих пространствах имен. Представим, что класс Array из Disney_Feature_Animation не имеет конструктора с одним параметром – размером. Тогда следующий код вызовет ошибку:
namespace LIB = Disney_Feature_Animation;
int main()
{
LIB::Array ia(1024);
}
Еще более удобным является способ использования простого, неквалифицированного имени для обращения к объектам, определенным в некотором пространстве имен. Для этого существует директива using:
#include "IBM_Canada_Laboratory.h"
using namespace IBM_Canada_Laboratory;
int main()
{
// IBM_Canada_Laboratory::Matrix
Matrix mat(4,4);
// IBM_Canada_Laboratory::Array
Array ia(1024);
// ...
}
Пространство имен IBM_Canada_Laboratory
становится видимым в программе. Можно сделать видимым не все пространство, а отдельные имена внутри него (селективная директива using):
#include "IBM_Canada_Laboratory.h"
using namespace IBM_Canada_Laboratory::Matrix;
// видимым становится только Matrix
int main()
{
// IBM_Canada_Laboratory::Matrix
Matrix mat(4,4);
// Ошибка: IBM_Canada_Laboratory::Array невидим
Array ia(1024);
// ...
}
Как мы уже упоминали, все компоненты стандартной библиотеки С++ объявлены внутри пространства имен std. Поэтому простого включения заголовочного файла недостаточно, чтобы напрямую пользоваться стандартными функциями и классами:
#include
// ошибка: string невидим
string current_chapter = "Обзор С++";
Необходимо использовать директиву using:
#include
using namespace std;
// Ok: видим string
string current_chapter = "Обзор С++";
Заметим, однако, что таким образом мы возвращаемся к проблеме “засорения” глобального пространства имен, ради решения которой и был создан механизм именованных пространств. Поэтому лучше использовать либо квалифицированное имя:
// правильно: квалифицированное имя
std::string current_chapter = "Обзор С++";
либо селективную директиву using:
#include
using namespace std::string;
// Ok: string видим
string current_chapter = "Обзор С++";
Мы рекомендуем пользоваться последним способом.
В большинстве примеров этой книги директивы пространств имен были опущены. Это сделано ради сокращения размера кода, а также потому, что большинство примеров были скомпилированы компилятором, не поддерживающим пространства имен – достаточно недавнего нововведения С++. (Детали применения using-объявлений при работе с стандартной библиотекой С++ обсуждаются в разделе 8.6.)
В нижеследующих главах мы создадим еще четыре класса: String, Stack, List и модификацию Stack. Все они будут заключены в одно пространство имен – Cplusplus_Primer_3E. (Более подробно работа с пространствами имен рассматривается в главе 8.)
Упражнение 2.21
Дано пространство имен
template
class Array { ... };
template
void print (Array< EType > );
class String { ... }
template
class List { ... };
}
и текст программы:
const int size = 1024;
Array as (size);
List il (size);
// ...
Array *pas = new Array(as);
List *pil = new List(il);
print (*pas);
}
Программа не компилируется, поскольку объявления используемых классов заключены в пространство имен Exercise. Модифицируйте код программы, используя
(a) квалифицированные имена
(b) селективную директиву using
(c) механизм псевдонимов
(d) директиву using
служит хорошей альтернативой встроенному массиву целых чисел. Но в жизни могут потребоваться массивы для самых разных типов данных. Можно предположить, что единственным отличием массива элементов типа double от нашего является тип данных в объявлениях, весь остальной код совпадает буквально.
Для решения данной проблемы в С++ введен механизм шаблонов. В объявлениях классов и функций допускается использование параметризованных типов. Типы-параметры заменяются в процессе компиляции настоящими типами, встроенными или определенными пользователем. Мы можем создать шаблон класса Array, заменив в классе IntArray тип элементов int на обобщенный тип-параметр. Позже мы конкретизируем
типы-параметры, подставляя вместо них реальные типы int, double и string. В результате появится способ использовать эти конкретизации так, как будто мы на самом деле определили три разных класса для этих трех типов данных.
Вот как может выглядеть шаблон класса Array:
};
Ключевое слово template
говорит о том, что задается шаблон,
параметры которого заключаются в угловые скобки (<>). В нашем случае имеется лишь один параметр elemType; ключевое слово class
перед его именем сообщает, что этот параметр представляет собой тип.
При конкретизации класса-шаблона Array
параметр elemType
заменяется на реальный тип при каждом использовании, как показано в примере:
}
Здесь определены три экземпляра класса Array:
Array da(array_size);
Array ca(array_size);
Что делает компилятор, встретив такое объявление? Подставляет текст шаблона Array, заменяя параметр elemType на тот тип, который указан в каждом конкретном случае. Следовательно, объявления членов приобретают в первом случае такой вид:
// Array ia(array_size);
int _size;
int *_ia;
Заметим, что это в точности соответствует определению массива IntArray.
Для оставшихся двух случаев мы получим следующий код:
int _size;
double *_ia;
// Array ca(array_size);
int _size;
char *_ia;
Что происходит с функциями-членами? В них тоже тип-параметр elemType заменяется на реальный тип, однако компилятор не конкретизирует те функции, которые не вызываются в каком-либо месте программы. (Подробнее об этом в разделе 16.8.)
При выполнении программа этого примера выдаст следующий результат:
[ 0 ] ia: 0 ca: a da: 0
[ 1 ] ia: 1 ca: b da: 1.75
[ 2 ] ia: 2 ca: c da: 3.5
[ 3 ] ia: 3 ca: d da: 5.25
Механизм шаблонов можно использовать и в наследуемых классах. Вот как выглядит определение шаблона класса ArrayRC:
#include "Array.h"
template
class ArrayRC : public Array {
public:
ArrayRC( int sz = DefaultArraySize )
: Array( sz ) {}
ArrayRC( const ArrayRC& r )
: Array( r ) {}
ArrayRC( const elemType *ar, int sz )
: Array( ar, sz ) {}
elemType& ArrayRC::operator[]( int ix )
{
assert( ix >= 0 && ix < Array::_size );
return _ia[ ix ];
}
private:
// ...
};
Подстановка реальных параметров вместо типа-параметра elemType происходит как в базовом, так и в производном классах. Определение
ArrayRC ia_rc(10);
ведет себя точно так же, как определение IntArrayRC из предыдущего раздела. Изменим пример использования из предыдущего раздела. Прежде всего, чтобы оператор
swap( ia1, 1, ia1.size() );
был допустимым, нам потребуется представить функцию swap() в виде шаблона.
template
inline void
swap( Array &array, int i, int j )
{
elemType tmp = array[ i ];
array[ i ] = array[ j ];
array[ j ] = tmp;
}
При каждом вызове swap()
генерируется подходящая конкретизация, которая зависит от типа массива. Вот как выглядит программа, использующая шаблоны Array и ArrayRC:
#include "Array.h"
#include "ArrayRC.h"
template
inline void
swap( Array &array, int i, int j )
{
elemType tmp = array[ i ];
array[ i ] = array[ j ];
array[ j ] = tmp;
}
int main()
{
Array ia1;
ArrayRC ia2;
cout << "swap() with Array ia1" << endl;
int size = ia1.size();
swap( ia1, 1, size );
cout << "swap() with ArrayRC ia2" << endl;
size = ia2.size();
swap( ia2, 1, size );
return 0;
}
Упражнение 2.13
Пусть мы имеем следующие объявления типов:
enum Status { ... };
typedef string *Pstring;
Есть ли ошибки в приведенных ниже описаниях объектов?
(e) Array< Pstring > aps(1024);
Упражнение 2.14
Перепишите следующее определение, сделав из него шаблон класса:
}
Упражнение 2.15
Имеется следующий шаблон класса:
public:
explicit Example2 (elemType val=0) : _val(val) {};
bool min(elemType value) { return _val < value; }
void value(elemType new_val) { _val = new_val; }
void print (ostream &os) { os << _val; }
private:
elemType _val;
}
template
ostream& operator<<(ostream &os,const Example2 &ex)
{ ex.print(os); return os; }
Какие действия вызывают следующие инструкции?
(f) cout << "exs: " << exs << endl;
Упражнение 2.16
Пример из предыдущего упражнения накладывает определенные ограничения на типы данных, которые могут быть подставлены вместо elemType. Так, параметр конструктора имеет по умолчанию значение 0:
explicit Example2 (elemType val=0) : _val(val) {};
Однако не все типы могут быть инициализированы нулем (например, тип string), поэтому определение объекта
Example2 exs("Walden");
является правильным, а
Example2 exs2;
приведет к синтаксической ошибке[4]. Также ошибочным будет вызов функции min(), если для данного типа не определена операция меньше. С++ не позволяет задать ограничения для типов, подставляемых в шаблоны. Как вы думаете, было бы полезным иметь такую возможность? Если да, попробуйте придумать синтаксис задания ограничений и перепишите в нем определение класса Example2. Если нет, поясните почему.
Упражнение 2.17
Как было показано в предыдущем упражнении, попытка использовать шаблон Example2 с типом, для которого не определена операция меньше, приведет к синтаксической ошибке. Однако ошибка проявится только тогда, когда в тексте компилируемой программы действительно встретится вызов функции min(), в противном случае компиляция пройдет успешно. Как вы считаете, оправдано ли такое поведение? Не лучше ли предупредить об ошибке сразу, при обработке описания шаблона? Поясните свое мнение.
имеет форму:
istream_iterator identifier( istream& );1[O.A.3].
где Type – это любой встроенный или пользовательский тип класса, для которого определен оператор ввода. Аргументом конструктора может быть объект либо класса istream, например cin, либо производного от него класса с открытым типом наследования – ifstream:
istream_iterator< string > is_string( infile );
При каждом применении оператора инкремента к объекту типа istream_iterator
читается следующий элемент из входного потока, для чего используется оператор operator>>(). Чтобы сделать то же самое в обобщенных алгоритмах, необходимо предоставить пару итераторов, обозначающих начальную и конечную позицию в файле. Начальную позицию дает istream_iterator, инициализированный объектом istream, – такой, скажем, как is_string. Для получения конечной позиции мы используем специальный конструктор по умолчанию класса istream_iterator:
может быть представлено в двух формах:
ostream_iterator identifier( ostream&, char * delimiter )
где Type – это любой встроенный или пользовательский тип класса, для которого определен оператор вывода (operator<<). Во второй форме delimiter – это разделитель, то есть C-строка символов, которая выводится в файл после каждого элемента. Такая строка должна заканчиваться двоичным нулем, иначе поведение программы не определено (скорее всего, она аварийно завершит выполнение). В качестве аргумента ostream может выступать объект класса ostream, например cout, либо производного от него класса с открытым типом наследования, скажем ofstream:
ostream_iterator< string > os_string( outfile, "\n" );
Вот простой пример чтения из стандартного ввода и копирования на стандартный вывод с помощью безымянных потоковых итераторов и обобщенного алгоритма copy():
}
Ниже приведена небольшая программа, которая открывает указанный пользователем файл и копирует его на стандартный вывод, применяя для этого алгоритм copy() и потоковый итератор записи ostream_iterator:
}
unique_copy( ivec.begin(), ivec.end(), vres.begin() );
Проблема вызвана тем, что алгоритм unique_copy() использует присваивание для копирования значения каждого элемента из вектора ivec, но эта операция завершится неудачно, поскольку в vres не выделено место для хранения девяти целых чисел.
Можно было бы написать две версии алгоритма unique_copy(): одна присваивает элементы, а вторая вставляет их. Эта последняя версия должна, в таком случае, поддерживать вставку в начало, в конец или в произвольное место контейнера.
Альтернативный подход, принятый в стандартной библиотеке, заключается в определении трех адаптеров, которые возвращают специальные итераторы вставки:
· back_inserter()
вызывает определенную для контейнера операцию вставки push_back()
вместо оператора присваивания. Аргументом back_inserter() является сам контейнер. Например, вызов unique_copy()
можно исправить, написав:
back_inserter( vres ) );
· front_inserter()
вызывает определенную для контейнера операцию вставки push_front()
вместо оператора присваивания. Аргументом front_inserter()
тоже является сам контейнер. Заметьте, однако, что класс vector не поддерживает push_front(), так что использовать такой адаптер для вектора нельзя:
front_inserter( vres ) );
· inserter()
вызывает определенную для контейнера операцию вставки insert()
вместо оператора присваивания. inserter() принимает два аргумента: сам контейнер и итератор, указывающий позицию, с которой должна начаться вставка:
inserter( vres ), vres.begin() );
· Итератор, указывающий на позицию начала вставки, сдвигается вперед после каждой вставки, так что элементы располагаются в нужном порядке, как если бы мы написали:
vres.insert( iter, *iter2 );
является итератором для какого-либо контейнера. Тогда
++iter;
перемещает итератор так, что он указывает на следующий элемент контейнера, а
*iter;
разыменовывает итератор, возвращая элемент, на который он указывает.
Все контейнеры имеют функции-члены begin() и end().
· begin()
возвращает итератор, указывающий на первый элемент контейнера.
· end()
возвращает итератор, указывающий на элемент, следующий за последним в контейнере.
Чтобы перебрать все элементы контейнера, нужно написать:
do_something_with_element( *iter );
Объявление итератора выглядит слишком сложным. Вот определение пары итераторов вектора типа string:
vector::iterator iter_end = vec.end();
В классе vector для определения iterator
используется typedef. Синтаксис
vector::iterator
ссылается на iterator, определенный с помощью
typedef
внутри класса vector, содержащего элементы типа string.
Для того чтобы напечатать все элементы вектора, нужно написать:
cout << *iter << '\n';
Здесь значением *iter
выражения является, конечно, элемент вектора.
В дополнение к типу iterator в каждом контейнере определен тип const_iterator, который необходим для навигации по контейнеру, объявленному как const. const_iterator позволяет только читать элементы контейнера:
<
}
Что делать, если мы хотим просмотреть некоторое подмножество элементов, например взять каждый второй или третий элемент, или хотим начать с середины? Итераторы поддерживают адресную арифметику, а значит, мы можем прибавить некоторое число к итератору:
vector::iterator iter = vec->begin()+vec.size()/2;
iter
получает значение адреса элемента из середины вектора, а выражение
iter += 2;
сдвигает iter на два элемента.
Арифметические действия с итераторами возможны только для контейнеров vector и deque. list не поддерживает адресную арифметику, поскольку его элементы не располагаются в непрерывной области памяти. Следующее выражение к списку неприменимо:
ilist.begin() + 2;
так как для перемещения на два элемента необходимо два раза перейти по адресу, содержащемуся в закрытом члене next. У классов vector и deque перемещение на два элемента означает прибавление 2 к указателю на текущий элемент. (Адресная арифметика рассматривается в разделе 3.3.)
Объект контейнерного типа может быть инициализирован парой итераторов, обозначающих начало и конец последовательности копируемых в новый объект элементов. (Второй итератор должен указывать на элемент, следующий за последним копируемым.) Допустим, есть вектор:
}
Вот как можно определить новые векторы, инициализируя их элементами первого вектора:
}
Использование специального типа istream_iterator (о нем рассказывается в разделе 12.4.3) упрощает чтение элементов из входного потока в svec:
}
Кроме итераторов, для задания диапазона значений, инициализирующих контейнер, можно использовать два указателя на массив встроенного типа. Пусть есть следующий массив строк:
};
Мы можем инициализировать вектор с помощью указателей на первый элемент массива и на элемент, следующий за последним:
vector< string > vwords( words, words+4 );
Второй указатель служит “стражем”: элемент, на который он указывает, не копируется.
Аналогичным образом можно инициализировать список целых элементов:
list< int > ilist( ia, ia+6 );
В разделе 12.4 мы снова обратимся к итераторам и опишем их более детально. Сейчас информации достаточно для того, чтобы использовать итераторы в нашей системе текстового поиска. Но прежде чем вернуться к ней, рассмотрим некоторые дополнительные операции, поддерживаемые контейнерами.
Упражнение 6.9
Какие ошибки допущены при использовании итераторов:
// ...
Упражнение 6.10
Найдите ошибки в использовании итераторов:
(f) vector svec2( sa, sa+6 );
}
Когда этот шаблон конкретизируется с аргументом типа const char*, то обобщенное определение оказывается семантически некорректным, если мы интерпретируем каждый аргумент как строку символов в смысле языка C, а не как указатель на символ. В этом случае необходимо предоставить специализированное определение для конкретизации шаблона.
Явное определение специализации – это такое определение, в котором за ключевым словом template следует пара угловых скобок <>, а за ними – определение специализированного шаблона. Здесь указывается имя шаблона, аргументы, для которых он специализируется, список параметров функции и ее тело. В следующем примере для max(const char*, const char*) определена явная специализация:
return ( strcmp( s1, s2 ) > 0 ? s1 : s2 );
Поскольку имеется явная специализация, шаблон не будет конкретизирован с типом const char* при вызове в программе функции max(const char*, const char*). При любом обращении к max() с двумя аргументами типа const char*
работает специализированное определение. Для любых других обращений функция сначала конкретизируется по обобщенному определению шаблона, а затем вызывается. Вот как это выглядит:
}
Можно объявлять явную специализацию шаблона функции, не определяя ее. Например, для функции max(const char*, const char*) она объявляется так:
template< > PCC max< PCC >( PCC, PCC );
При объявлении или определении явной специализации шаблона функции нельзя опускать слово template и следующую за ним пару скобок <>. Кроме того, в объявлении специализации обязательно должен быть список параметров функции:
template<> PCC max< PCC >;
Однако здесь можно опускать задание аргументов шаблона, если они выводятся из формальных параметров функции:
template<> PCC max( PCC, PCC );
В следующем примере шаблон функции sum()
явно специализирован:
<
template<> int sum( char, char );
Пропуск части template<> в объявлении явной специализации не всегда является ошибкой. Например:
const char* max( const char*, const char*);
Однако эта инструкция не является специализацией шаблона функции. Здесь просто объявляется обычная функция с типом возвращаемого значения и списком параметров, которые соответствуют полученным при конкретизации шаблона. Объявление обычной функции, являющееся конкретизацией шаблона, не считается ошибкой.
Так почему бы просто не объявить обычную функцию? Как было показано в разделе 10.3, для преобразования фактического аргумента функции, конкретизированной по шаблону, в соответствующий формальный параметр в случае, когда этот аргумент принимает участие в выводе аргумента шаблона, может быть применено лишь ограниченное множество преобразований типов. Точно так же обстоит дело и в ситуации, когда шаблон функции специализируется явно: к фактическим аргументам функции при этом тоже применимо лишь ограниченное множество преобразований. Явные специализации не помогают обойти соответствующие ограничения. Если мы хотим выйти за их пределы, то должны определить обычную функцию вместо специализации шаблона. (В разделе 10.8 этот вопрос рассматривается более подробно; там же показано, как работает разрешение перегруженной функции для вызова, который соответствует как обычной функции, так и экземпляру, конкретизированному из шаблона.)
Явную специализацию можно объявлять даже тогда, когда специализируемый шаблон объявлен, но не определен. В предыдущем примере шаблон функции sum()
лишь объявлен к моменту специализации. Хотя определение шаблона не обязательно, объявление все же требуется. То, что sum() – шаблон, должно быть известно до того, как это имя может быть специализировано.
Такое объявление должно быть видимо до его использования в исходном файле. Например:
template<> PCC max< PCC >(PCC s1, PCC s2 ) { /* ... */ }
В предыдущем примере конкретизация max(const char*, const char*) предшествует объявлению явной специализации. Поэтому компилятор имеет право предположить, что функция должна быть конкретизирована по обобщенному определению шаблона. Однако в программе не может одновременно существовать явная специализация и экземпляр, конкретизированный по тому же шаблону с тем же множеством аргументов. Когда в исходном файле после конкретизации встречается явная специализация
max(const char*, const char*), компилятор выдает сообщение об ошибке.
Если программа состоит из нескольких файлов, то объявление явной специализации шаблона должно быть видимо в каждом файле, в котором она используется. Не разрешается в одних файлах конкретизировать шаблон функции по обобщенному определению, а в других специализировать с тем же множеством аргументов. Рассмотрим следующий пример:
<
}
Эта программа состоит из двух файлов. В файле File1.C нет объявления явной специализации max(const char*, const char*). Вместо этого шаблон функции конкретизируется из обобщенного определения. В файле File2.C
объявлена явная специализация, и при обращении к max("hi", "again")
именно она и вызывается. Поскольку в одной и той же программе функция max(const char*, const char*) то конкретизируется по шаблону, то специализируется явно, компилятор считает программу некорректной. Для исправления этого объявление явной специализации шаблона должно предшествовать вызову функции max(const char*, const char*) в файле File1.C.
Чтобы избежать таких ошибок и гарантировать, что объявление явной специализации шаблона max(const char*, const char*) внесено в каждый файл, где используется шаблон функции max() с аргументами типа const char*, это объявление следует поместить в заголовочный файл "max.h" и включать его во все исходные файлы, в которых используется шаблон max():
}
Упражнение 10.10
Определите шаблон функции count() для подсчета числа появлений некоторого значения в массиве. Напишите вызывающую программу. Последовательно передайте в ней массив значений типа double, int и сhar. Напишите специализированный экземпляр шаблона count() для обработки строк.
Указатель на объект любого неконстантного типа может быть присвоен указателю типа
void*, который используется в тех случаях, когда действительный тип объекта либо неизвестен, либо может меняться в ходе выполнения программы. Поэтому указатель void*
иногда называют универсальным
указателем. Например:
const void *pcv = pci; // правильно
Однако указатель void* не может быть разыменован непосредственно. Компилятор не знает типа объекта, адресуемого этим указателем. Но это известно программисту, который хочет преобразовать указатель void* в указатель определенного типа. С++ не обеспечивает подобного автоматического преобразования:
}
Компилятор выдает сообщение об ошибке, так как в данном случае указатель pv
содержит адрес целого числа ival, и именно этот адрес пытаются присвоить указателю на строку. Если бы такая программа была допущена до выполнения, то вызов функции strcpy(), которая ожидает на входе строку символов с нулем в конце, скорее всего привел бы к краху, потому что вместо этого strcpy() получает указатель на целое число. Подобные ошибки довольно просто не заметить, именно поэтому С++ запрещает неявное преобразование указателя на void в указатель на другой тип. Однако такой тип можно изменить явно:
}
Другой причиной использования явного преобразования типов может служить необходимость избежать стандартного преобразования или выполнить вместо него собственное. Например, в следующем выражении ival
сначала преобразуется в double, потом к нему прибавляется dval, и затем результат снова трансформируется в int.
ival += dval;
Можно уйти от ненужного преобразования, явно заменив dval на int:
ival += static_cast< int >( dval );
Третьей причиной является желание избежать неоднозначных ситуаций, в которых возможно несколько вариантов применения правил преобразования по умолчанию. (Мы рассмотрим этот случай в главе 9, когда будем говорить о перегруженных функциях.)
Синтаксис операции явного преобразования типов таков:
cast-name< type >( expression );
Здесь cast-name – одно из ключевых слов static_cast, const_cast, dynamic_cast или reinterpret_cast, а type – тип, к которому приводится выражение expression.
Четыре вида явного преобразования введены для того, чтобы учесть все возможные формы приведения типов. Так const_cast
служит для трансформации константного типа в неконстантный и подвижного (volatile) – в неподвижный. Например:
char *pc = string_copy( const_cast< char* >( pc_str ));
Любое иное использование const_cast вызывает ошибку компиляции, как и попытка подобного приведения с помощью любого из трех других операторов.
С применением static_cast
осуществляются те преобразования, которые могут быть сделаны неявно, на основе правил по умолчанию:
<
char ch = static_cast< char >( d );
Зачем использовать static_cast? Дело в том, что без него компилятор выдаст предупреждение о возможной потере точности. Применение оператора static_cast
говорит и компилятору, и человеку, читающему программу, что программист знает об этом.
Кроме того, с помощью static_cast
указатель void*
можно преобразовать в указатель определенного типа, арифметическое значение – в значение перечисления (enum), а базовый класс – в производный. (О преобразованиях типов базовых и производных классов говорится в главе 19.)
Эти изменения потенциально опасны, поскольку их правильность зависит от того, какое конкретное значение имеет преобразуемое выражение в данный момент выполнения программы:
mumble mums_the_word = static_cast< mumble >( ival );
Трансформация ival в mumble
будет правильной только в том случае, если ival равен 1, 2 или 3.
reinterpret_cast
работает с внутренними представлениями объектов (re-interpret – другая интерпретация того же внутреннего представления), причем правильность этой операции целиком зависит от программиста. Например:
char *pc = reinterpret_cast< char* >( pcom );
Программист не должен забыть или упустить из виду, какой объект реально адресуется указателем char* pc. Формально это указатель на строку встроенного типа, и компилятор не будет препятствовать использованию pc для инициализации строки:
string str( pc );
хотя скорее всего такая команда вызовет крах программы.
Это хороший пример, показывающий, насколько опасны бывают явные преобразования типов. Мы можем присваивать указателям одного типа значения указателей совсем другого типа, и это будет работать до тех пор, пока мы держим ситуацию под контролем. Однако, забыв о подразумеваемых деталях, легко допустить ошибку, о которой компилятор не сможет нас предупредить.
Особенно трудно найти подобную ошибку, если явное преобразование типа делается в одном файле, а используется измененное значение в другом.
В некотором смысле это отражает фундаментальный парадокс языка С++: строгая проверка типов призвана не допустить подобных ошибок, в то же время наличие операторов явного преобразования позволяет “обмануть” компилятор и использовать объекты разных типов на свой страх и риск. В нашем примере мы “отключили” проверку типов при инициализации указателя pc и присвоили ему адрес комплексного числа. При инициализации строки str такая проверка производится снова, но компилятор считает, что pc указывает на строку, хотя, на самом-то деле, это не так!
Четыре оператора явного преобразования типов были введены в стандарт С++ как наименьшее зло при невозможности полностью запретить такое приведение. Устаревшая, но до сих пор поддерживаемая стандартом С++ форма явного преобразования выглядит так:
char *pc = (char*) pcom;
Эта запись эквивалентна применению оператора reinterpret_cast, однако выглядит не так заметно. Использование операторов xxx_cast
позволяет четко указать те места в программе, где содержатся потенциально опасные трансформации типов.
Если поведение программы становится ошибочным и непонятным, возможно, в этом виноваты явные видоизменения типов указателей. Использование операторов явного преобразования помогает легко обнаружить места в программе, где такие операции выполняются. (Другой причиной непредсказуемого поведения программы может стать нечаянное уничтожение объекта (delete), в то время как он еще должен использоваться в работе. Мы поговорим об этом в разделе 8.4, когда будем обсуждать динамическое выделение памяти.)
Оператор dynamic_cast
применяется при идентификации типа во время выполнения (run-time type identification). Мы вернемся к этой проблеме лишь в разделе 19.1.
В таких ситуациях приходится подавлять механизм вывода и задавать аргументы явно, указывая их с помощью заключенного в угловые скобки списка разделенных запятыми значений, который следует после имени конкретизируемого шаблона функции. Например, если мы хотим задать тип unsigned int в качестве значения аргумента шаблона T в рассмотренном выше примере использования min5(), то нужно записать вызов конкретизируемого шаблона так:
min5< unsigned int >( ui, 1024 );
В этом случае список аргументов шаблона явно задает их типы. Поскольку аргумент шаблона теперь известен, вызов функции больше не приводит к ошибке.
Обратите внимание, что при вызове функции min5() второй аргумент равен 1024, т.е. имеет тип int. Так как тип второго формального параметра функции при явном задании аргумента шаблона установлен в unsigned int, то второй фактический параметр функции приводится к типу unsigned int с помощью стандартного преобразования целых типов.
В предыдущем разделе мы говорили, что в процессе вывода аргументов шаблона к фактическим аргументам функции разрешается применять только ограниченное множество преобразований типов. Трансформация int в unsigned int в это множество не входит. Но если аргументы шаблона задаются явно, выполнять вывод типов не нужно, поскольку они уже зафиксированы. Следовательно, при явном задании аргументов шаблона для приведения типов фактических аргументов функции к типам формальных параметров можно применять любые стандартные преобразования.
Помимо разрешения любых преобразований фактических аргументов функции, явное задание аргументов шаблона помогает избежать и других проблем, встающих перед программистом. Рассмотрим следующую задачу. Мы хотим определить шаблон функции с именем sum()
так, чтобы его конкретизация возвращала значения типа, достаточно большого для представления суммы двух значений любых двух типов, переданных в любом порядке. Как это сделать? Какой тип возвращаемого значения следует задать?
??? sum( T, U );
В нашем случае нельзя использовать ни тот, ни другой параметрический тип, иначе мы неизбежно допустим ошибку:
sum( ui, ch ); // правильно: T sum( T, U );
Решение заключается в том, чтобы ввести в шаблон третий параметр для обозначения типа возвращаемого значения:
T1 sum( T2, T3 );
Поскольку тип возвращаемого значения может отличаться от типов аргументов функции, T1 не упоминается в списке формальных параметров. Это потенциальная проблема, так как тип T1 не может быть выведен из фактических аргументов функции. Однако, если при конкретизации sum() мы зададим аргументы шаблона явно, то избегнем сообщения компилятора о невозможности вывести T1.
Например:
}
Не хватает возможности явно задать T1, но не T2 и T3, поскольку их можно вывести из аргументов функции при вызове.
При явном задании аргументов шаблона необходимо перечислять только те, которые не могут быть выведены автоматически. Но, как и в случае аргументов функции со значениями по умолчанию, опускать можно исключительно “хвостовые”:
<
ui_type loc4 = sum< ui_type, , ui_type >( ch, ui );
Встречаются ситуации, когда невозможно вывести аргументы шаблона в контексте, где конкретизируется шаблон функции; следовательно, необходимо их явно задать. Именно выявление таких ситуаций и необходимость решить проблему послужила причиной поддержки явного задания аргументов шаблона в стандартном C++.
В следующем примере берется адрес конкретизированной функции sum() и передается в качестве аргумента перегруженной функции manipulate(). Как мы показали в разделе 10.2, невозможно понять, как именно нужно конкретизировать sum(), если есть только списки параметров функций manipulate(). Имеется две разных функции sum(), и обе удовлетворяют условиям вызова. Следовательно, вызов manipulate() неоднозначен. Одним из способов разрешения такой неоднозначности является явное приведение типов. Однако лучше использовать явное задание аргументов шаблона: оно позволяет указать, как именно конкретизировать sum(), и, следовательно, выбрать нужный вариант перегруженной функции manipulate(). Например:
}
Отметим, что явное задание аргументов шаблона следует использовать только тогда, когда это абсолютно необходимо для разрешения неоднозначности или для конкретизации шаблона функции в контексте, где вывести аргументы невозможно. Во-первых, определение типов и значений аргументов шаблона проще оставить компилятору. А во-вторых, если мы модифицируем объявления в программе, так что типы аргументов функции при вызове конкретизированного шаблона изменятся, то компилятор автоматически скорректирует вызов без нашего вмешательства. С другой стороны, если аргументы шаблона заданы явно, необходимо проверить, что они по-прежнему отвечают новым типам аргументов функции. Поэтому мы рекомендуем избегать явного задания аргументов шаблона.
Упражнение 10.6
Назовите две ситуации, когда использование явного задания аргументов шаблона необходимо.
Упражнение 10.7
Пусть дано следующее определение шаблона функции sum():
T1 sum( T2, T3 );
Какие из приведенных ниже вызовов ошибочны? Почему?
(d) sum( fobj2, dobj2 );
Результат работы программы не зависит от того, сколько раз конкретизировался шаблон: в конечном итоге используется лишь один экземпляр. Но если приложение состоит из большого числа файлов, то время компиляции приложения заметно возрастает.
Подобные проблемы, характерные для старых компиляторов, затрудняли использование шаблонов. Поэтому в стандарте C++ введено понятие явного объявления конкретизации, помогающее программисту управлять моментом, когда конкретизация происходит.
В явном объявлении конкретизации за ключевым словом template идет объявление шаблона функции, в котором его аргументы указаны явно. Рассмотрим шаблон sum(int*, int):
template int* sum< int* >( int*, int );
Здесь в качестве аргумента явно задается int*. Явное объявление конкретизации с одним и тем же множеством аргументов шаблона может встречаться в программе не более одного раза.
Определение шаблона функции должно находиться в том же файле, где и явное объявление конкретизации. Если же его не видно, то явное объявление приводит к ошибке:
<
char *arena = new char[ sizeof Image ];
то из хипа выделяется память, размер которой равен размеру объекта типа Image, она не инициализирована и заполнена случайными битами. Если же написать:
Image *ptr = new (arena) Image( "Quasimodo" );
то никакой новой памяти не выделяется. Вместо этого переменной ptr
присваивается адрес, ассоциированный с переменной arena. Теперь память, на которую указывает ptr, интерпретируется как занимаемая объектом класса Image, и конструктор применяется к уже существующей области. Таким образом, оператор размещения new()
позволяет сконструировать объект в ранее выделенной области памяти.
Закончив работать с изображением Quasimodo, мы можем произвести какие-то операции с изображением Esmerelda, размещенным по тому же адресу arena в памяти:
Image *ptr = new (arena) Image( "Esmerelda" );
Однако изображение Quasimodo при этом будет затерто, а мы его модифицировали и хотели бы записать на диск. Обычно сохранение выполняется в деструкторе класса Image, но если мы применим оператор delete:
delete ptr;
то, помимо вызова деструктора, еще и возвратим в хип память, чего делать не следовало бы. Вместо этого можно явно вызвать деструктор класса Image:
ptr->~Image();
сохранив отведенную под изображение память для последующего вызова оператора размещения new.
Отметим, что, хотя ptr и arena
адресуют одну и ту же область памяти в хипе, применение оператора delete к arena
delete arena;
не приводит к вызову деструктора класса Image, так как arena
имеет тип char*, а компилятор вызывает деструктор только тогда, когда операндом в delete
является указатель на объект класса, имеющего деструктор.
#include
Определение контейнера начинается именем его типа, за которым в угловых скобках следует тип данных его элементов[12]. Например:
list< int > ilist;
Переменная svec
определяется как вектор, способный содержать элементы типа string, а ilist – как список с элементами типа int. Оба контейнера при таком определении пусты. Чтобы убедиться в этом, можно вызвать функцию-член empty():
; // что-то не так
Простейший метод вставки элементов – использование функции-члена push_back(), которая добавляет элементы в конец контейнера. Например:
svec.push_back( text_word );
Здесь строки из стандартного ввода считываются в переменную text_word, и затем копия каждой строки добавляется в контейнер svec с помощью push_back().
Список имеет функцию-член push_front(), которая добавляет элемент в его начало. Пусть есть следующий массив:
int ia[ 4 ] = { 0, 1, 2, 3 };
Использование push_back()
ilist.push_back( ia[ ix ] );
создаст последовательность 0, 1, 2, 3, а push_front()
ilist.push_front( ia[ ix ] );
создаст последовательность 3, 2, 1, 0. [13]
Мы можем при создании явно указать размер массива – как константным, так и неконстантным выражением:
vector< string > svec(get_word_count(string("Chimera")));
Каждый элемент контейнера инициализируется значением по умолчанию, соответствующим типу данных. Для int это 0. Для строкового типа вызывается конструктор по умолчанию класса string.
Мы можем указать начальное значение всех элементов:
vector< string > svec( 24, "pooh" );
Разрешается не только задавать начальный размер контейнера, но и впоследствии изменять его с помощью функции-члена resize(). Например:
svec.resize( 2 * svec.size() );
Размер svec в этом примере удваивается. Каждый новый элемент получает значение по умолчанию. Если мы хотим инициализировать его каким-то другим значением, то оно указывается вторым параметром функции-члена resize():
svec.resize( 2 * svec.size(), "piglet" );
Кстати, какова наиболее вероятная емкость svec при определении, если его начальный размер равен 24? Правильно, 24! В общем случае минимальная емкость вектора равна его текущему размеру. При удвоении размера емкость, как правило, тоже удваивается
Мы можем инициализировать новый контейнер с помощью существующего. Например:
list< int > ilist2( ilist ) ;
Каждый контейнер поддерживает полный набор операций сравнения: равенство, неравенство, меньше, больше, меньше или равно, больше или равно. Сопоставляются попарно все элементы контейнера. Если они равны и размеры контейнеров одинаковы, то эти контейнеры равны; в противном случае – не равны. Результат операций “больше” или “меньше” определяется сравнением первых двух неравных элементов. Вот что печатает программа, сравнивающая пять векторов:
ivecl: 1 3 5 7 9 12
ivec2: 0 1 1 2 3 5 8 13
ivec3: 1 3 9
ivec4: 1 3 5 7
ivec5: 2 4
// первый неравный элемент: 1, О
// ivecl больше чем ivec2
ivecl < ivec2 //false
ivec2 < ivecl //true
// первый неравный элемент: 5, 9
ivecl < ivec3 //true
// все элементы равны, но ivec4 содержит меньше элементов
// следовательно, ivec4 меньше, чем ivecl
ivecl < ivec4 //false
// первый неравный элемент: 1, 2
ivecl < ivec5 //true
ivecl == ivecl //true
ivecl == ivec4 //false
ivecl != ivec4 //true
ivecl > ivec2 //true
ivec3 > ivecl //true
ivec5 > ivec2 //true
Существуют три ограничения на тип элементов контейнера (практически это касается только пользовательских классов). Для должны быть определены:
· операция “равно”;
· операция “меньше” (все операции сравнения контейнеров, о которых говорилось выше, используют только эти две операции сравнения);
· значение по умолчанию (для класса это означает наличие конструктора по умолчанию).
Все предопределенные типы данных, включая указатели и классы из стандартной библиотеки С++ удовлетворяют этим требованиям.
Упражнение 6.5
Объясните, что делает данная программа:
}
Упражнение 6.6
Может ли емкость контейнера быть меньше его размера? Желательно ли, чтобы емкость была равна размеру: изначально или после вставки элемента? Почему?
Упражнение 6.7
Если программа из упражнения 6.5 прочитает 256 слов, то какова наиболее вероятная емкость контейнера после изменения размера? А если она считает 512 слов? 1000? 1048?
Упражнение 6.8
Какие из данных классов не могут храниться в векторе:
}
int max( const matrix & );
Для каждого перегруженного объявления требуется отдельное определение функции max() с соответствующим списком параметров.
Если в некоторой области видимости имя функции объявлено более одного раза, то второе (и последующие) объявление интерпретируется компилятором так:
· если списки параметров двух функций отличаются числом или типами параметров, то функции считаются перегруженными:
void print( vector & );
· если тип возвращаемого значения и списки параметров в объявлениях двух функций одинаковы, то второе объявление считается повторным:
void print( const string & );
Имена параметров при сравнении объявлений во внимание не принимаются;
если списки параметров двух функций одинаковы, но типы возвращаемых значений различны, то второе объявление считается неправильным (несогласованным с первым) и помечается компилятором как ошибка:
// возвращаемых значений
Перегруженные функции не могут различаться лишь типами возвращаемого значения;
· если списки параметров двух функций разнятся только подразумеваемыми по умолчанию значениями аргументов, то второе объявление считается повторным:
int max ( int *ia, int = 10 );
Ключевое слово typedef
создает альтернативное имя для существующего типа данных, новый тип при этом не создается. Поэтому если списки параметров двух функций различаются только тем, что в одном используется typedef, а в другом тип, для которого typedef
служит псевдонимом, такие списки считаются одинаковыми, как, например, в следующих двух объявлениях функции calc(). В таком случае второе объявление даст ошибку компиляции, поскольку возвращаемое значение отличается от указанного раньше:
extern int calc( double );
Спецификаторы const или volatile при подобном сравнении не принимаются во внимание. Так, следующие два объявления считаются одинаковыми:
void f( const int );
Спецификатор const
важен только внутри определения функции: он показывает, что в теле функции запрещено изменять значение параметра. Однако аргумент, передаваемый по значению, можно использовать в теле функции как обычную инициированную переменную: вне функции изменения не видны. (Способы передачи аргументов, в частности передача по значению, обсуждаются в разделе 7.3.) Добавление спецификатора const к параметру, передаваемому по значению, не влияет на его интерпретацию. Функции, объявленной как f(int), может быть передано любое значение типа int, равно как и функции f(const int). Поскольку они обе принимают одно и то же множество значений аргумента, то приведенные объявления не считаются перегруженными. f()
можно определить как
void f( int i ) { }
или как
void f( const int i ) { }
Наличие двух этих определений в одной программе – ошибка, так как одна и та же функция определяется дважды.
Однако, если спецификатор const или volatile
применяется к параметру указательного или ссылочного типа, то при сравнении объявлений он учитывается.
void f( const int& );
Вектор может запрашивать память не под каждый новый элемент. Вместо этого она запрашивается с некоторым запасом, так что после очередного выделения вектор может поместить в себя некоторое количество элементов, не обращаясь за ней снова. (Каков размер этого запаса, зависит от реализации.) На практике такое свойство вектора обеспечивает значительное увеличение его эффективности, особенно для небольших объектов. Давайте рассмотрим некоторые примеры из реализации стандартной библиотеки С++ от компании Rogue Wave. Однако сначала определим разницу между размером и емкостью контейнера.
Емкость– это максимальное количество элементов, которое может вместить контейнер без дополнительного выделения памяти. (Емкостью обладают только те контейнеры, в которых элементы хранятся в непрерывной области памяти, – vector, deque и string. Для контейнера list это понятие не определено.) Емкость может быть получена с помощью функции capacity(). Размер – это реальное количество элементов, хранящихся в данный момент в контейнере. Размер можно получить с помощью функции size(). Например:
<
}
В реализации Rogue Wave и размер, и емкость ivec сразу после определения равны 0. После вставки первого элемента размер становится равным 1, а емкость – 256. Это значит, что до первого дополнительного выделения памяти в ivec
можно вставить 256 элементов. При добавлении 256-го элемента вектор должен увеличиться: выделить память объемом в два раза больше текущей емкости, скопировать в нее старые элементы и освободить прежнюю память. Обратите внимание: чем больше и сложнее тип данных элементов, тем менее эффективен вектор в сравнении со списком. В таблице 6.1 показана зависимость начальной емкости вектора от используемого типа данных.
Таблица 6.1. Размер и емкость для различных типов данных
Итак, в реализации Rogue Wave при первой вставке выделяется точно или примерно 1024 байта. После каждого дополнительного выделения памяти емкость удваивается. Для типа данных, имеющего большой размер, емкость мала, и увеличение памяти с копированием старых элементов происходит часто, вызывая потерю эффективности. (Говоря о сложных классах, мы имеем в виду класс, обладающий копирующим конструктором и операцией присваивания.) В таблице 6.2 показано время в секундах, необходимое для вставки десяти миллионов элементов разного типа в список и в вектор. Таблица 6.3 показывает время, требуемое для вставки 10 000 элементов (вставка элементов большего размера оказалась слишком медленной).
Таблица 6.2. Время в секундах для вставки 10 000 000 элементов
Таблица 6.3. Время в секундах для вставки 10 000 элементов
<
Отсюда следует, что вектор лучше подходит для типов данных малого размера, нежели список, и наоборот. Эта разница объясняется необходимостью выделения памяти и копирования в нее старых элементов. Однако размер данных – не единственный фактор, влияющий на эффективность. Сложность типа данных также ухудшает результат. Почему?
Вставка элемента как в список, так и в вектор, требует вызова копирующего конструктора, если он определен. (Копирующий конструктор инициализирует один объект значением другого. В разделе 2.2 приводится начальная информация, а в разделе 14.5 о таких конструкторах рассказывается подробно). Это и объясняет различие в поведении простых и сложных объектов при вставке в контейнер. Объекты простого класса вставляются побитовым копированием (биты одного объекта пересылаются в биты другого), а для строк и сложных классов это производится вызовом копирующего конструктора.
Вектор должен вызывать их для каждого элемента при перераспределении памяти. Более того, освобождение памяти требует работы деструкторов для всех элементов (понятие деструктора вводится в разделе 2.2). Чем чаще происходит перераспределение памяти, тем больше времени тратится на эти дополнительные вызовы конструкторов и деструкторов.
Конечно, одним из решений может быть переход от вектора к списку, когда эффективность вектора становится слишком низкой. Другое, более предпочтительное решение состоит в том, чтобы хранить в векторе не объекты сложного класса, а указатели на них. Такая замена позволяет уменьшить затраты времени на 10 000 вставок с 6.70 секунд до 0.82 секунды. Почему? Емкость возросла с 1 до 256, что существенно снизило частоту перераспределения памяти. Кроме того, копирующий конструктор и деструктор не вызываются больше для каждого элемента при копировании прежнего содержимого вектора.
Функция reserve()
позволяет программисту явно задать емкость контейнера[11]. Например:
<
}
svec
получает емкость 32 при размере 0. Однако эксперименты показали, что любое изменение начальной емкости для вектора, у которого она по умолчанию отлична от 1, ведет к снижению производительности. Так, для векторов типа string и double
увеличение емкости с помощью reserve() дало худшие показатели. С другой стороны, увеличение емкости для больших сложных типов дает значительный рост производительности, как показано в таблице 6.4.
Таблица 6.4. Время в секундах для вставки 10 000 элементов при различной емкости*
В нашей системе текстового поиска для хранения объектов типа string мы будем использовать вектор, не меняя его емкости по умолчанию. Наши измерения показали, что производительность вектора в данном случае лучше, чем у списка. Но прежде чем приступать к реализации, посмотрим, как определяется объект контейнерного типа.
Упражнение 6.2
Объясните разницу между размером и емкостью контейнера. Почему понятие емкости необходимо для контейнера, содержащего элементы в непрерывной области памяти, и не нужно для списка?
Упражнение 6.3
Почему большие сложные объекты удобнее хранить в контейнере в виде указателей на них, а для коллекции целых чисел применение указателей снижает эффективность?
Упражнение 6.4
Объясните, какой из типов контейнера – вектор или список – больше подходит для приведенных примеров (во всех случаях происходит вставка неизвестного заранее числа элементов):.
(a) Целые числа
(b) Указатели на большие сложные объекты
(c) Большие сложные объекты
Как мы уже говорили, необходимость создавать сложные выражения для манипуляции битовыми векторами затрудняет использование встроенных типов данных. Класс bitset
упрощает работу с битовым вектором. Вот какое выражение нам приходилось писать в предыдущем разделе для того, чтобы “взвести” 27-й бит:
quiz1 |= 1<<27;
При использовании bitset то же самое мы можем сделать двумя способами:
quiz1[27] = 1;
или
quiz1.set(27);
(В нашем примере мы не используем нулевой бит, чтобы сохранить “естественную” нумерацию. На самом деле, нумерация битов начинается с 0.)
Для использования класса bitset
необходимо включить заголовочный файл:
#include
Объект типа bitset
может быть объявлен тремя способами. В определении по умолчанию мы просто указываем размер битового вектора:
bitset<32> bitvec;
Это определение задает объект bitset, содержащий 32 бита с номерами от 0 до 31. Все биты инициализируются нулем. С помощью функции any()
можно проверить, есть ли в векторе единичные биты. Эта функция возвращает true, если хотя бы один бит отличен от нуля. Например:
bool is_set = bitvec.any();
Переменная is_set
получит значение false, так как объект bitset по умолчанию инициализируется нулями. Парная функция none()
возвращает true, если все биты равны нулю:
bool is_not_set = bitvec.none();
Изменить значение отдельного бита можно двумя способами: воспользовавшись функциями set() и reset() или индексом. Так, следующий цикл выставляет в 1 каждый четный бит:
bitvec[ index ] = 1;
Аналогично существует два способа проверки значений каждого бита – с помощью функции test() и с помощью индекса. Функция () возвращает true, если соответствующий бит равен 1, и false в противном случае. Например:
// присваивание bitvec[0]=1 сработало!;
Значения битов с помощью индекса проверяются таким образом:
cout << endl;
Следующая пара операторов демонстрирует сброс первого бита двумя способами:
bitvec[0] = 0;
Функции set() и reset()
могут применяться ко всему битовому вектору в целом. В этом случае они должны быть вызваны без параметра. Например:
// что-то опять не сработало
Функция flip()
меняет значение отдельного бита или всего битового вектора:
bitvec.flip(); // меняет значения всех битов
Существуют еще два способа определить объект типа bitset. Оба они дают возможность проинициализировать объект определенным набором нулей и единиц. Первый способ – явно задать целое беззнаковое число как аргумент конструктору. Начальные N позиций битового вектора получат значения соответствующих двоичных разрядов аргумента. Например:
bitset< 32 > bitvec2( Oxffff );
инициализирует bitvec2
следующим набором значений:
00000000000000001111111111111111
В результате определения
bitset< 32 > bitvec3( 012 );
у bitvec3
окажутся ненулевыми биты на местах 1 и 3:
00000000000000000000000000001010
В качестве аргумента конструктору может быть передано и строковое значение, состоящее из нулей и единиц. Например, следующее определение инициализирует bitvec4 тем же набором значений, что и bitvec3:
bitset< 32 > bitvec4( bitval );
Можно также указать диапазон символов строки, выступающих как начальные значения для битового вектора. Например:
bitset< 32 > bitvec5( bitval, 6, 4 );
Мы получаем то же значение, что и для bitvec3 и bitvec4. Если опустить третий параметр, подстрока берется до конца исходной строки:
bitset< 32 > bitvec6( bitval, 6 );
Класс bitset
предоставляет две функции-члена для преобразования объекта bitset в другой тип. Для трансформации в строку, состоящую из символов нулей и единиц, служит функция to_string():
string bitva1( bitvec3.to_string() );
Вторая функция, to_long(), преобразует битовый вектор в его целочисленное представление в виде unsigned long, если, конечно, оно помещается в unsigned long. Это видоизменение особенно полезно, если мы хотим передать битовый вектор функции на С или С++, не пользующейся стандартной библиотекой.
К объектам типа bitset
можно применять побитовые операции. Например:
bitset<32> bitvec7 = bitvec2 & bitvec3;
Объект bitvec7
инициализируется результатом побитового И двух битовых векторов bitvec2 и bitvec3.
bitset<32> bitvec8 = bitvec2 | bitvec3;
Здесь bitvec8
инициализируется результатом побитового ИЛИ векторов bitvec2 и bitvec3. Точно так же поддерживаются и составные операции присваивания и сдвига.
Упражнение 4.15
Допущены ли ошибки в приведенных определениях битовых векторов?
(a) bitset<64> bitvec(32);
(b) bitset<32> bv( 1010101 );
(c) string bstr; cin >> bstr; bitset<8>bv( bstr );
(d) bitset<32> bv; bitset<16> bvl6( bv );
Упражнение 4.16
Допущены ли ошибки в следующих операциях с битовыми векторами?
(c) bitvec.f1ip( bitvec.count() );
Упражнение 4.17
Дана последовательность: 1,2,3,5,8,13,21. Каким образом можно инициализировать объект bitset<32> для ее представления? Как присвоить значения для представления этой последовательности пустому битовому вектору? Напишите вариант инициализации и вариант с присваиванием значения каждому биту.
#include
Комплексное число состоит из двух частей – вещественной и мнимой. Мнимая часть представляет собой квадратный корень из отрицательного числа. Комплексное число принято записывать в виде
2 + 3i
где 2 – действительная часть, а 3i – мнимая. Вот примеры определений объектов типа complex:
comp1ex< double > purei2( purei );
Поскольку complex, как и vector, является шаблоном, мы можем конкретизировать его типами float, double и long double, как в приведенных примерах. Можно также определить массив элементов типа complex:
};
Вот как определяются указатель и ссылка на комплексное число:
complex< double > &ref = *ptr;
Комплексные числа можно складывать, вычитать, умножать, делить, сравнивать, получать значения вещественной и мнимой части. (Более подробно мы будем говорить о классе complex в разделе 4.6.)
деталями, касающимися обработки параметров командной строки, лучше отделить этот фрагмент. Можно написать для этого функцию. Например:
}
Как вернуть несколько значений? Обычно для этого используются глобальные объекты, которые не передаются ни в функцию для их обработки, ни обратно. Альтернативной стратегией является инкапсуляция обработки параметров командной строки в класс.
Данные-члены класса представляют собой параметры, заданные пользователем в командной строке. Набор открытых встроенных функций-членов позволяет получать их значения. Конструктор инициализирует параметры значениями по умолчанию. Функция-член получает argc и argv в качестве аргументов и обрабатывает их:
};
Так выглядит модифицированная функция main():[18]
}
Упражнение 7.15
Добавьте обработку опций -t
(включение таймера) и -b
(задание размера буфера bufsize). Не забудьте обновить usage(). Например:
prog -t -b 512 dataO
Упражнение 7.16
Наша реализация не обрабатывает случая, когда между опцией и ассоциированным с ней значением нет пробела. Модифицируйте программу для поддержки такой обработки.
Упражнение 7.17
Наша реализация не может различить лишний пробел между дефисом и опцией:
prog - d dataO
Модифицируйте программу так, чтобы она распознавала подобную ошибку и сообщала о ней.
Упражнение 7.18
В нашей программе не предусмотрен случай, когда опции -l или -o
задаются несколько раз. Реализуйте такую возможность. Какова должна быть стратегия при разрешении конфликта?
Упражнение 7.19
В нашей реализации задание неизвестной опции приводит к фатальной ошибке. Как вы думаете, это оправдано? Предложите другое поведение.
Упражнение 7.20
Добавьте поддержку опций, начинающихся со знака плюс (+), обеспечив обработку +s и +pt, а также +sp и +ps. Предположим, что +s
включает строгую проверку синтаксиса, а +p допускает использование устаревших конструкций. Например:
prog +s +p -d -b 1024 dataO
#endif
Этот класс наследует две реализации каждой интерфейсной функции Array: из Array_Sort и из виртуального базового класса Array через Array_RC (за исключением оператора взятия индекса, для которого из обоих базовых классов наследуется замещенный экземпляр). При невиртуальном наследовании вызов find() был бы помечен компилятором как неоднозначный, поскольку он не знает, какой из унаследованных экземпляров мы имели в виду. В нашем случае замещенным в Array_Sort
экземплярам отдается предпочтение по сравнению с экземплярами, унаследованными из виртуального базового класса через Array_RC (см. раздел 18.5.4). Таким образом, при виртуальном наследовании неквалифицированный вызов find()
разрешается в пользу экземпляра, унаследованного из класса Array_Sort.
}
Вот что печатает программа для класса, конкретизированного типом string (теперь ошибка выхода за границы массива перехватывается):
конкретизация класса Array_Sort
try_array: начальные значения массива
( 7 )< Eeyore, Gopher, Heffalump, Owl, Piglet, Pooh
Tigger >
try_array: после присваиваний
( 7 )< Eeyore, Gopher, Owl, Piglet, Pooh, Pooh
Pooh >
try_array: почленная инициализация
( 7 )< Eeyore, Gopher, Owl, Piglet, Pooh, Pooh
Pooh >
try_array: после почленного копирования
( 7 )< Eeyore, Piglet, Owl, Piglet, Pooh, Pooh
Pooh >
try_array: после вызова grow
( 7 )<, , , , Eeyore, Owl
Piglet, Piglet, Pooh, Pooh, Pooh >
искомое значение: Tigger возвращенный индекс: -1
Assertion failed: ix >= 0 && ix < size
Представленная в этой главе реализация иерархии класса Array иллюстрирует применение множественного и виртуального наследования. Детально проектирование класса массива описано в [NACKMAN94]. Однако, как правило, достаточно класса vector из стандартной библиотеки.
Упражнение 18.16
Добавьте в Array
функцию-член spy(). Она запоминает операции, примененные к объекту класса: число доступов по индексу; количество вызовов каждого члена; какой элемент искали с помощью find() и сколько было успешных поисков. Поясните свои проектные решения. Модифицируйте все подтипы Array
так, чтобы spy()
можно было использовать и для них тоже.
Упражнение 18.17
Стандартный библиотечный класс map
(отображение) называют еще ассоциативным массивом, поскольку он поддерживает индексирование значением ключа. Как вы думаете, является ли ассоциативный массив кандидатом на роль подтипа нашего класса Array? Почему?
Упражнение 18.18
Перепишите иерархию Array, пользуясь контейнерными классами из стандартной библиотеки и применяя обобщенные алгоритмы.
19
(пара) стандартной библиотеки С++ позволяет нам определить одним объектом пару значений, если между ними есть какая-либо семантическая связь. Эти значения могут быть одинакового или разного типа. Для использования данного класса необходимо включить заголовочный файл:
#inc1ude
Например, инструкция
pair< string, string > author( "James", "Joyce" );
создает объект author
типа pair, состоящий из двух строковых значений.
Отдельные части пары могут быть получены с помощью членов first и second:
firstBook = "Stephen Hero";
Если нужно определить несколько однотипных объектов этого класса, удобно использовать директиву typedef:
Authors musil( "robert", "musi1" );
Вот другой пример употребления пары. Первое значение содержит имя некоторого объекта, второе – указатель на соответствующий этому объекту элемент таблицы.
}
(Мы вернемся к рассмотрению класса pair в разговоре о контейнерных типах в главе 6 и об обобщенных алгоритмах в главе 12.)
стандартной библиотекой С++ призвана была положить конец этому изобретению велосипедов.
Попробуем специфицировать минимальный набор операций, которыми должен обладать класс string:
· инициализация массивом символов (строкой встроенного типа) или другим объектом типа string. Встроенный тип не обладает второй возможностью;
· копирование одной строки в другую. Для встроенного типа приходится использовать функцию strcpy();
· доступ к отдельным символам строки для чтения и записи. Во встроенном массиве для этого применяется операция взятия индекса или косвенная адресация;
· сравнение двух строк на равенство. Для встроенного типа используется функция strcmp();
· конкатенация двух строк, получая результат либо как третью строку, либо вместо одной из исходных. Для встроенного типа применяется функция strcat(), однако чтобы получить результат в новой строке, необходимо последовательно задействовать функции strcpy() и strcat();
· вычисление длины строки. Узнать длину строки встроенного типа можно с помощью функции strlen();
· возможность узнать, пуста ли строка. У встроенных строк для этой цели приходится проверять два условия:
return;
Класс string
стандартной библиотеки С++ реализует все перечисленные операции (и гораздо больше, как мы увидим в главе 6). В данном разделе мы научимся пользоваться основными операциями этого класса.
Для того чтобы использовать объекты класса string, необходимо включить соответствующий заголовочный файл:
#include
Вот пример строки из предыдущего раздела, представленной объектом типа string и инициализированной строкой символов:
string st( "Цена бутылки вина\n" );
Длину строки возвращает функция-член size() (длина не включает завершающий нулевой символ).
Вторая форма определения строки задает пустую строку:
string st2; // пустая строка
Как мы узнаем, пуста ли строка? Конечно, можно сравнить ее длину с 0:
if ( ! st.size() )
// правильно: пустая
Однако есть и специальный метод empty(), возвращающий true для пустой строки и false для непустой:
if ( st.empty() )
// правильно: пустая
Третья форма конструктора инициализирует объект типа string другим объектом того же типа:
string st3( st );
Строка st3
инициализируется строкой st. Как мы можем убедиться, что эти строки совпадают? Воспользуемся оператором сравнения (==):
if ( st == st3 )
// инициализация сработала
Как скопировать одну строку в другую? С помощью обычной операции присваивания:
st2 = st3; // копируем st3 в st2
Для конкатенации строк используется операция сложения (+) или операция сложения с присваиванием (+=). Пусть даны две строки:
string s1( "hello, " );
string s2( "world\n" );
Мы можем получить третью строку, состоящую из конкатенации первых двух, таким образом:
string s3 = s1 + s2;
Если же мы хотим добавить s2 в конец s1, мы должны написать:
s1 += s2;
Операция сложения может конкатенировать объекты класса string не только между собой, но и со строками встроенного типа. Можно переписать пример, приведенный выше, так, чтобы специальные символы и знаки препинания представлялись встроенным типом, а значимые слова – объектами класса string:
const char *pc = ", ";
string s2( "world" );
string s3 = s1 + pc + s2 + "\n";
Подобные выражения работают потому, что компилятор знает, как автоматически преобразовывать объекты встроенного типа в объекты класса string. Возможно и простое присваивание встроенной строки объекту string:
string s1;
const char *pc = "a character array";
s1 = pc; // правильно
Обратное преобразование, однако, не работает. Попытка выполнить следующую инициализацию строки встроенного типа вызовет ошибку компиляции:
char *str = s1; // ошибка компиляции
Чтобы осуществить такое преобразование, необходимо явно вызвать функцию-член с несколько странным названием c_str():
char *str = s1.c_str(); // почти правильно
Функция c_str()
возвращает указатель на символьный массив, содержащий строку объекта string в том виде, в каком она находилась бы во встроенном строковом типе.
Приведенный выше пример инициализации указателя char *str все еще не совсем корректен. c_str()
возвращает указатель на константный массив, чтобы предотвратить возможность непосредственной модификации содержимого объекта через этот указатель, имеющий тип
const char *
(В следующем разделе мы расскажем о ключевом слове const). Правильный вариант инициализации выглядит так:
const char *str = s1.c_str(); // правильно
К отдельным символам объекта типа string, как и встроенного типа, можно обращаться с помощью операции взятия индекса. Вот, например, фрагмент кода, заменяющего все точки символами подчеркивания:
str[ ix ] = '_';
Вот и все, что мы хотели сказать о классе string прямо сейчас. На самом деле, этот класс обладает еще многими интересными свойствами и возможностями. Скажем, предыдущий пример реализуется также вызовом одной-единственной функции replace():
replace( str.begin(), str.end(), '.', '_' );
replace() – один из обобщенных алгоритмов, с которыми мы познакомились в разделе 2.8 и которые будут детально разобраны в главе 12. Эта функция пробегает диапазон от begin() до end(), которые возвращают указатели на начало и конец строки, и заменяет элементы, равные третьему своему параметру, на четвертый.
Упражнение 3.12
Найдите ошибки в приведенных ниже операторах:
(h) pc = st; (l) *pc = ival;
Упражнение 3.13
Объясните разницу в поведении следующих операторов цикла:
++cnt;
while ( *st++ )
++cnt;
Упражнение 3.14
Даны две семантически эквивалентные программы. Первая использует встроенный строковый тип, вторая – класс string:
#include
#include
int main()
{
int errors = 0;
const char *pc = "a very long literal string";
for ( int ix = 0; ix < 1000000; ++ix )
{
int len = strlen( pc );
char *pc2 = new char[ len + 1 ];
strcpy( pc2, pc );
if ( strcmp( pc2, pc ))
++errors;
delete [] pc2;
}
cout << "C-строки: "
<< errors << " ошибок.\n";
}
#include
#include
int main()
{
int errors = 0;
string str( " a very long literal string" );
for ( int ix = 0; ix < 1000000; ++ix )
{
int len = str.size();
string str2 = str;
if ( str != str2 )
}
cout << "класс string: "
<< errors << " ошибок.\n;
}
Что эти программы делают?
Оказывается, вторая реализация выполняется в два раза быстрее первой. Ожидали ли вы такого результата? Как вы его объясните?
Упражнение 3.15
Могли бы вы что-нибудь улучшить или дополнить в наборе операций класса string, приведенных в последнем разделе? Поясните свои предложения.
зависит от реализации, но некоторые его характерные черты остаются неизменными в любой программе на C++:
};
Поскольку копирующие конструктор и оператор присваивания – закрытые члены класса type_info, то пользователь не может создать его объекты в своей программе:
type_info t2 (typeid( unsigned int ) );
Единственный способ создать объект класса type_info – воспользоваться оператором typeid.
В классе определены также операторы сравнения. Они позволяют сравнивать два объекта type_info, а следовательно, и результаты, возвращенные двумя операторами typeid. (Мы говорили об этом в предыдущем подразделе.)
typeid( *pe ) != typeid( employee ) // ложно
Функция name()
возвращает C-строку с именем типа, представленного объектом type_info. Этой функцией можно пользоваться в программах следующим образом:
}
Для работы с функцией-членом name()
нужно включить заголовочный файл.
Имя типа – это единственная информация, которая гарантированно возвращается всеми реализациями C++, при этом используется функция-член name() класса type_info. В начале этого раздела упоминалось, что поддержка RTTI зависит от реализации и иногда в классе type_info
бывают дополнительные функции-члены. Чтобы узнать, каким образом обеспечивается поддержка RTTI в вашем компиляторе, обратитесь к справочному руководству по нему. Кроме того, можно получить любую информацию, которую компилятор знает о типе, например:
· список функций-членов класса;
· способ размещения объекта в памяти, т.е. взаимное расположение подобъектов базового и производных классов.
Одним из способов расширения поддержки RTTI является включение дополнительной информации в класс, производный от type_info. Поскольку в классе type_info
есть виртуальный деструктор, то оператор dynamic_cast позволяет выяснить, имеется ли некоторое конкретное расширение RTTI. Предположим, что некоторый компилятор предоставляет расширенную поддержку RTTI посредством класса extended_type_info, производного от type_info. С помощью оператора dynamic_cast
программа может узнать, принадлежит ли объект типа type_info, возвращенный оператором typeid, к типу extended_type_info. Если да, то пользоваться расширенной поддержкой RTTI разрешено.
<
}
Если dynamic_cast
завершается успешно, то оператор typeid вернет объект класса extended_type_info, т.е. компилятор обеспечивает расширенную поддержку RTTI, чем программа может воспользоваться. В противном случае допустимы только базовые средства RTTI.
Упражнение 19.1
Дана иерархия классов, в которой у каждого класса есть конструктор по умолчанию и виртуальный деструктор:
class D : public X, public C { ... };
Какие из данных операторов dynamic_cast
завершатся неудачно?
A *pa = dynamic_cast< A* > ( pd );
C *pc = dynamic_cast< C* > ( pa );
D *pd = dynamic_cast< D* > ( pb );
X *px = dynamic_cast< X* > ( pa );
Упражнение 19.2
Объясните, когда нужно пользоваться оператором dynamic_cast вместо виртуальной функции?
Упражнение 19.3
Пользуясь иерархией классов из упражнения 19.1, перепишите следующий фрагмент так, чтобы в нем использовался ссылочный вариант dynamic_cast для преобразования *pa в тип D&:
}
Упражнение 19.4
Дана иерархия классов, в которой у каждого класса есть конструктор по умолчанию и виртуальный деструктор:
class D : public X, public C { ... };
Какое имя типа будет напечатано в каждом из следующих случаев:
cout << typeid( ra ).name() << endl;
(см. раздел 2.8) является альтернативой применению встроенных массивов. Этот класс предоставляет гораздо больше возможностей, поэтому его использование предпочтительней. Однако встречаются ситуации, когда не обойтись без массивов встроенного типа. Одна из таких ситуаций– обработка передаваемых программе параметров командной строки, о чем мы будем говорить в разделе 7.8. Класс vector, как и класс string, является частью стандартной библиотеки С++.
Для использования вектора необходимо включить заголовочный файл:
#include
Существуют два абсолютно разных подхода к использованию вектора, назовем их идиомой массива и идиомой STL. В первом случае объект класса vector
используется точно так же, как массив встроенного типа. Определяется вектор заданной размерности:
vector< int > ivec( 10 );
что аналогично определению массива встроенного типа:
int ia[ 10 ];
Для доступа к отдельным элементам вектора применяется операция взятия индекса:
}
Мы можем узнать размерность вектора, используя функцию size(), и проверить, пуст ли вектор, с помощью функции empty(). Например:
}
Элементы вектора инициализируются значениями по умолчанию. Для числовых типов и указателей таким значением является 0. Если в качестве элементов выступают объекты класса, то инициатор для них задается конструктором по умолчанию (см. раздел 2.3). Однако инициатор можно задать и явно, используя форму:
vector< int > ivec( 10, -1 );
Все десять элементов вектора будут равны -1.
Массив встроенного типа можно явно инициализировать списком:
int ia[ 6 ] = { -2, -1, О, 1, 2, 1024 };
Для объекта класса vector
аналогичное действие невозможно. Однако такой объект может быть инициализирован с помощью массива встроенного типа:
vector< int > ivec( ia, ia+6 );
Конструктору вектора ivec
передаются два указателя – указатель на начало массива ia и на элемент, следующий за последним. В качестве списка начальных значений допустимо указать не весь массив, а некоторый его диапазон:
vector< int > ivec( &ia[ 2 ], &ia[ 5 ] );
Еще одним отличием вектора от массива встроенного типа является возможность инициализации одного объекта типа vector другим и использования операции присваивания для копирования объектов. Например:
}
Говоря об идиоме STL[6], мы подразумеваем совсем другой подход к использованию вектора. Вместо того чтобы сразу задать нужный размер, мы определяем пустой вектор:
vector< string > text;
Затем добавляем к нему элементы при помощи различных функций. Например, функция push_back()вставляет элемент в конец вектора. Вот фрагмент кода, считывающего последовательность строк из стандартного ввода и добавляющего их в вектор:
}
Хотя мы можем использовать операцию взятия индекса для перебора элементов вектора:
cout << endl;
более типичным в рамках данной идиомы будет использование итераторов:
<
cout << endl;
Итератор – это класс стандартной библиотеки, фактически являющийся указателем на элемент массива.
Выражение
*it;
разыменовывает итератор и дает сам элемент вектора. Инструкция
++it;
сдвигает указатель на следующий элемент. Не нужно смешивать эти два подхода. Если следовать идиоме STL при определении пустого вектора:
vector ivec;
будет ошибкой написать:
ivec[0] = 1024;
У нас еще нет ни одного элемента вектора ivec; количество элементов выясняется с помощью функции size().
Можно допустить и противоположную ошибку. Если мы определили вектор некоторого размера, например:
vector ia( 10 );
то вставка элементов увеличивает его размер, добавляя новые элементы к существующим. Хотя это и кажется очевидным, тем не менее,
начинающий программист вполне мог бы написать:
ivec.push_back( ia[ ix ] );
Имелась в виду инициализация вектора ivec
значениями элементов ia, вместо чего получился вектор ivec размера 14.
Следуя идиоме STL, можно не только добавлять, но и удалять элементы вектора. (Все это мы рассмотрим подробно и с примерами в главе 6.)
Упражнение 3.24
Имеются ли ошибки в следующих определениях?
(e) vector< string > svec( 10, string( "null" ));
Упражнение 3.25
Реализуйте следующую функцию:
const vector &ivec );
Функция is_equal()
сравнивает поэлементно два контейнера. В случае разного размера контейнеров “хвост” более длинного в расчет не принимается. Понятно, что, если все сравниваемые элементы равны, функция возвращает true, если отличается хотя бы один – false. Используйте итератор для перебора элементов. Напишите функцию main(), обращающуюся к is_equal().
Node another; // cplusplus_primer::Node
Как было показано в двух предыдущих разделах, член класса (функция-член, статический член или вложенный класс) может быть определен вне его тела. Если мы реализуем библиотеку и помещаем определения наших классов в объявленное пользователем пространство имен, то где расположить определения членов, находящиеся вне тел своих классов? Их можно разместить либо в пространстве имен, которое содержит определение самого внешнего класса, либо в одном из объемлющих его пространств. Это дает возможность организовать код библиотеки следующим образом:
<
int cplusplus_primer::List::ListItem::action() { }
Члены вложенного класса ListItem
можно определить в пространстве имен cplusplus_primer, которое содержит определение List, или в глобальном пространстве, включающем определение cplusplus_primer. В любом случае имя члена в определении должно быть квалифицировано именами объемлющих классов и объявленных пользователем пространств, вне которых находится объявление члена.
Как происходит разрешение имени в определении члена, которое находится в объявленном пользователем пространстве? Например, как будет разрешено someVal:
}
Сначала просматриваются локальные области видимости в определении функции-члена, затем поиск продолжается в области видимости ListItem, затем – в области видимости List. До этого момента все происходит так же, как в процессе разрешения имен, описанном в разделе 13.10. Далее просматриваются объявления из пространства cplusplus_primer и наконец объявления в глобальной области видимости, причем во внимание принимаются только те, которые расположены до определения функции-члена action():
}
Определение пространства имен cplusplus_primer не является непрерывным. Определения класса List и объекта someVal
размещены в первом его разделе, который находится в заголовочном файле primer.h. Определение функции calc()
появляется в определении пространства имен, расположенном в файле реализации primer.C. Использование calc()
внутри action()
ошибочно, так как она объявлена после использования. Если calc() – часть интерфейса cplusplus_primer, ее следовало бы объявить в той части данного пространства, которая находится в заголовочном файле:
}
Если же calc()
используется только в action() и не является частью интерфейса пространства имен, то ее нужно объявить перед action(), чтобы можно было ссылаться на нее внутри определения action().
Здесь прослеживается аналогия с процессом поиска объявлений в глобальной области видимости, о котором мы говорили в предыдущих разделах: объявления, предшествующие определению члена, принимаются во внимание, тогда как следующие за ним игнорируются.
Довольно просто запомнить, в каком порядке просматриваются области видимости при поиске имени из определения функции, расположенного вне определения класса. Имена, которыми квалифицировано имя члена, указывают порядок рассмотрения пространств. Например, имя action() в предыдущем примере квалифицируется так:
cplusplus_primer::List::ListItem::action()
Квалификаторы cplusplus_primer::List::ListItem::
записаны в порядке, обратном тому, в котором просматриваются имена областей видимости классов и пространств имен. Сначала поиск ведется в области ListItem, затем продолжается в объемлющем классе List и наконец в пространстве cplusplus_primer, предшествующем той области, в которой находится определение action(). Во время поиска в любой области видимости класса просматриваются все объявления членов, а в любом пространстве имен – только те объявления, которые встречались перед определением члена.
Класс, определенный в области видимости пространства имен, потенциально виден во всей программе. Если заголовочный файл primer.h включен в несколько исходных файлов, то имя cplusplus_primer::List
везде относится к одному и тому же классу. Класс – это сущность, для которой в программе может быть более одного определения. Определение класса должно присутствовать один раз в каждом исходном файле, где определяются или используются сам класс или его члены. Однако оно должно быть одинаковым во всех файлах, где встречается, поэтому его следует помещать в заголовочный файл, например primer.h.
Затем такой файл можно включать в любой исходный, где определяются или используются члены класса. Это предотвратит несоответствия в случае, когда определение класса записывается более одного раза.
Невстроенные функции-члены и статические данные-члены класса в пространстве имен – это также программные сущности. Однако они могут быть определены лишь один раз во всей программе. Поэтому их определения помещаются не в заголовочный, а в отдельный исходный файл типа primer.C.
Упражнение 13.22
Используя класс iStack, определенный в упражнении 13.21, объявите классы исключений pushOnFull и popOnEmpty как члены пространства имен LibException:
}
а сам iStack – членом пространства имен Container. Модифицируйте соответствующим образом определение данного класса и его функций-членов, а также определение main().
(экран) или Account
(расчетный счет). Как правило, классы используются для абстракций, не отражаемых встроенными типами адекватно.
В этой главе мы узнаем, как определять типы и использовать объекты классов; увидим, что определение класса вводит как данные-члены, описывающие его, так и функции-члены, составляющие набор операций, применимых к объектам класса. Мы покажем, как можно обеспечить сокрытие информации, объявив внутреннее представление и реализацию закрытыми, но открыв операции над объектами. Говорят, что закрытое внутреннее представление инкапсулировано, а открытую часть класса называют его интерфейсом.
Далее в этой главе мы познакомимся с особым видом членов класса– статическими членами. Мы расскажем также, как можно использовать указатели на члены и функции-члены класса, и рассмотрим объединения, представляющие собой специализированный вид класса для хранения объектов разных типов в одной области памяти. Завершается глава обсуждением области видимости класса и описанием правил разрешения имен в этой области; затрагиваются такие понятия, как вложенные классы, классы-члены пространства имен и локальные классы.
вызывает функции-члены класса Screen для объектов myScreen и bufScreen
таким образом, что каждое действие – это отдельная инструкция. У нас есть возможность определить функции-члены так, чтобы конкатенировать их вызовы при обращении к одному и тому же объекту. Например, все вызовы внутри main() будут выглядеть так:
}
Именно так интуитивно представляется последовательность операций с экраном: очистить экран myScreen, переместить курсор в позицию (2,2), записать в эту позицию символ '*' и вывести результат.
Операторы доступа “точка” и “стрелка” левоассоциативны, т.е. их последовательность выполняется слева направо. Например, сначала вызывается myScreen.clear(), затем myScreen.move() и т.д. Чтобы myScreen.move()
можно было вызвать после myScreen.clear(), функция clear()
должна возвращать объект myScreen, для которого она была вызвана. Мы уже видели, что доступ к объекту внутри функции-члена класса производится в помощью указателя this. Вот реализация clear():
}
Обратите внимание, что возвращаемый тип этой функции-члена – Screen& – ссылка на объект ее же класса. Чтобы конкатенировать вызовы, необходимо также пересмотреть реализацию move() и set(). Возвращаемый тип следует изменить с void на Screen&, а в определении возвращать *this.
Аналогично функцию-член display()
можно написать так:
<
}
А вот реализация reSize():
}
Работа указателя this не исчерпывается возвратом объекта, к которому была применена функция-член. При рассмотрении copy() в разделе 13.3 мы видели и другой способ его использования:
}
Указатель this
хранит адрес объекта, для которого была вызвана функция-член. Если адрес, на который ссылается sobj, совпадает со значением this, то sobj и this
относятся к одному и тому же объекту, так что операция копирования не нужна. (Мы еще встретимся с этой конструкцией, когда будем рассматривать копирующий оператор присваивания в разделе 14.7.)
Упражнение 13.7
Указатель this
можно использовать для модификации адресуемого объекта, а также для его замены другим объектом того же типа. Например, функция-член assign()
класса classType
выглядит так. Можете ли вы объяснить, что она делает?
}
Напомним, что ~classType – это имя деструктора. Оператор new выглядит несколько причудливо, но мы уже встречались с подобным в разделе 8.4.
Как вы относитесь к такому стилю программирования? Безопасна ли эта операция? Почему?
Функции-члены можно объявить в теле класса встроенными и явно, поместив перед типом возвращаемого значения ключевое слово inline:
};
Определения home() и get() в приведенных примерах эквивалентны. Поскольку ключевое слово inline
избыточно, мы в этой книге не пишем его явно для функций-членов, определенных в теле класса.
Функции-члены, состоящие из двух или более строк, лучше определять вне тела. Для идентификации функции как члена некоторого класса требуется специальный синтаксис объявления: имя функции должно быть квалифицировано
именем ее класса. Вот как выглядит определение функции checkRange(), квалифицированное именем Screen:
}
Прежде чем определять функцию-член вне тела класса, необходимо объявить ее внутри тела, обеспечив ее видимость. Например, если бы перед определением функции checkRange() не был включен заголовочный файл Screen.h, то компилятор выдал бы сообщение об ошибке. Тело класса определяет полный список его членов. Этот список не может быть расширен после закрытия тела.
Обычно функции-члены, определенные вне тела класса, не делают встроенными. Но объявить такую функцию встроенной можно, если явно добавить слово inline в объявление функции внутри тела класса или в ее определение вне тела, либо сделав то и другое одновременно. В следующем примере move()
определена как встроенная функция-член класса Screen:
}
Функция get(int, int)
объявляется встроенной с помощью слова inline:
};
Определение функции следует после объявления класса. При этом слово inline
можно опустить:
}
Так как встроенные функции-члены должны быть определены в каждом исходном файле, где они вызываются, то встроенную функцию, не определенную в теле класса, следует поместить в тот же заголовочный файл, в котором определен ее класс. Например, представленные ранее определения move() и get() должны находиться в заголовочном файле Screen.h
после определения класса Screen.
void printDate( const Date& );
Эти функции работают с одним типом данных– классом Date, но выполняют семантически различные действия. В этом случае лексическая сложность, связанная с употреблением различных имен, проистекает из принятого программистом соглашения об обеспечении набора операций над типом данных и именования функций в соответствии с семантикой этих операций. Правда, механизм классов C++ делает такое соглашение излишним. Следовало бы сделать такие функции членами класса Date, но при этом оставить разные имена, отражающие смысл операции:
};
Приведем еще один пример. Следующие пять функций-членов Screen выполняют различные операции над экранным курсором, являющимся принадлежностью того же класса. Может показаться, что разумно перегрузить эти функции под общим названием move():
Screen& moveY( int );
Впрочем, последние две функции перегрузить нельзя, так как у них одинаковые списки параметров. Чтобы сделать сигнатуру уникальной, объединим их в одну функцию:
Screen& move( int, char xy );
Теперь у всех функций разные списки параметров, так что их можно перегрузить под именем move(). Однако этого делать не следует: разные имена несут информацию, без которой программу будет труднее понять. Так, выполняемые данными функциями операции перемещения курсора различны. Например, moveHome()
осуществляет специальный вид перемещения в левый верхний угол экрана. Какой из двух приведенных ниже вызовов более понятен пользователю и легче запоминается?
myScreen.move();
В некоторых случаях не нужно ни перегружать имя функции, ни назначать разные имена: применение подразумеваемых по умолчанию значений аргументов позволяет объединить несколько функций в одну. Например, функции управления курсором
moveAbs(int, int, char*);
различаются наличием третьего параметра типа char*. Если их реализации похожи и для третьего аргумента можно найти разумное значение по умолчанию, то обе функции можно заменить одной. В данном случае на роль значения по умолчанию подойдет указатель со значением 0:
move( int, int, char* = 0 );
Применять те или иные возможности следует тогда, когда этого требует логика приложения. Вовсе не обязательно включать перегруженные функции в программу только потому, что они существуют.
Контейнер list
(список) реализован в виде двусвязного списка: в каждом элементе, помимо собственно данных, хранятся два члена-указателя– на следующий и на предыдущий элементы. Основное преимущество списка – это эффективная вставка и удаление одного элемента или целого диапазона в произвольное место списка, а недостаток – невозможность произвольного доступа. Например, можно написать:
vector::iterator vec_iter = vec.begin() + 7;
Такая форма вполне допустима и инициализирует vec_iter адресом восьмого элемента вектора, но запись
list::iterator list_iter = slist.begin() + 7;
некорректна, так как элементы списка не занимают непрерывную область памяти. Для того чтобы добраться до восьмого элемента, необходимо посетить все промежуточные.
Поскольку список не поддерживает произвольного доступа, то алгоритмы merge(), remove(), reverse(), sort() и unique()
лучше к таким контейнерам не применять, хотя ни один из них явно не требует наличия соответствующего итератора. Вместо этого для списка определены специализированные версии названных операций в виде функций-членов, а также операция splice():
· list::merge()
объединяет два отсортированных списка
· list::remove()
удаляет элементы с заданным значением
· list::remove_if()удаляет элементы, удовлетворяющие некоторому условию
· list::reverse()
переставляет элементы списка в обратном порядке
· list::sort()
сортирует элементы списка
· list::splice()
перемещает элементы из одного списка в другой
· list::unique()
оставляет один элемент из каждой цепочки одинаковых смежных элементов
·
композиция по значению, когда членом одного класса объявляется сам объект другого класса. Мы показывали это в исправленной реализации PeekbackStack;
· композиция по ссылке, когда членом одного класса является указатель или ссылка на объект другого класса.
Композиция по значению обеспечивает автоматическое управление временем жизни объекта и семантику копирования. Кроме того, прямой доступ к объекту оказывается более эффективным. А в каких случаях следует предпочесть композицию по ссылке?
Предположим, что мы решили с помощью композиции представить класс Endangered. Надо ли определить его объект непосредственно внутри ZooAnimal или сослаться на него с помощью указателя или ссылки? Сначала выясним, все ли объекты ZooAnimal
обладают этой характеристикой, а если нет, то может ли она изменяться с течением времени (допустимо ли добавлять или удалять эту характеристику).
Если ответ на первый вопрос положительный, то, как правило, лучше применить композицию по значению. (Как правило, но не всегда, поскольку с точки зрения эффективности включение больших объектов не оптимально, особенно когда они часто копируются. В таких случаях композиция по ссылке позволит обойтись без ненужных копирований, если применять при этом подсчет ссылок и технику, называемую копированием при записи. Увеличение эффективности, правда, достигается за счет усложнения управления объектом. Обсуждение этой техники не вошло в наш вводный курс; тем, кому это интересно, рекомендуем прочитать книгу [KOENIG97], главы 6 и 7.)
Если же оказывается, что только некоторые объекты класса ZooAnimal обладают указанной характеристикой, то лучшим вариантом будет композиция по ссылке (скажем, в примере с зоопарком не имеет смысла включать в процветающие виды большой объект, описывающий виды вымирающие).
Поскольку объекта Endangered
может и не существовать, то представлять его надо указателем, а не ссылкой. (Предполагается, что нулевой указатель не адресует объект. Ссылка же всегда должна именовать определенный объект. В разделе 3.6 это различие объяснялось более подробно.)
Если ответ на второй вопрос положительный, то необходимо задать функции, позволяющие вставить и удалить объект Endangered во время выполнения.
В нашем примере лишь небольшая часть всего множества животных в зоопарке находится под угрозой вымирания. Кроме того, по крайней мере теоретически, данная характеристика не является постоянной, и, допустим, в один прекрасный день это может перестать грозить панде.
};
Если предполагается, что наше приложение будет работать на разных платформах, то полезно инкапсулировать всю платформенно-зависимую информацию в иерархию абстрактных классов, чтобы запрограммировать платформенно-независимый интерфейс. Например, для вывода объекта ZooAnimal на дисплей UNIX-машины и ПК, можно определить иерархию классов DisplayManager:
class DisplayPC : public DisplayManager { ... };
Наш класс ZooAnimal не является разновидностью класса DisplayManager, но содержит экземпляр последнего посредством композиции, а не наследования. Возникает вопрос: использовать композицию по значению или по ссылке?
Композиция по значению не может представить объект DisplayManager, с помощью которого можно будет адресовать либо объект DisplayUNIX, либо объект DisplayPC. Только ссылка или указатель на объект DisplayManager позволят нам полиморфно манипулировать его подтипами. Иначе говоря, объектно-ориентированное программирование поддерживается только композицией по ссылке (подробнее см. [LIPPMAN96a].)
Теперь нужно решить, должен ли член класса ZooAnimal
быть ссылкой или указателем на DisplayManager:
· член может быть объявлен ссылкой лишь в том случае, если при создании объекта ZooAnimal имеется реальный объект DisplayManager, который не будет изменяться по ходу выполнения программы;
· если применяется стратегия отложенного выделения памяти, когда память для объекта DisplayManager
выделяется только при попытке вывести объект на дисплей, то объект следует представить указателем, инициализировав его значением 0;
· если мы хотим переключать режим вывода во время выполнения, то тоже должны представить объект указателем, который инициализирован нулем. Под переключением
мы понимаем предоставление пользователю возможности выбрать один из подтипов DisplayManager в начале или в середине работы программы.
Конечно, маловероятно, что для каждого подобъекта ZooAnimal в нашем приложении будет нужен собственный подтип DisplayManager для отображения. Скорее всего мы ограничимся статическим членом в классе ZooAnimal, указывающим на объект DisplayManager.
Упражнение 18.6
Объясните, в каких случаях имеет место наследование типа, а в каких – наследование реализации:
(f) DrawableGeom : Geom, Canvas // рисуемая фигура : фигура, холст
Упражнение 18.7
Замените член IntArray в реализации PeekbackStack
(см. раздел 18.3.1) на класс deque из стандартной библиотеки. Напишите небольшую программу для тестирования.
Упражнение 18.8
Сравните композицию по ссылке с композицией по значению, приведите примеры их использования.
конкретизируется дважды: один раз для массива из пяти элементов типа int, а другой – для массива из шести элементов типа double:
}
Вызов
int i = min( ia );
приводит к конкретизации следующего экземпляра функции min(), в котором Type
заменено на int, а size на 5:
}
Аналогично вызов
double d = min( da );
конкретизирует экземпляр min(), в котором Type
заменено на double, а size на 6:
В качестве формальных параметров шаблона функции используются параметр-тип и параметр-константа. Для определения фактического типа и значения константы, которые надо подставить в шаблон, исследуются фактические аргументы, переданные при вызове функции. В нашем примере для идентификации аргументов шаблона при конкретизации используются тип ia (массив из пяти int) и da
(массив из шести double). Процесс определения типов и значений аргументов шаблона по известным фактическим аргументам функции называется выведением (deduction) аргументов шаблона. (В следующем разделе мы расскажем об этом подробнее. А в разделе 10.4 речь пойдет о возможности явного задания аргументов.)
Шаблон конкретизируется либо при вызове, либо при взятии адреса функции. В следующем примере указатель pf инициализируется адресом конкретизированного экземпляра шаблона. Его аргументы определяются путем исследования типа параметра функции, на которую указывает pf:
int (*pf)(int (&)[10]) = &min;
Тип pf – это указатель на функцию с параметром типа int(&)[10], который определяет тип аргумента шаблона Type и значение аргумента шаблона size при конкретизации min(). Аргумент шаблона Type
будет иметь тип int, а значением аргумента шаблона size будет 10. Конкретизированная функция представляется как min(int(&)[10]), и указатель pf
адресует именно ее.
Когда берется адрес шаблона функции, контекст должен быть таким, чтобы можно было однозначно определить типы и значения аргументов шаблона. Если сделать это не удается, компилятор выдает сообщение об ошибке:
<
}
Функция func()
перегружена и тип ее параметра не позволяет однозначно определить ни аргумент шаблона Type, ни значение аргумента шаблона size. Результатом конкретизации вызова func()
может быть любая из следующих функций:
min( double (*)(double(&)[20]) )
Поскольку однозначно определить аргументы функции func() нельзя, взятие адреса конкретизированного шаблона в таком контексте приводит к ошибке компиляции.
Этого можно избежать, если использовать явное приведение типов для указания типа аргумента:
}
Лучше, однако, применять явное задание аргументов шаблона, как будет показано в разделе 10.4.
автоматически генерируются экземпляры классов Queue с разными типами элементов. Например, если написать:
Queue qi;
то из обобщенного определения шаблона автоматически создается класс Queue для объектов типа int.
Генерация конкретного класса из обобщенного определения шаблона называется конкретизацией шаблона. При такой конкретизации Queue для объектов типа int
каждое вхождение параметра Type в определении шаблона заменяется на int, так что определение класса Queue принимает вид:
};
Чтобы создать класс Queue для объектов типа string, надо написать:
Queue qs;
При этом каждое вхождение Type в определении шаблона будет заменено на string. Объекты qi и qs
являются объектами автоматически созданных классов.
Каждый конкретизированный по одному и тому же шаблону экземпляр класса совершенно не зависит от всех остальных. Так, у Queue для типа int нет никаких прав доступа к неоткрытым членам того же класса для типа string.
Конкретизированный экземпляр шаблона будет иметь соответственно имя Queue или Queue. Части и , следующие за именем Queue, называются фактическими аргументами шаблона. Они должны быть заключены в угловые скобки и отделяться друг от друга запятыми. В имени конкретизируемого шаблона аргументы всегда должны задаваться явно. В отличие от аргументов шаблона функции, аргументы шаблона класса никогда не выводятся из контекста:
Queue qs; // ошибка: как конкретизируется шаблон?
Конкретизированный шаблон класса Queue
можно использовать в программе всюду, где допустимо употребление типа обычного класса:
Queue *pqc = static_cast< Queue* > ( 0 );
Объекты типа класса, конкретизированного по шаблону Queue, объявляются и используются так же, как объекты обычных классов:
}
В объявлении и определении шаблона можно ссылаться как на сам шаблон, так и на конкретизированный по нему класс:
)
Однако вне такого определения употребляются только конкретизированные экземпляры. Например, в теле обычной функции всегда надо задавать фактические аргументы шаблона Queue:
}
Шаблон класса конкретизируется только тогда, когда имя полученного экземпляра употребляется в контексте, где требуется определение шаблона. Не всегда определение класса должно быть известно. Например, перед объявлением указателей и ссылок на класс его знать необязательно:
void inverse( Matrix & ); // тоже правильно
Поэтому объявление указателей и ссылок на конкретизированный шаблон класса не приводит к его конкретизации. (Отметим, что в некоторых компиляторах, написанных до принятия стандарта C++, шаблон конкретизируется при первом упоминании имени конкретизированного класса в тексте программы.) Так, в функции foo()
объявляются указатель и ссылка на Queue, но это не вызывает конкретизации шаблона Queue:
}
Определение класса необходимо знать, когда определяется объект этого типа. В следующем примере определение obj1
ошибочно: чтобы выделить для него память, компилятору необходимо знать размер класса Matrix:
Matrix obj2; // правильно
Таким образом, конкретизация происходит тогда, когда определяется объект класса, конкретизированного по этому шаблону. В следующем примере определение объекта qi
приводит к конкретизации шаблона Queue:
Queue qi; // конкретизируется Queue
Определение Queue
становится известно компилятору именно в этой точке, которая называется точкой конкретизации данного класса.
Если имеется указатель или ссылка на конкретизированный шаблон, то конкретизация также производится в момент обращения к объекту, на который они ссылаются. В определенной выше функции foo() класс Queue
конкретизируется в следующих случаях: когда разыменовывается указатель pqi, когда ссылка qi
используется для получения значения именуемого объекта и когда pqi или qi
употребляются для доступа к членам или функциям-членам этого класса:
}
Определение Queue
становится известным компилятору еще до вызова функции-члена add() из foo().
Напомним, что в определении шаблона класса Queue есть также ссылка на шаблон QueueItem:
<
};
При конкретизации
Queue типом int
члены front и back
становятся указателями на QueueItem. Следовательно, конкретизированный экземпляр Queue
ссылается на экземпляр QueueItem, конкретизированный типом int. Но поскольку соответствующие члены являются указателями, то QueueItem
конкретизируется лишь в момент их разыменования в функциях-членах класса Queue.
Наш класс QueueItem
служит вспомогательным средством для реализации класса Queue и не будет непосредственно употребляться в вызывающей программе. Поэтому пользовательская программа способна манипулировать только объектами Queue. Конкретизация шаблона QueueItem
происходит лишь в момент конкретизации шаблона класса Queue или его членов. (В следующих разделах мы рассмотрим конкретизации членов шаблона класса.)
В зависимости от типов, которыми может конкретизироваться шаблон, при его определении надо учитывать некоторые нюансы. Почему, например, следующее определение конструктора класса QueueItem не подходит для конкретизации общего вида?
};
В данном определении аргумент передается по значению. Это допустимо, если QueueItem
конкретизируется встроенным типом (например, QueueItem). Но если такая конкретизация производится для объемного типа (скажем, Matrix), то накладные расходы, вызванные неправильным выбором на этапе проектирования, становятся неприемлемыми. (В разделе 7.3 обсуждались вопросы производительности, связанные с передачей параметров по значению и по ссылке.) Поэтому аргумент конструктора объявляется как ссылка на константный тип:
QueueItem( const Type & );
Следующее определение приемлемо, если у типа, для которого конкретизируется QueueItem, нет ассоциированного конструктора:
<
};
Если аргументом шаблона является тип класса с конструктором (например, string), то item
инициализируется дважды! Конструктор по умолчанию string
вызывается для инициализации item перед выполнением тела конструктора QueueItem. Затем для созданного объекта item производится почленное присваивание. Избежать такого можно с помощью явной инициализации item в списке инициализации членов внутри определения конструктора QueueItem:
};
(Списки инициализации членов и основания для их применения обсуждались в разделе 14.5.)
состоит из подобъекта Query и объекта-члена string. Для иллюстрации поведения конструктора производного класса введем еще один член встроенного типа:
};
Если _present
установлен в false, то слово _name в тексте отсутствует.
Рассмотрим случай, когда в NameQuery
конструктор не определен. Тогда при определении объекта этого класса
NameQuery nq;
по очереди вызывается конструктор по умолчанию Query, а затем конструктор по умолчанию класса string
(ассоциированный с объектом _name). Член _present
остается неинициализированным, что потенциально может служить источником ошибок. Чтобы инициализировать его, можно так определить конструктор по умолчанию для класса NameQuery:
inline NameQuery::NameQuery() { _present = false; }
Теперь при определении nq
вызываются три конструктора по умолчанию: для базового класса Query, для класса string при инициализации члена _name и для класса NameQuery.
А как передать аргумент конструктору базового класса Query? Ответить на этот вопрос можно, рассуждая по аналогии.
Для передачи одного или более аргументов конструктору объекта-члена мы используем список инициализации членов (здесь можно также задать начальные значения членам, не являющимся объектами классов; подробности см. в разделе 14.5):
{}
Для передачи одного или более аргументов конструктору базового класса также разрешается использовать список инициализации членов. В следующем примере мы передаем конструктору string
аргумент name, а конструктору базового класса Query – объект, адресованный указателем ploc:
<
{}
Хотя Query
помещен в список инициализации вторым, его конструктор всегда вызывается раньше конструктора для _name. Порядок их вызова следующий:
Конструктор базового класса. Если базовых классов несколько, то конструкторы вызываются в порядке их следования в списке базовых классов, а не в порядке появления в списке инициализации. (О множественном наследовании в этой связи мы поговорим в главе 18.)
Конструктор объекта-члена. Если в классе есть несколько таких членов, то конструкторы вызываются в порядке их объявления в классе, а не в порядке появления в списке инициализации (подробнее см. раздел 14.5).
Конструктор производного класса.
Конструктор производного класса должен стремиться передать значение члена базового класса подходящему конструктору того же класса, а не присваивать его напрямую. В противном случае реализации двух классов становятся сильно связанными и тогда изменить или расширить реализацию базового будет затруднительно. (Ответственность разработчика базового класса ограничивается предоставлением подходящего множества конструкторов.)
В оставшейся части этого раздела мы последовательно изучим конструктор базового класса и конструкторы четырех производных от него, а после этого рассмотрим альтернативный дизайн иерархии классов Query, чтобы познакомиться с иерархиями глубиной больше двух. В конце раздела речь пойдет о деструкторах классов.
};
Конструктор Query по умолчанию должен явно инициализировать только член _solution. Для инициализации _loc автоматически вызывается конструктор класса vector. Вот реализация нашего конструктора:
inline Query::Query(): _solution( 0 ) {}
В Query нам понадобится еще один конструктор, принимающий ссылку на вектор позиций:
{}
Он вызывается только из конструктора NameQuery, когда объект этого класса используется для представления указанного в запросе слова. В таком случае передается предварительно подготовленный для него вектор позиций. Остальные три производных класса вычисляют свои векторы позиций в соответствующей функции-члене eval(). (В следующем подразделе мы покажем, как это делается. Реализации функций-членов eval() приведены в разделе 17.5.)
Какой уровень доступа обеспечить для конструкторов? Мы не хотим объявлять их открытыми, так как предполагается, что Query будет существовать в программе только в виде подобъекта в составе объектов производных от него классов. Поэтому мы объявим конструктор не открытым, а защищенным:
};
Ко второму конструктору класса Query
предъявляются еще более жесткие требования: он не только должен конструировать Query в виде подобъекта производного класса, но этот производный класс должен к тому же быть NameQuery. Можно объявить конструктор закрытым, а NameQuery сделать другом класса Query. (В предыдущем разделе мы говорили, что производный класс может получить доступ только к открытым и защищенным членам базового. Поэтому любая попытка вызвать второй конструктор из классов AndQuery, OrQuery или NotQuery приведет к ошибке компиляции.)
};
(Необходимость второго конструктора спорна; вероятно, правильнее заполнить _loc в функции eval()
класса NameQuery. Однако принятый подход в большей степени отвечает нашей цели проиллюстрировать использование конструктора базового класса.)
класса SmallInt, определяет множество неявных преобразований в значения типа SmallInt. Так, конструктор SmallInt(int)
преобразует значения типа int в значения типа SmallInt.
calc( i );
При вызове calc(i)
число i
преобразуется в значение типа SmallInt с помощью конструктора SmallInt(int), вызванного компилятором для создания временного объекта нужного типа. Затем копия этого объекта передается в calc(), как если бы вызов функции был записан в форме:
}
Фигурные скобки в этом примере обозначают время жизни данного объекта: он уничтожается при выходе из функции.
Типом параметра конструктора может быть тип некоторого класса:
};
В таком случае значение типа SmallInt
можно использовать всюду, где допустимо значение типа Number:
}
Если конструктор используется для выполнения неявного преобразования, то должен ли тип его параметра точно соответствовать типу подлежащего преобразованию значения? Например, будет ли в следующем коде вызван SmallInt(int), определенный в классе SmallInt, для приведения dobj к типу SmallInt?
calc( dobj );
Если необходимо, к фактическому аргументу применяется последовательность стандартных преобразований до того, как вызвать конструктор, выполняющий определенное пользователем преобразование. При обращении к функции calc()употребляется стандартное преобразование dobj из типа double в тип int. Затем уже для приведения результата к типу SmallInt вызывается SmallInt(int).
Компилятор неявно использует конструктор с единственным параметром для преобразования его типа в тип класса, к которому принадлежит конструктор. Однако иногда удобнее, чтобы конструктор Number(const SmallInt&) можно было вызывать только для инициализации объекта типа Number значением типа SmallInt, но ни в коем случае не для выполнения неявных преобразований. Чтобы избежать такого употребления конструктора, объявим его явным (explicit):
};
Компилятор никогда не применяет явные конструкторы для выполнения неявных преобразований типов:
}
Однако такой конструктор все же можно использовать для преобразования типов, если оно запрошено явно в форме оператора приведения типа:
}
};
Единственное синтаксическое ограничение, налагаемое на конструктор, состоит в том, что он не должен иметь тип возвращаемого значения, даже void. Поэтому следующие объявления ошибочны:
Account* Account::Account( const char *pc ) { ... }
Количество конструкторов у одного класса может быть любым, лишь бы все они имели разные списки формальных параметров.
Откуда мы знаем, сколько и каких конструкторов определить? Как минимум, необходимо присвоить начальное значение каждому члену, который в этом нуждается. Например, номер счета либо задается явно, либо генерируется автоматически таким образом, чтобы гарантировать его уникальность. Предположим, что он будет создаваться автоматически. Тогда мы должны разрешить инициализировать оставшиеся два члена _name и _balance:
Account( const char *name, double open_balance );
Объект класса Account, инициализируемый конструктором, можно объявить следующим образом:
Account newAcct( "Mikey Matz", 0 );
Если же есть много счетов, для которых начальный баланс равен 0, то полезно иметь конструктор, задающий только имя владельца и автоматически инициализирующий _balance
нулем. Один из способов сделать это– предоставить конструктор вида:
Account( const char *name );
Другой способ – включить в конструктор с двумя параметрами значение по умолчанию, равное нулю:
Account( const char *name, double open_balance = 0.0 );
Оба конструктора обладают необходимой пользователю функциональностью, поэтому оба решения приемлемы. Мы предпочитаем использовать аргумент по умолчанию, поскольку в такой ситуации общее число конструкторов класса сокращается.
Нужно ли поддерживать также задание одного лишь начального баланса без указания имени клиента? В данном случае спецификация класса явно запрещает это. Наш конструктор с двумя параметрами, из которых второй имеет значение по умолчанию, предоставляет полный интерфейс для указания начальных значений тех членов класса Account, которые могут быть инициализированы пользователем:
};
Ниже приведены два примера правильного определения объекта класса Account, где конструктору передается один или два аргумента:
}
C++ требует, чтобы конструктор применялся к определенному объекту до его первого использования. Это означает, что как для acct, так и для объекта, на который указывает pact, конструктор будет вызван перед проверкой в инструкции if.
Компилятор перестраивает нашу программу, вставляя вызовы конструкторов. Вот как, по всей вероятности, будет модифицировано определение acct
внутри main():
}
Конечно, если конструктор определен как встроенный, то он подставляется в точке вызова.
Обработка оператора new
несколько сложнее. Конструктор вызывается только тогда, когда он успешно выделил память. Модификация определения pact в несколько упрощенном виде выглядит так:
<
}
Существует три в общем случае эквивалентных формы задания аргументов конструктора:
Account acct3 = "Anna Press";
Форма acct3
может использоваться только при задании единственного аргумента. Если аргументов два или более, мы рекомендуем пользоваться формой acct1, хотя допустима и acct2.
Account acct1( "Anna Press" );
Новички часто допускают ошибку при объявлении объекта, инициализированного конструктором по умолчанию:
Account newAccount();
Эта инструкция компилируется без ошибок. Однако при попытке использовать объект в таком контексте:
if ( ! newAccount.name() ) ...
компилятор не сможет применить к функции нотацию доступа к членам класса. Определение
Account newAccount();
интерпретируется компилятором как определение функции без параметров, которая возвращает объект типа Account. Правильное объявление объекта класса, инициализируемого конструктором по умолчанию, не содержит пустых скобок:
Account newAccount;
Определять объект класса, не указывая списка фактических аргументов, можно в том случае, если в нем либо объявлен конструктор по умолчанию, либо вообще нет объявлений конструкторов. Если в классе объявлен хотя бы один конструктор, то не разрешается определять объект класса, не вызывая ни одного из них. В частности, если в классе определен конструктор, принимающий один или более параметров, но не определен конструктор по умолчанию, то в каждом определении объекта такого класса должны присутствовать необходимые аргументы. Можно возразить, что не имеет смысла определять конструктор по умолчанию для класса Account, поскольку не бывает счетов без имени владельца. В пересмотренной версии класса Account
такой конструктор исключен:
};
Теперь при объявлении каждого объекта Account в конструкторе обязательно надо указать как минимум аргумент типа C-строки, но это скорее всего бессмысленно. Почему? Контейнерные классы (например, vector) требуют, чтобы для класса помещаемых в них элементов был либо задан конструктор по умолчанию, либо вообще никаких конструкторов. Аналогичная ситуация имеет место при выделении динамического массива объектов класса. Так, следующая инструкция вызвала бы ошибку компиляции для новой версии Account:
Account *pact = new Account[ new_client_cnt ];
На практике часто требуется задавать конструктор по умолчанию, если имеются какие-либо другие конструкторы.
А если для класса нет разумных значений по умолчанию? Например, класс Account
требует задавать для любого объекта фамилию владельца счета. В таком случае лучше всего установить состояние объекта так, чтобы было видно, что он еще не инициализирован корректными значениями:
}
Однако в функции-члены класса Account
придется включить проверку целостности объекта перед его использованием.
Существует и альтернативный синтаксис: список инициализации членов, в котором через запятую указываются имена и начальные значения. Например, конструктор по умолчанию можно переписать следующим образом:
{}
Такой список допустим только в определении, но не в объявлении конструктора. Он помещается между списком параметров и телом конструктора и отделяется двоеточием. Вот как выглядит наш конструктор с двумя параметрами при частичном использовании списка инициализации членов:
}
get_unique_acct_nmbr() – это не являющаяся открытой функция-член, которая возвращает гарантированно не использованный ранее номер счета.
Конструктор нельзя объявлять с ключевыми словами const или volatile (см. раздел 13.3.5), поэтому приведенные записи неверны:
};
Это не означает, что объекты класса с такими спецификаторами запрещено инициализировать конструктором. Просто к объекту применяется подходящий конструктор, причем без учета спецификаторов в объявлении объекта. Константность объекта класса устанавливается после того, как работа по его инициализации завершена, и пропадает в момент вызова деструктора. Таким образом, объект класса со спецификатором const
считается константным с момента завершения работы конструктора до момента запуска деструктора. То же самое относится и к спецификатору volatile.
Рассмотрим следующий фрагмент программы:
}
По умолчанию конструктор с одним параметром (или с несколькими – при условии, что все параметры, кроме первого, имеют значения по умолчанию) играет роль оператора преобразования. В этом фрагменте программы конструктор Account
неявно применяется компилятором для трансформации литеральной строки в объект класса Account при вызове print(), хотя в данной ситуации такое преобразование не нужно.
Непреднамеренные неявные преобразования классов, например трансформация "oops" в объект класса Account, оказались источником трудно обнаруживаемых ошибок. Поэтому в стандарт C++ было добавлено ключевое слово explicit, говорящее компилятору, что такие преобразования не нужны:
};
Данный модификатор применим только к конструктору. (Операторы преобразования и слово explicit
обсуждаются в разделе 15.9.2.)
Complex::Complex(double re=0.0, double im=0.0) { ... }
Когда мы пишем:
}
то компилятор сначала проверяет, определен ли для класса Account конструктор по умолчанию. Возникает одна из следующих ситуаций:
1. Такой конструктор определен. Тогда он применяется к acct.
2. Конструктор определен, но не является открытым. В данном случае определение acct помечается компилятором как ошибка: у функции main() нет прав доступа.
3. Конструктор по умолчанию не определен, но есть один или несколько конструкторов, требующих задания аргументов. Определение acct помечается как ошибка: слишком мало аргументов у конструктора.
4. Нет ни конструктора по умолчанию, ни какого-либо другого. Определение считается корректным, acct не инициализируется, конструктор не вызывается.
Пункты 1 и 3 должны быть уже достаточно понятны (если это не так, перечитайте данную главу) Посмотрим более внимательно на пункты 2 и 4.
Допустим, что все члены класса Account
объявлены открытыми и не объявлено никакого конструктора:
};
В таком случае при определении объекта класса Account специальной инициализации не производится. Начальные значения всех трех членов зависят только от контекста, в котором встретилось определение. Например, для статических объектов гарантируется, что все их члены будут обнулены (как и для объектов, не являющихся экземплярами классов):
<
}
Однако объекты, определенные локально или распределенные динамически, в начальный момент будут содержать случайный набор битов, оставшихся в стеке программы:
}
Новички часто полагают, что компилятор автоматически генерирует конструктор, если он не задан, и применяет его для инициализации членов класса. Для Account в том виде, в каком мы его определили, это неверно. Никакой конструктор не генерируется и не вызывается. Для более сложных классов, имеющих члены, которые сами являются классами, или использующих наследование, это отчасти справедливо: конструктор по умолчанию может быть сгенерирован, но и он не присваивает начальных значений членам встроенных или составных типов, таким, как указатели или массивы.
Если мы хотим, чтобы подобные члены инициализировались, то должны сами позаботиться об этом, предоставив один или несколько конструкторов. В противном случае отличить корректное значение члена такого типа от неинициализированного, если объект создан локально или распределен из хипа,3 практически невозможно.
также определены два конструктора. Они объявлены открытыми, поскольку ожидается, что в приложении будут создаваться объекты этого класса:
};
Конструктор с одним параметром принимает в качестве аргумента строку. Она передается конструктору объекта типа string, который вызывается для инициализации члена _name. Конструктор по умолчанию базового класса Query
вызывается неявно:
{}
Конструктор с двумя параметрами также принимает строку в качестве одного из них. Второй его параметр– это указатель на вектор позиций. Он передается закрытому конструктору базового класса Query. (Обратите внимание, что _present нам больше не нужен, и мы исключили его из числа членов NameQuery.)
{}
Конструкторы можно использовать так:
else pname = new NameQuery( title );
В каждом из классов NotQuery, OrQuery и AndQuery определено по одному конструктору, каждый из которых вызывает конструктор базового класса неявно:
{}
(В разделе 17.7 мы построим объекты каждого из производных классов для представления различных запросов пользователя.)
}
Функциональный try-блок ассоциирует группу catch-обработчиков с телом функции. Если инструкция внутри тела возбуждает исключение, то поиск его обработчика ведется среди тех, что следуют за телом функции.
Функциональный try-блок необходим для конструкторов класса. Почему? Определение конструктора имеет следующий вид:
{ /* ... */ }
выражение1 и выражение2
могут быть выражениями любого вида, в частности функциями, которые возбуждают исключения.
Рассмотрим еще раз класс Account, описанный в главе 14. Его конструктор можно переопределить так:
}
Функция ServiceCharge(), вызываемая для инициализации члена _balance, может возбуждать исключение. Как нужно реализовать конструктор, если мы хотим обрабатывать все исключения, возбуждаемые функциями, которые вызываются при конструировании объекта типа Account?
Помещать try-блок в тело функции нельзя:
}
Поскольку try- блок не охватывает список инициализации членов, то catch-обработчик, находящийся в конце конструктора, не рассматривается при поиске кандидатов, которые способны перехватить исключение, возбужденное в функции ServiceCharge().
Использование функционального try-блока – это единственное решение, гарантирующее, что все исключения, возбужденные при создании объекта, будут перехвачены в конструкторе. Для конструктора класса Account такой try-блок можно определить следующим образом:
}
}
Обратите внимание, что ключевое слово try
находится перед списком инициализации членов, а составная инструкция, образующая try-блок, охватывает тело конструктора. Теперь предложение catch(...) принимается во внимание при поиске обработчика исключения, возбужденного как в списке инициализации членов, так и в теле конструктора.
(мультимножество) позволяют сохранять ключи с дублирующимися значениями. Например, в телефонном справочнике может понадобиться отдельный список номеров для каждого абонента. В перечне книг одного автора может быть несколько названий, а в нашей программе с одним словом текста сопоставляется несколько позиций. Для использования multimap и multiset нужно включить соответствующий заголовочный файл – map или set:
multiset< type > multisetName;
Для прохода по мультиотображению или мультимножеству можно воспользоваться комбинацией итератора, который возвращает find() (он указывает на первый найденный элемент), и значения, которое возвращает count(). (Это работает, поскольку в данных контейнерах элементы с одинаковыми ключами обязательно являются соседними). Например:
}
Более элегантный способ перебрать все значения с одинаковыми ключами использует специальную функцию-член equal_range(), которая возвращает пару итераторов. Один из них указывает на первое найденное значение, а второй – на следующее за последним найденным. Если последний из найденных элементов является последним в контейнере, второй итератор содержит величину, равную end():
<
}
Вставка и удаление элементов в multimap и multiset ничем не отличаются от аналогичных операций с контейнерами map и set. Функция equal_range() доставляет итераторную пару, задающую диапазон удаляемых элементов:
authors.erase( pos.first, pos.second );
При каждом вызове функции-члена insert()
добавляется новый элемент, даже если в контейнере уже был элемент с таким же ключом. Например:
string( "Lost in the Funhouse" )));
Контейнер multimap не поддерживает операцию взятия индекса. Поэтому следующее выражение ошибочно:
authors[ "Barth, John" ]; // ошибка: multimap
Упражнение 6.28
Перепишите программу текстового поиска из раздела 6.14 с использованием multimap для хранения позиций слов. Каковы производительность и дизайн в обоих случаях? Какое решение вам больше нравится? Почему?
Имя, находящееся за ключевым словом, не обязательно должно быть именем одного из встроенных типов. В показанном ниже классе Token определено несколько конвертеров. В одном из них для задания имени типа используется typedef tName, а в другом – тип класса SmallInt.
};
Обратите внимание, что определения конвертеров в типы SmallInt и int одинаковы. Конвертер Token::operator int()
возвращает значение члена val. Поскольку val
имеет тип SmallInt, то неявно применяется SmallInt::operator int() для преобразования val в тип int. Сам Token::operator int() неявно употребляется компилятором для преобразования объекта типа Token в значение типа int. Например, этот конвертер используется для неявного приведения фактических аргументов t1 и t2
типа Token к типу int
формального параметра функции print():
}
После компиляции и запуска программа выведет такие строки:
print( int ) : 255
Общий вид конвертера следующий:
operator type();
где type
может быть встроенным типом, типом класса или именем typedef. Конвертеры, в которых type – тип массива или функции, не допускаются. Конвертер должен быть функцией-членом. В его объявлении не должны задаваться ни тип возвращаемого значения, ни список параметров:
};
Конвертер вызывается в результате явного преобразования типов. Если преобразуемое значение имеет тип класса, у которого есть конвертер, и в операции приведения указан тип этого конвертера, то он и вызывается:
char *tokName = static_cast< char * >( tok );
У конвертера Token::operator tName()
может быть нежелательный побочный эффект. Попытка прямого обращения к закрытому члену Token::name
помечается компилятором как ошибка:
char *tokName = tok.name; // ошибка: Token::name - закрытый член
Однако наш конвертер, разрешая пользователям непосредственно изменять Token::name, делает как раз то, от чего мы хотели защититься. Скорее всего, это не годится. Вот, например, как могла бы произойти такая модификация:
*tokname = 'P'; // но теперь в члене name находится Punction!
Мы намереваемся разрешить доступ к преобразованному объекту класса Token только для чтения. Следовательно, конвертер должен возвращать тип const char*:
const char *pn2 = tok; // правильно
Другое решение – заменить в определении Token тип char* на тип string из стандартной библиотеки C++:
<
};
Семантика конвертера Token::operator string() состоит в возврате копии значения ( а не указателя на значение) строки, представляющей имя лексемы. Это предотвращает случайную модификацию закрытого члена name класса Token.
Должен ли целевой тип точно соответствовать типу конвертера? Например, будет ли в следующем коде вызван конвертер int(), определенный в классе Token?
calc( tok );
Если целевой тип (в данном случае double) не точно соответствует типу конвертера (в нашем случае int), то конвертер все равно будет вызван при условии, что существует последовательность стандартных преобразований, приводящая к целевому типу из типа конвертера. (Эти последовательности описаны в разделе 9.3.) При обращении к функции calc()
вызывается Token::operator int() для преобразования tok из типа Token в тип int. Затем для приведения результата от типа int к типу double
применяется стандартное преобразование.
Вслед за определенным пользователем преобразованием допускаются только стандартные. Если для достижения целевого типа необходимо еще одно пользовательское преобразование, то компилятор не применяет никаких преобразований. Предположим, что в классе Token не определен operator int(), тогда следующий вызов будет ошибочным:
calc( tok );
Если конвертер Token::operator int() не определен, то приведение tok к типу int
потребовало бы вызова двух определенных пользователем конвертеров. Сначала фактический аргумент tok
надо было бы преобразовать из типа Token в тип SmallInt с помощью конвертера
Token::operator SmallInt()
а затем результат привести к типу int – тоже с помощью пользовательского конвертера
Token::operator int()
Вызов calc(tok)
помечается компилятором как ошибка, так как не существует неявного преобразования из типа Token в тип int.
Если логического соответствия между типом конвертера и типом класса нет, назначение конвертера может оказаться непонятным читателю программы:
};
Какое значение должен вернуть конвертер int() класса Date? Сколь бы основательными ни были причины для того или иного решения, читатель останется в недоумении относительно того, как пользоваться объектами класса Date, поскольку между ними и целыми числами нет явного логического соответствия. В таких случаях лучше вообще не
определять конвертер.
Часто почленная инициализация не обеспечивает корректного поведения класса. Поэтому мы явно определяем копирующий конструктор. В нашем классе Account это необходимо, иначе два объекта будут иметь одинаковые номера счетов, что запрещено спецификацией класса.
Копирующий конструктор принимает в качестве формального параметра ссылку на объект класса (традиционно объявляемую со спецификатором const). Вот его реализация:
}
Когда мы пишем:
Account acct2( acct1 );
компилятор определяет, объявлен ли явный копирующий конструктор для класса Account. Если он объявлен и доступен, то он и вызывается; а если недоступен, то определение acct2
считается ошибкой. В случае, когда копирующий конструктор не объявлен, выполняется почленная инициализация по умолчанию. Если впоследствии объявление копирующего конструктора будет добавлено или удалено, никаких изменений в программы пользователей вносить не придется. Однако перекомпилировать их все же необходимо. (Более подробно почленная инициализация рассматривается в разделе 14.6.)
Упражнение 14.1
Какие из следующих утверждений ложны? Почему?
1. У класса должен быть хотя бы один конструктор.
2. Конструктор по умолчанию – это конструктор с пустым списком параметров.
3. Если разумных начальных значений у членов класса нет, то не следует предоставлять конструктор по умолчанию.
4. Если в классе нет конструктора по умолчанию, то компилятор генерирует его автоматически и инициализирует каждый член значением по умолчанию для соответствующего типа.
Упражнение 14.2
Предложите один или несколько конструкторов для данного множества членов. Объясните свой выбор:
};
Упражнение 14.3
Выберите одну из следующих абстракций (или предложите свою собственную). Решите, какие данные (задаваемые пользователем) подходят для представляющего эту абстракцию класса. Напишите соответствующий набор конструкторов. Объясните свое решение.
· Книга
· Дата
· Служащий
· Транспортное средство
· Объект
· Дерево
Упражнение 14.4
Пользуясь приведенным определением класса:
};
объясните, что происходит в результате следующих определений:
(e) Account acct5 = Account( acct3 );
Упражнение 14.5
Параметр копирующего конструктора может и не быть константным, но обязан быть ссылкой. Почему ошибочна такая инструкция:
Account::Account( const Account rhs );
1. По очереди исследовать каждый элемент.
2. Если элемент равен искомому значению, то вернуть его позицию в коллекции.
3. В противном случае анализировать следующий элемент Повторять шаг 2, пока значение не будет найдено либо пока не будет просмотрена вся коллекция.
4. Если мы достигли конца коллекции и не нашли искомого, то вернуть некоторое значение, показывающее, что нужного элемента нет.
Алгоритм, как мы и утверждали, не зависит ни от типа контейнера, к которому применяется, ни от типа искомого значения, однако для его использования необходимы:
· способ обхода коллекции: переход к следующему элементу и распознавание того, что достигнут конец коллекции. При работе с встроенным типом массива мы решаем эту проблему, передавая два аргумента: указатель на первый элемент и число элементов, подлежащих обходу (в случае строк символов в стиле C передавать второй аргумент необязательно, так как конец строки обозначается двоичным нулем);
· умение сравнивать каждый элемент контейнера с искомым значением. Обычно это делается с помощью оператора равенства, ассоциированного со значениями типа, или путем передачи указателя на функцию, осуществляющую сравнение;
· некоторый обобщенный тип для представления позиции элемента внутри контейнера и специального признака на случай, если элемент не найден. Обычно мы возвращаем индекс элемента либо указатель на него. В ситуации, когда поиск неудачен, возвращается –1 вместо индекса или 0 вместо указателя.
Обобщенные алгоритмы решают первую проблему, обход контейнера, с помощью абстракции итератора – обобщенного указателя, поддерживающего оператор инкремента для доступа к следующему элементу, оператор разыменования для получения его значения и операторы равенства и неравенства для определения того, совпадают ли два итератора. Диапазон, к которому применяется алгоритм, помечается парой итераторов: first
адресует первый элемент, а last – тот, который следует за последним. К самому элементу, адресованному итератором last, алгоритм не применяется; он служит стражем, прекращающим обход. Кроме того, last используется как возвращаемое значение с семантикой “отсутствует”. Если же значение получено, то возвращается итератор, помечающий позицию найденного элемента.
Имеется по две версии каждого обобщенного алгоритма: в одной для сравнения применяется оператор равенства, а в другой – объект-функция или указатель на функцию, реализующую сравнение. (Объекты-функции рассматриваются в разделе 12.3.) Вот, например, реализация обобщенного алгоритма find(), в котором используется оператор сравнения для типов хранимых в контейнере элементов:
}
ForwardIterator
(однонаправленный итератор) – это один из пяти категорий итераторов, предопределенных в стандартной библиотеке. Он поддерживает чтение и запись адресуемого элемента. (Все пять категорий рассматриваются в разделе 12.4.)
Алгоритмы достигают независимости от типов за счет того, что никогда не обращаются к элементам контейнера непосредственно; доступ и обход элементов осуществляются только с помощью итераторов. Неизвестны ни фактический тип контейнера, ни даже то, является ли он контейнером или встроенным массивом. Для работы со встроенным типом массива обобщенному алгоритму можно передать не только обычные указатели, но и итераторы. Например, алгоритм find() для встроенного массива элементов типа int можно использовать так:
}
Если возвращенный указатель равен адресу &ia[6] (который расположен за последним элементом массива), то поиск оказался безрезультатным, в противном случае значение найдено.
Вообще говоря, при передаче адресов элементов массива обобщенному алгоритму мы можем написать
int *presult = find( &ia[0], &ia[6], search_value );
или
int *presult = find( ia, ia+6, search_value );
Если бы мы хотели ограничиться лишь отрезком массива, то достаточно было бы модифицировать передаваемые алгоритму адреса. Так, при следующем обращении к find()
просматриваются только второй и третий элементы (напомним, что элементы массива нумеруются с нуля):
int *presult = find( &ia[1], &ia[3], search_value );
А вот пример использования контейнера типа vector с алгоритмом find():
}
find() можно применить и к списку:
}
( В следующем разделе мы обсудим построение программы, в которой используются различные обобщенные алгоритмы, а затем рассмотрим объекты-функции. В разделе 12.4 мы подробнее расскажем об итераторах. Развернутое введение в обобщенные алгоритмы – предмет раздела 12.5, а их детальное обсуждение и иллюстрация применения вынесено в Приложение. В конце главы речь пойдет о случаях, когда применение обобщенных алгоритмов неуместно.)
Упражнение 12.1
Обобщенные алгоритмы критикуют за то, что при всей элегантности дизайна проверка корректности возлагается на программиста. Например, если передан неверный итератор или пара итераторов, помечающая неверный диапазон, то поведение программы не определено. Вы согласны с такой критикой? Следует ли оставить применение обобщенных алгоритмов только наиболее квалифицированным специалистам? Может быть, нужно запретить использование потенциально опасных конструкций, таких, как обобщенные алгоритмы, указатели и явные приведения типов?
служит для хранения отдельных символов и небольших целых чисел. Он занимает один машинный байт. Типы short, int и long
предназначены для представления целых чисел. Эти типы различаются только диапазоном значений, которые могут принимать числа, а конкретные размеры перечисленных типов зависят от реализации. Обычно short
занимает половину машинного слова, int– одно слово, long – одно или два слова. В 32-битных системах int и long, как правило, одного размера.
Типы float, double и long double предназначены для чисел с плавающей точкой и различаются точностью представления (количеством значащих разрядов) и диапазоном. Обычно float
(одинарная точность) занимает одно машинное слово, double
(двойная точность) – два, а long double (расширенная точность) – три.
char, short, int и long вместе составляют целые типы, которые, в свою очередь, могут быть знаковыми (signed) и беззнаковыми (unsigned). В знаковых типах самый левый бит служит для хранения знака (0 – плюс, 1 – минус), а оставшиеся биты содержат значение. В беззнаковых типах все биты используются для значения. 8-битовый тип signed char
может представлять значения от -128 до 127, а unsigned char – от 0 до 255.
Когда в программе встречается некоторое число, например 1, то это число называется литералом, или литеральной константой. Константой, потому что мы не можем изменить его значение, и литералом, потому что его значение фигурирует в тексте программы. Литерал является неадресуемой величиной: хотя реально он, конечно, хранится в памяти машины, нет никакого способа узнать его адрес. Каждый литерал имеет определенный тип. Так, 0 имеет тип int, 3.14159 – тип double.
Литералы целых типов можно записать в десятичном, восьмеричном и шестнадцатеричном виде. Вот как выглядит число 20, представленное десятичным, восьмеричным и шестнадцатеричным литералами:
20 // десятичный
024 // восьмеричный
0х14 // шестнадцатеричный
Если литерал начинается с 0, он трактуется как восьмеричный, если с 0х или 0Х, то как шестнадцатеричный. Привычная запись рассматривается как десятичное число.
По умолчанию все целые литералы имеют тип signed int. Можно явно определить целый литерал как имеющий тип long, приписав в конце числа букву L (используется как прописная L, так и строчная l, однако для удобства чтения не следует употреблять строчную: ее легко перепутать с 1).
Буква U
(или u) в конце определяет литерал как unsigned int, а две буквы – UL или LU – как тип unsigned long. Например:
128u 1024UL 1L 8Lu
Литералы, представляющие действительные числа, могут быть записаны как с десятичной точкой, так и в научной (экспоненциальной) нотации. По умолчанию они имеют тип double. Для явного указания типа float
нужно использовать суффикс F или f, а для long double - L или l, но только в случае записи с десятичной точкой. Например:
3.14159F 0/1f 12.345L 0.0
3el 1.0E-3E 2. 1.0L
Слова true и false
являются литералами типа bool.
Представимые литеральные символьные константы записываются как символы в одинарных кавычках. Например:
'a' '2' ',' ' ' (пробел)
Специальные символы (табуляция, возврат каретки) записываются как escape-последовательности . Определены следующие такие последовательности (они начинаются с символа обратной косой черты):
новая строка \n
горизонтальная табуляция \t
забой \b
вертикальная табуляция \v
возврат каретки \r
прогон листа \f
звонок \a
обратная косая черта \\
вопрос \?
одиночная кавычка \'
двойная кавычка \"
escape-последовательность общего вида имеет форму \ooo, где ooo – от одной до трех восьмеричных цифр. Это число является кодом символа. Используя ASCII-код, мы можем написать следующие литералы:
\7 (звонок) \14 (новая строка)
\0 (null) \062 ('2')
Символьный литерал может иметь префикс L
(например, L'a'), что означает специальный тип wchar_t – двухбайтовый символьный тип, который применяется для хранения символов национальных алфавитов, если они не могут быть представлены обычным типом char, как, например, китайские или японские буквы.
Строковый литерал – строка символов, заключенная в двойные кавычки. Такой литерал может занимать и несколько строк, в этом случае в конце строки ставится обратная косая черта. Специальные символы могут быть представлены своими escape-последовательностями. Вот примеры строковых литералов:
"" (пустая строка)
"a"
"\nCC\toptions\tfile.[cC]\n"
"a multi-line \
string literal signals its \
continuation with a backslash"
Фактически строковый литерал представляет собой массив символьных констант, где по соглашению языков С и С++ последним элементом всегда является специальный символ с кодом 0 (\0).
Литерал 'A'
задает единственный символ А, а строковый литерал "А" – массив из двух элементов: 'А' и \0
(пустого символа).
Раз существует тип wchar_t, существуют и литералы этого типа, обозначаемые, как и в случае с отдельными символами, префиксом L:
L"a wide string literal"
Строковый литерал типа wchar_t – это массив символов того же типа, завершенный нулем.
Если в тесте программы идут подряд два или несколько строковых литералов (типа char или wchar_t), компилятор соединяет их в одну строку. Например, следующий текст
"two" "some"
породит массив из восьми символов – twosome и завершающий нулевой символ. Результат конкатенации строк разного типа не определен. Если написать:
"two" L"some"
то на каком-то компьютере результатом будет некоторая осмысленная строка, а на другом может оказаться нечто совсем иное. Программы, использующие особенности реализации того или иного компилятора или операционной системы, являются непереносимыми. Мы крайне не рекомендуем пользоваться такими конструкциями.
Упражнение 3.1
Объясните разницу в определениях следующих литералов:
(c) 3.14, 3.14f, 3.14L
Упражнение 3.2
Какие ошибки допущены в приведенных ниже примерах?
comment"
· Логическое И: logical_and
dres = BinaryFunc( logical_and(), dval1, dval2 );
· Логическое ИЛИ: logical_or
dres = BinaryFunc( logical_or(), dval1, dval2 );
· Логическое НЕ: logical_not
dres = UnaryFunc( logical_or(), dval1 );
}
Первая локальная область видимости – тело функции binSearch(). В ней объявлены параметры функции vec и val, а также переменные low и high. Цикл while
внутри функции задает вложенную локальную область, в которой определена одна переменная mid. Параметры vec и val и переменные low и high
видны во вложенной области. Глобальная область видимости включает в себя обе локальных. В ней определена одна целая константа notFound.
Имена параметров функции vec и val
принадлежат к первой локальной области видимости тела функции, и в ней использовать те же имена для других сущностей нельзя. Например:
// ...
Имена параметров употребляются как внутри тела функции binSearch(), так и внутри вложенной области видимости цикла while. Параметры vec и val
недоступны вне тела функции binSearch().
Разрешение имени в локальной области видимости происходит следующим образом: просматривается та область, где оно встретилось. Если объявление найдено, имя разрешено. Если нет, просматривается область видимости, включающая текущую. Этот процесс продолжается до тех пор, пока объявление не будет найдено либо не будет достигнута глобальная область видимости. Если и там имени нет, оно будет считаться ошибочным.
Из- за порядка просмотра областей видимости в процессе разрешения имен объявление из внешней области может быть скрыто
объявлением того же имени во вложенной области. Если бы в предыдущем примере переменная low
была объявлена в глобальной области видимости перед определением функции binSearch(), то использование low в локальной области видимости цикла while все равно относилось бы к локальному объявлению, скрывающему глобальное:
}
Для некоторых инструкций языка C++ разрешено объявлять переменные внутри управляющей части. Например, в цикле for переменную можно определить внутри инструкции инициализации:
if ( index != vecSize ) // элемент найден
Подобные переменные видны только в локальной области самого цикла for и вложенных в него (это верно для стандарта С++, в предыдущих версиях языка поведение было иным). Компилятор рассматривает это объявление так же, как если бы оно было записано в виде:
}
Тем самым программисту запрещается применять управляющую переменную вне локальной области видимости цикла. Если нужно проверить index, чтобы определить, было ли найдено значение, то данный фрагмент кода следует переписать так:
if ( index != vecSize ) // элемент найден
Поскольку переменная, объявленная в инструкции инициализации цикла for, является локальной для цикла, то же самое имя допустимо использовать аналогичным образом и в других циклах, расположенных в данной локальной области видимости:
}
Аналогично переменная может быть объявлена внутри условия инструкций if и switch, а также внутри условия циклов while и for. Например:
}
Переменные, определенные в условии инструкции if, как переменная pi, видны только внутри if и соответствующей части else, а также во вложенных областях. Значением условия является значение этой переменной, которое она получает в результате инициализации. Если pi
равна 0
(нулевой указатель), условие ложно и выполняется ветвь else. Если pi
инициализируется любым другим значением, условие истинно и выполняется ветвь if. (Инструкции if, switch, for и while
рассматривались в главе 5.)
Упражнение 8.1
Найдите различные области видимости в следующем примере. Какие объявления ошибочны и почему?
}
Упражнение 8.2
К каким объявлениям относятся различные использования переменных ix и iy в следующем примере:
}
Поскольку невозможно определить член локального класса в области видимости пространства имен, то в таком классе не бывает статических членов.
Класс, вложенный в локальный, может быть определен вне определения объемлющего класса, но только в локальной области видимости, содержащей это определение. Имя вложенного класса в таком определении должно быть квалифицировано именем объемлющего класса. Объявление вложенного класса в объемлющем нельзя опускать:
}
У объемлющей функции нет никаких специальных прав доступа к закрытым членам локального класса. Разумеется, это можно обойти, объявив ее другом данного класса. Однако необходимость делать его члены закрытыми вообще сомнительна, поскольку часть программы, из которой разрешается обратиться к нему, весьма ограничена. Локальный класс инкапсулирован в своей локальной области видимости. Дальнейшая инкапсуляция путем сокрытия информации не требуется: вряд ли на практике найдется причина, по которой не все члены локального класса должны быть открыты.
У локального класса, как и у вложенного, ограничен доступ к именам из объемлющей области видимости. Он может обратиться только к именам типов, статических переменных и элементов перечислений, определенных в объемлющих локальных областях. Например:
}
Имена в теле локального класса разрешаются лексически путем поиска в объемлющих областях видимости объявлений, предшествующих определению такого класса. При разрешении имен, встречающихся в телах его функций-членов, сначала просматривается область видимости класса, а только потом – объемлющие области,
Как всегда, если первое найденное объявление таково, что употребление имени оказывается некорректным, поиск других объявлений не производится. Несмотря на то что использование val в fooBar()
выше является ошибкой, глобальная переменная val не будет найдена, если только ее имени не предшествует оператор разрешения глобальной области видимости.
Account table[ 16 ];
определяет массив из 16 объектов Account. Каждый элемент по очереди инициализируется конструктором по умолчанию. Можно и явно передать конструкторам аргументы внутри заключенного в фигурные скобки списка инициализации массива. Строка:
Account pooh_pals[] = { "Piglet", "Eeyore", "Tigger" };
определяет массив из трех элементов, инициализируемых конструкторами:
Account( "Tigger", 0.0 ); // третий элемент (Тигра)
Один аргумент можно задать явно, как в примере выше. Если же необходимо передать несколько аргументов, то придется воспользоваться явным вызовом конструктора:
};
Чтобы включить в список инициализации массива конструктор по умолчанию, мы употребляем явный вызов с пустым списком параметров:
};
Эквивалентный массив из трех элементов можно объявить и так:
};
Таким образом, члены списка инициализации последовательно используются для заполнения очередного элемента массива. Те элементы, для которых явные аргументы не заданы, инициализируются конструктором по умолчанию. Если его нет, то в списке должны быть заданы аргументы конструктора для каждого элемента массива.
Доступ к отдельным элементам массива объектов производится с помощью оператора взятия индекса, как и для массива элементов любого из встроенных типов. Например:
pooh_pals[0];
обращается к Piglet, а
pooh_pals[1];
к Eeyore и т.д. Для доступа к членам объекта, находящегося в некотором элементе массива, мы сочетаем операторы взятия индекса и доступа к членам:
pooh_pals[1]._name != pooh_pals[2]._name;
Не существует способа явно указать начальные значения элементов массива, память для которого выделена из хипа. Если класс поддерживает создание динамических массивов с помощью оператора new, он должен либо иметь конструктор по умолчанию, либо не иметь никаких конструкторов. На практике почти у всех классов есть такой конструктор.
Объявление
Account *pact = new Account[ 10 ];
создает в памяти, выделенной из хипа, массив из десяти объектов класса Account, причем каждый инициализируется конструктором по умолчанию.
Чтобы уничтожить массив, адресованный указателем pact, необходимо применить оператор delete. Однако написать
delete pact;
недостаточно, так как pact при этом не идентифицируется как массив объектов. В результате деструктор класса Account
применяется лишь к первому элементу массива. Чтобы применить его к каждому элементу, мы должны включить пустую пару скобок между оператором delete и адресом удаляемого объекта:
delete [] pact;
Пустая пара скобок говорит о том, что pact
адресует именно массив. Компилятор определяет, сколько в нем элементов, и применяет деструктор к каждому из них.
int (*testCases[10])();
testCases – это массив из десяти элементов, каждый из которых является указателем на функцию, возвращающую значение типа int и не имеющую параметров.
Подобные объявления трудно читать, поскольку не сразу видно, с какой частью ассоциируется тип функции.
В этом случае помогает использование имен, определенных с помощью директивы typedef:
PFV testCases[10];
Данное объявление эквивалентно предыдущему.
Вызов функций, адресуемых элементами массива testCases, выглядит следующим образом:
}
Массив указателей на функции может быть инициализирован списком, каждый элемент которого является функцией. Например:
};
Можно объявить и указатель на compareFuncs, его типом будет “указатель на массив указателей на функции”:
PFI2S (*pfCompare)[2] = compareFuncs;
Это объявление раскладывается на составные части следующим образом:
(*pfCompare)
Оператор разыменования говорит, что pfCompare является указателем. [2]
сообщает о количестве элементов массива:
(*pfCompare) [2]
PFI2S – имя, определенное с помощью директивы typedef, называет тип элементов. Это “указатель на функцию, возвращающую int и имеющую два параметра типа const string &”. Тип элемента массива тот же, что и выражения &lexicoCompare.
Такой тип имеет и первый элемент массива compareFuncs, который может быть получен с помощью любого из выражений:
(*pfCompare)[ 0 ];
Чтобы вызвать функцию lexicoCompare
через pfCompare, нужно написать одну из следующих инструкций:
((*pfCompare)[ 0 ])( string1, string2 ); // явная форма
int ia[ 4 ][ 3 ];
Первая величина (4) задает количество строк, вторая (3)– количество столбцов. Объект ia
определен как массив из четырех строк по три элемента в каждой. Многомерные массивы тоже могут быть инициализированы:
};
Внутренние фигурные скобки, разбивающие список значений на строки, необязательны и используются, как правило, для удобства чтения кода. Приведенная ниже инициализация в точности соответствует предыдущему примеру, хотя менее понятна:
int ia[4][3] = { 0,1,2,3,4,5,6,7,8,9,10,11 };
Следующее определение инициализирует только первые элементы каждой строки. Оставшиеся элементы будут равны нулю:
int ia[ 4 ][ 3 ] = { {0}, {3}, {6}, {9} };
Если же опустить внутренние фигурные скобки, результат окажется совершенно иным. Все три элемента первой строки и первый элемент второй получат указанное значение, а остальные будут неявно инициализированы 0.
int ia[ 4 ][ 3 ] = { 0, 3, 6, 9 };
При обращении к элементам многомерного массива необходимо использовать индексы для каждого измерения (они заключаются в квадратные скобки). Так выглядит инициализация двумерного массива с помощью вложенных циклов:
}
Конструкция
ia[ 1, 2 ]
является допустимой с точки зрения синтаксиса С++, однако означает совсем не то, чего ждет неопытный программист. Это отнюдь не объявление двумерного массива 1 на 2. Агрегат в квадратных скобках – это список выражений через запятую, результатом которого будет последнее значение 2 (см. оператор “запятая” в разделе 4.2). Поэтому объявление ia[1,2] эквивалентно ia[2]. Это еще одна возможность допустить ошибку.
void foo( ... );
Первый формат предоставляет объявления для части параметров. В этом случае проверка типов для объявленных параметров производится, а для оставшихся фактических аргументов – нет. Запятая после объявления известных параметров необязательна.
Примером вынужденного использования многоточия служит функция printf()
стандартной библиотеки С. Ее первый параметр является C-строкой:
int printf( const char* ... );
Это гарантирует, что при любом вызове printf() ей будет передан первый аргумент типа const char*. Содержание такой строки, называемой форматной, определяет, необходимы ли дополнительные аргументы при вызове. При наличии в строке формата метасимволов, начинающихся с символа %, функция ждет присутствия этих аргументов. Например, вызов
printf( "hello, world\n" );
имеет один строковый аргумент. Но
printf( "hello, %s\n", userName );
имеет два аргумента. Символ %
говорит о наличии второго аргумента, а буква s, следующая за ним, определяет его тип – в данном случае символьную строку.
Большинство функций с многоточием в объявлении получают информацию о типах и количестве фактических параметров по значению явно объявленного параметра. Следовательно, первый формат многоточия употребляется чаще.
Отметим, что следующие объявления неэквивалентны:
void f( ... );
В первом случае f()
объявлена как функция без параметров, во втором – как имеющая ноль или более параметров. Вызовы
f( cnt, a, b, с );
корректны только для второго объявления. Вызов
f();
применим к любой из двух функций.
Упражнение 7.4
Какие из следующих объявлений содержат ошибки? Объясните.
(c) void operate( int *matrix[] );
(e) void putValues( int (&ia)[] );
Упражнение 7.5
Повторные объявления всех приведенных ниже функций содержат ошибки. Найдите их.
void manip( int *pi, int first = 0, int end = 0 );
Упражнение 7.6
Даны объявления функций.
char background = ' ' );
Вызовы этих функций содержат ошибки. Найдите их и объясните.
print( arr, 5 );
Упражнение 7.7
Перепишите функцию putValues( vector ), приведенную в подразделе 7.3.4, так, чтобы она работала с контейнером list. Печатайте по одному значению на строке. Вот пример вывода для списка из двух строк:
( 2 )
<
"first string"
"second string"
>
Напишите функцию main(), вызывающую новый вариант putValues() со следующим списком строк:
"use less than eight parameters"
Упражнение 7.8
В каком случае вы применили бы параметр-указатель? А в каком – параметр-ссылку? Опишите достоинства и недостатки каждого способа.
C++, – основная тема настоящей главы.
class Bear : public ZooAnimal { ... };
расширяется: допускается наличие нескольких базовых классов, разделенных запятыми:
class Panda : public Bear, public Endangered { ... };
Для каждого из перечисленных базовых классов должен быть указан уровень доступа: public, protected или private. Как и при одиночном наследовании, множественно наследовать можно только классу, определение которого уже встречалось ранее.
Язык не накладывает никаких ограничений на число базовых классов, которым может наследовать производный. На практике чаще всего встречается два класса, один из которых представляет открытый абстрактный интерфейс, а второй – закрытую реализацию (хотя ни один из рассмотренных выше примеров этой модели не следует). Производные классы, наследующие от трех или более базовых, – это пример такого стиля проектирования, когда каждый базовый класс представляет одну грань полного интерфейса производного.
В случае множественного наследования объект производного класса содержит по одному подобъекту каждого из своих базовых (см. раздел 17.3). Например, когда мы пишем
Panda ying_yang;
то объект ying_yang
будет состоять из подобъекта класса Bear (который в свою очередь содержит подобъект ZooAnimal), подобъекта Endangered и нестатических членов, объявленных в самом классе Panda, если таковые есть (см. рис. 18.3).
ZooAnimal Endangered
Bear
Panda
Рис. 18.3. Иерархия множественного наследования класса Panda
Конструкторы базовых классов вызываются в порядке объявления в списке базовых классов. Например, для ying_yang эта последовательность такова: конструктор Bear (но поскольку класс Bear – производный от ZooAnimal, то сначала вызывается конструктор ZooAnimal), затем конструктор Endangered и в самом конце конструктор Panda.
Как отмечалось в разделе 17.4, на порядок вызова не
влияет ни наличие базовых классов в списке инициализации членов, ни порядок их перечисления. Иными словами, если бы конструктор Bear
вызывался неявно и потому не был бы упомянут в списке инициализации членов, как в следующем примере:
{ ... }
то все равно конструктор по умолчанию Bear был бы вызван раньше, чем явно заданный в списке конструктор класса Endangered с двумя аргументами.
Порядок вызова деструкторов всегда противоположен порядку вызова конструкторов. В нашем примере деструкторы вызываются в такой последовательности: ~Panda(), ~Endangered(), ~Bear(), ~ZooAnimal().
В разделе 17.3 уже говорилось, что в случае одиночного наследования к открытым и защищенным членам базового класса можно обращаться напрямую (не квалифицируя имя члена именем его класса), как если бы они были членами производного класса. То же самое справедливо и для множественного наследования. Однако при этом можно унаследовать одноименные члены из двух или более базовых классов. В таком случае прямое обращение оказывается неоднозначным и приводит к ошибке компиляции.
Однако такую ошибку вызывает не потенциальная
неоднозначность неквалифицированного доступа к одному из двух одноименных членов, а лишь попытка фактического обращения к нему (см. раздел 17.4). Например, если в обоих классах Bear и Endangered определена функция-член print(), то инструкция
ying_yang.print( cout );
приводит к ошибке компиляции, даже если у двух унаследованных функций-членов разные списки параметров.
<
Endangered::print( ostream&, int )
Endangered::print( ostream&, int )
Причина в том, что унаследованные функции- члены не образуют множество перегруженных функций внутри производного класса (см. раздел 17.3). Поэтому print()
разрешается только по имени, а не по типам фактических аргументов. (О том, как производится разрешение, мы поговорим в разделе 18.4.)
В случае одиночного наследования указатель, ссылка или объект производного класса при необходимости автоматически преобразуются в указатель, ссылку или объект базового класса, которому открыто наследует производный. Это остается верным и для множественного наследования. Так, указатель, ссылку или сам объект класса Panda
можно преобразовать в указатель, ссылку или объект ZooAnimal, Bear или Endangered:
cout << ying_yang << endl; // правильно
Однако вероятность неоднозначных преобразований при множественном наследовании намного выше. Рассмотрим, к примеру, две функции:
extern void display( const Endangered& );
Неквалифицированный вызов display() для объекта класса Panda
display( ying_yang ); // ошибка: неоднозначность
приводит к ошибке компиляции:
Error: display( ying_yang ) -- ambiguous, one of
display( const Bear& );
display( const Endangered& );
Ошибка: display( ying_yang ) -- неоднозначно, одна из
display( const Bear& );
display( const Endangered& );
Компилятор не может различить два непосредственных базовых класса с точки зрения преобразования производного. Равным образом применимы обе трансформации. (Мы покажем способ разрешения этого конфликта в разделе 18.4.)
Чтобы понять, какое влияние оказывает множественное наследование на механизм виртуальных функций, определим их набор в каждом из непосредственных базовых классов Panda. (Виртуальные функции введены в разделе 17.2 и подробно обсуждались в разделе 17.5.)
};
Теперь определим в классе Panda
собственный экземпляр print(), собственный деструктор и еще одну виртуальную функцию cuddle():
};
Множество виртуальных функций, которые можно напрямую вызывать для объекта Panda, представлено в табл. 18.1.
Таблица 18.1. Виртуальные функции для класса Panda
Когда ссылка или указатель на объект Bear или ZooAnimal
инициализируется адресом объекта Panda или ему присваивается такой адрес, то части интерфейса, связанные с классами Panda и Endangered, становятся недоступны:
<
delete pb; // правильно: Panda::~Panda()
( Обратите внимание, что если бы объекту класса Panda был присвоен указатель на ZooAnimal, то все показанные выше вызовы разрешались бы так же.)
Аналогично, если ссылка или указатель на объект Endangered инициализируется адресом объекта Panda или ему присваивается такой адрес, то части интерфейса, связанные с классами Panda и Bear, становятся недоступными:
delete pe; // правильно: Panda::~Panda()
Обработка виртуального деструктора выполняется правильно независимо от типа указателя, через который мы уничтожаем объект. Например, во всех четырех инструкциях порядок вызова деструкторов один и тот же – обратный порядку вызова конструкторов:
delete pe;
Деструктор класса Panda
вызывается с помощью механизма виртуализации. После его выполнения по очереди статически вызываются деструкторы Endangered и Bear, а в самом конце – ZooAnimal.
Почленная инициализация и присваивание объекту производного класса, наследующего нескольким базовым, ведут себя точно так же, как и при одиночном наследовании (см. раздел 17.6). Например, для нашего объявления класса Panda
{ ... };
в результате почленной инициализации объекта ling_ling
Panda ling_ling = yin_yang;
вызывается копирующий конструктор класса Bear (но, так как Bear
производный от ZooAnimal, сначала выполняется копирующий конструктор класса ZooAnimal), затем – класса Endangered и только потом – класса Panda. Почленное присваивание ведет себя аналогично.
Упражнение 18.1
Какие из следующих объявлений ошибочны? Почему?
(a) class CADVehicle : public CAD, Vehicle { ... };
public List, public List { ... };
private istream, private ostream { ... };
Упражнение 18.2
Дана иерархия, в каждом классе которой определен конструктор по умолчанию:
class MI : public C, public Z { ... };
Каков порядок вызова конструкторов в таком определении:
MI mi;
Упражнение 18.3
Дана иерархия, в каждом классе которой определен конструктор по умолчанию:
class D : public X, public C { ... };
Какие из следующих преобразований недопустимы:
D *pd = new D;
(a) X *px = pd; (c) B *pb = pd;
(b) A *pa = pd; (d) C *pc = pd;
Упражнение 18.4
Дана иерархия классов, обладающая приведенным ниже набором виртуальных функций:
};
Какой экземпляр виртуальной функции вызывается в каждом из следующих случаев:
(b) pb->debug(); (d) pb->writeOn(); (f) delete pb;
Упражнение 18.5
На примере иерархии классов из упражнения 18.4 определите, какие виртуальные функции активны при вызове через pd1 и pd2:
(a) Derived1 *pd1 new MI;
Derived2 d2 = obj;
}
Этот заголовочный файл включается в каждый файл, где конкретизируется функция min():
double dobj = min( i, j );
Заголовочный файл можно включить в несколько файлов с исходными текстами программы. Означает ли это, что компилятор конкретизирует экземпляр функции min() с целыми параметрами в каждом файле, где имеется обращение к ней? Нет. Программа должна вести себя так, словно min() с целыми параметрами определена только один раз. Где и когда в действительности конкретизируется шаблон функции, оставляется на усмотрение разработчика компилятора. Нам достаточно знать, что где-то в программе нужная функция min() была конкретизирована. (Как мы покажем далее, с помощью явного объявления конкретизации можно указать, где и когда оно должно быть выполнено. Такие объявления желательно использовать на поздних стадиях разработки продукта для улучшения производительности.)
Решение включать определения шаблонов функций в заголовочные файлы не всегда удачно. Тело шаблона описывает детали реализации, которые пользователям не интересны или которые мы хотели бы от них скрыть. В действительности, если определение шаблона велико, то количество кода в заголовочном файле может превысить разумные пределы. Кроме того, многократная компиляция одного и того же определения при обработке разных файлов увеличивает общее время компиляции программы. Отделить объявления шаблонов функций от их определений позволяет модель компиляции с разделением. Посмотрим, как ее можно использовать.
В этой модели мы включаем определения функций-членов и статических членов шаблонов классов в каждый файл, где они конкретизируются. Для встроенных функций-членов, определенных в теле шаблона, это происходит автоматически. В противном случае такое определение следует поместить в один заголовочный файл с определением шаблона класса. Именно этой моделью мы и пользуемся в настоящей книге. Например, определения шаблонов Queue и QueueItem, как и их функций-членов и статических членов, находятся в заголовочном файле Queue.h.
Подобное размещение не лишено недостатков: определения функций-членов могут быть довольно большими и содержать детали реализации, которые неинтересны пользователям или должны быть скрыты от них. Кроме того, многократная компиляция одного определения шаблона при обработке разных файлов увеличивает общее время компиляции программы. Описанная модель (если она доступна) позволяет отделить интерфейс шаблона от реализации (т.е. от определений функций-членов и статических данных-членов).
}
он сохраняет внутреннее представление min(), но и только. Позже, когда встретится ее реальное использование, скажем:
double dobj = min( i, j );
компилятор строит определение min() по сохраненному внутреннему представлению.
Здесь возникает несколько вопросов. Чтобы компилятор мог конкретизировать шаблон функции, должно ли его определение быть видимо при вызове экземпляра этой функции? Например, нужно ли определению шаблона min() появиться до ее конкретизации c целыми параметрами при инициализации dobj? Следует ли помещать шаблоны в заголовочные файлы, как мы поступаем с определениями встроенных (inline) функций? Или в заголовочные файлы можно помещать только объявления шаблонов, оставляя определения в файлах исходных текстов?
Чтобы ответить на эти вопросы, нам придется объяснить принятую в C++ модель компиляции шаблонов, сформулировать требования к организации определений и объявлений шаблонов в программах. В C++ поддерживаются две таких модели: модель с включением и модель с разделением. В данном разделе описываются обе модели и объясняется их использование.
· преобразование аргумента типа производного класса в параметр типа любого из его базовых;
· преобразование указателя на тип производного класса в указатель на тип любого из его базовых;
· инициализация ссылки на тип базового класса с помощью l-значения типа производного.
Они не являются пользовательскими, так как не зависят от конвертеров и конструкторов, имеющихся в классе:
release( yinYang );
Поскольку аргумент yinYang
типа Panda
инициализирует ссылку на тип базового класса, то преобразование имеет ранг стандартного.
В разделе 15.10 мы говорили, что стандартные преобразования имеют более высокий ранг, чем пользовательские:
release( yinYang );
Как release(const char*), так и release(ZooAnimal&)
являются устоявшими функциями: первая потому, что инициализация параметра-ссылки значением аргумента – стандартное преобразование, а вторая потому, что аргумент можно привести к типу const char* с помощью конвертера ZooAnimal::operator const char*(), который представляет собой пользовательское преобразование. Так как стандартное преобразование лучше пользовательского, то в качестве наилучшей из устоявших выбирается функция release(const ZooAnimal&).
При ранжировании различных стандартных преобразований из производного класса в базовые лучшим считается приведение к тому базовому классу, который ближе к производному. Так, показанный ниже вызов не будет неоднозначным, хотя в обоих случаях требуется стандартное преобразование. Приведение к базовому классу Bear
лучше, чем к ZooAnimal, поскольку Bear
ближе к классу Panda. Поэтому лучшей из устоявших будет функция release(const Bear&):
release( yinYang );
Аналогичное правило применимо и к указателям. При ранжировании стандартных преобразований из указателя на тип производного класса в указатели на типы различных базовых лучшим считается то, для которого базовый класс наименее удален от производного. Это правило распространяется и на тип void*.
Стандартное преобразование в указатель на тип любого базового класса всегда лучше, чем преобразование в void*. Например, если дана пара перегруженных функций:
void receive( ZooAnimal* );
то наилучшей из устоявших для вызова с аргументом типа Panda* будет receive(ZooAnimal*).
В случае множественного наследования два стандартных преобразования из типа производного класса в разные типы базовых могут иметь одинаковый ранг, если оба базовых класса равноудалены от производного. Например, Panda
наследует классам Bear и Endangered. Поскольку они равноудалены от производного Panda, то преобразования объекта Panda в любой из этих классов одинаково хороши. Но тогда единственной наилучшей из устоявших функции для следующего вызова не существует, и он считается ошибочным:
mumble( yinYang );
Для разрешения неоднозначности программист может применить явное приведение типа:
mumble( static_cast< Bear >( yinYang ) ); // правильно
Инициализация объекта производного класса или ссылки на него объектом типа базового, а также преобразование указателя на тип базового класса в указатель на тип производного никогда не выполняются компилятором неявно. (Однако их можно выполнить с помощью явного применения dynamic_cast, как мы видели в разделе 19.1.) Для данного вызова не существует наилучшей из устоявших функции, так как нет неявного преобразования аргумента типа ZooAnimal в тип производного класса:
release( za );
В следующем примере наилучшей из устоявших будет release(const char*). Это может показаться удивительным, так как к аргументу применена последовательность пользовательских преобразований, в которой участвует конвертер const char*(). Но поскольку неявного приведения от типа базового класса к типу производного не существует, то release(const Bear&) не является устоявшей функцией, так что остается только release(const char*):
release( za );Ошибка! Закладка не определена.Ошибка! Закладка не определена.Ошибка! Закладка не определена.
Упражнение 19.9
Дана такая иерархия классов:
<
};
Какие функции входят в множество кандидатов для каждого из следующих вызовов:
(b) pi->debug(); (d) pi->reset(0); (f) pi->writeOn();
Упражнение 19.10
Дана такая иерархия классов:
};
Удастся ли выбрать наилучшую из устоявших функций для каждого из следующих вызовов? Назовите кандидаты, устоявшие функции и преобразования типов аргументов для каждой из них, наилучшую из устоявших (если она есть):
operate( *pd );
operate( *pb );
20
·
преобразования, примененные к аргументам, не хуже
преобразований, необходимых для вызова любой другой устоявшей функции;
· хотя бы для одного аргумента примененное преобразование лучше, чем для того же аргумента в любой другой устоявшей функции.
Может оказаться так, что для приведения фактического аргумента к типу соответствующего формального параметра нужно выполнить несколько преобразований. Так, в следующем примере
}
для приведения аргумента arr от типа “массив из трех int” к типу “указатель на const int” применяется последовательность преобразований:
1. Преобразование массива в указатель, которое трансформирует массив из трех int в указатель на int.
2. Преобразование спецификатора, которое трансформирует указатель на int в указатель на const int.
Поэтому было бы более правильно говорить, что для приведения фактического аргумента к типу формального параметра устоявшей функции требуется последовательность преобразований. Поскольку применяется не одна, а несколько трансформаций, то на третьем шаге процесса разрешения перегрузки функции на самом деле ранжируются последовательности преобразований.
Рангом такой последовательности считается ранг самой плохой из входящих в нее трансформаций. Как объяснялось в разделе 9.2, преобразования типов ранжируются следующим образом: точное соответствие лучше расширения типа, а расширение типа лучше стандартного преобразования. В предыдущем примере оба изменения имеют ранг точного соответствия. Поэтому и у всей последовательности такой же ранг.
Такая совокупность состоит из нескольких преобразований, применяемых в указанном порядке:
преобразование l-значения ->
расширение типа или стандартное преобразование ->
преобразование спецификаторов
Термин преобразование l-значения
относится к первым трем трансформациям из категории точных соответствий, рассмотренных в разделе 9.2: преобразование l-значения в r-значение, преобразование массива в указатель и преобразование функции в указатель. Последовательность трансформаций состоит из нуля или одного преобразования l-значения, за которым следует нуль или одно расширение типа или стандартное преобразование, и наконец нуль или одно преобразование спецификаторов. Для приведения фактического аргумента к типу формального параметра может быть применено только одна трансформация каждого вида.
Описанная последовательность называется последовательностью стандартных преобразований. Существует также последовательность определенных пользователем преобразований, которая связана с функцией-конвертером, являющейся членом класса. (Конвертеры и последовательности определенных пользователем преобразований рассматриваются в главе 15.)
Каковы последовательности изменений фактических аргументов в следующем примере?
}
Аргументы в вызове функции max()
имеют тип char. Последовательность преобразований аргументов при вызове функции libs_R_us::max(int,int)
следующая:
1a. Так как аргументы передаются по значению, то с помощью преобразования l-значения в r-значение извлекаются значения аргументов c1 и c2.
2a. С помощью расширения типа аргументы трансформируются из char в int.
Последовательность преобразований аргументов при вызове функции libs_R_us::max(double,double)
следующая:
1b. С помощью преобразования l-значения в r-значение извлекаются значения аргументов c1 и c2.
2b. Стандартное преобразование между целым и плавающим типом приводит аргументы от типа char к типу double.
Ранг первой последовательности – расширение типа (самое худшее из примененных изменений), тогда как ранг второй – стандартное преобразование. Так как расширение типа лучше, чем преобразование, то в качестве наилучшей из устоявших для данного вызова выбирается функция libs_R_us::max(int,int).
Если ранжирование последовательностей преобразований аргументов не может выявить единственной устоявшей функции, то вызов считается неоднозначным. В данном примере для обоих вызовов calc()
требуется такая последовательность:
1. Преобразование l-значения в r-значение для извлечения значений аргументов i и j.
2. Стандартное преобразование для приведения типов фактических аргументов к типам соответствующих формальных параметров.
Поскольку нельзя сказать, какая из этих последовательностей лучше другой, вызов неоднозначен:
}
Преобразование спецификаторов (добавление спецификатора const или volatile к типу, который адресует указатель) имеет ранг точного соответствия. Однако, если две последовательности трансформаций отличаются только тем, что в конце одной из них есть дополнительное преобразование спецификаторов, то последовательность без него считается лучше. Например:
}
Последовательность стандартных преобразований, примененная к фактическому аргументу для первой функции-кандидата reset(int*), – это точное соответствие, требуется лишь переход от l-значения к r-значению, чтобы извлечь значение аргумента. Для второй функции-кандидата reset(const int *)
также применяется трансформация l-значения в r-значение, но за ней следует еще и преобразование спецификаторов для приведения результирующего значения от типа “указатель на int” к типу “указатель на const int”. Обе последовательности представляют собой точное соответствие, но неоднозначности при этом не возникает. Так как вторая последовательность отличается от первой наличием трансформации спецификаторов в конце, то последовательность без такого преобразования считается лучшей. Поэтому наилучшей из устоявших функций будет reset(int*).
Вот еще пример, в котором приведение спецификаторов влияет на то, какая последовательность будет выбрана:
}
Здесь для вызова есть две устоявших функции: extract(void*) и extract(const void*). Последовательность преобразований для функции extract(void*)
состоит из трансформации l-значения в r-значение для извлечения значения аргумента, сопровождаемого стандартным преобразованием указателя: из указателя на int в указатель на void. Для функции extract(const void*)
такая последовательность отличается от первой дополнительным преобразованием спецификаторов для приведения типа результата от указателя на void к указателю на const void. Поскольку последовательности различаются лишь этой трансформацией, то первая выбирается как более подходящая и, следовательно, наилучшей из устоявших будет функция extract(const void*).
Спецификаторы const и volatile
влияют также на ранжирование инициализации параметров-ссылок. Если две такие инициализации отличаются только добавлением спецификатора const и volatile, то инициализация без дополнительной спецификации считается лучшей при разрешении перегрузки:
<
}
В первом вызове инициализация ссылок для вызова любой функции является точным соответствием. Но этот вызов все же не будет неоднозначным. Так как обе инициализации одинаковы во всем, кроме наличия дополнительной спецификации const во втором случае, то инициализация без такой спецификации считается лучше, поэтому перегрузка будет разрешена в пользу устоявшей функции manip(vector&).
Для второго вызова существует только одна устоявшая функция manip(const vector&). Поскольку фактический аргумент является временной переменной, содержащей результат, возвращенный f(), то такой аргумент представляет собой r-значение, которое нельзя использовать для инициализации неконстантного формального параметра-ссылки функции manip(vector&). Поэтому наилучшей является единственная устоявшая manip(const vector&).
Разумеется, у функций может быть несколько фактических аргументов. Выбор наилучшей из устоявших должен производиться с учетом ранжирования последовательностей преобразований всех аргументов. Рассмотрим пример:
}
Функция ff(), принимающая два аргумента типа int, выбирается в качестве наилучшей из устоявших по следующим причинам:
1. ее первый аргумент лучше. 0
дает точное соответствие с формальным параметром типа int, тогда как для установления соответствия с параметром типа char *
требуется стандартное преобразование указателя;
2. ее второй аргумент имеет тот же ранг. К аргументу 'a'
типа char для установления соответствия со вторым формальным параметром любой из двух функций должна быть применена последовательность преобразований, имеющая ранг расширения типа.
Вот еще один пример:
}
Обе функции compute( const int&, short ) и compute( int&, double )
устояли. Вторая выбирается в качестве наилучшей по следующим причинам:
1. ее первый аргумент лучше. Инициализация ссылки для первой устоявшей функции хуже потому, что она требует добавления спецификатора const, не нужного для второй функции;
2. ее второй аргумент имеет тот же ранг. К аргументу 'c'
типа char для установления соответствия со вторым формальным параметром любой из двух функций должна быть применена последовательность трансформаций, имеющая ранг стандартного преобразования.
Открытое наследование – это мощный механизм для поддержки отношения “ЯВЛЯЕТСЯ”. Однако реализация PeekbackStack по отношению к IntArray – пример отношения “СОДЕРЖИТ”. Класс PeekbackStack содержит класс IntArray как часть своей реализации. Отношение “СОДЕРЖИТ”, как правило, лучше поддерживается с помощью композиции, а не наследования. Для ее реализации надо один класс сделать членом другого. В нашем случае объект IntArray
делается членом PeekbackStack. Вот реализация PeekbackStack на основе композиции:
}
Решая, следует ли использовать при проектировании класса с отношением “СОДЕРЖИТ” композицию или закрытое наследование, можно руководствоваться такими соображениями:
· если мы хотим заместить какие-либо виртуальные функции базового класса, то должны закрыто наследовать ему;
· если мы хотим разрешить нашему классу ссылаться на класс из иерархии типов, то должны использовать композицию по ссылке (мы подробно расскажем о ней в разделе 18.3.4);
· если, как в случае с классом PeekbackStack, мы хотим воспользоваться готовой реализацией, то композиция по значению предпочтительнее наследования. Если требуется отложенное выделение памяти для объекта, то следует выбрать композицию по ссылке (с помощью указателя).
Программа для запуска нашей системы текстового поиска будет выглядеть следующим образом:
}
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] Результат исполнения этого запроса приведен в начале данного раздела. Кроме того, система не должна многократно отображать одну и ту же строку.
}
Если наше отображение не содержит элементов, данная функция не нужна. Проверить, пусто ли оно, можно с помощью функции-члена size():
display_map_text( text_map );
Но более простым способом, без подсчета элементов, будет вызов функции-члена empty():
display_map_text( text_map );
tomorrow and tomorrow and tomorrow
однако такая строка будет представлена только один раз.
Одним из способов не учитывать повторное вхождение слова в строку является использование множества, как показано в следующем фрагменте кода:
}
Контейнер set не допускает дублирования ключей. Поэтому можно гарантировать, что occurrence_lines не содержит повторений. Теперь нам достаточно перебрать данное множество, чтобы показать все номера строк, где встретилось данное слово:
}
(Полная реализация 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
Определите вектор, содержащий названия книг, которые вы собираетесь прочесть в ближайшие шесть виртуальных месяцев, и множество, включающее названия уже прочитанных произведений. Напишите программу, которая выбирает для вас книгу из вектора при условии, что вы ее еще не прочитали. Выбранное название программа должна заносить в множество прочитанных. Однако вы могли отложить книгу; следовательно, нужно обеспечить возможность удалять ее название из множества прочитанных. По окончании шести виртуальных месяцев распечатайте список прочитанного и непрочитанного.
· арифметическое выражение с операндами разных типов: все операнды приводятся к наибольшему типу из встретившихся. Это называется арифметическим преобразованием. Например:
ival + dva1;
· присваивание значения выражения одного типа объекту другого типа. В этом случае результирующим является тип объекта, которому значение присваивается. Так, в первом примере литерал 0
типа int
присваивается указателю типа int*, значением которого будет 0. Во втором примере double
преобразуется в int.
ivat = dva1;
· передача функции аргумента, тип которого отличается от типа соответствующего формального параметра. Тип фактического аргумента приводится к типу параметра:
cout << "Квадратный корень из 2: " << sqrt( 2 ) << endt;
· возврат из функции значения, тип которого не совпадает с типом возвращаемого результата, заданным в объявлении функции. Тип фактически возвращаемого значения приводится к объявленному. Например:
}
}
У объекта myScreen
есть свои члены _width, _height, _cursor и _screen, а у объекта bufScreen – свои. Однако каждая функция-член класса существует в единственном экземпляре. Их и вызывают myScreen и bufScreen.
В предыдущем разделе мы видели, что функция-член может обращаться к членам своего класса, не используя операторы доступа. Так, определение функции move()
выглядит следующим образом:
}
Если функция 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. Изменить определение функции-члена класса, добавив дополнительный параметр:
}
В этом определении использование указателя this для доступа к членам _width и _cursor
сделано явным.
2. Изменение каждого вызова функции-члена класса с целью передачи одного дополнительного аргумента – адреса объекта, для которого она вызвана:
myScreen.move( 2, 2 );
транслируется в
move( &myScreen, 2, 2 );
Программист может явно обращаться к указателю this внутри функции. Так, вполне корректно, хотя и излишне, определить функцию-член home()
следующим образом:
}
Однако бывают случаи, когда без такого обращения не обойтись, как мы видели на примере функции-члена copy()
класса Screen. В следующем подразделе мы рассмотрим и другие примеры.
В С++ есть два типа комментариев. Один– такой же, как и в С, использующий символы /* для обозначения начала и */ для обозначения конца комментария. Между этими парами символов может находиться любой текст, занимающий одну или несколько строк: вся последовательность между /* и */
считается комментарием. Например:
}
Слишком большое число комментариев, перемежающихся с кодом программы, может ухудшить читаемость текста. Например, объявления переменных width и height в данном тексте окружены комментариями и почти не заметны. Рекомендуется писать развернутое объяснение перед блоком текста. Как и любая программная документация, комментарии должны обновляться в процессе модификации кода. Увы, нередко случается, что они относятся к устаревшей версии.
Комментарии в стиле С не могут быть вложенными. Попробуйте откомпилировать нижеследующую программу в своей системе. Большинство компиляторов посчитают ее ошибочной:
}
Один из способов решить проблему вложенных комментариев – поставить пробел между звездочкой и косой чертой:
/* * /
Последовательность символов */
считается концом комментария только в том случае, если между ними нет пробела.
Второй тип комментариев – однострочный. Он начинается последовательностью символов // и ограничен концом строки. Часть строки вправо от двух косых черт игнорируется компилятором. Вот пример нашего класса Screen с использованием двух строчных комментариев:
}
Обычно в программе употребляют сразу оба типа комментариев. Строчные комментарии удобны для кратких пояснений – в одну или полстроки, а комментарии, ограниченные /* и */, лучше подходят для развернутых многострочных пояснений.
};
и такое использование оператора operator==:
}
Тогда при сравнении
flower == pf
вызывается оператор равенства класса String:
String::operator==( const String & ) const;
Для трансформации правого операнда pf из типа const char* в тип String
параметра operator==()
применяется определенное пользователем преобразование, которое вызывает конструктор:
String( const char * )
Если добавить в определение класса String
конвертер в тип 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()? Перечислите все функции-кандидаты, все устоявшие функции и преобразования типов, которые надо применить к аргументам для каждой устоявшей функции.
}
16
заголовочный файл.
Такие файлы позволяют добиться двух целей. Во-первых, гарантируется, что все исходные файлы содержат одно и то же объявление для глобального объекта или функции. Во-вторых, при необходимости изменить объявление это изменение делается в одном месте, что исключает возможность забыть внести правку в какой-то из исходных файлов.
Пример с addToken()
имеет следующий заголовочный файл:
// ...
При проектировании заголовочных файлов нужно учитывать несколько моментов. Все объявления такого файла должны быть логически связанными. Если он слишком велик или содержит слишком много не связанных друг с другом элементов, программисты не станут включать его, экономя на времени компиляции. Для уменьшения временных затрат в некоторых реализациях С++ предусматривается использование предкомпилированных заголовочных файлов. В руководстве к компилятору сказано, как создать такой файл из обычного. Если в вашей программе используются большие заголовочные файлы, применение предкомпиляции может значительно сократить время обработки.
Чтобы это стало возможным, заголовочный файл не должен содержать объявлений встроенных (inline) функций и объектов. Любая из следующих инструкций является определением и, следовательно, не может быть использована в заголовочном файле:
<
extern void dummy () {}
Хотя переменная i
объявлена с ключевым словом extern, явная инициализация превращает ее объявление в определение. Точно так же и функция dummy(), несмотря на явное объявление как extern, определяется здесь же: пустые фигурные скобки содержат ее тело. Переменная fica_rate определяется и без явной инициализации: об этом говорит отсутствие ключевого слова extern.
Включение такого заголовочного файла в два или более исходных файла одной программы вызовет ошибку связывания – повторные определения объектов.
В файле token.h, приведенном выше, константа INLINE и встроенная функция is_relational()
кажутся нарушающими правило. Однако это не так.
Определения символических констант и встроенных функций являются специальными видами определений: те и другие могут появиться в программе несколько раз.
При возможности компилятор заменяет имя символической константы ее значением. Этот процесс называют подстановкой константы. Например, компилятор подставит 128 вместо INLINE
везде, где это имя встретится в исходном файле. Для того чтобы компилятор произвел такую замену, определение константы (значение, которым она инициализирована) должно быть видимо в том месте, где она используется. Определение символической константы может появиться несколько раз в разных файлах, потому что в результирующем исполняемом файле благодаря подстановке оно будет только одно.
В некоторых случаях, однако, такая подстановка невозможна. Тогда лучше вынести инициализацию константы в отдельный исходный файл. Это делается с помощью явного объявления константы как extern. Например:
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
Установите, какие из приведенных ниже инструкций являются объявлениями, а какие – определениями, и почему:
(e) void print( const matrix & );
Упражнение 8.4
Какие из приведенных ниже объявлений и определений вы поместили бы в заголовочный файл? В исходный файл? Почему?
(e) extern int total = 255;
Рассмотрим пример, иллюстрирующий использование объединения. Лексический анализатор, входящий в состав компилятора, разбивает программу на последовательность лексем. Так, инструкция
int i = 0;
преобразуется в последовательность из пяти лексем:
1. Ключевое слово int.
2. Идентификатор i.
3. Оператор =
4. Константа 0
типа int.
5. Точка с запятой.
Лексический анализатор передает эти лексемы синтаксическому анализатору, парсеру, который идентифицирует полученную последовательность. Полученная информация должна дать парсеру возможность распознать эту последовательность лексем как объявление. Для этого с каждой лексемой ассоциируется информация, позволяющая парсеру увидеть следующее:
(Тип ИД Присваивание Константа Точка с запятой)
Далее парсер анализирует значения каждой лексемы. В данном случае он видит:
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
имеет лишь один из множества возможных типов и, следовательно, будет задействован только один член класса, хотя памяти выделяется столько, сколько нужно для хранения всех членов. Чтобы память резервировалась только для нужного в данный момент члена, применяется объединение. Вот как оно определяется:
};
Если самым большим типом среди всех членов TokenValue является dval, то размер TokenValue
будет равен размеру объекта типа double. По умолчанию члены объединения открыты. Имя объединения можно использовать в программе всюду, где допустимо имя класса:
TokenValue *pt = new TokenValue;
Обращение к членам объединения, как и к членам класса, производится с помощью операторов доступа:
char ch = pt->_cval;
Члены объединения можно объявлять открытыми, закрытыми или защищенными:
}
У объединения не бывает статических членов или членов, являющихся ссылками. Его членом не может быть класс, имеющий конструктор, деструктор или копирующий оператор присваивания. Например:
};
Для объединения разрешается определять функции-члены, включая конструкторы и деструкторы:
}
Вот пример работы объединения TokenValue:
};
Объект типа Token
можно использовать так:
}
Опасность, связанная с применением объединения, заключается в том, что можно случайно извлечь хранящееся в нем значение, пользуясь не тем членом. Например, если в последний раз значение присваивалось _ival, то вряд ли понадобится значение, оказавшееся в _sval. Это, по всей вероятности, приведет к ошибке в программе.
Чтобы защититься от подобного рода ошибок, следует создать дополнительный объект, дискриминант объединения, определяющий тип значения, которое в данный момент хранится в объединении. В классе Token
роль такого объекта играет член tok:
idVal = curToken.val._sval;
При работе с объединением, являющимся членом класса, полезно иметь набор функций для каждого хранящегося в объединении типа данных:
}
Имя в определении объединения задавать необязательно. Если оно не используется в программе как имя типа для объявления других объектов, его можно опустить. Например, следующее определение объединения Token эквивалентно приведенному выше, но без указания имени:
};
Существует анонимное объединение – объединение без имени, за которым не следует определение объекта. Вот, например, определение класса Token, содержащее анонимное объединение:
};
К данным-членам анонимного объединения можно напрямую обращаться в той области видимости, в которой оно определено. Перепишем функцию lex(), используя предыдущее определение:
}
Анонимное объединение позволяет убрать один уровень доступа, поскольку обращение к его членам идет как к членам класса Token. У него не может быть закрытых или защищенных членов, а также функций-членов. Такое объединение, определенное в глобальной области видимости, должно быть объявлено в безымянном пространстве имен или иметь модификатор static.
С помощью наследования можно определить взаимосвязи между независимыми классами запросов. Для этого мы вводим в рассмотрение абстрактный класс Query, который будет служить для них базовым
(соответственно сами эти классы будут считаться производными). Абстрактный класс можно представить себе как неполный, который становится более или менее завершенным, когда из него порождаются производные классы, – в нашем случае AndQuery, OrQuery, NotQuery и NameQuery.
В нашем абстрактном классе Query
определены данные и функции-члены, общие для всех четырех типов запроса. При порождении из Query
производного класса, скажем AndQuery, мы выделяем уникальные характеристики каждого вида запроса. К примеру, NameQuery – это специальный вид Query, в котором операндом всегда является строка. Мы будем называть NameQuery производным и говорить, что Query
является его базовым классом. (То же самое относится и к классам, представляющим другие типы запросов.) Производный класс наследует данные и функции-члены базового и может обращаться к ним непосредственно, как к собственным членам.
Основное преимущество иерархии наследования в том, что мы программируем открытый интерфейс абстрактного базового класса, а не отдельных производных от него специализированных типов, что позволяет защитить наш код от последующих изменений иерархии. Например, мы определяем eval() как открытую виртуальную функцию абстрактного базового класса Query. Пользовательский код, записанный в виде:
_rop->eval();
экранирован от любых изменений в языке запросов. Это не только позволяет добавлять, модифицировать и удалять типы, не изменяя программы пользователя, но и освобождает автора нового вида запроса от необходимости заново реализовывать поведение или действия, общие для всех типов в иерархии. Такая гибкость достигается за счет двух характеристик механизма наследования: полиморфизма
и динамического связывания.
Когда мы говорим о полиморфизме в языке C++, то имеем в виду главным образом способность указателя или ссылки на базовый класс адресовать любой из производных от него. Если определить обычную функцию eval() следующим образом:
}
то мы вправе вызывать ее, передавая адрес объекта любого из четырех типов запросов:
}
В то же время попытка передать eval()
адрес объекта класса, не являющегося производным от Query, вызовет ошибку компиляции:
}
Внутри eval()
выполнение инструкции вида
pquery->eval();
должно вызывать нужную виртуальную функцию-член eval() в зависимости от фактического класса объекта, адресуемого указателем pquery. В примере выше pquery
последовательно адресует объекты AndQuery, NotQuery, OrQuery и NameQuery. В каждой точке вызова определяется фактический тип класса объекта и вызывается подходящий экземпляр eval().
Механизм, с помощью которого это достигается, называется динамическим связыванием. (Мы вернемся к проектированию и использованию виртуальных функций в разделе 17.5.)
В объектно-ориентированной парадигме программист манипулирует неизвестным экземпляром, принадлежащим к одному из ограниченного, но потенциально бесконечного множества различных типов. (Ограничено оно иерархией наследования. Теоретически, однако, ни на глубину, ни на ширину такой иерархии не накладывается никаких ограничений.) В C++ это достигается путем манипулирования объектами исключительно через указатели и ссылки на базовый класс. В объектной (не объектно-ориентированной) парадигме программист работает с экземпляром фиксированного типа, который полностью определен на этапе компиляции.
Хотя для полиморфной манипуляции объектом требуется, чтобы доступ к нему осуществлялся с помощью указателя или ссылки, сам по себе факт их использования не обязательно приводит к полиморфизму. Рассмотрим такие объявления:
Query *pquery;
В C++ полиморфизм существует только в пределах отдельных иерархий классов. Указатели типа void*
можно назвать полиморфными, но в языке их поддержка не предусмотрена. Такими указателями программист должен управлять самостоятельно, с помощью явных приведений типов и той или иной формы дискриминанта, показывающего, объект какого типа в данный момент адресуется. (Можно сказать, что это “второсортные” полиморфные объекты.)
Язык C++ обеспечивает поддержку полиморфизма следующими способами:
· путем неявного преобразования указателя или ссылки на производный класс к указателю или ссылке на открытый базовый:
Query *pquery = new NameQuery( "Class" );
· через механизм виртуальных функций:
pquery->eval();
· с помощью операторов dynamic_cast и typeid (они подробно обсуждаются в разделе 19.1):
dynamic_cast< NameQuery* >( pquery )) ...
Проблему представления запроса мы решим, определив каждый операнд в классах AndQuery, NotQuery и OrQuery как указатель на тип Query*. Например:
};
Теперь оба операнда могут адресовать объект любого класса, производного от абстрактного базового класса Query, без учета того, определен он уже сейчас или появится в будущем. Благодаря механизму виртуальных функций, вычисление операнда, происходящее во время выполнения программы, не зависит от фактического типа:
_rop->eval();
На рис. 17. 1 показана иерархия наследования, состоящая из абстрактного класса Query и четырех производных от него классов. Как этот рисунок транслируется в код программы на C++?
Query
AndQuery OrQuery NotQuery NameQuery
Рис. 17.1. Иерархия классов Query
В разделе 2.4 мы рассматривали реализацию иерархии классов IntArray. Синтаксическая структура определения иерархии, изображенной на рис. 17.1, аналогична:
class NameQuery : public Query { ... };
Наследование задается с помощью списка базовых классов. В случае одиночного наследования этот список имеет вид:
: уровень-доступа базовый-класс
где уровень-доступа – это одно из ключевых слов public, protected, private (смысл защищенного и закрытого наследования мы обсудим в разделе 18.3), а базовый-класс – имя ранее определенного класса. Например, Query является открытым базовым классом для любого из четырех классов запросов.
Класс, встречающийся в списке базовых, должен быть предварительно определен. Следующего опережающего объявления Query
недостаточно для того, чтобы он мог выступать в роли базового:
class NameQuery : piblic Query { ... };
Опережающее объявление производного класса должно включать только его имя, но не список базовых классов. Поэтому следующее опережающее объявление класса NameQuery
приводит к ошибке компиляции:
class NameQuery : public Query;
Правильный вариант в данном случае выглядит так:
<
class NameQuery;
Главное различие между базовыми классами Query и IntArray (см. раздел 2.4) состоит в том, что Query не представляет никакого реального объекта в нашем приложении. Пользователи класса IntArray
вполне могут определять и использовать объекты этого типа непосредственно. Что же касается Query, то разрешается определять лишь указатели и ссылки на него, используя их для косвенного манипулирования объектами производных классов. О Query
говорят, что это абстрактный базовый класс. В противоположность этому IntArray является конкретным базовым классом. Преобладающей формой в объектно-ориентированном проектировании является определение абстрактного базового класса типа Query и одиночное открытое наследование ему.
Упражнение 17.1
Библиотека может выдавать на руки предметы, для каждого из которых определены специальные правила выдачи и возврата. Организуйте их в иерархию наследования:
книга на компакт-диске видеоигра для приставки Nintendo
Упражнение 17.2
Выберите или придумайте собственную абстракцию, содержащую семейство типов. Организуйте типы в иерархию наследования:
(a) Форматы графических файлов (gif, tiff, jpeg, bmp и т.д.)
(b) Геометрические примитивы (прямоугольник, круг, сфера, конус и т.д.)
(c) Типы языка C++ (класс, функция, функция-член и т.д.)
упорядочен, то реализация таких функций, как min(), max(), find(), должна отличаться от их реализации для массива неупорядоченного большей эффективностью. Вместе с тем, для поддержания массива в упорядоченном состоянии все прочие функции должны быть сильно усложнены.
Мы выбрали наиболее общий случай – неупорядоченный массив. Но как же быть с теми немногочисленными пользователями, которым обязательно нужна функциональность массива упорядоченного? Мы должны специально для них создать другой вариант массива?
А вот и еще одна категория недовольных пользователей: их не удовлетворяют накладные расходы на проверку правильности индекса. Мы исходили из того, что корректность работы нашего класса превыше всего, и старались обезопасить себя от ошибочных ситуаций. Но возьмем, к примеру, разработчиков систем виртуальной реальности. Трехмерные изображения должны строиться с максимально возможной скоростью, быть может, за счет точности.
Да, мы можем удовлетворить и тех и других, создав для каждой группы пользователей свой, немного модернизированный, вариант IntArray. Более того, его даже не слишком трудно сделать, поскольку мы старались создать хорошую реализацию и необходимые изменения затронут совсем небольшие участки кода. Итак, копируем исходный текст, вносим необходимые изменения в нужные места и получаем три класса:
class IntSortedArray { ... };
Подобное решение имеет следующие недостатки:
· нам необходимо сопровождать три копии кода, различающиеся весьма незначительно. Хорошо бы выделить общие участки кода. Кроме упрощения сопровождения, это позволит использовать их впоследствии, если мы захотим создать еще один вариант массива, например упорядоченный с проверкой границ индекса;
· если понадобится какая- то общая функция для обработки всех наших массивов, то нам придется написать три копии, поскольку типы ее параметров будут различаться:
void process_array (IntSortedArray&);
хотя реализация этих функций может быть совершенно идентичной. Было бы лучше написать единственную функцию, которая могла бы работать не только со всеми нашими массивами, но и с теми их вариациями, какие мы, возможно, реализуем впоследствии.
Парадигма объектно-ориентированного программирования позволяет осуществить все эти пожелания. Механизм наследования
обеспечивает пожелания из первого пункта. Если один класс является потомком другого (например, IntArrayRC
потомок класса IntArray), то наследник имеет возможность пользоваться всеми данными и функциями-членами, определенными в классе-предке. То есть класс IntArrayRC может просто использовать всю основную функциональность, предоставляемую классом IntArray, и добавить только то, что нужно ему для обеспечения проверки границ индекса.
В С++ класс, свойства которого наследуются, называют также базовым классом, а класс-наследник – производным классом, или подклассом
базового. Класс и подкласс имеют общий интерфейс, предоставляемый базовым классом (т.к. подкласс имеет все функции-члены базового класса). Значит, программу, использующую только функции из этого общего интерфейса, не должен интересовать фактический тип объекта, с которым она работает, – базового ли типа этот объект или производного. В этом смысле общий интерфейс скрывает специфичные для подкласса детали. Отношения между классами и подклассами называются иерархией наследования классов. Вот как может выглядеть реализация функции swap(), которая меняет местами два указанных элемента массива. Первым параметром функции является ссылка на базовый класс IntArray:
<
// ошибка - 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:
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() одинакова для всех подклассов, следовательно, они не будут виртуальными.
При вызове невиртуальной функции компилятор определяет все необходимое еще на этапе компиляции. Если же он встречает вызов виртуальной функции, то не пытается сделать этого. Выбор нужной из набора виртуальных функций (разрешение вызова) происходит во время выполнения программы и основывается на типе объекта, из которого она вызвана. Рассмотрим пример:
{
for (int ix=0; ix
ia[ix] = ix;
}
Формальный параметр функции ia
может быть ссылкой на IntArray, IntArrayRC или на IntSortedArray. Функция-член size() не является виртуальной и разрешается на этапе компиляции. А вот виртуальный оператор взятия индекса не может быть разрешен на данном этапе, поскольку реальный тип объекта, на который ссылается ia, в этот момент неизвестен.
(В главе 17 мы будем говорить о виртуальных функциях более подробно. Там мы рассмотрим также и накладные расходы, которые влечет за собой их использование.)
Вот как выглядит определение производного класса IntArrayRC:
#endif
Этот текст мы поместим в заголовочный файл IntArrayRC.h. Обратите внимание на то, что в наш файл включен заголовочный файл IntArray.h.
В классе IntArrayRC мы должны реализовать только те особенности, которые отличают его от IntArray: класс IntArrayRC
должен иметь свою собственную реализацию операции взятия индекса; функцию для проверки индекса и собственный набор конструкторов.
Все данные и функции-члены класса IntArray
можно использовать в классе IntArrayRC так, как будто это его собственные члены. В этом и заключается смысл наследования. Синтаксически наследование выражается строкой
class IntArrayRC : public IntArray
Эта строка показывает, что класс IntArrayRC
произведен от класса IntArray, другими словами, наследует ему. Ключевое слово public в данном контексте говорит о том, что производный класс сохраняет открытый интерфейс базового класса, то есть что все открытые функции базового класса остаются открытыми и в производном. Объект типа IntArrayRC
может использоваться вместо объекта типа IntArray, как, например, в приведенном выше примере с функцией swap(). Таким образом, подкласс IntArrayRC – это расширенная версия класса IntArray.
Вот как выглядит реализация операции взятия индекса:
}
А вот реализация встроенной функции check_range():
}
(Мы говорили о макросе assert() в разделе 1.3.)
Почему проверка индекса вынесена в отдельную функцию, а не выполняется прямо в теле оператора взятия индекса? Потому что, если мы когда-нибудь потом захотим изменить что-то в реализации проверки, например написать свою обработку ошибок, а не использовать assert(), это будет сделать проще.
В каком порядке активизируются конструкторы при создании производного класса? Первым вызывается конструктор базового класса, инициализирующий те члены, которые входят в базовый класс. Затем начинает работать конструктор производного класса, где мы должны проинициализировать только те члены, которые являются специфичными для подкласса, то есть отсутствуют в базовом классе.
Однако заметим, что в нашем производном классе IntArrayRC нет новых членов, представляющих данные. Значит ли это, что нам не нужно реализовывать конструкторы для него? Ведь вся работа по инициализации членов данных уже проделана конструкторами базового класса.
На самом деле конструкторы, как и деструкторы или операторы присваивания, не наследуются – это правило языка С++. Кроме того, конструктор производного класса обеспечивает механизм передачи параметров конструктору базового класса. Рассмотрим пример. Пусть мы хотим создать объект класса IntArrayRC
следующим образом:
IntArrayRC iarc(ia,8);
Нам нужно передать параметры ia и 8
конструктору базового класса IntArray. Для этого служит специальная синтаксическая конструкция. Вот как выглядят реализации двух конструкторов IntArrayRC:
: IntArray( iar, sz ) {}
(Мы будем подробно говорить о конструкторах в главах 14 и 17. Там же мы покажем, почему не нужно реализовывать конструктор копирования для IntArrayRC.)
Часть определения, следующая за двоеточием, называется списком инициализации членов. Именно здесь, указав конструктор базового класса, мы можем передать ему параметры. Тела обоих конструкторов пусты, поскольку их работа состоит исключительно в передаче параметров конструктору базового класса. Нам не нужно реализовывать деструктор для IntArrayRC, так как ему просто нечего делать. Точно так же, как при создании объекта производного типа вызывается сначала конструктор базового типа, а затем производного, при уничтожении автоматически вызываются деструкторы – естественно, в обратном порядке: сначала деструктор производного, затем базового. Таким образом, деструктор базового класса будет вызван для объекта типа IntArrayRC, хотя тот и не имеет собственной аналогичной функции.
Мы поместим все встроенные функции класса IntArrayRC в тот же заголовочный файл IntArrayRC.h. Поскольку у нас нет невстроенных функций, то создавать файл IntArrayRC.C не нужно.
Вот пример простой программы, использующей классы IntArray и IntArrayRC:
<
}
При выполнении программа выдаст следующий результат:
swap() with IntArray ia1
swap() with IntArrayRC ia2
Assertion failed: ix >= 0 && ix < _size,
file IntArrayRC.h, line 19
Упражнение 2.8
Отношение наследования между типом и подтипом служит примером отношения является. Так, массив IntArrayRC
является подвидом массива IntArray, книга является подвидом выдаваемых библиотекой предметов, аудиокнига является подвидом книги и т.д. Какие из следующих утверждений верны?
(i) читатель является подвидом библиотеки
Упражнение 2.9
Определите, какие из следующих функций могут различаться в реализации для производных классов и, таким образом, выступают кандидатами в виртуальные функции:
(h) is_on_loan(); // книга выдана
Упражнение 2.10
Ходят споры о том, не нарушает ли принципа инкапсуляции введение защищенного уровня доступа. Есть мнение, что для соблюдения этого принципа следует отказаться от использования такого уровня и работать только с закрытыми членами. Противоположная точка зрения гласит, что без защищенных членов производные классы невозможно реализовывать достаточно эффективно и в конце концов пришлось бы везде задействовать открытый уровень доступа. А каково ваше мнение по этому поводу?
Упражнение 2.11
Еще одним спорным аспектом является необходимость явно указывать виртуальность функций в базовом классе. Есть мнение, что все функции должны быть виртуальными по умолчанию, тогда ошибка в разработке базового класса не повлечет таких серьезных последствий в разработке производного, когда из-за невозможности изменить реализацию функции, ошибочно не определенной в базовом классе как виртуальная, приходится сильно усложнять реализацию. С другой стороны, виртуальные функции невозможно объявить как встроенные, и использование только таких функций сильно снизит эффективность. Каково ваше мнение?
Упражнение 2.12
Каждая из приведенных ниже абстракций определяет целое семейство подвидов, как, например, абстракция “транспортное средство” может определять “самолет”, “автомобиль”, “велосипед”. Выберите одно из семейств и составьте для него иерархию подвидов. Приведите пример открытого интерфейса для этой иерархии, включая конструкторы. Определите виртуальные функции. Напишите псевдокод маленькой программы, использующей данный интерфейс.
(f) Курс_продажи
Первый шаг состоит в том, чтобы определить, какие операции будет поддерживать наш массив. Конечно, было бы заманчиво реализовать все мыслимые и немыслимые операции, но невозможно сделать сразу все на свете. Поэтому для начала определим то, что должен уметь наш массив:
1.
обладать некоторыми знаниями о самом себе. Пусть для начала это будет знание собственного размера;
2. поддерживать операцию присваивания и операцию сравнения на равенство;
3. отвечать на некоторые вопросы, например: какова величина минимального и максимального элемента; содержит ли массив элемент с определенным значением; если да, то каков индекс первого встречающегося элемента, имеющего это значение;
4. сортировать сам себя. Пусть такая операция покажется излишней, все-таки реализуем ее в качестве дополнительного упражнения: ведь кому-то это может пригодиться.
5. Конечно, мы должны реализовать и базовые операции работы с массивом, а именно:Возможность задать размер массива при его создании. (Речь не идет о том, чтобы знать эту величину на этапе компиляции.)
6. Возможность проинициализировать массив некоторым набором значений.
7. Возможность обращаться к элементу массива по индексу. Пусть эта возможность реализуется с помощью стандартной операции взятия индекса.
8. Возможность обнаруживать обращения к несуществующим элементам массива и сигнализировать об ошибке. Не будем обращать внимание на тех потенциальных пользователей нашего класса, которые привыкли работать со встроенными массивами С и не считают данную возможность полезной – мы хотим создать такой массив, который был бы удобен в использовании даже самым неискушенным программистам на С++.
Кажется, мы перечислили достаточно потенциальных достоинств нашего будущего массива, чтобы загореться желанием немедленно приступить к его реализации. Как же это будет выглядеть на С++? В самом общем случае объявление класса выглядит следующим образом:
};
class, public и private – это ключевые слова С++, а classname – имя, которое программист дал своему классу. Назовем наш проектируемый класс IntArray: на первом этапе этот массив будет содержать только целые числа. Когда мы научим его обращаться с данными любого типа, можно будет переименовать его в Array.
Определяя класс, мы создаем новый тип данных. На имя класса можно ссылаться точно так же, как на любой встроенный описатель типа. Можно создавать объекты этого нового типа аналогично тому, как мы создаем объекты встроенных типов:
IntArray myArray;
// указатель на динамический объект типа IntArray
IntArray *pArray = new IntArray;
Определение класса состоит из двух частей: заголовка
(имя, предваренное ключевым словом class) и тела, заключенного в фигурные скобки. Заголовок без тела может служить объявлением класса.
// без определения его
class IntArray;
Тело класса состоит из определений членов и спецификаторов доступа – ключевых слов public, private и protected. (Пока мы ничего не будем говорить об уровне доступа protected.) Членами класса могут являться функции, которые определяют набор действий, выполняемых классом, и переменные, содержащие некие внутренние данные, необходимые для реализации класса. Функции, принадлежащие классу, называют функциями-членами или, по-другому, методами класса. Вот набор методов класса IntArray:
<
int find (int value) const; // #3c
private:
// дальше идут закрытые члены,
// обеспечивающие реализацию класса
...
}
Номера, указанные в комментариях при объявлениях методов, ссылаются на спецификацию класса, которую мы составили в начале данного раздела. Сейчас мы не будем объяснять смысл ключевого слова const, он не так уж важен для понимания того, что мы хотим продемонстрировать на данном примере. Будем считать, что это ключевое слово необходимо для правильной компиляции программы.
Именованная функция-член (например, min()) может быть вызвана с использованием одной из двух операций доступа к члену класса.
Первая операция доступа, обозначаемая точкой (.), применяется к объектам класса, вторая – стрелка (->) – к указателям на объекты. Так, чтобы найти минимальный элемент в объекте, имеющем тип IntArray, мы должны написать:
// минимальным элементом myArray
Чтобы найти минимальный элемент в динамически созданном объекте типа IntArray, мы должны написать:
int min_val = pArray->min();
(Да, мы еще ничего не сказали о том, как же проинициализировать наш объект – задать его размер и наполнить элементами. Для этого служит специальная функция-член, называемая конструктором. Мы поговорим об этом чуть ниже.)
Операции применяются к объектам класса точно так же, как и к встроенным типам данных. Пусть мы имеем два объекта типа IntArray:
IntArray myАrray0, myArray1;
Инструкции присваивания и сравнения с этими объектами выглядят совершенно обычным образом:
cout << "Ура! Оператор присваивания сработал!\n";
Спецификаторы доступа public и private
определяют уровень доступа к членам класса. К тем членам, которые перечислены после public, можно обращаться из любого места программы, а к тем, которые объявлены после private, могут обращаться только функции-члены данного класса. (Помимо функций-членов, существуют еще функции-друзья
класса, но мы не будем говорить о них вплоть до раздела 15.2.)
В общем случае открытые члены класса составляют его открытый интерфейс, то есть набор операций, которые определяют поведение класса. Закрытые члены класса обеспечивают его скрытую реализацию.
Такое деление на открытый интерфейс и скрытую реализацию называют сокрытием информации, или инкапсуляцией. Это очень важная концепция программирования, мы еще поговорим о ней в следующих главах. В двух словах, эта концепция помогает решить следующие проблемы:
· если мы меняем или расширяем реализацию класса, то изменения можно выполнить так, что большинство пользовательских программ, использующих наш класс, их “не заметят”: модификации коснутся лишь скрытых членов (мы поговорим об этом в разделе 6.18);
· если в реализации класса обнаруживается ошибка, то обычно для ее исправления достаточно проверить код, составляющий именно скрытую реализацию, а не весь код программы, где данный класс используется.
Какие же внутренние данные потребуются для реализации класса IntArray? Необходимо где-то сохранить размер массива и сами его элементы. Мы будем хранить их в массиве встроенного типа, память для которого выделяется динамически. Так что нам потребуется указатель на этот массив. Вот как будут выглядеть определения этих данных-членов:
int _size;
int *ia;
};
Поскольку мы поместили член _size в закрытую секцию, пользователь класса не имеет возможности обратиться к нему напрямую. Чтобы позволить внешней программе узнать размер массива, мы написали функцию-член size(), которая возвращает значение члена _size. Нам пришлось добавить символ подчеркивания к имени нашего скрытого члена _size, поскольку функция-член с именем size() уже определена. Члены класса – функции и данные – не могут иметь одинаковые имена.
Может показаться, что реализуя подобным образом доступ к скрытым данным класса, мы очень сильно проигрываем в эффективности. Сравним два выражения (предположим, что мы изменили спецификатор доступа члена _size на public):
int array_size = array.size();
array_size = array._size;
Действительно, вызов функции гораздо менее эффективен, чем прямой доступ к памяти, как во втором операторе. Так что же, принцип сокрытия информации заставляет нас жертвовать эффективностью?
На самом деле, нет. С++ имеет механизм встроенных
(inline) функций. Текст встроенной функции подставляется компилятором в то место, где записано обращение к ней. (Это напоминает механизм макросов, реализованный во многих языках, в том числе и в С++. Однако есть определенные отличия, о которых мы сейчас говорить не будем.) Вот пример. Если у нас есть следующий фрагмент кода:
// ...
то функция size() не будет вызываться _size раз во время исполнения. Вместо вызова компилятор подставит ее текст, и результат компиляции предыдущего кода будет в точности таким же, как если бы мы написали:
for (int index=0; index
// ...
Если функция определена внутри тела класса (как в нашем случае), она автоматически считается встроенной. Существует также ключевое слово inline, позволяющее объявить встроенной любую функцию[3].
Мы до сих пор ничего не сказали о том, как будем инициализировать наш массив.
Одна из самых распространенных ошибок при программировании (на любом языке) состоит в том, что объект используется без предварительной инициализации. Чтобы помочь избежать этой ошибки, С++ обеспечивает механизм автоматической инициализации для определяемых пользователем классов – конструктор
класса.
Конструктор – это специальная функция-член, которая вызывается автоматически при создании объекта типа класса. Конструктор пишется разработчиком класса, причем у одного класса может быть несколько конструкторов.
Функция- член класса, носящее то же имя, что и сам класс, считается конструктором. (Нет никаких специальных ключевых слов, позволяющих определить конструктор как-то по-другому.) Мы уже сказали, что конструкторов может быть несколько. Как же так: разные функции с одинаковыми именами?
В С++ это возможно. Разные функции могут иметь одно и то же имя, если у этих функций различны количество и/или типы параметров. Это называется перегрузкой функции. Обрабатывая вызов перегруженной функции, компилятор смотрит не только на ее имя, но и на список параметров. По количеству и типам передаваемых параметров компилятор может определить, какую же из одноименных функций нужно вызывать в данном случае. Рассмотрим пример. Мы можем определить следующий набор перегруженных функций min(). (Перегружаться могут как обычные функции, так и функции-члены.)
string min (string,string);
Поведение перегруженных функций во время выполнения ничем не отличается от поведения обычных. Компилятор определяет нужную функцию и помещает в объектный код именно ее вызов. (В главе 9 подробно обсуждается механизм перегрузки.)
Итак, вернемся к нашему классу IntArray. Давайте определим для него три конструктора:
}
Первый из перечисленных конструкторов
IntArray (int sz = DefaultArraySize);
называется конструктором по умолчанию, потому что он может быть вызван без параметров. (Пока не будем объяснять ключевое слово explicit.) Если при создании объекта ему задается параметр типа int, например
IntArray array1(1024);
то значение 1024
будет передано в конструктор. Если же размер не задан, допустим:
IntArray array2;
то в качестве значения отсутствующего параметра конструктор принимает величину DefaultArraySize. (Не будем пока обсуждать использование ключевого слова static в определении члена DefaultArraySize: об этом говорится в разделе 13.5. Скажем лишь, что такой член данных существует в единственном экземпляре и принадлежит одновременно всем объектам данного класса.)
Вот как может выглядеть определение нашего конструктора по умолчанию:
}
Это определение содержит несколько упрощенный вариант реализации. Мы не позаботились о том, чтобы попытаться избежать возможных ошибок во время выполнения. Какие ошибки возможны? Во-первых, оператор new может потерпеть неудачу при выделении нужной памяти: в реальной жизни память не бесконечна. (В разделе 2.6 мы увидим, как обрабатываются подобные ситуации.) А во-вторых, параметр sz
из-за небрежности программиста может иметь некорректное значение, например нуль или отрицательное.
Что необычного мы видим в таком определении конструктора? Сразу бросается в глаза первая строчка, в которой использована операция разрешения области видимости
(::):
IntArray::IntArray(int sz);
Дело в том, что мы определяем нашу функцию-член (в данном случае конструктор) вне тела класса. Для того чтобы показать, что эта функция на самом деле является членом класса IntArray, мы должны явно предварить имя функции именем класса и двойным двоеточием. (Подробно области видимости разбираются в главе 8; области видимости применительно к классам рассматриваются в разделе 13.9.)
Второй конструктор класса IntArray
инициализирует объект IntArray
значениями элементов массива встроенного типа. Он требует двух параметров: массива встроенного типа со значениями для инициализации и размера этого массива. Вот как может выглядеть создание объекта IntArray с использованием данного конструктора:
IntArray iA3(ia,10);
Реализация второго конструктора очень мало отличается от реализации конструктора по умолчанию. (Как и в первом случае, мы пока опустили обработку ошибочных ситуаций.)
}
Третий конструктор называется копирующим
конструктором. Он инициализирует один объект типа IntArray значением другого объекта IntArray. Такой конструктор вызывается автоматически при выполнении следующих инструкций:
IntArray array;
// следующие два объявления совершенно эквивалентны:
IntArray ia1 = array;
IntArray ia2 (array);
Вот как выглядит реализация копирующего конструктора для IntArray, опять-таки без обработки ошибок:
{
// инициализация членов данных
_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():
<
{ init (rhs._size,rhs.ia); }
Имеется еще одна специальная функция-член – деструктор,
который автоматически вызывается в тот момент, когда объект прекращает существование. Имя деструктора совпадает с именем класса, только в начале идет символ тильды (~). Основное назначение данной функции – освободить ресурсы, отведенные объекту во время его создания и использования. Применение деструкторов помогает бороться с трудно обнаруживаемыми ошибками, ведущими к утечке памяти и других ресурсов. В случае класса IntArray эта функция-член должна освободить память, выделенную в момент создания объекта. (Подробно конструкторы и деструкторы описаны в главе 14.) Вот как выглядит деструктор для IntArray:
public:
// конструкторы
explicit IntArray (int sz = DefaultArraySize);
IntArray (int *array, int array_size);
IntArray (const IntArray &rhs);
// деструктор
~IntArray() { delete[] ia; }
// ...
private:
// ...
};
Теперь нам нужно определить операции доступа к элементам массива IntArray. Мы хотим, чтобы обращение к элементам IntArray выглядело точно так же, как к элементам массива встроенного типа, с использованием оператора взятия индекса:
int last_pos = array.size()-1;
int temp = array[0];
array[0] = array[last_pos];
array[last_pos] = temp;
Для реализации доступа мы используем возможность перегрузки операций. Вот как выглядит функция, реализующая операцию взятия индекса:
}
Обычно для проектируемого класса перегружают операции присваивания, операцию сравнения на равенство, возможно, операции сравнения по величине и операции ввода/вывода. Как и перегруженных функций, перегруженных операторов, отличающихся типами операндов, может быть несколько. К примеру, можно создать несколько операций присваивания объекту значения другого объекта того же самого или иного типа. Конечно, эти объекты должны быть более или менее “похожи”. (Подробно о перегрузке операций мы расскажем в главе 15, а в разделе 3.15 приведем еще несколько примеров.)
Определения класса, различных относящихся к нему констант и, быть может, каких-то еще переменных и макросов по принятым соглашениям помещаются в заголовочный файл, имя которого совпадает с именем класса. Для класса IntArray мы должны создать заголовочный файл IntArray.h. Любая программа, в которой будет использоваться класс IntArray, должна включать этот заголовочный файл директивой препроцессора #include.
По тому же самому соглашению функции-члены класса, определенные вне его описания, помещаются в файл с именем класса и расширением, обозначающим исходный текст С++ программы. Мы будем использовать расширение .С
(напомним, что в разных системах вы можете встретиться с разными расширениями исходных текстов С++ программ) и назовем наш файл IntArray.C.
Упражнение 2.5
Ключевой особенностью класса С++ является разделение интерфейса и реализации. Интерфейс представляет собой набор операций (функций), выполняемых объектом; он определяет имя функции, возвращаемое значение и список параметров. Обычно пользователь не должен знать об объекте ничего, кроме его интерфейса. Реализация скрывает алгоритмы и данные, нужные объекту, и может меняться при развитии объекта, никак не затрагивая интерфейс. Попробуйте определить интерфейсы для одного из следующих классов (выберите любой):
(a) матрица
(b) булевское значение
(c) паспортные данные человека
(d) дата
(e) указатель
(f) точка
Упражнение 2.6
Попробуйте определить набор конструкторов, необходимых для класса, выбранного вами в предыдущем упражнении. Нужен ли деструктор для вашего класса? Помните, что на самом деле конструктор не создает объект: память под объект отводится до начала работы данной функции, и конструктор только производит определенные действия по инициализации объекта. Аналогично деструктор уничтожает не сам объект, а только те дополнительные ресурсы, которые могли быть выделены в результате работы конструктора или других функций-членов класса.
Упражнение 2.7
В предыдущих упражнениях вы практически полностью определили интерфейс выбранного вами класса. Попробуйте теперь написать программу, использующую ваш класс. Удобно ли пользоваться вашим интерфейсом? Не хочется ли Вам пересмотреть спецификацию? Сможете ли вы сделать это и одновременно сохранить совместимость со старой версией?
дает хороший пример как возможностей, так и ограничений механизма шаблонов:
}
Достоинство этого механизма – возможность определить единственный шаблон min(), который конкретизируется для бесконечного множества типов. Ограничение же заключается в том, что даже при такой конкретизации min()
будет работать не со всеми.
Это ограничение вызвано использованием оператора “меньше”: в некоторых случаях базовый тип его не поддерживает. Так, класс изображения Image
может и не предоставлять реализации такого оператора, но мы об этом не знаем и пытаемся найти минимальный кадр анимации в данном массиве изображений. Однако попытка конкретизировать min() для такого массива приведет к ошибке компиляции:
error: invalid types applied to the < operator: Image < Image
(ошибка: оператор < применен к некорректным типам: Image < Image)
Возможна и другая ситуация: оператор “меньше” существует, но имеет неподходящую семантику. Например, если мы хотим найти наименьшую строку, но при этом принимать во внимание только буквы, не учитывая регистр, то такой реализованный в классе оператор не даст нужного результата.
Традиционное решение состоит в том, чтобы параметризовать оператор сравнения. В данном случае это можно сделать, объявив указатель на функцию, принимающую два аргумента и возвращающую значение типа bool:
}
Такое решение вместе с нашей первой реализацией на основе встроенного оператора “меньше” обеспечивает универсальную поддержку для любого типа, включая и класс Image, если только мы придумаем подходящую семантику для сравнения двух изображений. Основной недостаток указателя на функцию связан с низкой эффективностью, так как косвенный вызов не дает воспользоваться преимуществами встроенных функций.
Альтернативная стратегия параметризации заключается в применении объекта-функции вместо указателя (примеры мы видели в предыдущем разделе). Объект-функция – это класс, перегружающий оператор вызова (operator()). Такой оператор инкапсулирует семантику обычного вызова функции. Объект-функция, как правило, передается обобщенному алгоритму в качестве аргумента, хотя можно определять и независимые объекты-функции. Например, если бы был определен объект-функция AddImages, который принимает два изображения, объединяет их некоторым образом и возвращает новое изображение, то мы могли бы объявить его следующим образом:
AddImages AI;
Чтобы объект-функция удовлетворял нашим требованиям, мы применяем оператор вызова, предоставляя необходимые операнды в виде объектов класса Image:
Image new_image = AI (im1, im2 );
У объекта-функции есть два преимущества по сравнению с указателем на функцию. Во-первых, если перегруженный оператор вызова – это встроенная функция, то компилятор может выполнить ее подстановку, обеспечивая значительный выигрыш в производительности. Во-вторых, объект-функция способен содержать произвольное количество дополнительных данных, например кэш или информацию, полезную для выполнения текущей операции.
Ниже приведена измененная реализация шаблона min() (отметим, что это объявление допускает также и передачу указателя на функцию, но без проверки прототипа):
}
Как правило, обобщенные алгоритмы поддерживают обе формы применения операции: как использование встроенного (или перегруженного) оператора, так и применение указателя на функцию либо объекта-функции.
Есть три источника появления объектов-функций:
1. из набора предопределенных арифметических, сравнительных и логических объектов-функций стандартной библиотеки;
2. из набора предопределенных адаптеров функций, позволяющих специализировать или расширять предопределенные (или любые другие) объекты-функции;
3. определенные нами собственные объекты-функции для передачи обобщенным алгоритмам. К ним можно применять и адаптеры функций.
В этом разделе мы рассмотрим все три источника объектов-функций.
}
Но мы можем перепроектировать иерархию классов исключений и определить виртуальные функции, которые можно вызывать из catch-обработчика для базового класса Excp с целью получения доступа к функциям-членам более специализированного производного:
};
};
Функцию print()
теперь можно использовать в catch-обработчике следующим образом:
}
Хотя возбужденное исключение имеет тип pushOnFull, а функция print()
виртуальна, инструкция eObj.print() печатает такую строку:
Произошло исключение
Вызываемая print()
является членом базового класса Excp, а не замещает ее в производном. Но почему?
Вспомните, что объявление исключения в catch-обработчике ведет себя почти так же, так объявление параметра. Когда управление попадает в catch-обработчик, то, поскольку в нем объявлен объект, а не ссылка, eObj
инициализируется копией подобъекта Excp базового класса объекта исключения. Поэтому eObj – это объект типа Excp, а не pushOnFull. Чтобы вызвать виртуальные функции из производных классов, в объявлении исключения должен быть указатель или ссылка:
}
Объявление исключения в этом примере тоже относится к базовому классу Excp, но так как eObj – ссылка и при этом именует объект-исключение типа pushOnFull, то для нее можно вызывать виртуальные функции, определенные в классе pushOnFull. Когда catch-обработчик обращается к виртуальной функции print(), вызывается функция из производного класса, и программа печатает следующую строку:
попытка поместить значение 879 в полный стек
Таким образом, ссылка в объявлении исключения позволяет вызывать виртуальные функции, ассоциированные с классом объекта-исключения.
Изменим реализацию класса исключения pushOnFull, сохранив в объекте-исключении то значение, которое не удалось поместить в стек. Catch-обработчик, сообщая об ошибке, теперь будет выводить его в cerr. Для этого мы сначала модифицируем определение типа класса pushOnFull
следующим образом:
};
Новый закрытый член _value
содержит число, которое не удалось поместить в стек. Конструктор принимает значение типа int и сохраняет его в члене _data. Вот как вызывается этот конструктор для сохранения значения из выражения throw:
}
У класса pushOnFull
появилась также новая функция-член value(), которую можно использовать в catch-обработчике для вывода хранящегося в объекте-исключении значения:
}
Обратите внимание, что в объявлении исключения в catch-обработчике фигурирует объект eObj, с помощью которого вызывается функция-член value() класса pushOnFull.
Объект-исключение всегда создается в точке возбуждения, даже если выражение throw – это не вызов конструктора и, на первый взгляд, не должно создавать объекта. Например:
}
В этом примере объект state не используется в качестве объекта-исключения. Вместо этого выражением throw
создается объект-исключение типа EHstate, который инициализируется значением глобального объекта state. Как программа может различить их? Для ответа на этот вопрос мы должны присмотреться к объявлению исключения в catch-обработчике более внимательно.
Это объявление ведет себя почти так же, как объявление формального параметра. Если при входе в catch-обработчик исключения выясняется, что в нем объявлен объект, то он инициализируется копией объекта-исключения. Например, следующая функция calculate()
вызывает определенную выше mathFunc(). При входе в catch-обработчик внутри calculate()
объект eObj
инициализируется копией объекта-исключения, созданного выражением throw.
}
Объявление исключения в этом примере напоминает передачу параметра по значению. Объект eObj
инициализируется значением объекта-исключения точно так же, как переданный по значению формальный параметр функции – значением соответствующего фактического аргумента. (Передача параметров по значению рассматривалась в разделе 7.3.)
Как и в случае параметров функции, в объявлении исключения может фигурировать ссылка. Тогда catch-обработчик будет напрямую ссылаться на объект-исключение, сгенерированный выражением throw, а не создавать его локальную копию:
}
Для предотвращения ненужного копирования больших объектов применять ссылки следует не только в объявлениях параметров типа класса, но и в объявлениях исключений того же типа.
В последнем случае catch-обработчик сможет модифицировать объект-исключение. Однако переменные, определенные в выражении throw, остаются без изменения. Например, модификация eObj
внутри catch-обработчика не затрагивает глобальную переменную state, установленную в выражении throw:
}
Catch-обработчик переустанавливает eObj в noErr
после исправления ошибки, вызвавшей исключение. Поскольку eObj – это ссылка, можно ожидать, что присваивание модифицирует глобальную переменную state. Однако изменяется лишь объект-исключение, созданный в выражении throw, поэтому модификация eObj не затрагивает state.
};
то определение
Screen myScreen;
выделяет область памяти, достаточную для хранения четырех членов Screen. Имя myScreen
относится к этой области. У каждого объекта класса есть собственная копия данных-членов. Изменение членов myScreen не отражается на значениях членов любого другого объекта типа Screen.
Область видимости объекта класса зависит от его положения в тексте программы. Он определяется в иной области, нежели сам тип класса:
}
Тип Screen
объявлен в глобальной области видимости, тогда как объект mainScreen – в локальной области функции main().
Объект класса также имеет время жизни. В зависимости от того, где (в области видимости пространства имен или в локальной области) и как (статическим или нестатическим) он объявлен, он может существовать в течение всего времени выполнения программы или только во время вызова некоторой функции. Область видимости объекта класса и его время жизни ведут себя очень похоже. (Понятия области видимости и времени жизни введены в главе 8.)
Объекты одного и того же класса можно инициализировать и присваивать друг другу. По умолчанию копирование объекта класса эквивалентно копированию всех его членов. Например:
// bufScreen._screen = myScreen._screen;
Указатели и ссылки на объекты класса также можно объявлять. Указатель на тип класса разрешается инициализировать адресом объекта того же класса или присвоить ему такой адрес. Аналогично ссылка инициализируется l-значением объекта того же класса. (В объектно-ориентированном программировании указатель или ссылка на объект базового класса могут относиться и к объекту производного от него класса.)
}
По умолчанию объект класса передается по значению, если он выступает в роли аргумента функции или ее возвращаемого значения. Можно объявить формальный параметр функции или возвращаемое ею значение как указатель или ссылку на тип класса. (В разделе 7.3 были представлены параметры, являющиеся указателями или ссылками на типы классов, и объяснялось, когда их следует использовать. В разделе 7.4 с этой точки зрения рассматривались типы возвращаемых значений.)
Для доступа к данным или функциям-членам объекта класса следует пользоваться соответствующими операторами. Оператор “точка” (.) применяется, когда операндом является сам объект или ссылка на него; а “стрелка” (->) – когда операндом служит указатель на объект:
}
isEqual() – это не являющаяся членом функция, которая сравнивает два объекта Screen. У нее нет права доступа к закрытым членам Screen, поэтому напрямую обращаться к ним она не может. Сравнение проводится с помощью открытых функций-членов данного класса.
Для получения высоты и ширины экрана isEqual()
должна пользоваться функциями-членами height() и width() для чтения закрытых членов класса. Их реализация тривиальна:
};
Применение оператора доступа к указателю на объект класса эквивалентно последовательному выполнению двух операций: применению оператора разыменования (*) к указателю, чтобы получить адресуемый объект, и последующему применению оператора “точка” для доступа к нужному члену класса. Например, выражение
s2->height()
можно переписать так:
(*s2).height()
Результат будет одним и тем же.
Можно объявить класс, не определяя его. Например:
class Screen; // объявление класса Screen
Это объявление вводит в программу имя Screen и указывает, что оно относится к типу класса.
Тип объявленного, но еще не определенного класса допустимо использовать весьма ограниченно. Нельзя определять объект типа класса, если сам класс еще не определен, поскольку размер класса в этом момент неизвестен и компилятор не знает, сколько памяти отвести под объект.
Однако указатель или ссылку на объект такого класса объявлять можно, так как они имеют фиксированный размер, не зависящий от типа. Но, поскольку размеры класса и его членов неизвестны, применять оператор разыменования (*) к такому указателю, а также использовать указатель или ссылку для обращения к члену не разрешается, пока класс не будет полностью определен.
Член некоторого класса можно объявить принадлежащим к типу какого-либо класса только тогда, когда компилятор уже видел определение этого класса. До этого объявляются лишь члены, являющиеся указателями или ссылками на такой тип. Ниже приведено определение StackScreen, один из членов которого служит указателем на Screen, который объявлен, но еще не определен:
};
Поскольку класс не считается определенным, пока не закончилось его тело, то в нем не может быть данных-членов его собственного типа. Однако класс считается объявленным, как только распознан его заголовок, поэтому в нем допустимы члены, являющиеся ссылками или указателями на его тип. Например:
};
Упражнение 13.1
Пусть дан класс Person со следующими двумя членами:
string _address;
и такие функции-члены:
string address() { return _address; }
Какие члены вы объявили бы в секции public, а какие– в секции private? Поясните свой выбор.
Упражнение 13.2
Объясните разницу между объявлением и определением класса. Когда вы стали бы использовать объявление класса? А определение?
константным возникают некоторые проблемы. Предполагается, что после инициализации объекта Screen, его содержимое уже нельзя изменять. Но это не должно мешать нам читать содержимое экрана. Рассмотрим следующий константный объект класса Screen:
const Screen cs ( 5, 5 );
Если мы хотим прочитать символ, находящийся в позиции (3,4), то попробуем сделать так:
char ch = cs.get();
Но такая конструкция не работает: move() – это не константная функция-член, и сделать ее таковой непросто. Определение move()
выглядит следующим образом:
}
Обратите внимание, что move()изменяет член класса _cursor, следовательно, не может быть объявлена константной.
Но почему нельзя модифицировать _cursor для константного объекта класса Screen? Ведь _cursor – это просто индекс. Изменяя его, мы не модифицируем содержимое экрана, а лишь пытаемся установить позицию внутри него. Модификация _cursor
должна быть разрешена несмотря на то, что у класса Screen
есть спецификатор const.
Чтобы разрешить модификацию члена класса, принадлежащего константному объекту, объявим его изменчивым (mutable). Член с таким спецификатором не бывает константным, даже если он член константного объекта. Его можно обновлять, в том числе функцией-членом со спецификатором const. Объявлению изменчивого члена класса должно предшествовать ключевое слово mutable:
};
Теперь любая константная функция способна модифицировать _cursor, и move()
может быть объявлена константной. Хотя move() изменяет данный член, компилятор не считает это ошибкой.
}
Показанные в начале этого подраздела операции позиционирования внутри экрана теперь можно выполнить без сообщения об ошибке.
Отметим, что изменчивым объявлен только член _cursor, тогда как _screen, _height и _width не имеют спецификатора mutable, поскольку их значения в константном объекте класса Screen
изменять нельзя.
Упражнение 13.3
Объясните, как будет вести себя copy() при следующих вызовах:
myScreen.copy( myScreen );
Упражнение 13.4
К дополнительным перемещениям курсора можно отнести его передвижение вперед и назад на один символ. Из правого нижнего угла экрана курсор должен попасть в левый верхний угол. Реализуйте функции forward() и backward().
Упражнение 13.5
Еще одной полезной возможностью является перемещение курсора вниз и вверх на одну строку. По достижении верхней или нижней строки экрана курсор не перепрыгивает на противоположный край; вместо этого подается звуковой сигнал, и курсор остается на месте. Реализуйте функции up() и down(). Для подачи сигнала следует вывести на стандартный вывод cout
символ с кодом '007'.
Упражнение 13.6
Пересмотрите описанные функции-члены класса Screen и объявите те, которые сочтете нужными, константными. Объясните свое решение.
становится виртуальным базовым для Bear и Raccoon:
class Raccoon : virtual public ZooAnimal { ... };
Виртуальное наследование не является явной характеристикой самого базового класса, а лишь описывает его отношение к производному. Как мы уже отмечали, виртуальное наследование – это разновидность композиции по ссылке. Иначе говоря, доступ к подобъекту и его нестатическим членам косвенный, что обеспечивает гибкость, необходимую для объединения нескольких виртуально унаследованных подобъектов базовых классов в один разделяемый экземпляр внутри производного. В то же время объектом производного класса можно манипулировать через указатель или ссылку на тип базового, хотя последний является виртуальным. Например, все показанные ниже преобразования базовых классов Panda выполняются корректно, хотя Panda
использует виртуальное наследование:
}
Любой класс, который можно задать в качестве базового, разрешается сделать виртуальным, причем он способен содержать все те же элементы, что обычные базовые классы. Так выглядит объявление ZooAnimal:
<
};
К объявлению и реализации непосредственного базового класса при использовании виртуального наследования добавляется ключевое слово virtual. Вот, например, объявление нашего класса Bear:
};
А вот объявление класса Raccoon:
};
объявлены друзьями всех конкретизаций шаблона QueueItem:
};
Ни класс foobar, ни функцию foo() не обязательно объявлять или определять в глобальной области видимости перед объявлением их друзьями шаблона QueueItem.
Однако перед тем как объявить другом какой-либо из членов класса Foo, необходимо определить его. Напомним, что член класса может быть введен в область видимости только через определение объемлющего класса. QueueItem не может ссылаться на Foo::bar(), пока не будет найдено определение Foo;
· связанный
дружественный шаблон класса или функции. В следующем примере определено взаимно однозначное соответствие между классами, конкретизированными по шаблону QueueItem, и их друзьями – также конкретизациями шаблонов. Для каждого класса, конкретизированного по шаблону QueueItem, ассоциированные конкретизации foobar, foo() и Queue::bar()
являются друзьями.
};
Прежде чем шаблон класса можно будет использовать в объявлениях друзей, он сам должен быть объявлен или определен. В нашем примере шаблоны классов foobar и Queue, а также шаблон функции foo()
следует объявить до того, как они объявлены друзьями в QueueItem.
Синтаксис, использованный для объявления foo() другом, может показаться странным:
friend void foo( QueueItem );
За именем функции следует список явных аргументов шаблона: foo. Такой синтаксис показывает, что в качестве друга объявляется конкретизированный шаблон функции foo(). Если бы список явных аргументов был опущен:
friend void foo( QueueItem );
то компилятор интерпретировал бы объявление как относящееся к обычной функции (а не к шаблону), у которой тип параметра – это экземпляр шаблона QueueItem. Как отмечалось в разделе 10.6, шаблон функции и одноименная обычная функция могут сосуществовать, и присутствие объявления такого шаблона перед определением класса QueueItem не вынуждает компилятор соотнести объявление друга именно с ним. Для того, чтобы соотнесение было верным, в конкретизированном шаблоне функции необходимо указать список явных аргументов;
· несвязанный
дружественный шаблон класса или функции. В следующем примере имеется отображение один-ко-многим между конкретизациями шаблона класса QueueItem и его друзьями. Для каждой конкретизации типа QueueItem все конкретизации foobar, foo() и Queue::bar()
являются друзьями:
};
Следует отметить, что этот вид объявлений друзей в шаблоне класса не поддерживается компиляторами, написанными до принятия стандарта C++.
необходимо объявить другом QueueItem, чтобы можно было создавать и манипулировать объектами последнего.
Существует два способа объявить шаблон класса другом. Первый заключается в том, чтобы объявить любой экземпляр Queue
другом любого экземпляра QueueItem:
};
Однако нет смысла объявлять, например, класс Queue, конкретизированный типом string, другом QueueItem, конкретизированного типом complex. Queue
должен быть другом только для класса QueueItem. Таким образом, нам нужно взаимно однозначное соответствие между экземплярами Queue и QueueItem, конкретизированными одинаковыми типами. Чтобы добиться этого, применим второй метод объявления друзей:
};
Данное объявление говорит о том, что для любой конкретизации QueueItem некоторым типом экземпляр Queue, конкретизированный тем же типом, является другом. Так, экземпляр Queue, конкретизированный типом int, будет другом экземпляра QueueItem, тоже конкретизированного типом int. Но для экземпляров QueueItem, конкретизированных типами complex или string, этот экземпляр Queue
другом не будет.
В любой точке программы у пользователю может понадобиться распечатать содержимое объекта Queue. Такая возможность предоставляется с помощью перегруженного оператора вывода. Этот оператор должен быть объявлен другом шаблона Queue, так как ему необходим доступ к закрытым членам класса. Какой же будет его сигнатура?
ostream& operator<<( ostream &, ??? );
Поскольку Queue – это шаблон класса, то в имени конкретизированного экземпляра должен быть задан полный список аргументов:
ostream& operator<<( ostream &, const Queue & );
Так мы определили оператор вывода для класса, конкретизированного из шаблона Queue
типом int. Но что, если Queue – это очередь элементов типа string?
ostream& operator<<( ostream &, const Queue & );
Вместо того чтобы явно определять нужный оператор вывода по мере необходимости, желательно сразу определить общий оператор, который будет работать для любой конкретизации Queue. Например:
ostream& operator<<( ostream &, const Queue & );
Однако из этого перегруженного оператора вывода придется сделать шаблон функции:
operator<<( ostream &, const Queue & );
Теперь всякий раз, когда оператору ostream
передается конкретизированный экземпляр Queue, конкретизируется и вызывается шаблон функции. Вот одна из возможных реализаций оператора вывода в виде такого шаблона:
}
Если очередь объектов типа int
содержит значения 3, 5, 8, 13, то распечатка ее содержимого с помощью такого оператора дает
< 3 5 8 13 >
Обратите внимание, что оператор вывода обращается к закрытому члену front
класса Queue. Поэтому оператор необходимо объявить другом Queue:
};
Здесь, как и при объявлении друга в шаблоне класса Queue, создается взаимно однозначное соответствие между конкретизациями Queue и оператора operator<<().
Распечатка элементов Queue производится оператором вывода operator<<()
класса QueueItem:
os << *p;
Этот оператор также должен быть реализован в виде шаблона функции; тогда можно быть уверенным, что в нужный момент будет конкретизирован подходящий экземпляр:
}
Поскольку здесь имеется обращение к закрытому члену item класса QueueItem, оператор следует объявить другом шаблона QueueItem. Это делается следующим образом:
};
Оператор вывода класса QueueItem
полагается на то, что item
умеет распечатывать себя:
os << qi.item;
Это порождает тонкую зависимость типов при конкретизации Queue. Любой определенный пользователем и связанный с Queue
класс, содержимое которого нужно распечатывать, должен предоставлять оператор вывода. В языке нет механизма, с помощью которого можно было бы задать такую зависимость в определении самого шаблона Queue. Но если оператор вывода не определен для типа, с которым конкретизируется данный шаблон, и делается попытка вывести содержимое конкретизированного экземпляра, то в том месте, где используется отсутствующий оператор вывода, компилятор выдает сообщение об ошибке. Шаблон Queue
можно конкретизировать типом, не имеющим оператора вывода, – при условии, что не будет попытки распечатать содержимое очереди.
Следующая программа демонстрирует конкретизацию и использование функций-друзей шаблонов классов Queue и QueueItem:
}
После компиляции и запуска программа выдает результат:
< >
< 0 1 2 3 4 5 6 7 8 9 >
< >
!! queue executed ok
Упражнение 16.6
Пользуясь шаблоном класса Screen, определенным в упражнении 16.5, реализуйте операторы ввода и вывода (см. упражнение 15.6 из раздела 15.2) в виде шаблонов. Объясните, почему вы выбрали тот, а не иной способ объявления друзей класса Screen, добавленных в его шаблон.
функции устанавливает ее имя, а также тип возвращаемого значения и список параметров. Определение функции, помимо этой информации, задает еще и тело– набор инструкций, заключенных в фигурные скобки. Функция должна быть объявлена перед вызовом. Например:
}
Определение объекта имеет две формы:
type_specifier object_name = initializer;
Вот, например, определение obj1. Здесь obj1
инициализируется значением 97:
int obj1 = 97;
Следующая инструкция задает obj2, хотя начальное значение не задано:
int obj2;
Объект, определенный в глобальной области видимости без явной инициализации, гарантированно получит нулевое значение. Таким образом, в следующих двух примерах и var1, и var2
будут равны нулю:
int var2;
Глобальный объект можно определить в программе только один раз. Поскольку он должен быть объявлен в исходном файле перед использованием, то для программы, состоящей из нескольких файлов, необходима возможность объявить объект, не определяя его. Как это сделать?
С помощью ключевого слова extern, аналогичного объявлению функции: оно указывает, что объект определен в другом месте – в этом же исходном файле или в другом. Например:
extern int i;
Эта инструкция “обещает”, что в программе имеется определение, подобное
int i;
extern-объявление не выделяет места под объект. Оно может встретиться несколько раз в одном и том же исходном файле или в разных файлах одной программы. Однако обычно находится в общедоступном заголовочном файле, который включается в те модули, где необходимо использовать глобальный объект:
int obj2;
Объявление глобального объекта с указанием ключевого слова extern и с явной инициализацией считается определением. Под этот объект выделяется память, и другие определения не допускаются:
const double pi; // ошибка: повторное определение pi
Ключевое слово extern
может быть указано и при объявлении функции – для явного обозначения его подразумеваемого смысла: “определено в другом месте”. Например:
extern void putValues( int*, int );
};
Как и в случае функций, объявленных в пространстве имен, функции-члены могут иметь одинаковые имена при условии, что списки их параметров различны либо по числу параметров, либо по их типам. Если же объявления двух функций-членов отличаются только типом возвращаемого значения, то второе объявление считается ошибкой компиляции:
};
В отличие от функций в пространствах имен, функции-члены должны быть объявлены только один раз. Если даже тип возвращаемого значения и списки параметров двух функций-членов совпадают, то второе объявление компилятор трактует как неверное повторное объявление:
};
Все функции из множества перегруженных должны быть объявлены в одной и той же области видимости. Поэтому функции-члены никогда не перегружают функций, объявленных в пространстве имен. Кроме того, поскольку у каждого класса своя область видимости, функции, являющиеся членами разных классов, не перегружают друг друга.
Множество перегруженных функций-членов может содержать как статические, так и нестатические функции:
};
Какая из функций-членов будет вызвана– статическая или нестатическая – зависит от результатов разрешения перегрузки. Процесс разрешения в ситуации, когда устояли как статические, так и нестатические члены, мы подробно рассмотрим в следующем разделе.
Вы уже воспользовались предопределенной перегруженной функцией. Например, для вычисления выражения
1 + 3
вызывается операция целочисленного сложения, тогда как вычисление выражения
1.0 + 3.0
осуществляет сложение с плавающей точкой. Выбор той или иной операции производится незаметно для пользователя. Операция сложения перегружена, чтобы обеспечить работу с операндами разных типов. Ответственность за распознавание контекста и применение операции, соответствующей типам операндов, возлагается на компилятор, а не на программиста.
В этой главе мы покажем, как определять собственные перегруженные функции.
Для обращения к ним применяются операторы доступа (точка и стрелка) и оператор разрешения области видимости (::). Когда употребляется оператор доступа, то предшествующее ему имя обозначает объект или указатель на объект типа класса, а следующее за ним имя должно находиться в области видимости этого класса. Аналогично при использовании оператора разрешения области видимости поиск имени, следующего за ним, идет в области видимости класса, имя которого стоит перед оператором. (В главах 17 и 18 мы увидим, что производный класс может обращаться к членам своих базовых.)
Однако применение операторов доступа или оператора разрешения области видимости нужно не всегда. Некоторые части программы сами по себе находятся в области видимости класса, и в них к членам класса можно обращаться напрямую. Одной из таких частей является само определение класса. Имя его члена можно использовать в теле после объявления:
};
Порядок объявления членов класса в его теле важен: нельзя ссылаться на члены, которые будут объявлены позже. Например, если объявление оператора operator[]()
находится раньше объявления typedef index_type, то приведенное ниже объявление operator[]() оказывается ошибочным, поскольку в нем используется еще неизвестное имя index_type:
};
Однако из этого правила есть два исключения. Первое касается имен, использованных в определениях встроенных функций-членов, второе – имен, применяемых как аргументы по умолчанию. Рассмотрим обе ситуации.
Разрешение имен в определениях встроенных функций-членов происходит в два этапа. Сначала объявление функции (т.е. тип возвращаемого значения и список параметров) обрабатывается в том месте, где оно встретилось в определении класса. Затем тело функции обрабатывается во всей области видимости, сразу после того, как были просмотрены объявления всех членов. Посмотрим на наш пример, в котором оператор operator[]()
определен как встроенный внутри тела класса:
};
На первом этапе просматриваются имена, использованные в объявлении operator[](), чтобы найти имя типа параметра index_type. Поскольку первый шаг выполняется тогда, когда в теле класса встретилось определение функции-члена, то имя index_type
должно быть объявлено до определения operator[]().
Обратите внимание, что член _string
объявлен в теле класса после определения operator[](). Это правильно, и _string не является в теле operator[]()
необъявленным именем. Имена в телах функций-членов просматриваются на втором шаге разрешения имен в определениях встроенных функций-членов. Этот этап выполняется во всей области видимости класса, как если бы тела функций-членов обрабатывались последними, прямо перед закрытием тела класса, когда все его члены уже объявлены.
Аргументы по умолчанию также разрешаются на втором шаге. Например, в объявлении функции-члена clear()
используется имя статического члена bkground, который определен позже:
};
Хотя такие аргументы в объявлениях функций-членов разрешаются во всей области видимости класса, программа будет считаться ошибочной, если он ссылается на нестатический член. Нестатический член должен быть привязан к объекту своего класса или к указателю на такой объект, иначе использовать его нельзя. Употребление подобных членов в качестве аргументов по умолчанию нарушает это ограничение. Если переписать предыдущий пример так:
};
то имя аргумента по умолчанию разрешается нестатическим членом bkground, а это считается ошибкой.
Определения членов класса, появляющиеся вне его тела, – это еще один пример части программы, которая находится в области видимости класса. В ней имена членов распознаются несмотря на то, что оператор доступа или оператор разрешения области видимости при обращении к ним не применяется. Как же разрешаются имена в определениях членов?
Как правило, если такое определение появляется вне тела, то часть программы, следующая за именем определяемого члена, считается находящейся в области видимости класса вплоть до конца определения члена. Вынесем определение оператора operator[]() из класса String:
}
Обратите внимание, что в списке параметров встречается typedef index_type без квалифицирующего имени класса String::.Текст, следующий за именем члена String::operator[] и до конца определения функции, находится в области видимости класса. Объявленные в этой области типы рассматриваются при разрешении имен типов, использованных в списке параметров функции-члена.
Определения статических данных-членов также появляются вне определения класса. В них часть программы, следующая за именем статического члена вплоть до конца определения, считается находящейся в области видимости класса. Например, инициализатор статического члена может непосредственно, без соответствующих операторов, ссылаться на члены класса:
double Account::_interestRate = initInterest();
Инициализатор _interestRate
вызывает статическую функцию-член Account::initInterest()
несмотря на то, что ее имя не квалифицировано именем класса.
Не только инициализатор, но и все, что следует за именем статического члена _interestRate до завершающей точки с запятой, находится в области видимости класса Account. Поэтому в определении статического члена name
может быть обращение к члену класса nameSize:
const char Account::name[nameSize] = "Savins Account";
Хотя член nameSize не квалифицирован именем класса Account, определение name не является ошибкой, так как оно находится в области видимости своего класса и может ссылаться на его члены после того, как компилятор прочитал Account::name.
В определении члена, которое появляется вне тела, часть программы перед определяемым именем не находится в области видимости класса. При обращении к члену в этой части следует пользоваться оператором разрешения области видимости. Например, если типом статического члена является typedef Money, определенный в классе Account, то имя Money
должно быть квалифицировано, когда статический член данных определяется вне тела класса:
Account::Money Account::_interestRate = initInterest();
С каждым классом ассоциируется отдельная область видимости, причем у разных классов эти области различны. К членам одного класса нельзя напрямую обращаться в определениях членов другого класса, если только один из них не является для второго базовым. (Наследование и базовые классы рассматриваются в главах 17 и 18.)
Именно эта иерархическая вложенность областей видимости классов при наследовании и делает возможным обращение к именам членов базового класса так, как если бы они были членами производного. Рассмотрим сначала несколько примеров одиночного наследования, а затем перейдем к множественному. Предположим, есть упрощенное определение класса ZooAnimal:
};
и упрощенное определение производного класса Bear:
};
Когда мы пишем:
bear.is_a;
то имя разрешается следующим образом:
· bear – это объект класса Bear. Сначала поиск имени is_a
ведется в области видимости Bear. Там его нет.
· Поскольку класс Bear
производный от ZooAnimal, то далее поиск is_a
ведется в области видимости последнего. Обнаруживается, что имя принадлежит его члену. Разрешение закончилось успешно.
Хотя к членам базового класса можно обращаться напрямую, как к членам производного, они сохраняют свою принадлежность к базовому классу. Как правило, не имеет значения, в каком именно классе определено имя. Но это становится важным, если в базовом и производном классах есть одноименные члены. Например, когда мы пишем:
bear.ival;
ival – это член класса Bear, найденный на первом шаге описанного выше процесса разрешения имени.
Иными словами, член производного класса, имеющий то же имя, что и член базового, маскирует последний. Чтобы обратиться к члену базового класса, необходимо квалифицировать его имя с помощью оператора разрешения области видимости:
bear.ZooAnimal::ival;
Тем самым мы говорим компилятору, что объявление ival следует искать в области видимости класса ZooAnimal.
Проиллюстрируем использование оператора разрешения области видимости на несколько абсурдном примере (надеемся, вы никогда не напишете чего-либо подобного в реальном коде):
}
Неквалифицированное обращение к ival
разрешается в пользу формального параметра. (Если бы переменная ival не была определена внутри mumble(), то имел бы место доступ к члену класса Bear. Если бы ival не была определена и в Bear, то подразумевался бы член ZooAnimal. А если бы ival не было и там, то речь шла бы о глобальном объекте.)
Разрешение имени члена класса всегда предшествует выяснению того, является ли обращение к нему корректным. На первый взгляд, это противоречит интуиции. Например, изменим реализацию mumble():
}
Можно возразить, что алгоритм разрешения должен остановиться на первом допустимом в данном контексте имени, а не на первом найденном. Однако в приведенном примере алгоритм разрешения выполняется следующим образом:
(a) Определено ли dval в локальной области видимости функции-члена класса Bear? Нет.
(b) Определено ли dval в области видимости Bear? Нет.
(c) Определено ли dval в области видимости ZooAnimal? Да. Обращение разрешается в пользу этого имени.
После того как имя разрешено, компилятор проверяет, возможен ли доступ к нему. В данном случае нет: dval
является закрытым членом, и прямое обращение к нему из mumble()
запрещено. Правильное (и, возможно, имевшееся в виду) разрешение требует явного употребления оператора разрешения области видимости:
return ival + ::dval; // правильно
Почему же имя члена разрешается перед проверкой уровня доступа? Чтобы предотвратить тонкие изменения семантики программы в связи с совершенно независимым, казалось бы, изменением уровня доступа к члену. Рассмотрим, например, такой вызов:
}
Если бы функция foo()
была перегруженной, то перемещение члена ZooAnimal::dval из закрытой секции в защищенную вполне могло бы изменить всю последовательность вызовов внутри mumble(), а разработчик об этом даже и не подозревал бы.
Если в базовом и производном классах есть функции-члены с одинаковыми именами и сигнатурами, то их поведение такое же, как и поведение данных-членов: член производного класса лексически скрывает в своей области видимости член базового. Для вызова члена базового класса необходимо применить оператор разрешения области видимости:
}
};
Panda объявляется производным от двух классов:
};
Хотя при наследовании функций print() и highlight() из обоих базовых классов Bear и Endangered
имеется потенциальная неоднозначность, сообщение об ошибке не выдается до момента явно неоднозначного обращения к любой из этих функций.
В то время как неоднозначность двух унаследованных функций print() очевидна с первого взгляда, наличие конфликта между членами highlight() удивляет (ради этого пример и составлялся): ведь у них разные уровни доступа и разные прототипы. Более того, экземпляр из Endangered – это член непосредственного базового класса, а из ZooAnimal – член класса, стоящего на две ступеньки выше в иерархии.
Однако все это не имеет значения (впрочем, как мы скоро увидим, может иметь, но в случае виртуального наследования). Bear наследует закрытую функцию-член highlight() из ZooAnimal; лексически она видна, хотя вызывать ее из Bear или Panda
запрещено. Значит, Panda
наследует два лексически видимых члена с именем highlight, поэтому любое неквалифицированное обращение к этому имени приводит к ошибке компиляции.
Поиск имени начинается в ближайшей области видимости, объемлющей его вхождение. Например, в коде
}
ближайшей будет область видимости класса Panda, к которому принадлежит yin_yang. Если же мы напишем:
}
то ближайшей будет локальная область видимости функции-члена mumble(). Если объявление dance в ней имеется, то разрешение имени на этом благополучно завершится. В противном случае поиск будет продолжен в объемлющих областях видимости.
В случае множественного наследования имитируется одновременный просмотр всех поддеревьев наследования – в нашем случае это класс Endangered и поддерево Bear/ZooAnimal. Если объявление обнаружено только в поддереве одного из базовых классов, то разрешение имени заканчивается успешно, как, например, при таком вызове dance():
yin_yang.dance( Bear::macarena );
Если же объявление найдено в двух или более поддеревьях, то обращение считается неоднозначным и компилятор выдает сообщение об ошибке. Так будет при неквалифицированном обращении к print():
}
На уровне программы в целом для разрешения неоднозначности достаточно явно квалифицировать имя нужной функции-члена с помощью оператора разрешения области видимости:
}
Предложенный способ неэффективен: теперь пользователь вынужден решать, каково правильное поведение класса Panda; однако лучше, если такого рода ответственность примет на себя проектировщик и класс Panda сам устранит все неоднозначности, свойственные его иерархии наследования. Простейший способ добиться этого – задать квалификацию уже в определении экземпляра в производном классе, указав тем самым требуемое поведение:
сумму значений члена dval
класса Base1 и члена
dval
класса Derived.
(b) Присвойте вещественную часть члена cval класса MI
члену fval
класса Base2.
(c) Присвойте значение члена cval
класса Base1
первому символу члена sval
класса Derived.
Упражнение 18.12
Дана следующая иерархия классов, в которых имеются функции-члены print():
};
(a) Почему приведенный фрагмент дает ошибку компиляции?
mi.print( dancer );
(b) Как изменить определение MI, чтобы этот фрагмент компилировался и выполнялся правильно?
}
Поскольку успешная компиляция производного класса, наследующего нескольким базовым, не гарантирует отсутствия скрытых неоднозначностей, мы рекомендуем при тестировании вызывать все функции-члены, даже самые тривиальные.
Упражнение 18.9
Дана следующая иерархия классов:
};
и структура функции-члена MI::foo():
}
(a) Какие члены видны в классе MI? Есть ли среди них такие, которые видны в нескольких базовых?
(b) Какие члены видны в MI::foo()?
Упражнение 18.10
Пользуясь иерархией классов из упражнения 18.9, укажите, какие из следующих присваиваний недопустимы внутри функции-члена MI::bar():
(c) id = 1;
Упражнение 18.11
Даны иерархия классов из упражнения 18.9 и скелет функции-члена MI::foobar():
}
(a) Присвойте локальной переменной dval
и область видимости класса.
Локальная область– это часть исходного текста программы, содержащаяся в определении функции (или в блоке). Любая функция имеет собственную такую часть, и каждая составная инструкция (или блок) внутри функции также представляет собой отдельную локальную область.
Область видимости пространства имен – часть исходного текста программы, не содержащаяся внутри объявления или определения функции или определения класса. Самая внешняя часть называется глобальной областью видимости или глобальной областью видимости пространства имен.
Объекты, функции, типы и шаблоны могут быть определены в глобальной области видимости. Программисту разрешено задать пользовательские
пространства имен, заключенные внутри глобальной области с помощью определения пространства имен. Каждое такое пространство является отдельной областью видимости. Пользовательское пространство, как и глобальное, может содержать объявления и определения объектов, функций, типов и шаблонов, а также вложенные пользовательские пространства имен. (Они рассматриваются в разделах 8.5 и 8.6.)
Каждое определение класса представляет собой отдельную область видимости класса. (О таких областях мы расскажем в главе 13.)
Имя может обозначать различные сущности в зависимости от области видимости. В следующем фрагменте программы имя s1
относится к четырем разным сущностям:
<
}
Поскольку определения функций lexicoCompare(), sizeCompare() и sort()
представляют собой различные области видимости и все они отличны от глобальной, в каждой из этих областей можно завести переменную с именем s1.
Имя, введенное с помощью объявления, можно использовать от точки объявления до конца области видимости (включая вложенные области). Так, имя s1 параметра функции lexicoCompare()
разрешается употреблять до конца ее области видимости, то есть до конца ее определения.
Имя глобального массива s1
видимо с точки его объявления до конца исходного файла, включая вложенные области, такие, как определение функции main().
В общем случае имя должно обозначать одну сущность внутри одной области видимости. Если в предыдущем примере после объявления массива s1 добавить следующую строку, компилятор выдаст сообщение об ошибке:
void s1(); // ошибка: повторное объявление s1
Перегруженные функции являются исключением из правила: можно завести несколько одноименных функций в одной области видимости, если они отличаются списком параметров. (Перегруженные функции рассматриваются в главе 9.)
В С++ имя должно быть объявлено до момента его первого использования в выражении. В противном случае компилятор выдаст сообщение об ошибке. Процесс сопоставления имени, используемого в выражении, с его объявлением называется разрешением. С помощью этого процесса имя получает конкретный смысл. Разрешение имени зависит от способа его употребления и от его области видимости. Мы рассмотрим этот процесс в различных контекстах. (В следующем подразделе описывается разрешение имен в локальной области видимости; в разделе 10.9 – разрешение в шаблонах функций; в конце главы 13 – в области видимости классов, а в разделе 16.12 – в шаблонах классов.)
Области видимости и разрешение имен – понятия времени компиляции. Они применимы к отдельным частям текста программы. Компилятор интерпретирует текст программы согласно правилам областей видимости и правилам разрешения имен.
Первыми двумя аргументами всех обобщенных алгоритмов (естественно, не без исключений) является пара итераторов, обычно first и last, обозначающих диапазон элементов внутри контейнера или встроенного массива, над которым работает алгоритм. Этот диапазон (часто называемый интервалом с включенной левой границей), как правило, записывается в виде:
[ first, last )
Это означает, что диапазон начинается с first и заканчивается last, однако сам элемент last
не включается. Если
first == last
то говорят, что диапазон пуст.
К паре итераторов предъявляется такое требование: last должен быть достижим, если начать с first и последовательно применять оператор инкремента. Однако компилятор не может проверить выполнение данного ограничения. Если требование не будет выполнено, поведение программы не определено; обычно это заканчивается ее крахом и дампом памяти.
В объявлении каждого алгоритма подразумевается минимальная поддержка, которую должны обеспечить итераторы (краткое обсуждение пяти категорий итераторов см. в разделе 12.4). Например, алгоритм find(), реализующий однопроходный обход контейнера и выполняющий только чтение, требует итератора чтения InputIterator. Ему также можно передать одно- или двунаправленный итератор или итератор с произвольным доступом. Однако передача итератора записи приведет к ошибке. Не гарантируется, что подобные ошибки (при передаче итератора неподходящей категории) будут обнаружены компилятором, поскольку категории итераторов– это не сами типы, а лишь параметры, которыми конкретизируется шаблон функции.
viter = find( svec.begin(), svec.end(), some_string_value );
Контейнер list
поддерживает дополнительные операции, такие, как sort() и merge(), поскольку в нем не реализован произвольный доступ к элементам. (Эти операции описаны в разделе 12.6.)
Теперь вернемся к нашей поисковой системе.
Упражнение 6.11
Напишите программу, в которой определены следующие объекты:
vector ivec;
Используя различные операции вставки и подходящие значения ia, ia2 и ia3, модифицируйте вектор ivec
так, чтобы он содержал последовательность:
{
0, 1, 1, 2, 3, 5, 8, 13, 21, 55, 89 }
Упражнение 6.12
Напишите программу, определяющую данные объекты:
list 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(), которому передается объект-предикат или указатель на функцию.
имеет серьезный недостаток: он может хранить элементы только целого типа. Если бы он мог содержать элементы любого типа– как встроенного, так и определенного пользователем, – то его область применения была бы гораздо шире. Модифицировать ilist для поддержки произвольных типов данных позволяет механизм шаблонов (см. главу 16).
При использовании шаблона вместо параметра подставляется реальный тип данных. Например:
list< string > slist;
создает экземпляр списка, способного содержать объекты типа string, а
list< int > ilist;
создает список, в точности повторяющий наш ilist. С помощью шаблона класса можно обеспечить поддержку произвольных типов данных одним экземпляром кода. Рассмотрим последовательность действий, уделив особое внимание классу list_item.
Определение шаблона класса начинается ключевым словом template, затем следует список параметров в угловых скобках. Параметр представляет собой идентификатор, перед которым стоит ключевое слово class или typename. Например:
class list_item;
Эта инструкция объявляет list_item
шаблоном класса с единственным параметром-типом. Следующее объявление эквивалентно предыдущему:
class list_item;
Ключевые слова class и typename
имеют одинаковое значение, можно использовать любое из них. Более удобное для запоминания typename появилось в стандарте С++ сравнительно недавно и поддерживается еще не всеми компиляторами. Поскольку наши тексты были написаны до появления этого ключевого слова, в них употребляется class. Шаблон класса list_item
выглядит так:
<
};
Все упоминания типа int в определении класса ilist_item
заменены на параметр elemType. Когда мы пишем:
list_item *ptr = new list_item( 3.14 );
компилятор подставляет double
вместо elemType и создает экземпляр list_item, поддерживающий данный тип.
Аналогичным образом модифицируем класс ilist в шаблон класса list:
};
Объекты шаблона класса list
используются точно так же, как и объекты класса ilist. Основное преимущество шаблона в том, что он обеспечивает поддержку произвольных типов данных с помощью единственного определения.
(Шаблоны являются важной составной частью концепции программирования на С++. В главе 6 мы рассмотрим набор классов контейнерных типов, предоставляемых стандартной библиотекой С++. Неудивительно, что она содержит шаблон класса, реализующего операции со списками, равно как и шаблон класса, поддерживающего векторы; мы рассматривали их в главах 2 и 3.)
Наличие класса списка в стандартной библиотеке представляет некоторую проблему. Мы выбрали для нашей реализации название list, но, к сожалению, стандартный класс также носит это название. Теперь мы не можем использовать в программе одновременно оба класса. Конечно, проблему решит переименование нашего шаблона, однако во многих случаях эта возможность отсутствует.
Более общее решение состоит в использовании механизма пространства имен, который позволяет разработчику библиотеки заключить все свои имена в некоторое поименованное пространство и таким образом избежать конфликта с именами из глобального пространства. Применяя нотацию квалифицированного доступа, мы можем употреблять эти имена в программах. Стандартная библиотека С++ помещает свои имена в пространство std. Мы тоже поместим наш код в собственное пространство:
}
Для использования такого класса в пользовательской программе необходимо написать следующее:
// ...
(Пространства имен описываются в разделах 8.5 и 8.6.)
Упражнение 5.16
Мы не определили деструктор для ilist_item, хотя класс содержит указатель на динамическую область памяти. Причина заключается в том, что класс не выделяет память для объекта, адресуемого указателем _next, и, следовательно, не несет ответственности за ее освобождение. Начинающий программист мог бы допустить ошибку, вызвав деструктор для ilist_item:
}
Посмотрите на функции remove_all() и remove_front() и объясните, почему наличие такого деструктора является ошибочным.
Упражнение 5.17
Наш класс ilist не поддерживает следующие операции:
void ilist::remove( ilist_item* );
Как вы думаете, почему мы их не включили? Реализуйте их.
Упражнение 5.18
Модифицируйте функцию find()
так, чтобы вторым параметром она принимала адрес элемента, с которого нужно начинать поиск. Если этот параметр не задан, поиск начинается с первого элемента. (Поскольку мы добавляем второй параметр, имеющий значение по умолчанию, открытый интерфейс данной функции не меняется. Программы, использующие предыдущую версию find(), будут работать без модификации.)
};
Упражнение 5.19
Используя новую версию find(), напишите функцию count(), которая подсчитывает количество вхождений элементов с заданным значением. Подготовьте тестовую программу.
Упражнение 5.20
Модифицируйте insert(int value)
так, чтобы она возвращала указатель на вставленный объект ilist_item.
Упражнение 5.21
Используя модифицированную версию insert(), напишите функцию:
int elem_cnt );
где array_of_value
указывает на массив значений, который нужно вставить в ilist, elem_cnt – на размер этого массива, а begin – на элемент, после которого производится вставка. Например, если есть ilist:
(3)( 0 1 21 )
и массив:
int ia[] = { 1, 2, 3, 5, 8, 13 };
вызов этой новой функции
mylist.insert( it, ia, 6 );
изменит список таким образом:
(9) ( 0 1 1 2 3 5 8 13 21 )
Упражнение 5.22
Функции concat() и reverse()
модифицируют оригинальный список. Это не всегда желательно. Напишите аналогичную пару функций, которые создают новый объект ilist:
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() для поиска всех вхождений нежелательных символов:
!= string::npos )
Найденный символ удаляется с помощью функции-члена erase():
word.erase(pos,1);
Первый аргумент этой функции означает позицию подстроки, а второй – ее длину. Мы удаляем один символ, находящийся в позиции pos. Второй аргумент является необязательным; если его опустить, будут удалены все символы от pos до конца строки.
Вот полный текст функции filter_text(). Она имеет два параметра: указатель на вектор строк, содержащий текст, и строку с символами, которые нужно убрать.
<
}
Почему мы не увеличиваем значение pos на каждой итерации? Что было бы, если бы мы написали:
}
Возьмем строку
thing,"
На первой итерации pos
получит значение 5 , т.е. позиции, в которой находится запятая. После удаления запятой строка примет вид
thing"
Теперь в 5-й позиции стоит двойная кавычка. Если мы увеличим значение pos, то пропустим этот символ.
Так мы будем вызывать функцию filter_text():
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 s2 ( "pixie" )
составит предложение
"A whistling-dixie kind of walk"
}
Здесь порядок catch-обработчиков желательно изменить. Напоминаем, что они просматриваются в порядке появления после try-блока. Как только будет найден обработчик, способный обработать данное исключение, поиск прекращается. В примере выше Excp
может обработать исключения типа pushOnFull, а это значит, что специализированный обработчик таких исключений задействован не будет. Правильная последовательность такова:
}
catch-обработчик для производного класса должен идти первым. Тогда catch-обработчик для базового класса получит управление только в том случае, если более специализированного обработчика не нашлось.
Если исключения организованы в иерархии, то пользователи библиотеки классов могут выбрать в своем приложении уровень детализации при работе с исключениями, возбужденными внутри библиотеки. Например, кодируя функцию main(), мы решили, что исключения типа pushOnFull
должны обрабатываться несколько иначе, чем прочие, и потому написали для них специализированный catch-обработчик. Что касается остальных исключений, то они обрабатываются единообразно:
<
}
Как отмечалось в разделе 11.3, процесс поиска catch- обработчика для возбужденного исключения не похож на процесс разрешения перегрузки функций. При выборе наилучшей из устоявших функций принимаются во внимание все кандидаты, видимые в точке вызова, а при обработке исключений найденный catch-обработчик совсем не обязательно будет лучше остальных соответствовать типу исключения. Выбирается первый подходящий обработчик, т.е. первый из просмотренных, который способен обработать данное исключение. Поэтому в списке обработчиков наиболее специализированные должны стоять ближе к началу.
Объявление исключения в catch-обработчике (находящееся в скобках после слова catch) очень похоже на объявление параметра функции. В приведенном примере оно напоминает параметр, передаваемый по значению. Объект eObj
инициализируется копией значения объекта-исключения точно так же, как передаваемый по значению формальный параметр функции инициализируется значением фактического аргумента. Как и в случае с параметрами функции, в объявлении исключения можно использовать ссылки. Тогда catch-обработчик имеет доступ непосредственно к объекту-исключению, созданному выражением throw, а не к его локальной копии. Чтобы избежать копирования больших объектов, параметры типа класса следует объявлять как ссылки; в объявлениях исключений тоже желательно делать исключения типа класса ссылками. В зависимости от того, что находится в таком объявлении (объект или ссылка), поведение обработчика различается (мы покажем эти различия в данном разделе).
В главе 11 были введены выражения повторного возбуждения исключения, которые используются в catch-обработчике для передачи исключения какому-то другому обработчику выше в цепочке вызовов. Такое выражение имеет вид
throw;
Как ведет себя эта инструкция, если она расположена в catch-обработчике исключений базового класса? Например, каким будет тип повторно возбужденного исключения, если mathFunc()
возбуждает исключение типа divideByZero?
<
}
Будет ли повторно возбужденное исключение иметь тип divideByZero–тот же, что и исключение, возбужденное функцией mathFunc()? Или тип mathExcp, который указан в объявлении исключения в catch-обработчике?
Напомним, что выражение throw
повторно генерирует исходный
объект-исключение. Так как исходный объект имеет тип divideByZero, то повторно возбужденное исключение будет такого же типа. В catch-обработчике объект mExcp
инициализируется копией подобъекта объекта типа divideByZero, который соответствует его базовому классу MathExcp. Доступ к ней осуществляется только внутри catch-обработчика, она не является исходным объектом-исключением, который повторно генерируется.
Предположим, что классы в нашей иерархии исключений имеют деструкторы:
};
Когда они вызываются? Чтобы ответить на этот вопрос, рассмотрим catch-обработчик:
}
Поскольку в объявлении исключения eObj
объявлен как локальный для catch-обработчика объект, а в классе pushOnFull
есть деструктор, то eObj
уничтожается при выходе из обработчика. Когда же вызывается деструктор для объекта-исключения, созданного в момент возбуждения исключения, – при входе в catch-обработчик или при выходе из него? Однако уничтожать исключение в любой из этих точек может быть слишком рано. Можете сказать, почему? Если catch-обработчик возбуждает исключение повторно, передавая его выше по цепочке вызовов, то уничтожать объект-исключение нельзя до момента выхода из последнего catch-обработчика.
возвращают соответственно итераторы, указывающие на первый элемент и на элемент, расположенный за последним. Можно также вернуть обратный итератор, обходящий контейнер от последнего элемента к первому. Во всех контейнерах для поддержки такой возможности используются операции rbegin() и rend(). Есть константные и неконстантные версии обратных итераторов:
vector< int >::const_reverse_iterator r_iter1 = vec1.rbegin();
Обратный итератор применяется так же, как прямой. Разница состоит в реализации операторов перехода к следующему и предыдущему элементам. Для прямого итератора оператор ++
дает доступ к следующему элементу контейнера, тогда как для обратного – к предыдущему. Например, для обхода вектора в обратном направлении следует написать:
{ /* ... */ }
Инвертирование семантики операторов инкремента и декремента может внести путаницу, но зато позволяет программисту передавать алгоритму пару обратных итераторов вместо прямых. Так, для сортировки вектора в порядке убывания мы передаем алгоритму sort()
пару обратных итераторов:
sort( vec0.rbegin(), vec0.rend() );
Текущий элемент помещается не в конец такой очереди, а перед элементами с более низким приоритетом. Программист, определяющий такую структуру, задает способ вычисления приоритетов. В реальной жизни подобное можно увидеть, скажем, при регистрации багажа в аэропорту. Как правило, пассажиры, чей рейс через 15 минут, передвигаются в начало очереди, чтобы не опоздать на самолет. Примером из практики программирования служит планировщик операционной системы, определяющий последовательность выполнения процессов.
Для использования queue и priority_queue
необходимо включить заголовочный файл:
#include
Полный набор операций с контейнерами queue и priority_queue приведен в таблице 6.6.
Таблица 6.6. Операции с queue и priority_queue
Элементы priority_queue
отсортированы в порядке убывания приоритетов. По умолчанию упорядочение основывается на операции “меньше”, определенной над парами элементов. Конечно, можно явно задать указатель на функцию или объект-функцию, которая будет использоваться для сортировки. (В разделе 12.3 можно найти более подробное объяснение и иллюстрации использования такой очереди.)
объявлен закрытым, а с двумя параметрами– открытым:
};
Обычная программа сможет теперь определять объекты класса Account, лишь указав как имя владельца счета, так и начальный баланс. Однако функции-члены Account и дружественный ему класс vector могут создавать объекты, пользуясь любым конструктором.
Конструкторы, не являющиеся открытыми, в реальных программах C++ чаще всего используются для:
· предотвращения копирования одного объекта в другой объект того же класса (эта проблема рассматривается в следующем подразделе);
· указания на то, что конструктор должен вызываться только в случае, когда данный класс выступает в роли базового в иерархии наследования, а не для создания объектов, которыми программа может манипулировать напрямую (см. обсуждение наследования и объектно-ориентированного программирования в главе 17).
Например, библиотека iostream не соответствовала текущему стандарту. Шаблоны не поддерживали значения аргументов по умолчанию. Возможно, вам придется изменить кое-что в этой программе, чтобы она компилировалась в вашей системе.
<
}
Упражнение 6.25
Объясните, почему нам потребовался специальный класс inserter для заполнения набора стоп-слов (это упоминается в разделе 6.13.1, а детально рассматривается в 12.4.1).
inserter(exclusion_set, exclusion_set.begin() ));
Упражнение 6.26
Первоначальная реализация поисковой системы отражает процедурный подход: набор глобальных функций оперирует набором независимых структур данных. Окончательный вариант представляет собой альтернативный подход, когда мы инкапсулируем функции и данные в класс TextQuery. Сравните оба способа. Каковы недостатки и преимущества каждого?
Упражнение 6.27
В данной версии программы имя файла с текстом вводится по запросу. Более удобно было бы задавать его как параметр командной строки; в главе 7 мы покажем, как это делается. Какие еще параметры командной строки желательно реализовать?
}
компилятор подставит деструктор перед каждой инструкцией return. Деструктор класса Account
невелик, и затраты времени и памяти на его подстановку тоже малы. В противном случае придется либо объявить деструктор невстроенным, либо реорганизовать программу. В примере выше инструкцию return в каждой метке case
можно заменить инструкцией break с тем, чтобы у функции была единственная точка выхода:
return;
Упражнение 14.6
Напишите подходящий деструктор для приведенного набора членов класса, среди которых pstring
адресует динамически выделенный массив символов:
};
Упражнение 14.7
Необходим ли деструктор для класса, который вы выбрали в упражнении 14.3? Если нет, объясните почему. В противном случае предложите реализацию.
Упражнение 14.8
Сколько раз вызываются деструкторы в следующем фрагменте:
}
}
Выражение
ix_vec++
является постфиксной формой оператора инкремента. Значение переменной ix_vec
увеличивается после того, как ее текущее значение употреблено в качестве индекса. Например, на первой итерации цикла значение ix_vec
равно 0. Именно это значение применяется как индекс массива ivec, после чего ix_vec
увеличивается и становится равным 1, однако новое значение используется только на следующей итерации. Постфиксная форма операции декремента работает точно так же: текущее значение ix_ia берется в качестве индекса для ia, затем ix_ia
уменьшается на 1.
Существует и префиксная форма этих операторов. При использовании такой формы текущее значение сначала уменьшается или увеличивается, а затем используется новое значение. Если мы пишем:
ivec[ ++ix_vec ] = ia[ --ix_ia ];
значение ix_vec
увеличивается на единицу и становится равным 1 до первого использования в качестве индекса. Аналогично ix_ia
получает значение 8 при первом использовании. Для того чтобы наша программа работала правильно, мы должны скорректировать начальные значения переменных ix_ivec и ix_ia:
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 *pi = 0;
В результате операции присваивания объект получает новое значение, при этом старое пропадает:
pi = &iva1;
Иногда путают инициализацию и присваивание, так как они обозначаются одним и тем же знаком =. Объект инициализируется только один раз– при его определении. В то же время операция может быть применена к нему многократно.
Что происходит, если тип объекта не совпадает с типом значения, которое ему хотят присвоить? Допустим,
ival = 3.14159; // правильно?
В таком случае компилятор пытается трансформировать тип объекта, стоящего справа, в тип объекта, стоящего слева. Если такое преобразование возможно, компилятор неявно изменяет тип, причем при потере точности обычно выдается предупреждение. В нашем случае вещественное значение 3.14159 преобразуется в целое значение 3, и это значение присваивается переменной ival.
Если неявное приведение типов невозможно, компилятор сигнализирует об ошибке:
pi = ival; // ошибка
Неявная трансформация типа int в тип указатель на int
невозможна. (Набор допустимых неявных преобразований типов мы обсудим в разделе 4.14.)
Левый операнд операции присваивания должен быть l-значением. Очевидный пример неправильного присваивания:
1024 = ival; // ошибка
Возможно, имелось в виду следующее:
value = ival; // правильно
Однако недостаточно потребовать, чтобы операнд слева от знака присваивания был l-значением.
Так, после определений
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 в обоих случаях. Это свойство операции присваивания можно использовать в подвыражениях. Например, следующий цикл
}
может быть переписан так:
}
Заметим, что вокруг выражения присваивания необходимы скобки, поскольку приоритет этой операции ниже, чем операции сравнения. Без скобок первым выполняется сравнение:
next_char() != '\n'
и его результат, true или false, присваивается переменной ch. (Приоритеты операций будут рассмотрены в разделе 4.13.)
Аналогично несколько операций присваивания могут быть объединены, если это позволяют типы операндов. Например:
// ...
}
Обеим переменным ival и jval
присваивается значение 0. Следующий пример неправилен, потому что типы pval и ival
различны, и неявное преобразование типов невозможно. Отметим, что 0
является допустимым значением для обеих переменных:
// ...
}
Верен или нет приведенный ниже пример, мы сказать не можем, , поскольку определение jval в нем отсутствует:
}
Это правильно только в том случае, если переменная jval определена в программе ранее и имеет тип, приводимый к int. Обратите внимание: в этом случае мы присваиваем 0
значение jval и инициализируем ival. Для того чтобы инициализировать нулем обе переменные, мы должны написать:
}
В практике программирования часты случаи, когда к объекту применяется некоторая операция, а результат этой операции присваивается тому же объекту. Например:
}
Для более компактной записи С и С++ предоставляют составные операции присваивания. С использованием такого оператора данный пример можно переписать следующим образом:
}
Общий синтаксис составного оператора присваивания таков:
a op= b;
где op=
является одним из десяти операторов:
+= -= *= /= %=
<<= >>= &= ^= |=
Запись a op= b в точности эквивалентна записи a = a op b.
Упражнение 4.6
Найдите ошибку в данном примере. Исправьте запись.
}
Упражнение 4.7
Следующие выражения синтаксически правильны, однако скорее всего работают не так, как предполагал программист. Почему? Как их изменить?
(c) ival += ival + 1;
complex< double > с = a * b + a / b;
Комплексные и арифметические типы разрешается смешивать в одном выражении:
complex< double > complex_obj = a + 3.14159;
Аналогично комплексные числа инициализируются арифметическим типом, и им может быть присвоено такое значение:
complex_obj = dval;
Или
complex_obj = ival;
Однако обратное неверно. Например, следующее выражение вызовет ошибку компиляции:
double dval = complex_obj;
Нужно явно указать, какую часть комплексного числа – вещественную или мнимую – мы хотим присвоить обычному числу. Класс комплексных чисел имеет две функции, возвращающих соответственно вещественную и мнимую части. Мы можем обращаться к ним, используя синтаксис доступа к членам класса:
double im = complex_obj.imag();
или эквивалентный синтаксис вызова функции:
double im = imag(complex_obj);
Класс комплексных чисел поддерживает четыре составных оператора присваивания: +=, -=, *= и /=. Таким образом,
complex_obj += second_complex_obj;
Поддерживается и ввод/вывод комплексных чисел. Оператор вывода печатает вещественную и мнимую части через запятую, в круглых скобках. Например, результат выполнения операторов вывода
cout << complexO << " " << complex1 << endl;
выглядит так:
( 3.14159, -2.171 ) ( 3.14159, 0.0 )
Оператор ввода понимает любой из следующих форматов:
3.14159 ( 3.14159 ) ( 3.14, -1.0 )
Кроме этих операций, класс комплексных чисел имеет следующие функции-члены: sqrt(), abs(), polar(), sin(), cos(), tan(), exp(), log(), log10() и pow().
Упражнение 4.9
Реализация стандартной библиотеки С++, доступная нам в момент написания книги, не поддерживает составных операций присваивания, если правый операнд не является комплексным числом. Например, подобная запись недопустима:
complex_obj += 1;
(Хотя согласно стандарту С++ такое выражение должно быть корректно, производители часто не успевают за стандартом.) Мы можем определить свой собственный оператор для реализации такой операции. Вот вариант функции, реализующий оператор сложения для complex:
}
(Это пример перегрузки оператора для определенного типа данных, детально рассмотренной в главе 15.)
Используя этот пример, реализуйте три других составных оператора присваивания для типа complex. Добавьте свою реализацию к программе, приведенной ниже, и запустите ее для проверки.
<
}
Упражнение 4.10
Стандарт С++ не специфицирует реализацию операций инкремента и декремента для комплексного числа. Однако их семантика вполне понятна: если уж мы можем написать:
cval += 1;
что означает увеличение на 1
вещественной части cval, то и операция инкремента выглядела бы вполне законно. Реализуйте эти операции для типа complex и выполните следующую программу:
}
позволяет добавить единственный элемент в конец контейнера. Но как вставить элемент в произвольную позицию? А целую последовательность элементов? Для этих случаев существуют более общие операции.
Например, для вставки элемента в начало контейнера можно использовать:
svec.insert( svec.begin(), spouse );
Первый параметр функции-члена insert() (итератор, адресующий некоторый элемент контейнера) задает позицию, а второй – вставляемое перед этой позицией значение. В примере выше элемент добавляется в начало контейнера. А так можно реализовать вставку в произвольную позицию:
slist.insert( iter, spouse );
Здесь find()
возвращает позицию элемента в контейнере, если элемент найден, либо итератор end(), если ничего не найдено. (Мы вернемся к функции find() в конце следующего раздела.) Как можно догадаться, push_back()
эквивалентен следующей записи:
slist.insert( slist.end(), value );
Вторая форма функции-члена insert()
позволяет вставить указанное количество одинаковых элементов, начиная с определенной позиции. Например, если мы хотим добавить десять элементов Anna в начало вектора, то должны написать:
svec.insert( svec.begin(), 10, anna );
insert()
имеет и третью форму, помогающую вставить в контейнер несколько элементов. Допустим, имеется следующий массив:
string sarray[4] = { "quasi", "simba", "frollo", "scar" };
Мы можем добавить все его элементы или только некоторый диапазон в наш вектор строк:
<
sarray+2, sarray+4 );
Такой диапазон отмечается и с помощью пары итераторов
svec.begin(), svec.end() );
или любого контейнера, содержащего строки:[14]
slist.insert( iter, svec.begin(), svec.end() );
Операции сравнения и логические операции в результате дают значение типа bool, то есть true или false. Если же такое выражение встречается в контексте, требующем целого значения, true
преобразуется в 1, а false – в 0. Вот фрагмент кода, подсчитывающего количество элементов вектора, меньших некоторого заданного значения:
}
Мы просто прибавляем результат операции “меньше” к счетчику. (Пара +=
обозначает составной оператор присваивания, который складывает операнд, стоящий слева, и операнд, стоящий справа. То же самое можно записать более компактно: elem_count = elem_count + n. Мы рассмотрим такие операторы в разделе 4.4.)
Логическое И (&&) возвращает истину только тогда, когда истинны оба операнда. Логическое ИЛИ (||) дает истину, если истинен хотя бы один из операндов. Гарантируется, что операнды вычисляются слева направо и вычисление заканчивается, как только результирующее значение становится известно. Что это значит? Пусть даны два выражения:
expr1 || expr2
Если в первом из них expr1
равно false, значение всего выражения тоже будет равным false вне зависимости от значения expr2, которое даже не будет вычисляться. Во втором выражении expr2 не оценивается, если expr1
равно true, поскольку значение всего выражения равно true вне зависимости от expr2.
Подобный способ вычисления дает возможность удобной проверки нескольких выражений в одном операторе AND:
{ ... }
Указатель с нулевым значением не указывает ни на какой объект, поэтому применение к нулевому указателю операции доступа к члену вызвало бы ошибку (ptr->value). Однако, если ptr
равен 0, проверка на первом шаге прекращает дальнейшее вычисление подвыражений. Аналогично на втором и третьем шагах проверяется попадание величины ptr->value в нужный диапазон, и операция взятия индекса не применяется к массиву ia, если этот индекс неправилен.
Операция логического НЕ дает true, если ее единственный оператор равен false, и наоборот. Например:
}
Подвыражение
! found
дает true, если переменная found
равна false. Это более компактная запись для
found == false
Аналогично
if ( found )
эквивалентно более длинной записи
if ( found == true )
Использование операций сравнения достаточно очевидно. Нужно только иметь в виду, что, в отличие от И и ИЛИ, порядок вычисления операндов таких выражений не определен. Вот пример, где возможна подобная ошибка:
// поменять местами элементы
Программист предполагал, что левый операнд оценивается первым и сравниваться будут элементы ia[0] и ia[1]. Однако компилятор не гарантирует вычислений слева направо, и в таком случае элемент ia[0]
может быть сравнен сам с собой. Гораздо лучше написать более понятный и машинно-независимый код:
++index;
Еще один пример возможной ошибки. Мы хотели убедиться, что все три величины ival, jval и kval различаются. Где мы промахнулись?
// do something ...
Значения 0, 1 и 0 дают в результате вычисления такого выражения true. Почему? Сначала проверяется ival != jval, а потом итог этой проверки (true/false – преобразованной к 1/0) сравнивается с kval. Мы должны были явно написать:
// сделать что-то ...
Упражнение 4.4
Найдите неправильные или непереносимые выражения, поясните. Как их можно изменить? (Заметим, что типы объектов не играют роли в данных примерах.)
(b) ival != jva1 < kva1 (d) iva1++ && ival
Упражнение 4.5
Язык С++ не диктует порядок вычисления операций сравнения для того, чтобы позволить компилятору делать это оптимальным образом. Как вы думаете, стоило бы в данном случае пожертвовать эффективностью, чтобы избежать ошибок, связанных с предположением о вычислении выражения слева направо?
void list::merge( list rhs, Compare comp );
Элементы двух упорядоченных списков объединяются либо на основе оператора “меньше”, определенного для типа элементов в контейнере, либо на основе указанной пользователем операции сравнения. (Заметьте, что элементы списка rhs перемещаются в список, для которого вызвана функция-член merge(); по завершении операции список rhs
будет пуст.) Например:
ilist1.merge( ilist2 );
После выполнения операции merge() список ilist2
пуст, а ilist1
содержит первые 15 чисел Фибоначчи в порядке возрастания.
void list::remove_if( Predicate pred );
Операция remove_if()
удаляет все элементы, для которых выполняется указанное условие, т.е. предикат pred
возвращает true. Например:
ilist1.remove_if( Even() );
удаляет все четные числа из списка, определенного при рассмотрении merge().
Операция remove()
удаляет все элементы с заданным значением:
ilist1.remove( 1 );
Операция reverse()
изменяет порядок следования элементов списка на противоположный:
ilist1.reverse();
void list::sort( Compare comp );
По умолчанию sort()
упорядочивает элементы списка по возрастанию с помощью оператора “меньше”, определенного в классе элементов контейнера. Вместо этого можно явно передать в качестве аргумента оператор сравнения. Так,
list1.sort();
упорядочивает list1 по возрастанию, а
list1.sort( greater() );
упорядочивает list1 по убыванию, используя оператор “больше”.
iterator first, iterator last );
Операция splice()
имеет три формы: перемещение одного элемента, всех элементов или диапазона из одного списка в другой. В каждом случае передается итератор, указывающий на позицию вставки, а перемещаемые элементы располагаются непосредственно перед ней. Если даны два списка:
list< int > ilist2( array, array + 2 ); // содержит 0, 1
то следующее обращение к splice()
перемещает первый элемент ilist1 в ilist2. Теперь ilist2
содержит элементы 0, 1 и 0, тогда как в ilist1
элемента 0
больше нет.
ilis2.splice( ilist2.end(), ilist1, ilist1.begin() );
В следующем примере применения splice() передаются два итератора, ограничивающие диапазон перемещаемых элементов:
ilist2.splice( ilist2.begin(), ilist1, first, last );
В данном случае элементы 2, 3, 5 и 8
удаляются из ilist1 и вставляются в начало ilist2. Теперь ilist1
содержит пять элементов 1, 1, 13, 21 и 34. Для их перемещения в ilist2
можно воспользоваться третьей вариацией операции splice():
ilist2.splice( pos, ilist1 );
Итак, список ilist1
пуст. Последние пять элементов перемещены в позицию списка ilist2, предшествующую той, которую занимает элемент 5.
void list::unique( BinaryPredicate pred );
Операция unique()
удаляет соседние дубликаты. По умолчанию при сравнении используется оператор равенства, определенный для типа элементов контейнера. Например, если даны значения {0,2,4,6,4,2,0}, то после применения unique()
список останется таким же, поскольку в соседних позициях дубликатов нет. Но если мы сначала отсортируем список, что даст {0,0,2,2,4,4,6}, а потом применим unique(), то получим четыре различных значения {0,2,4,6}.
ilist.unique();
Вторая форма unique()
принимает альтернативный оператор сравнения. Например,
ilist.unique( EvenPair() );
удаляет соседние элементы, если второй элемент без остатка делится на первый.
Эти операции, являющиеся членами класса, следует предпочесть соответствующим обобщенным алгоритмам при работе со списками. Остальные обобщенные алгоритмы, такие, как find(), transform(), for_each() и т.д., работают со списками так же эффективно, как и с другими контейнерами (еще раз напомним, что подробно все алгоритмы рассматриваются в Приложении).
Упражнение 12.8
Измените программу из раздела 12.2, используя список вместо вектора.
Часть IV
Объектное программирование
В части 4 мы сосредоточимся на объектном программировании, т.е. на применении классов C++ для определения новых типов, манипулировать которыми так же просто, как и встроенными. Создавая новые типы для описания предметной области, C++ помогает программисту писать более легкие для понимания приложения. Классы позволяют отделить детали, касающиеся реализации нового типа, от определения интерфейса и операций, предоставляемых пользователю. При этом уделяется меньше внимания мелочам, из-за чего программирование становится таким утомительным занятием. Значимые для приложения типы можно реализовать всего один раз, после чего использовать повторно. Средства, обеспечивающие инкапсуляцию данных и функций, необходимых для реализации типа, помогают значительно упростить последующее сопровождение и развитие приложения.
В главе 13 мы рассмотрим общий механизм классов: порядок их определения, концепцию сокрытия информации (т.е. отделение открытого интерфейса от закрытой реализации), способы определения и манипулирования объектами класса, область видимости, вложенные классы и классы как члены пространства имен.
В главе 14 изучаются предоставляемые C++ средства инициализации и уничтожения объектов класса, а также присваивания им значений путем применения таких специальных функций-членов класса, как конструкторы, деструкторы и копирующие конструкторы. Мы рассмотрим вопрос о почленной инициализации и копировании, когда объект класса инициализируется или ему присваивается значение другого объекта того же класса.
В главе 15 мы расскажем о перегрузке операторов, которая позволяет использовать операнды типа класса со встроенными операторами, описанными в главе 4. Таким образом, работа с объектами типа класса может быть сделана столь же понятной, как и работа со встроенными типами. В начале главы 15 представлены общие концепции и соображения, касающиеся проектирования перегрузки операторов, а затем рассмотрены конкретные операторы, такие, как присваивание, взятие индекса, вызов, а также специфичные для классов операторы new и delete. Иногда необходимо объявить перегруженный оператор, как друга класса, наделив его специальными правами доступа, в данной главе объясняется, зачем это нужно. Здесь же представлен еще один специальный вид функций-членов – конвертеры, которые позволяют программисту определить стандартные преобразования. Конвертеры неявно применяются компилятором, когда объекты класса используются в качестве фактических аргументов функции или операндов встроенного либо перегруженного оператора. Завершается глава изложением правил разрешения перегрузки функций с учетом аргументов типа класса, функций-членов и перегруженных операторов.
Тема главы 16 – шаблоны классов. Шаблон – это предписание для создания класса, в котором один или несколько типов параметризованы. Например, vector
может быть параметризован типом элементов, хранящихся в нем, а buffer – типом элементов в буфере или его размером. В этой главе объясняется, как определить и конкретизировать шаблон. Поддержка классов в C++ теперь рассматривается иначе – в свете наличия шаблонов, и снова обсуждаются функции-члены, объявления друзей и вложенные типы. Здесь мы еще раз вернемся к модели компиляции шаблонов, описанной в главе 10, чтобы показать, какое влияние оказывают на нее шаблоны классов.
13
можно применять для преобразования указателя, ссылающегося на объект типа класса в указатель на тип класса из той же иерархии. Его также используют для трансформации l-значения объекта типа класса в ссылку на тип класса из той же иерархии. Приведение типов с помощью оператора dynamic_cast, в отличие от других имеющихся в C++ способов, осуществляется во время выполнения программы. Если указатель или l-значение не могут быть преобразованы в целевой тип, то dynamic_cast
завершается неудачно. В случае приведения типа указателя признаком неудачи служит возврат нулевого значения. Если же l-значение нельзя трансформировать в ссылочный тип, возбуждается исключение. Ниже мы приведем примеры неудачного выполнения
этого оператора.
Прежде чем перейти к более детальному рассмотрению dynamic_cast, посмотрим, зачем его нужно применять. Предположим, что в программе используется библиотека классов для представления различных категорий служащих компании. Входящие в иерархию классы поддерживают функции-члены для вычисления зарплаты:
}
В компании есть разные категории служащих. Параметром функции-члена payroll()
класса company
является указатель на объект employee, который может адресовать один из типов manager или programmer. Поскольку payroll()
обращается к виртуальной функции-члену salary(), то вызывается подходящая замещающая функция, определенная в классе manager или programmer, в зависимости от того, какой объект адресован указателем.
Допустим, класс employee
перестал удовлетворять нашим потребностям, и мы хотим его модифицировать, добавив еще одну функцию-член bonus(), используемую совместно с salary() при расчете платежной ведомости. Для этого нужно включить новую функцию-член в классы, составляющие иерархию employee:
}
Если параметр pe
функции payroll()
указывает на объект типа manager, то вызывается виртуальная функция-член bonus() из базового класса employee, поскольку в классе manager она не замещена. Если же pe
указывает на объект типа programmer, то вызывается виртуальная функция-член bonus() из класса programmer.
После добавления новых виртуальных функций в иерархию классов придется перекомпилировать все функции-члены. Добавить bonus() можно, если у нас есть доступ к исходным текстам функций-членов в классах employee, manager и programmer. Однако если иерархия была получена от независимого поставщика, то не исключено, что в нашем распоряжении имеются только заголовочные файлы, описывающие интерфейс библиотечных классов и объектные файлы с их реализацией, а исходные тексты функций-членов недоступны. В таком случае перекомпиляция всей иерархии невозможна.
Если мы хотим расширить функциональность библиотеки классов, не добавляя новые виртуальные функции-члены, можно воспользоваться оператором dynamic_cast.
Этот оператор применяется для получения указателя на производный класс, чтобы иметь возможность работать с теми его элементами, которые по-другому не доступны. Предположим, что мы расширяем библиотеку за счет добавления новой функции-члена bonus() в класс programmer. Ее объявление можно включить в определение programmer, находящееся в заголовочном файле, а саму функцию определить в одном из своих исходных файлов:
<
};
Напомним, что payroll()
принимает в качестве параметра указатель на базовый класс employee. Мы можем применить оператор dynamic_cast для получения указателя на производный programmer и воспользоваться им для вызова функции-члена bonus():
}
Оператор
dynamic_cast< programmer* >( pe )
приводит свой операнд pe к типу programmer*. Преобразование будет успешным, если pe ссылается на объект типа programmer, и неудачным в противном случае: тогда результатом dynamic_cast
будет 0.
Таким образом, оператор dynamic_cast
осуществляет сразу две операции. Он проверяет, выполнимо ли запрошенное приведение, и если это так, выполняет его. Проверка производится во время работы программы. dynamic_cast
безопаснее, чем другие операции приведения типов в C++, поскольку проверяет возможность корректного преобразования.
Если в предыдущем примере pe
действительно указывает на объект типа programmer, то операция dynamic_cast
завершится успешно и pm
будет инициализирован указателем на объект типа programmer. В противном случае pm
получит значение 0. Проверив значение pm, функция company::payroll()
может узнать, указывает ли pm на объект programmer. Если это так, то она вызывает функцию-член programmer::bonus() для вычисления премии программисту. Если же dynamic_cast завершается неудачно, то pe
указывает на объект типа manager, а значит, необходимо применить более общий алгоритм расчета, не использующий новую функцию-член programmer::bonus().
Оператор dynamic_cast
употребляется для безопасного приведения указателя на базовый класс к указателю на производный. Такую операцию часто называют понижающим приведением (downcasting). Она применяется, когда необходимо воспользоваться особенностями производного класса, отсутствующими в базовом. Манипулирование объектами производного класса с помощью указателей на базовый обычно происходит автоматически, с помощью виртуальных функций. Однако иногда использовать виртуальные функции невозможно. В таких ситуациях dynamic_cast
предлагает альтернативное решение, хотя этот механизм в большей степени подвержен ошибкам, чем виртуализация, и должен применяться с осторожностью.
Одна из возможных ошибок – это работа с результатом dynamic_cast без предварительной проверки на 0: нулевой указатель нельзя использовать для адресации объекта класса. Например:
}
Результат, возвращенный dynamic_cast, всегда следует проверять, прежде чем использовать в качестве указателя. Более правильное определение функции company::payroll()
могло бы выглядеть так:
}
Результат операции dynamic_cast
используется для инициализации переменной pm внутри условного выражения в инструкции if. Это возможно, так как объявления в условиях возвращают значения. Ветвь, соответствующая истинности условия, выполняется, если pm не равно нулю: мы знаем, что операция dynamic_cast завершилась успешно и pe
указывает на объект programmer. В противном случае результатом объявления будет 0 и выполняется ветвь else. Поскольку теперь оператор и проверка его результата находятся в одной инструкции программы, то невозможно случайно вставить какой-либо код между выполнением dynamic_cast и проверкой, так что pm
будет использоваться только тогда, когда содержит правильный указатель.
В предыдущем примере операция dynamic_cast
преобразует указатель на базовый класс в указатель на производный. Ее также можно применять для трансформации l-значения типа базового класса в ссылку на тип производного. Синтаксис такого использования dynamic_cast
следующий:
dynamic_cast< Type & >( lval )
где Type& – это целевой тип преобразования, а lval – l-значение типа базового класса. Операнд lval
успешно приводится к типу Type& только в том случае, когда lval
действительно относится к объекту класса, для которого один из производных имеет тип Type.
Поскольку нулевых ссылок не бывает (см. раздел 3.6), то проверить успешность выполнения операции путем сравнения результата (т.е. возвращенной оператором dynamic_cast
ссылки) с нулем невозможно. Если вместо указателей используются ссылки, условие
if ( programmer *pm = dynamic_cast< programmer* >( pe ) )
нельзя переписать в виде
if ( programmer &pm = dynamic_cast< programmer& >( pe ) )
Для извещения об ошибке в случае приведения к ссылочному типу оператор dynamic_cast
возбуждает исключение. Следовательно, предыдущий пример можно записать так:
}
В случае неудачного завершения ссылочного варианта dynamic_cast возбуждается исключение типа bad_cast. Класс bad_cast
определен в стандартной библиотеке; для ссылки на него необходимо включить в программу заголовочный файл. (Исключения из стандартной библиотеки мы будем рассматривать в следующем разделе.)
Когда следует употреблять ссылочный вариант dynamic_cast вместо указательного? Это зависит только от желания программиста. При его использовании игнорировать ошибку приведения типа и работать с результатом без проверки (как в указательном варианте) невозможно; с другой стороны, применение исключений увеличивает накладные расходы во время выполнения программы (см. главу 11).
new (place_address) type-specifier
place_address
должен быть указателем. Такая форма (она включается заголовочным файлом) позволяет программисту предварительно выделить большую область памяти, которая впоследствии будет содержать различные объекты. Например:
}
Результат работы программы:
Оператор new сработал!
Для оператора размещения new нет парного оператора delete: он не нужен, поскольку эта форма не выделяет память. В предыдущем примере необходимо освободить память, адресуемую указателем buf, а не pb. Это происходит в конце программы, когда буфер больше не нужен. Поскольку buf
ссылается на символьный массив, оператор delete имеет форму
delete[] buf;
При уничтожении buf
прекращают существование все объекты, созданные в нем. В нашем примере pb
больше не ссылается на существующий объект класса Foo.
Упражнение 8.5
Объясните, почему приведенные операторы new
ошибочны:
(d) const int *pci = new const int;
Упражнение 8.6
Как бы вы уничтожили pa?
<
int *pa = new arr;
Упражнение 8.7
Какие из следующих операторов delete
содержат потенциальные ошибки времени выполнения и почему:
}
Упражнение 8.8
Какие из данных объявлений auto_ptr
неверны или грозят ошибками времени выполнения? Объясните каждый случай.
(h) auto_ptr p7(p2);
Упражнение 8.9
Объясните разницу между следующими инструкциями:
int *pi1 = p2.release() ;
Для каких случаев более приемлем тот или иной вызов?
Упражнение 8.10
Пусть мы имеем:
auto_ptr< string > ps( new string( "Daniel" ) );
В чем разница между этими двумя вызовами assign()?Какой их них предпочтительнее и почему?
ps->assign( "Danny" );
может быть перегружен при условии, что все объявления имеют разные списки параметров. Первый параметр должен иметь тип size_t:
};
Остальные параметры инициализируются аргументами размещения, заданными при вызове new:
}
Та часть выражения, которая находится после ключевого слова new и заключена в круглые скобки, представляет аргументы размещения. В примере выше вызывается оператор new(), принимающий два параметра. Первый автоматически инициализируется значением, равным размеру класса Screen в байтах, а второй– значением аргумента размещения start.
Можно также перегружать и оператор-член delete(). Однако такой оператор никогда не вызывается из выражения delete. Перегруженный delete()
неявно вызывается компилятором, если конструктор, вызванный при выполнении оператора new
(это не опечатка, мы действительно имеем в виду new), возбуждает исключение. Рассмотрим использование delete()
более внимательно.
Последовательность действий при вычислении выражения
Screen *ps = new ( start ) Screen;
такова:
1. Вызывается определенный в классе оператор new(size_t, Screen*).
2. Вызывается конструктор по умолчанию класса Screen для инициализации созданного объекта.
Переменная ps
инициализируется адресом нового объекта Screen.
Предположим, что оператор класса new(size_t, Screen*) выделяет память с помощью глобального new(). Как разработчик может гарантировать, что память будет освобождена, если вызванный на шаге 2 конструктор возбуждает исключение? Чтобы защитить пользовательский код от утечки памяти, следует предоставить перегруженный оператор delete(), который вызывается только в подобной ситуации.
Если в классе имеется перегруженный оператор с параметрами, типы которых соответствуют типам параметров new(), то компилятор автоматически вызывает его для освобождения памяти. Предположим, есть следующее выражение с оператором размещения new:
void func( matrix &m );
Объявление члена пространства имен скрыто в своем пространстве. Если мы не укажем компилятору, где именно искать объявление, он произведет поиск только в текущей области видимости и в областях, включающих текущую. Допустим, если переписать предыдущую программу так:
void func( matrix &m );
то определение класса matrix
компилятор находит в глобальной области видимости и программа компилируется без ошибок. Поскольку объявление matrix как члена пространства имен cplusplus_primer
скрыто в этом пространстве, оно не конфликтует с классом, объявленным в глобальной области видимости.
Именно поэтому мы говорим, что пространства имен решают проблему засорения глобального пространства: имена их членов невидимы, если имя пространства не указано явно, с помощью оператора разрешения области видимости. Существуют и другие механизмы, позволяющие сделать объявление члена пространства имен видимым вне его. Это using-объявления и using-директивы. Мы рассмотрим их в следующем разделе.
Отметим, что оператор области видимости может быть использован и для того, чтобы сослаться на элемент глобального пространства имен. Поскольку это пространство не имеет имени, запись
::member_name
относится к его элементу. Такой способ полезен для указания членов глобального пространства, если их имена оказываются скрыты именами, объявленными во вложенных локальных областях видимости.
Следующий пример демонстрирует использование оператора области видимости для обращения к скрытому члену глобального пространства имен. Функция вычисляет последовательность чисел Фибоначчи. В программе два определения переменной max. Глобальная переменная указывает максимальное значение элемента последовательности, при превышении которого вычисление прекращается, а локальная – желаемую длину последовательности при данном вызове функции. (Напоминаем, что параметры функции относятся к ее локальной области видимости.) Внутри функции должны быть доступны обе переменных. Однако неквалифицированное имя max
ссылается на локальное объявление этой переменной. Чтобы получить глобальную переменную, нужно использовать оператор разрешения области видимости ::max. Вот текст программы:
}
Так выглядит функция main(), вызывающая fibonacci():
}
Результат работы программы:
Числа Фибоначчи: 16
0 1 1 2 3 5 8 13 21 34 55 89
144 233 377 610
возвращает размер в байтах объекта или типа данных. Синтаксис его таков:
sizeof object;
Результат имеет специальный тип size_t, который определен как typedef в заголовочном файле cstddef. Вот пример использования обеих форм оператора sizeof:
size_t element_size = array_size / sizeof( int );
Применение sizeof к массиву дает количество байтов, занимаемых массивом, а не количество его элементов и не размер в байтах каждого из них. Так, например, в системах, где int
хранится в 4 байтах, значением array_size будет 12. Применение sizeof к указателю дает размер самого указателя, а не объекта, на который он указывает:
size_t pointer_size = sizeof ( pi );
Здесь значением pointer_size
будет память под указатель в байтах (4 в 32-битных системах), а не массива ia.
Вот пример программы, использующей оператор sizeof:
<
}
Результатом работы программы будет:
pi: 4 *pi: 4
st1: 12 st2: 12 ps: 4 *ps:12
short : 2
short* : 4
short& : 2
short[3] : 6
Из данного примера видно, что применение sizeof к указателю позволяет узнать размер памяти, необходимой для хранения адреса. Если же аргументом sizeof
является ссылка, мы получим размер связанного с ней объекта.
Гарантируется, что в любой реализации С++ размер типа char равен 1.
size_t char_size = sizeof( char );
Значение оператора sizeof
вычисляется во время компиляции и считается константой. Оно может быть использовано везде, где требуется константное значение, в том числе в качестве размера встроенного массива. Например:
int array[ sizeof( some_type_T )];
Допустим, мы хотим определить тип класса для представления указателя на объект Screen (см. главу 13):
};
Определение ScreenPtr
должно быть таким, чтобы объект этого класса гарантировано указывал на объект Screen: в отличие от встроенного указателя, он не может быть нулевым. Тогда приложение сможет пользоваться объектами типа ScreenPtr, не проверяя, указывают ли они на какой-нибудь объект Screen. Для этого нужно определить класс ScreenPtr с конструктором, но без конструктора по умолчанию (детально конструкторы рассматривались в разделе 14.2):
};
В любом определении объекта класса ScreenPtr
должен присутствовать инициализатор– объект класса Screen, на который будет ссылаться объект ScreenPtr:
ScreenPtr ps( myScreen ); // правильно
Чтобы класс ScreenPtr вел себя как встроенный указатель, необходимо определить некоторые перегруженные операторы – разыменования (*) и “стрелку” для доступа к членам:
};
Оператор доступа к членам унарный, поэтому параметры ему не передаются. При использовании в составе выражения его результат зависит только от типа левого операнда. Например, в инструкции
point->action();
исследуется тип point. Если это указатель на некоторый тип класса, то применяется семантика встроенного оператора доступа к члену. Если же это объект или ссылка на объект, то проверяется, есть ли в этом классе перегруженный оператор доступа. Когда перегруженный оператор “стрелка” определен, он вызывается для объекта point, иначе инструкция неверна, поскольку для обращения к членам самого объекта (в том числе по ссылке) следует использовать оператор “точка”.
Перегруженный оператор “стрелка” должен возвращать либо указатель на тип класса, либо объект класса, в котором он определен. Если возвращается указатель, то к нему применяется семантика встроенного оператора “стрелка”. В противном случае процесс продолжается рекурсивно, пока не будет получен указатель или определена ошибка. Например, так можно воспользоваться объектом ps
класса ScreenPtr для доступа к членам Screen:
ps->move( 2, 3 );
Поскольку слева от оператора “стрелка” находится объект типа ScreenPtr, то употребляется перегруженный оператор этого класса, который возвращает указатель на объект Screen. Затем к полученному значению применяется встроенный оператор “стрелка” для вызова функции-члена move().
Ниже приводится небольшая программа для тестирования класса ScreenPtr. Объект типа ScreenPtr
используется точно так же, как любой объект типа Screen*:
}
Разумеется, подобные манипуляции с указателями на объекты классов не так эффективны, как работа со встроенными указателями. Поэтому интеллектуальный указатель должен предоставлять дополнительную функциональность, важную для приложения, чтобы оправдать сложность своего использования.
сообщает тип производного класса объекта:
coiut << typeid( re ).name() << endl;
Операнд re
оператора typeid
имеет тип employee. Но так как re – это ссылка на тип класса с виртуальными функциями, то typeid
говорит, что тип адресуемого объекта – programmer (а не employee, на который ссылается re). Программа, использующая такой оператор, должна включать заголовочный файл, что мы и сделали в этом примере.
Где применяется typeid? В сложных системах разработки, например при построении отладчиков, а также при использовании устойчивых объектов, извлеченных из базы данных. В таких системах необходимо знать фактический тип объекта, которым программа манипулирует с помощью указателя или ссылки на базовый класс, например для получения списка его свойств во время сеанса работы с отладчиком или для правильного сохранения или извлечения объекта из базы данных. Оператор typeid
допустимо использовать с выражениями и именами любых типов. Например, его операндами могут быть выражения встроенных типов и константы. Если операнд не принадлежит к типу класса, то typeid просто возвращает его тип:
cout << typeid( 8.16 ).name() << endl; // печатается: double
Если операнд имеет тип класса, в котором нет виртуальных функций, то typeid
возвращает тип операнда, а не связанного с ним объекта:
cout << typeid( *pb ).name() << endl; // печатается: Base
Операнд typeid
имеет тип Base, т.е. тип выражения *pb. Поскольку в классе Base нет виртуальных функций, результатом typeid будет Base, хотя объект, на который указывает pb, имеет тип Derived.
Результаты, возвращенные оператором typeid, можно сравнивать. Например:
*/
Условие в инструкции if
сравнивает результаты применения typeid к операнду, являющемуся выражением, и к операнду, являющемуся именем типа. Обратите внимание, что сравнение
typeid( pe ) == typeid( employee* )
возвращает истину. Это удивит пользователей, привыкших писать:
pe->salary();
что приводит к вызову виртуальной функции salary() из производного класса manager. Поведение typeid(pe) не подчиняется данному механизму. Это связано с тем, что pe – указатель, а для получения типа производного класса операндом typeid
должен быть тип класса с виртуальными функциями. Выражение typeid(pe)
возвращает тип pe, т.е. указатель на employee. Это значение совпадает со значением typeid(employee*), тогда как все остальные сравнения дают ложь.
Только при употреблении выражения *pe в качестве операнда typeid
результат будет содержать тип объекта, на который указывает pe:
typeid( *pe ) == typeid( employee ) // ложно
В этих сравнениях *pe – выражение типа класса, который имеет виртуальные функции, поэтому результатом применения typeid
будет тип адресуемого операндом объекта manager.
Такой оператор можно использовать и со ссылками:
typeid( &re ) == typeid( manager* ) // ложно
В первых двух сравнениях операнд re
имеет тип класса с виртуальными функциями, поэтому результат применения typeid
содержит тип объекта, на который ссылается re. В последних двух сравнениях операнд &re
имеет тип указателя, следовательно, результатом будет тип самого операнда, т.е. employee*.
На самом деле оператор typeid
возвращает объект класса типа type_info, который определен в заголовочном файле. Интерфейс этого класса показывает, что можно делать с результатом, возвращенным typeid. (В следующем подразделе мы подробно рассмотрим этот интерфейс.)
}
печатает на терминале строку:
сплетница Анна Ливия
Имеются операторы, принимающие аргументы любого встроенного типа данных, включая const char*, а также типов string и complex из стандартной библиотеки. Любое выражение, включая вызов функции, может быть аргументом оператора вывода при условии, что результатом его вычисления будет тип, принимаемый каким-либо вариантом этого оператора. Например, программа
}
выводит на терминал следующее:
Длина 'Улисс' равна:7
Размер 'Улисс' равен:8
endl – это манипулятор вывода, который вставляет в выходной поток символ перехода на новую строку, а затем сбрасывает буфер объекта ostream. (С буферизацией мы познакомимся в разделе 20.9.)
Операторы вывода, как правило, удобнее сцеплять в одну инструкцию. Например, предыдущую программу можно записать таким образом:
}
Сцепление операторов вывода (и ввода тоже) возможно потому, что результатом выражения
cout << "некоторая строка";
служит левый операнд оператора вывода, т.е. сам объект cout. Затем этот же объект передается следующему оператору и далее по цепочке (мы говорим, что оператор << левоассоциативен).
Имеется также предопределенный оператор вывода для указательных типов, который печатает адрес объекта. По умолчанию адреса отображаются в шестнадцатеричном виде. Например, программа
}
выводит на терминал следующее:
i: 1024 &i: 0x7fff0b4
*pi: 1024 pi: 0x7fff0b4
&pi: 0x7fff0b0
Позже мы покажем, как напечатать адреса в десятичном виде.
Следующая программа ведет себя странно. Мы хотим напечатать адрес, хранящийся в переменной pstr:
}
Но после компиляции и запуска программа неожиданно выдает такую строку:
Адрес pstr равен: vermeer
Проблема в том, что тип const char*
интерпретируется как C-строка. Чтобы все же напечатать адрес, хранящийся в pstr, необходимо подавить обработку типа const char* по умолчанию. Для этого мы сначала убираем спецификатор const, а затем приводим pstr к типу void*:
<< static_cast(const_cast(pstr))
Теперь программа
выводит ожидаемый результат:
Адрес pstr равен: 0x116e8
А вот еще одна загадка. Нужно напечатать большее из двух чисел:
<
}
Однако программа выдает неправильный результат:
Большее из 10, 20 равно 0
Проблема в том, что оператор вывода имеет более высокий приоритет, чем оператор условного выражения, поэтому печатается результат сравнения val1 и val2. Иными словами, выражение
cout << ( val1 > val2 ) ? val1 : val2;
вычисляется как
(cout << ( val1 > val2 )) ? val1 : val2;
Поскольку val1 не больше val2, то результатом сравнения будет false, обозначаемый нулем. Чтобы изменить приоритет операций, весь оператор условного выражения следует заключить в скобки:
cout << ( val1 > val2 ? val1 : val2 );
Теперь результат получается правильный:
Большее из 10, 20 равно 20
Такого рода ошибку было бы проще найти, если бы значения литералов true и false
типа bool
печатались как строки, а не как 1 и 0. Тогда мы увидели бы строку:
Большее из 10, 20 равно false
и все стало бы ясно. По умолчанию литерал false
печатается как 0, а true – как 1. Это можно изменить, воспользовавшись манипулятором boolalpha(), что и сделано в следующей программе:
}
Вот результат:
печать значений типа bool по умолчанию: 1 0
и в виде строк: true false
Для вывода массива, а также вектора или отображения, необходимо обойти все элементы и напечатать каждый из них:
<
}
Вместо того чтобы явно обходить все элементы контейнера, выводя каждый по очереди, можно воспользоваться потоковым итератором ostream_iterator. Так выглядит эквивалентная программа, где используется эта техника (подробное обсуждение итератора ostream_iterator см. в разделе 12.4):
}
Программа печатает такую строку:
Ýòî äðóçüÿ Ïóõà: Òèãðà Ïÿòà÷îê Èà-Èà Êðîëèê
Упражнение 20.1
Даны следующие определения объектов:
complex purei( 0, 7 );
(a) Направьте значение каждого объекта в стандартный вывод.
(b) Напечатайте значение адреса pc.
(c) Напечатайте наименьшее из двух значений ival и dval, пользуясь оператором условного выражения:
ival < dval ? ival : dval
можно определить класс absInt:
};
Перегруженный оператор operator()
должен быть объявлен как функция-член с произвольным числом параметров. Параметры и возвращаемое значение могут иметь любые типы, допустимые для функций (см. разделы 7.2, 7.3 и 7.4). operator() вызывается путем применения списка аргументов к объекту того класса, в котором он определен. Мы рассмотрим, как он используется в одном из обобщенных алгоритмов, описанных в главе 12. В следующем примере обобщенный алгоритм transform() вызывается для применения определенной в absInt
операции к каждому элементу вектора ivec, т.е. для замены элемента его абсолютным значением.
}
Первый и второй аргументы transform()
ограничивают диапазон элементов, к которым применяется операция absInt. Третий указывает на начало вектора, где будет сохранен результат применения операции.
Четвертый аргумент – это временный объект класса absInt, создаваемый с помощью конструктора по умолчанию. Конкретизация обобщенного алгоритма transform(), вызываемого из main(), могла бы выглядеть так:
}
func – это объект класса, который предоставляет операцию absInt, заменяющую число типа int его абсолютным значением. Он используется для вызова перегруженного оператора operator()
класса absInt. Этому оператору передается аргумент *iter, указывающий на тот элемент вектора, для которого мы хотим получить абсолютное значение.
можно определять для классов, представляющих абстракцию контейнера, из которого извлекаются отдельные элементы. Примерами таких контейнеров могут служить наш класс String, класс IntArray, представленный в главе 2, или шаблон класса vector, определенный в стандартной библиотеке C++. Оператор взятия индекса обязан быть функцией-членом класса.
У пользователей String
должна иметься возможность чтения и записи отдельных символов члена _string. Мы хотим поддержать следующий способ применения объектов данного класса:
mycopy[ ix ] = entry[ ix ];
Оператор взятия индекса может появляться как слева, так и справа от оператора присваивания. Чтобы быть в левой части, он должен возвращать l-значение индексируемого элемента. Для этого мы возвращаем ссылку:
}
В следующем фрагменте нулевому элементу массива color присваивается символ 'V':
color[ 0 ] = 'V';
Обратите внимание, что в определении оператора проверяется выход индекса за границы массива. Для этого используется библиотечная C-функция assert(). Можно также возбудить исключение, показывающее, что значение elem меньше 0 или больше длины C-строки, на которую ссылается _string. (Возбуждение и обработка исключений обсуждались в главе 11.)
}
Для класса могут быть определены и другие операторы присваивания. Если объектам класса надо присваивать значения типа, отличного от этого класса, то разрешается определить такие операторы, принимающие подобные параметры. Например, чтобы поддержать присваивание C-строки объекту String:
car = "Studebaker";
мы предоставляем оператор, принимающий параметр типа const char*. Эта операция уже была объявлена в нашем классе:
};
Такой оператор реализуется следующим образом. Если объекту String присваивается нулевой указатель, он становится “пустым”. В противном случае ему присваивается копия C-строки:
}
_string
ссылается на копию той C-строки, на которую указывает sobj. Почему на копию? Потому что непосредственно присвоить sobj
члену _string
нельзя:
_string = sobj; // ошибка: несоответствие типов
sobj – это указатель на const и, следовательно, не может быть присвоен указателю на “не-const” (см. раздел 3.5). Изменим определение оператора присваивания:
String& String::operator=( const *sobj ) { // ... }
Теперь _string
прямо ссылается на C-строку, адресованную sobj. Однако при этом возникают другие проблемы. Напомним, что C-строка имеет тип const char*. Определение параметра как указателя на не-const делает присваивание невозможным:
car = "Studebaker"; // недопустимо с помощью operator=( char
*) !
Итак, выбора нет. Чтобы присвоить C- строку объекту типа String, параметр должен иметь тип const char*.
Хранение в _string
прямой ссылки на C-строку, адресуемую sobj, порождает и иные сложности. Мы не знаем, на что именно указывает sobj. Это может быть массив символов, который модифицируется способом, неизвестным объекту String. Например:
ia[3] = 'g'; // а вот это нам не нужно:
// модифицируется и ia, и trap._string
Если trap._string
напрямую ссылался на ia, то объект trap
демонстрировал бы своеобразное поведение: его значение может изменяться без вызова функций-членов класса String. Поэтому мы полагаем, что выделение области памяти для хранения копии значения C-строки менее опасно.
Обратите внимание, что в операторе присваивания используется delete. Член _string
содержит ссылку на массив символов, расположенный в хипе. Чтобы предотвратить утечку, память, выделенная под старую строку, освобождается с помощью delete до выделения памяти под новую. Поскольку _string адресует массив символов, следует использовать версию delete для массивов (см. раздел 8.4).
И последнее замечание об операторе присваивания. Тип возвращаемого им значения – это ссылка на класс String. Почему именно ссылка? Дело в том, что для встроенных типов операторы присваивания можно сцеплять:
iobj = jobj = 63;
Они ассоциируются справа налево, т.е. в предыдущем примере присваивания выполняются так:
iobj = (jobj = 63);
Это удобно и при работе с объектами класса String: поддерживается, к примеру, следующая конструкция:
verb = noun = "count";
При первом присваивании из этой цепочки вызывается определенный ранее оператор для const char*. Тип полученного результата должен быть таким, чтобы его можно было использовать как аргумент для копирующего оператора присваивания класса String. Поэтому, хотя параметр данного оператора имеет тип const char *, возвращается все же ссылка на String.
Операторы присваивания бывают перегруженными. Например, в нашем классе String
есть такой набор:
String& operator=( const char * );
Отдельный оператор присваивания может существовать для каждого типа, который разрешено присваивать объекту String. Однако все такие операторы должны быть определены как функции-члены класса.
int res = si + iobj;
операторной функцией-кандидатом является operator+. Какие объявления operator+
принимаются во внимание?
Потенциально в случае применения операторного синтаксиса с операндами, имеющими тип класса, строится пять множеств кандидатов. Первые три– те же, что и при вызове обычных функций с аргументами типа класса:
· множество операторов, видимых в точке вызова. Объявления функции operator+(), видимые в точке использования оператора, являются кандидатами. Например, operator+(), объявленный в глобальной области
видимости, – кандидат в случае применения operator+() внутри main():
}
· множество операторов, объявленных в пространстве имен, в котором определен тип операнда. Если операнд имеет тип класса и этот тип объявлен в пользовательском пространстве имен, то операторные функции, объявленные в том же пространстве и имеющие то же имя, что и использованный оператор, считаются кандидатами:
}
Операнд si
имеет тип класса SmallInt, объявленного в пространстве имен NS. Поэтому перегруженный operator+(const SmallInt, double), объявленный в том же пространстве, добавляется к множеству кандидатов;
· множество операторов, объявленных друзьями классов, к которым принадлежат операнды. Если операнд принадлежит к типу класса и в определении этого класса есть одноименные применяемому оператору функции-друзья, то они добавляются к множеству кандидатов:
}
Операнд si
имеет тип SmallInt. Операторная функция operator+(const SmallInt&, int), являющаяся другом этого класса, – член пространства имен NS, хотя непосредственно в этом пространстве она не объявлена. При обычном поиске в NS эта операторная функция не будет найдена. Однако при использовании operator+() с аргументом типа SmallInt
функции-друзья, объявленные в области видимости этого класса, включаются в рассмотрение и добавляются к множеству кандидатов.
Эти три множества операторных функций-кандидатов формируются точно так же, как и для вызовов обычных функций с аргументами типа класса. Однако при использовании операторного синтаксиса строятся еще два множества:
· множество операторов-членов, объявленных в классе левого операнда. Если такой операнд оператора operator+()
имеет тип класса, то в множество функций-кандидатов включаются объявления operator+(), являющиеся членами этого класса:
}
Оператор-член SmallInt::operator+(const myFloat &), определенный в SmallInt, включается в множество функций-кандидатов для разрешения вызова operator+() в main();
· множество встроенных операторов. Учитывая типы, которые можно использовать со встроенным operator+(), кандидатами являются также:
T* operator+( I, T* );
Первое объявление относится к встроенному оператору для сложения двух значений целых типов, второе – к оператору для сложения значений типов с плавающей точкой. Третье и четвертое соответствуют встроенному оператору сложения указательных типов, который используется для прибавления целого числа к указателю. Два последних объявления представлены в символическом виде и описывают целое семейство встроенных операторов, которые могут быть выбраны компилятором на роль кандидатов при обработке операций сложения.
Любое из первых четырех множеств может оказаться пустым. Например, если среди членов класса SmallInt нет функции с именем operator+(), то четвертое множество будет пусто.
Все множество операторных функций-кандидатов является объединением пяти подмножеств, описанных выше:
}
В эти пять множеств входят семь операторных функций-кандидатов на роль operator+() в main():
· первое множество пусто. В глобальной области видимости, а именно в ней употреблен operator+() в функции main(), нет объявлений перегруженного оператора operator+();
· второе множество содержит операторы, объявленные в пространстве имен NS, где определен класс SmallInt. В этом пространстве имеется один оператор:
NS::SmallInt NS::operator+( const SmallInt &, double );
· третье множество содержит операторы, объявленные друзьями класса SmallInt. Сюда входит
NS::SmallInt NS::operator+( const SmallInt &, int );
· четвертое множество содержит операторы, объявленные членами SmallInt. Такой тоже есть:
NS::SmallInt NS::SmallInt::operator+( const myFloat & );
· пятое множество содержит встроенные бинарные операторы:
T* operator+( I, T* );
Да, формирование множества кандидатов для разрешения оператора, использованного с применением операторного синтаксиса, утомительно. Но после того как оно построено, устоявшие функции и наилучшая из них находятся, как и прежде, путем анализа преобразований, применимых к операндам отобранных кандидатов.
Чтобы использовать класс ScreenPtr для ссылки на элементы массива объектов Screen, туда придется добавить несколько дополнительных членов.
Сначала мы определим новый член size, который содержит либо нуль (это говорит о том, что объект ScreenPtr
указывает на единственный объект), либо размер массива, адресуемого объектом ScreenPtr. Нам также понадобится член offset, запоминающий смещение от начала данного массива:
};
Модифицируем конструктор класса ScreenPtr с учетом его новой функциональности и дополнительных членов,. Пользователь нашего класса должен передать конструктору дополнительный аргумент, если создаваемый объект указывает на массив:
};
С помощью этого аргумента задается размер массива. Чтобы сохранить прежнюю функциональность, предусмотрим для него значение по умолчанию, равное нулю. Таким образом, если второй аргумент конструктора опущен, то член size окажется равен 0 и, следовательно, такой объект будет указывать на единственный объект Screen. Объекты нового класса ScreenPtr
можно определять следующим образом:
ScreenPtr parr( *parray, arrSize ); // правильно: указывает на массив
Теперь мы готовы определить в ScreenPtr
перегруженные операторы инкремента и декремента. Однако они бывают двух видов: префиксные и постфиксные. К счастью, можно определить оба варианта. Для префиксного оператора объявление не содержит ничего неожиданного:
};
Такие операторы определяются как унарные операторные функции. Использовать префиксный оператор инкремента можно, к примеру, следующим образом:
printScreen( parr );
Определения этих перегруженных операторов приведены ниже:
}
Чтобы отличить префиксные операторы от постфиксных, в объявлениях последних имеется дополнительный параметр типа int. В следующем фрагменте объявлены префиксные и постфиксные варианты операторов инкремента и декремента для класса ScreenPtr:
};
Ниже приведена возможная реализация постфиксных операторов:
<
}
Обратите внимание, что давать название второму параметру нет необходимости, поскольку внутри определения оператора он не употребляется. Компилятор сам подставляет для него значение по умолчанию, которое можно игнорировать. Вот пример использования постфиксного оператора:
printScreen( parr++ );
При его явном вызове необходимо все же передать значение второго целого аргумента. В случае нашего класса ScreenPtr это значение игнорируется, поэтому может быть любым:
parr.operator++(1024); // вызов постфиксного operator++
Перегруженные операторы инкремента и декремента разрешается объявлять как дружественные функции. Изменим соответствующим образом определение класса ScreenPtr:
};
Упражнение 15.7
Напишите определения перегруженных операторов инкремента и декремента для класса ScreenPtr, предположив, что они объявлены как друзья класса.
Упражнение 15.8
С помощью ScreenPtr
можно представить указатель на массив объектов класса Screen. Модифицируйте перегруженные operator*() и operator?>()
(см. раздел 15.6) так, чтобы указатель ни при каком условии не адресовал элемент перед началом или за концом массива. Совет: в этих операторах следует воспользоваться новыми членами size и offset.
int *pi =
new int;
размещает объект типа int в памяти и инициализирует указатель pi адресом этого объекта. Сам объект в таком случае не инициализируется, но это легко изменить:
int *pi = new int( 1024 );
Можно динамически выделить память под массив:
int *pia = new int[ 10 ];
Такая инструкция размещает в памяти массив встроенного типа из десяти элементов типа int. Для подобного массива нельзя задать список начальных значений его элементов при динамическом размещении. (Однако если размещается массив объектов типа класса, то для каждого из элементов вызывается конструктор по умолчанию.) Например:
string *ps = new string;
размещает в памяти один объект типа string, инициализирует ps его адресом и вызывает конструктор по умолчанию для вновь созданного объекта типа string. Аналогично
string *psa = new string[10];
размещает в памяти массив из десяти элементов типа string, инициализирует psa его адресом и вызывает конструктор по умолчанию для каждого элемента массива.
Объекты, размещаемые в памяти с помощью оператора new, не имеют собственного имени. Вместо этого возвращается указатель на безымянный объект, и все действия с этим объектом производятся посредством косвенной адресации.
После использования объекта, созданного таким образом, мы должны явно освободить память, применив оператор delete к указателю на этот объект. (Попытка применить оператор delete к указателю, не содержащему адрес объекта, полученного описанным способом, вызовет ошибку времени выполнения.) Например:
delete pi;
освобождает память, на которую указывает объект типа int, на который указывает pi. Аналогично
delete ps;
освобождает память, на которую указывает объект класса string, адрес которого содержится в ps. Перед уничтожением этого объекта вызывается деструктор. Выражение
delete [] pia;
освобождает память, отведенную под массив pia. При выполнении такой операции необходимо придерживаться указанного синтаксиса.
(Об операциях new и delete мы еще поговорим в главе 8.)
Упражнение 4.11
Какие из следующих выражений ошибочны?
(a) vector svec( 10 );
(b) vector *pvecl = new vector(10);
(c) vector **pvec2 = new vector[10];
(d) vector *pvl = &svec;
(e) vector *pv2 = pvecl;
(f) delete svec;
(g) delete pvecl;
(h) delete [] pvec2;
(i) delete pvl;
(j) delete pv2;
};
Когда операндом delete
служит указатель на объект типа класса, компилятор проверяет, определен ли в этом классе оператор delete(). Если да, то для освобождения памяти вызывается именно он, в противном случае – глобальная версия оператора. Следующая инструкция
delete ps;
освобождает память, занятую объектом класса Screen, на который указывает ps. Поскольку в Screen
есть оператор-член delete(), то применяется именно он. Параметр оператора типа void*
автоматически инициализируется значением ps.
Добавление delete() в класс или его удаление оттуда никак не сказываются на пользовательском коде. Вызов delete
выглядит одинаково как для глобального оператора, так и для оператора-члена. Если бы в классе Screen не было собственного оператора delete(), то обращение осталось бы правильным, только вместо оператора-члена вызывался бы глобальный оператор.
С помощью оператора разрешения глобальной области видимости можно вызвать глобальный delete(), даже если в Screen
определена собственная версия:
::delete ps;
В общем случае используемый оператор delete()
должен соответствовать тому оператору new(), с помощью которого была выделена память. Например, если ps указывает на область памяти, выделенную глобальным new(), то для ее освобождения следует использовать глобальный же delete().
Оператор delete(), определенный для типа класса, может содержать два параметра вместо одного. Первый параметр по-прежнему должен иметь тип void*, а второй – предопределенный тип size_t (не забудьте включить заголовочный файл):
};
Если второй параметр есть, компилятор автоматически инициализирует его значением, равным размеру адресованного первым параметром объекта в байтах. (Этот параметр важен в иерархии классов, когда оператор delete() может наследоваться производным классом. Подробнее наследование обсуждается в главе 17.)
Рассмотрим реализацию операторов new()
и delete() в классе Screen
более детально. В основе нашей стратегии распределения памяти будет лежать связанный список объектов Screen, на начало которого указывает член freeStore. При каждом обращении к оператору-члену new() возвращается следующий объект из списка. При вызове delete()
объект возвращается в список. Если при создании нового объекта список, адресованный freeStore, пуст, то вызывается глобальный оператор new(), чтобы получить блок памяти, достаточный для хранения screenChunk объектов класса Screen.
Как screenChunk, так и freeStore
представляют интерес только для Screen, поэтому мы сделаем их закрытыми членами. Кроме того, для всех создаваемых объектов нашего класса значения этих членов должны быть одинаковыми, а следовательно, нужно объявить их статическими. Чтобы поддержать структуру связанного списка объектов Screen, нам понадобится третий член next:
};
Вот одна из возможных реализаций оператора new() для класса Screen:
<
}
А вот реализация оператора delete():
}
Оператор new()
можно объявить в классе и без соответствующего delete(). В таком случае объекты освобождаются с помощью одноименного глобального оператора. Разрешается также объявить и оператор delete() без new(): объекты будут создаваться с помощью одноименного глобального оператора. Однако обычно эти операторы реализуются одновременно, как в примере выше, поскольку разработчику класса, как правило, нужны оба.
Они являются статическими членами класса, даже если программист явно не объявит их таковыми, и подчиняются обычным ограничениями для подобных функций-членов: им не передается указатель this, а следовательно, напрямую они могут получить доступ только к статическим членам. (См. обсуждение статических функций-членов в разделе 13.5.) Причина, по которой эти операторы делаются статическими, заключается в том, что они вызываются либо перед конструированием объекта класса (new()), либо после его уничтожения (delete()).
Выделение памяти с помощью оператора
new(), например:
Screen *ptr = new Screen( 10, 20 );
эквивалентно последовательному выполнению таких инструкций:
Screen::Screen( ptr, 10, 20 );
Иными словами, сначала вызывается определенный в классе оператор new(), чтобы выделить память для объекта, а затем этот объект инициализируется конструктором. Если new()
неудачно завершает работу, то возбуждается исключение типа bad_alloc и конструктор не вызывается.
Освобождение памяти с помощью оператора delete(), например:
delete ptr;
эквивалентно последовательному выполнению таких инструкций:
Screen::operator delete( ptr, sizeof( *ptr ) );
Таким образом, при уничтожении объекта сначала вызывается деструктор класса, а затем определенный в классе оператор delete() для освобождения памяти. Если значение ptr равно 0, то ни деструктор, ни delete() не вызываются.
класса Screen:
Screen *ps = new Screen( 24, 80 );
тогда как ниже вызывается глобальный оператор new[]() для выделения из хипа памяти под массив объектов типа Screen:
Screen *psa = new Screen[10];
В классе можно объявить также операторы new[]() и delete[]() для работы с массивами.
Оператор-член new[]()
должен возвращать значение типа void* и принимать в качестве первого параметра значение типа size_t. Вот его объявление для Screen:
};
Когда с помощью new
создается массив объектов типа класса, компилятор проверяет, определен ли в классе оператор new[](). Если да, то для выделения памяти под массив вызывается именно он, в противном случае – глобальный new[](). В следующей инструкции в хипе создается массив из десяти объектов Screen:
Screen *ps = new Screen[10];
В этом классе есть оператор new[](), поэтому он и вызывается для выделения памяти. Его параметр size_t
автоматически инициализируется значением, равным объему памяти в байтах, необходимому для размещения десяти объектов Screen.
Даже если в классе имеется оператор-член new[](), программист может вызвать для создания массива глобальный new[](), воспользовавшись оператором разрешения глобальной области видимости:
Screen *ps = ::new Screen[10];
Оператор delete(), являющийся членом класса, должен иметь тип void, а в качестве первого параметра принимать void*. Вот как выглядит его объявление для Screen:
};
Чтобы удалить массив объектов класса, delete должен вызываться следующим образом:
delete[] ps;
Когда операндом delete
является указатель на объект типа класса, компилятор проверяет, определен ли в этом классе оператор delete[](). Если да, то для освобождения памяти вызывается именно он, в противном случае – его глобальная версия. Параметр типа void* автоматически инициализируется значением адреса начала области памяти, в которой размещен массив.
Даже если в классе имеется оператор-член delete[](), программист может вызвать глобальный delete[](), воспользовавшись оператором разрешения глобальной области видимости:
::delete[] ps;
Добавление операторов new[]() или delete[]() в класс или удаление их оттуда не отражаются на пользовательском коде: вызовы как глобальных операторов, так и операторов-членов выглядят одинаково.
При создании массива сначала вызывается new[]()
для выделения необходимой памяти, а затем каждый элемент инициализируется с помощью конструктора по умолчанию. Если у класса есть хотя бы один конструктор, но нет конструктора по умолчанию, то вызов оператора new[]()
считается ошибкой. Не существует синтаксической конструкции для задания инициализаторов элементов массива или аргументов конструктора класса при создании массива подобным образом.
При уничтожении массива сначала вызывается деструктор класса для уничтожения элементов, а затем оператор delete[]() – для освобождения всей памяти. При этом важно использовать правильный синтаксис. Если в инструкции
delete ps;
ps указывает на массив объектов класса, то отсутствие квадратных скобок приведет к вызову деструктора лишь для первого элемента, хотя память будет освобождена полностью.
У оператора-члена delete[]()
может быть не один, а два параметра, при этом второй должен иметь тип size_t:
};
Если второй параметр присутствует, то компилятор автоматически инициализирует его значением, равным объему отведенной под массив памяти в байтах.
представляют:
· множество операций, поддерживаемых всеми производными от него классами запросов. Сюда входят как виртуальные операции, переопределяемые в производных классах, так и невиртуальные, разделяемые всеми производными классами (мы приведем примеры тех и других);
· множество данных-членов, общих для всех производных классов. Если вынести такие члены в абстрактный базовый класс Query, мы сможем обращаться к ним вне зависимости от того, с объектом какого производного класса мы работаем.
Если имеется запрос вида:
fiery || untamed
то двумя основными операциями для него будут: нахождение строк текста, удовлетворяющих условиям запроса, и представление найденных строк пользователю. Назовем эти операции соответственно eval() и display().
Алгоритм работы eval()
свой для каждого производного класса, поэтому эту функцию следует объявить виртуальной в определении Query. Всякий производный класс должен предоставить собственную реализацию для нее. Сам же Query
лишь включает ее в свой открытый интерфейс.
Алгоритм работы функции display(), выводящей найденные строки текста, не зависит от типа производного класса. Нам необходимо лишь иметь доступ к представлению самого текста и списку строк, удовлетворяющих запросу. Вместо того чтобы дублировать реализацию алгоритма и необходимые для него данные в каждом производном классе, определим единственный наследуемый экземпляр в Query.
Такое проектное решение позволит нам вызывать любую операцию, не зная фактического типа объекта, которым мы манипулируем:
}
Как следует представить найденные строки текста? Каждому упомянутому в запросе слову будет соответствовать вектор позиций, построенный во время поиска. Позиция – это пара (строка, колонка), в которой каждый член – это значение типа short int. Отображение слов на векторы позиций, построенное функцией build_text_map(), содержит такие векторы для каждого встречающегося в тексте слова, распознанного нашей системой. Ключами для этого отображения служат значения типа string, представляющие слова. Например, для текста
Alice Emma has long flowing red hair. Her Daddy says
when the wind blows through her hair, it looks almost alive,
like a fiery bird in flight. A beautiful fiery bird, he tells her,
magical but untamed. "Daddy, shush, there is no such thing,"
she tells him, at the same time wanting him to tell her more.
Shyly, she asks, "I mean, Daddy, is there?"
приведена часть отображения для некоторых слов, встречающихся неоднократно (слово – это ключ отображения; пары значений в скобках – элементы вектора позиций; отметим, что нумерация строк и колонок начинается с нуля):
bird ((2,3),(2,9))
daddy ((0,8),(3,3),(5,5))
fiery ((2,2),(2,8))
hair ((0,6),(1,6))
her ((0,7),(1,5),(2,12),(4,11))
him ((4,2),(4,8))
she ((4,0),(5,1))
tell ((2,11),(4,1),(4,10))
Однако такой вектор – это еще ответ на запрос. К примеру, слово fiery
представлено двумя позициями, причем обе находятся в одной и той же строке.
Нам нужно вычислить множество неповторяющихся строк, соответствующих вектору позиций. Для этого можно, например, создать вектор, в который помещаются все номера строк, представленные в векторе позиций, а затем передать его обобщенному алгоритму unique(), который удалит все дубликаты (см. алгоритм unique() в Приложении). Оставшиеся строки должны быть расположены в порядке возрастания номеров. Чтобы не оставалось никаких сомнений, к вектору строк можно применить обобщенный алгоритм sort().
Мы выбрали другой подход – построить множество (объект set) из номеров строк в векторе позиций. Такое множество содержит по одному экземпляру каждого элемента, причем хранит их в отсортированном виде. Нам потребуется функция для преобразования вектора позиций в множество неповторяющихся номеров строк:
set* Query::_vec2set( const vector< location >* );
Объявим _vec2set()
защищенной функцией-членом Query. Она не является открытой, поскольку не принадлежит к числу операций, которые могут вызывать пользователи данной иерархии. Но она и не закрыта, поскольку это вспомогательная функция, которая должна быть доступна производным классам. (Подчерк в имени функции призван обратить внимание на то, что это не часть открытого интерфейса иерархии Query.)
Например, вектор позиций для слова bird
содержит два вхождения в одной и той же строке, поэтому его разрешающее множество будет состоять из одного элемента: (2). Вектор позиций для слова tell
содержит три вхождения, из них два относятся к одной и той же строке; следовательно, в его разрешающем множестве будет два элемента: (2,4). Вот как выглядят результаты для всех представленных выше векторов позиций:
bird (2)
daddy (0,3,5)
fiery (2)
hair (0,1)
her (0,1,2,4)
him (4)
she (4,5)
tell (2,4)
Чтобы вычислить результат запроса NameQuery, достаточно получить вектор позиций для указанного слова, преобразовать его в множество неповторяющихся номеров строк и вывести соответствующие строки текста.
Ответом на NotQuery
служит множество строк, в которых не встречается указанное слово. Так, результатом запроса
! daddy
служит множество (1,2,4). Для вычисления результата надо знать, сколько всего строк содержится в тексте. (Мы не сохраняли эту информацию, поскольку не были уверены, что она потребуется; к сожалению, недостаточно и этого.) Чтобы упростить обработку NotQuery, полезно сгенерировать множество всех номеров строк текста (0,1,2,3,4,5): теперь для получения результата достаточно с помощью алгоритма set_difference()
вычислить разность двух множеств. (Ответом на показанный выше запрос будет множество (0,3,5).)
Результатом OrQuery
является объединение номеров строк, где встречается левый или правый операнд. Например, если дан запрос:
fiery || her
то результирующим множеством будет (0,1,2,4), которое получается объединением множества (2) для слова fiery и множества (0,1,2,4) для слова her. Такое множество должно быть упорядочено по возрастанию номеров строк и не содержать дубликатов.
До сих пор нам удавалось вычислять результат запроса, работая только с множествами неповторяющихся номеров строк. Однако для обработки AndQuery
надо принимать во внимание как номер строки, так и номер колонки в каждой паре. Так, указанные в запросе
her && hair
слова встречаются в четырех разных строках. Определенная нами семантика AndQuery
говорит, что строка является подходящей, если содержит точную последовательность her hair. Вхождения слов в первую строку не удовлетворяют этому условию, хотя они стоят рядом:
Alice Emma has long flowing red hair. Her Daddy says
а вот во второй строке слова расположены так, как нужно:
when the wind blows through her hair, it looks almost alive,
Для оставшихся двух вхождений слова her
слово hair не является соседним. Таким образом, ответом на запрос является вторая строка текста: (1).
Если бы не операция AndQuery, нам не пришлось бы вычислять вектор позиций для каждой операции. Но, поскольку операндом AndQuery
может быть результат любого запроса, то для каждого приходится вычислять и сохранять не только множество неповторяющихся строк, но и пары (строка, колонка). Рассмотрим следующие запросы:
fiery && ( hair || bird || potato )
fiery && ( ! burr )
NotQuery
может быть операндом AndQuery, следовательно, мы должны создать не просто вектор, содержащий по одному элементу для каждой подходящей строки, но и вектор, в котором хранятся позиции. (Мы еще вернемся к этому при рассмотрении функции eval() для класса NotQuery в разделе 17.5.)
Таким образом, идентифицирован еще один необходимый член – вектор позиций, ассоциированный с вычислением каждой операции. У нас есть выбор: объявить его членом каждого производного класса или членом абстрактного базового класса Query, наследуемым всеми производными. Объем памяти для хранения этого члена в обоих случаях одинаков. Мы поместим его в базовый класс, локализовав поддержку инициализации и доступа к члену.
Решение о том, представлять ли множество неповторяющихся номеров строк (мы называем его разрешающим множеством) в виде члена класса или каждый раз вычислять его, принимает разработчик. Мы предпочли вычислять его по мере необходимости, а затем сохранять адрес для последующего доступа, объявляя этот адрес членом абстрактного базового класса Query.
Для вывода найденных строк нам необходимо как разрешающее множество, так и фактический текст, из которого взяты строки. Причем вектор позиций у каждой операции должен быть свой, а экземпляр текста нужен только один. Поэтому мы определим его статическим членом класса Query. (Реализация функции display()
опирается только на эти два члена.)
Вот результат первой попытки создать абстрактный базовый класс Query (конструкторы, деструктор и копирующий оператор присваивания еще не объявлены: этим мы займемся в разделах 17.4 и 17.6):
}
Странный синтаксис
virtual void eval() = 0;
говорит о том, что для виртуальной функции eval() в абстрактном базовом классе Query нет определения: это чисто виртуальная функция, “удерживающая место” в открытом интерфейсе иерархии классов и не предназначенная для непосредственного вызова из программы. Вместо нее каждый производный класс должен предоставить настоящую реализацию. (Подробно виртуальные функции будут рассматриваться в разделе 17.5.)
появляются внутри вложенного пространства имен MatrixLib, а определения функций operator+() и inverse()
приводятся где-то в другом месте текста программы:
}
Член пространства имен можно определить и вне соответствующего пространства. В таком случае имя члена должно быть квалифицировано именами пространств, к которым он принадлежит. Например, если определение функции operator+()
помещено в глобальную область видимости, то оно должно выглядеть следующим образом:
{ /* ... */ }
Имя operator+()
квалифицировано в данном случае именами пространств cplusplus_primer и MatrixLib.
Однако обратите внимание на тип matrix в списке параметров operator+(): употреблено неквалифицированное имя. Как такое может быть?
В определении функции operator+()
можно использовать неквалифицированные имена для членов своего пространства, поскольку определение принадлежит к его области видимости. При разрешении имен внутри функции operator+()
используется MatrixLib. Заметим, однако, что в типе возвращаемого значения все же нужно указывать квалифицированное имя, поскольку он расположен вне области видимости, заданной определением функции:
cplusplus_primer::MatrixLib::operator+
В определении operator+()
неквалифицированные имена могут встречаться в любом объявлении или выражении внутри списка параметров или тела функции. Например, локальное объявление внутри operator+()
способно создать объект класса matrix:
}
Хотя члены могут быть определены вне своего пространства имен, такие определения допустимы не в любом месте. Их разрешается помещать только в пространства, объемлющие данное. Например, определение operator+() может появиться в глобальной области видимости, в пространстве имен cplusplus_primer и в пространстве MatrixLib. В последнем случае это выглядит так:
}
Член может определяться вне своего пространства только при условии, что ранее он был объявлен внутри. Последнее приведенное определение operator+()
было бы ошибочным, если бы ему не предшествовало объявление в файле primer.h:
}
AndQuery // William && Shakespeare
В каждом классе определим функцию-член eval(), которая выполняет соответствующую операцию. К примеру, для NameQuery она возвращает вектор позиций, содержащий координаты (номера строки и колонки) начала каждого вхождения слова (см. раздел 6.8); для OrQuery
строит объединение векторов позиций обоих своих операндов и т.д.
Таким образом, запрос
untamed || fiery
состоит из объекта класса OrQuery, который содержит два объекта NameQuery в качестве операндов. Для простых запросов этого достаточно, но при обработке составных запросов типа
Alice || Emma && Weeks
возникает проблема. Данный запрос состоит из двух подзапросов: объекта OrQuery, содержащего объекты NameQuery для представления слов Alice и Emma, и объекта AndQuery. Правым операндом AndQuery
является объект NameQuery для слова Weeks.
NameQuery ("Weeks")
Но левый операнд – это объект OrQuery, предшествующий оператору &&. На его месте мог бы быть объект NotQuery или другой объект AndQuery. Как же следует представить операнд, если он может принадлежать к типу любого из четырех классов? Эта проблема имеет две стороны:
· необходимо уметь объявлять тип операнда в классах OrQuery, AndQuery и NotQuery так, чтобы с его помощью можно было представить тип любого из четырех классов запросов;
· какое бы решение мы ни выбрали в предыдущем случае, мы должны иметь возможность вызывать соответствующий классу каждого операнда вариант функции-члена eval().
Решение, не согласующееся с объектной ориентированностью, состоит в том, чтобы определить тип операнда как объединение и включить дискриминант, показывающий текущий тип операнда:
};
Хранить указатели на объекты можно и с помощью типа void*:
};
Нам все равно нужен дискриминант, поскольку напрямую использовать объект, адресуемый указателем типа void*, нельзя, равно как невозможно определить тип такого объекта по указателю. (Мы не рекомендуем применять описанное решение в C++, хотя в языке C это весьма распространенный подход.)
Основной недостаток рассмотренных решений состоит в том, что ответственность за определение типа возлагается на программиста. Например, в случае решения, основанного на void*-указателях, операцию eval() для объекта AndQuery
можно реализовать так:
<
}
В результате явного управления разрешением типов увеличивается размер и сложность кода и добавление нового типа или исключение существующего при сохранении работоспособности программы затрудняется.
Объектно-ориентированное программирование предлагает альтернативное решение, в котором работа по разрешению типов перекладывается с программиста на компилятор. Например, так выглядит код операции eval() для класса AndQuery в случае применения объектно-ориентированного подхода (eval()
объявлена виртуальной):
}
Если потребуется добавить или исключить какие-либо типы, эту часть программы не придется ни переписывать, ни перекомпилировать.
можно инициализировать указателем на вектор строк, представляющий запрос пользователя, или передать ему адрес этого вектора позже, с помощью функции-члена query(). Это позволяет использовать один объект для нескольких запросов. Фактическое построение иерархии классов Query выполняется функцией eval_query():
while ( /* пользователь продолжает формулировать запросы */ );
Вот определение нашего класса UserQuery:
<
#endif
Обратите внимание, что два объявленных нами стека содержат указатели на объекты типа Query, а не сами объекты. Хотя правильное поведение обеспечивается обеими реализациями, хранение объектов значительно менее эффективно, поскольку каждый объект (и его операнды) должен быть почленно скопирован в стек (напомним, что операнды копируются виртуальной функцией clone()) только для того, чтобы вскоре быть уничтоженным. Если мы не собираемся модифицировать объекты, помещаемые в контейнер, то хранение указателей на них намного эффективнее.
Ниже показаны реализации различных встроенных операций eval. Операции evalAnd() и evalOr()
выполняют следующие шаги. Сначала объект извлекается из стека _query_stack
(напомним, что для класса stack, определенного в стандартной библиотеке, это требует двух операций: top() для получения элемента и pop() для удаления его из стека). Затем из хипа выделяется память для объекта класса AndQuery или OrQuery, и указатель на него передается объекту, извлеченному из стека. Каждая операция передает объекту AndQuery или OrQuery
счетчики левых или правых скобок, необходимые ему для вывода своего содержимого. И наконец неполный оператор помещается в стек _current_op:
}
Операция evalNot()
работает следующим образом. В хипе создается новый объект класса NotQuery, которому передаются счетчики левых и правых скобок для правильного отображения содержимого. Затем неполный оператор помещается в стек _current_op:
}
При обнаружении закрывающей скобки вызывается операция evalRParen(). Если число активных левых скобок больше числа элементов в стеке _current_op, то ничего не происходит. В противном случае выполняются следующие действия. Из стека _query_stack
извлекается текущий еще не присоединенный к оператору операнд, а из стека _current_op – текущий неполный оператор. Вызывается виртуальная функция add_op()
класса Query, которая их объединяет. И наконец полный оператор помещается в стек _query_stack:
}
Операция evalWord()
выполняет следующие действия. Она ищет указанное слово в отображении _word_map
взятых из файла слов на векторы позиций. Если слово найдено, берется его вектор позиций и в хипе посредством конструктора с двумя параметрами создается новый объект NameQuery. В противном случае объект порождается с помощью конструктора с одним параметром. Если число элементов в стеке _current_op меньше либо равно числу встреченных ранее скобок, то нет неполного оператора, ожидающего операнда типа NameQuery, поэтому новый объект помещается в стек _query_stack. Иначе из стека _current_op
извлекается неполный оператор, к которому с помощью виртуальной функции add_op()
присоединяется операнд NameQuery, после чего ставший полным оператор помещается в стек _query_stack:
}
Упражнение 17.21
Напишите деструктор, копирующий конструктор и копирующий оператор присваивания для класса UserQuery.
Упражнение 17.22
Напишите функции print() для класса UserQuery. Обоснуйте свой выбор того, что она выводит.
class Screen { /* ... */ } myScreen, yourScreen;
Внутри тела объявляются данные-члены и функции-члены и указываются уровни доступа к ним. Таким образом, тело класса определяет список его членов.
Каждое определение вводит новый тип данных. Даже если два класса имеют одинаковые списки членов, они все равно считаются разными типами:
Second obj2 = obj1; // ошибка: obj1 и obj2 имеют разные типы
Тело класса определяет отдельную область видимости. Объявление членов внутри тела помещает их имена в область видимости класса. Наличие в двух разных классах членов с одинаковыми именами – не ошибка, эти имена относятся к разным объектам. (Подробнее об областях видимости классов мы поговорим в разделе 13.9.)
После того как тип класса определен, на него можно ссылаться двумя способами:
· написать ключевое слово class, а после него – имя класса. В предыдущем примере объект obj1
класса First
объявлен именно таким образом;
· указать только имя класса. Так объявлен объект obj2
класса Second из приведенного примера.
Оба способа сослаться на тип класса эквивалентны. Первый заимствован из языка C и остается корректным методом задания типа класса; второй способ введен в C++ для упрощения объявлений.
map word_count;
Здесь задается объект word_count
типа map, для которого ключом служит объект типа string, а ассоциированным с ним значением – объект типа int. Аналогично
map>
personnel;
определяет personnel как отображение ключа типа int (уникальный номер служащего) на указатель, адресующий объект класса employee.
Для нашей поисковой системы полезно такое отображение:
map text_map;
Поскольку имевшийся в нашем распоряжении компилятор не поддерживал аргументы по умолчанию для параметров шаблона, нам пришлось написать более развернутое определение:
text_map;
По умолчанию сортировка ассоциативных контейнеров производится с помощью операции “меньше”. Однако можно указать и другой оператор сравнения (см. раздел 12.3 об объектах-функциях).
После того как отображение определено, необходимо заполнить его парами ключ/значение. Интуитивно хочется написать примерно так:
// и так далее ...
Когда мы пишем:
word_count[ string("Anna") ] = 1;
на самом деле происходит следующее:
1. Безымянный временный объект типа string
инициализируется значением "Anna" и передается оператору взятия индекса, определенному в классе map.
2. Производится поиск элемента с ключом "Anna" в массиве word_count. Такого элемента нет.
3. В word_count вставляется новая пара ключ/значение. Ключом является, естественно, строка "Anna". Значением – 0, а не 1.
4. После этого значению присваивается величина 1.
Если элемент отображения вставляется в отображение с помощью операции взятия индекса, то значением этого элемента становится значение по умолчанию для его типа данных. Для встроенных арифметических типов – 0.
Следовательно, если инициализация отображения производится оператором взятия индекса, то каждый элемент сначала получает значение по умолчанию, а затем ему явно присваивается нужное значение. Если элементы являются объектами класса, у которого инициализация по умолчанию и присваивание значения требуют больших затрат времени, программа будет работать правильно, но недостаточно эффективно.
Для вставки одного элемента предпочтительнее использовать следующий метод:
);
В контейнере map
определен тип value_type для представления хранимых в нем пар ключ/значение. Строки
value_type( string("Anna"), 1 )
создают объект pair, который затем непосредственно вставляется в map. Для удобства чтения можно использовать typedef:
typedef map::value_type valType;
Теперь операция вставки выглядит проще:
word_count.insert( valType( string("Anna"), 1 ));
Чтобы вставить элементы из некоторого диапазона, можно использовать метод insert(), принимающий в качестве параметров два итератора. Например:
word_count_two.insert(word_count.begin(),word_count.end());
Мы могли бы сделать то же самое, просто проинициализировав одно отображение другим:
map< string, int > word_count_two( word_count );
Посмотрим, как можно построить отображение для хранения нашего текста. Функция separate_words(), описанная в разделе 6.8, создает два объекта: вектор строк, хранящий все слова текста, и вектор позиций, хранящий пары (номер строки, номер колонки) для каждого слова. Таким образом, первый объект дает нам множество значений ключей нашего отображения, а второй – множество ассоциированных с ними значений.
}
Синтаксически сложное выражение
push_back((*text_locs)[ix]);
будет проще понять, если мы разложим его на составляющие:
ploc->push_back(loc);
Выражение все еще остается сложным, так как наши векторы представлены указателями. Поэтому вместо употребления оператора взятия индекса:
string word = text_words[ix]; // ошибка
мы вынуждены сначала разыменовать указатель на вектор:
string word = (*text_words) [ix]; // правильно
В конце концов build_word_map() возвращает построенное отображение:
return word_map;
Вот как выглядит вызов этой функции из main():
}
separate_words()
возвращает эти два вектора как объект типа pair, содержащий указатели на них. Сделаем эту пару аргументом функции build_word_map(), в результате которой будет получено соответствие между словами и позициями:
build_word_map( const text_loc *text_locations );
Сначала выделим память для пустого объекта map и получим из аргумента-пары указатели на векторы:
vector *text_locs = text_locations->second;
Теперь нам надо синхронно обойти оба вектора, учитывая два случая:
· слово встретилось впервые. Нужно поместить в map
новую пару ключ/значение;
· слово встречается повторно. Нам нужно обновить вектор позиций, добавив дополнительную пару (номер строки, номер колонки).
Вот текст функции:
<
необходимо включить соответствующий заголовочный файл:
#include
Вот определение нашего множества стоп-слов:
set exclusion_set;
Отдельные элементы могут добавляться туда с помощью операции insert(). Например:
exclusion_set.insert( "and" );
Передавая insert() пару итераторов, можно добавить целый диапазон элементов. Скажем, наша поисковая система позволяет указать файл со стоп-словами. Если такой файл не задан, берется некоторый набор слов по умолчанию:
}
В этом фрагменте кода встречаются два элемента, которые мы до сих пор не рассматривали: тип difference_type и класс inserter. difference_type – это тип результата вычитания двух итераторов для нашего множества строк. Он передается в качестве одного из параметров шаблона istream_iterator.
copy() –один из обобщенных алгоритмов. (Мы рассмотрим их в главе 12 и в Приложении.) Первые два параметра – пара итераторов или указателей – задают диапазон. Третий параметр является либо итератором, либо указателем на начало контейнера, в который элементы копируются.
Проблема с этой функцией вызвана ограничением, вытекающим из ее реализации: количество копируемых элементов не может превосходить числа элементов в контейнере-адресате. Дело в том, что copy() не вставляет элементы, она только присваивает каждому элементу новое значение. Однако ассоциативные контейнеры не позволяют явно задать размер. Чтобы скопировать элементы в наше множество, мы должны заставить copy()
вставлять элементы. Именно для этого служит класс inserter
(детально он рассматривается в разделе 12.4).
unsigned long distance;
В одном операторе можно определить несколько объектов одного типа. В этом случае их имена перечисляются через запятую:
double salary, wage;
int month,
day, year;
unsigned long distance;
Простое определение переменной не задает ее начального значения. Если объект определен как глобальный, спецификация С++ гарантирует, что он будет инициализирован нулевым значением. Если же переменная локальная либо динамически размещаемая (с помощью оператора new), ее начальное значение не определено, то есть она может содержать некоторое случайное значение.
Использование подобных переменных– очень распространенная ошибка, которую к тому же трудно обнаружить. Рекомендуется явно указывать начальное значение объекта, по крайней мере в тех случаях, когда неизвестно, может ли объект инициализировать сам себя. Механизм классов вводит понятие конструктора по умолчанию, который служит для присвоения значений по умолчанию. (Мы уже сказали об этом в разделе 2.3. Разговор о конструкторах по умолчанию будет продолжен немного позже, в разделах 3.11 и 3.15, где мы будем разбирать классы string и complex из стандартной библиотеки.)
// неинициализированный локальный объект
int ival;
// объект типа string инициализирован
// конструктором по умолчанию
string project;
// ...
}
Начальное значение может быть задано прямо в операторе определения переменной. В С++ допустимы две формы инициализации переменной – явная, с использованием оператора присваивания:
string project = "Fantasia 2000";
и неявная, с заданием начального значения в скобках:
string project( "Fantasia 2000" );
Оба варианта эквивалентны и задают начальные значения для целой переменной ival как 1024 и для строки project как "Fantasia 2000".
Явную инициализацию можно применять и при определении переменных списком:
day = 07, year = 1955;
Переменная становится видимой (и допустимой в программе) сразу после ее определения, поэтому мы могли проинициализировать переменную wage
суммой только что определенной переменной salary с некоторой константой. Таким образом, определение:
// корректно, но бессмысленно
int bizarre = bizarre;
является синтаксически допустимым, хотя и бессмысленным.
Встроенные типы данных имеют специальный синтаксис для задания нулевого значения:
int ival = int();
double dval = double();
В следующем определении:
vector< int > ivec( 10 );
к каждому из десяти элементов вектора применяется инициализация с помощью int(). (Мы уже говорили о классе vector в разделе 2.8. Более подробно об этом см. в разделе 3.10 и главе 6.)
Переменная может быть инициализирована выражением любой сложности, включая вызовы функций. Например:
#include
double price = 109.99, discount = 0.16;
double sale_price( price * discount );
string pet( "wrinkles" );
extern int get_value();
int val = get_value();
unsigned abs_val = abs( val );
abs() – стандартная функция, возвращающая абсолютное значение параметра. get_value() – некоторая пользовательская функция, возвращающая целое значение.
Упражнение 3.3
Какие из приведенных ниже определений переменных содержат синтаксические ошибки?
(e) cin >> int input_value;
Упражнение 3.4
Объясните разницу между l-значением и r-значением. Приведите примеры.
Упражнение 3.5
Найдите отличия в использовании переменных name и student в первой и второй строчках каждого примера:
vector students;
Упражнение 3.6
Какие имена объектов недопустимы в С++? Измените их так, чтобы они стали синтаксически правильными:
(e) char 1_or_2 = '1'; (f) float Float = 3.14f;
Упражнение 3.7
В чем разница между следующими глобальными и локальными определениями переменных?
int main() {
int local_int;
string local_class;
// ...
}
Наконец, для получения ассоциированного вектора позиций должно быть доступно отображение слов на векторы. Поскольку один такой объект разделяется всеми объектами класса NameQuery, мы объявляем его статическим членом. Первая попытка определения NameQuery
(рассмотрение конструкторов, деструктора и копирующего оператора присваивания мы снова отложим) выглядит так:
};
Класс NotQuery в дополнение к предоставлению реализации виртуальной функции eval() должен обеспечить поддержку своего единственного операнда. Поскольку им может быть объект любого из производных классов, определим его как указатель на тип Query. Результат запроса NotQuery, напомним, обязан содержать не только строки текста, где нет указанного слова, но также и номера колонок внутри каждой строки. Например, если есть запрос:
! daddy
то операнд запроса NotQuery
включает следующий вектор позиций:
daddy ((0,8),(3,3),(5,5))
Вектор позиций, возвращаемый в ответ на исходный запрос, должен включать все номера колонок в строках (1,2,4). Кроме того, он должен включать все номера колонок в строке (0), кроме колонки (8), все номера колонок в строке (3), кроме колонки (3), и все номера колонок в строке (5), кроме колонки (5).
Простейший способ вычислить все это – создать единственный разделяемый всеми объектами вектор позиций, который содержит пары (строка, колонка) для каждого слова в тексте (полную реализацию мы рассмотрим в разделе 17.5, когда будем обсуждать функцию eval()
класса NotQuery). Так или иначе, этот член мы объявим статическим для NotQuery.
Вот определение класса NotQuery (и снова рассмотрение конструкторов, деструктора и копирующего оператора присваивания отложено):
};
Классы AndQuery и OrQuery
представляют бинарные операции, у которых есть левый и правый операнды. Оба операнда могут быть объектами любого из производных классов, поэтому мы определим соответствующие члены как указатели на тип Query. Кроме того, в каждом классе нужно переопределить виртуальную функцию eval(). Вот начальное определение OrQuery:
};
Любой объект AndQuery
должен иметь доступ к числу слов в каждой строке. В противном случае при обработке запроса AndQuery мы не сможем найти соседние слова, расположенные в двух смежных строках. Например, если есть запрос:
tell && her && magical
то нужная последовательность находится в третьей и четвертой строках:
like a fiery bird in flight. A beautiful fiery bird, he tells her,
magical but untamed. "Daddy, shush, there is no such thing,"
Векторы позиций, ассоциированные с каждым из трех слов, следующие:
her ((0,7),(1,5),(2,12),(4,11))
magical ((3,0))
tell ((2,11),(4,1),(4,10))
Если функция eval() класса AndQuery “не знает”, сколько слов содержится в строке (2), то она не сможет определить, что слова magical и her
соседствуют. Мы создадим единственный экземпляр вектора, разделяемый всеми объектами класса, и объявим его статическим членом. (Реализацию eval() мы детально рассмотрим в разделе 17.5.) Итак, определим AndQuery:
};
тривиален, сильная типизация требует, чтобы его разновидности были реализованы для всех типов, которые мы собираемся сравнивать:
}
Заманчивую альтернативу явному определению каждого экземпляра функции min()
представляет использование макросов, расширяемых препроцессором:
#define min(a, b) ((a) < (b) ? (a) : (b))
Но этот подход таит в себе потенциальную опасность. Определенный выше макрос правильно работает при простых обращениях к min(), например:
min( 10.0, 20.0 );
но может преподнести сюрпризы в более сложных случаях: такой механизм ведет себя не как вызов функции, он лишь выполняет текстовую подстановку аргументов. В результате значения обоих аргументов оцениваются дважды: один раз при сравнении a и b, а второй – при вычислении возвращаемого макросом результата:
}
На первый взгляд, эта программа подсчитывает количество элементов в массиве ia
целых чисел. Но в этом случае макрос min() расширяется неверно, поскольку операция постинкремента применяется к аргументу-указателю дважды при каждой подстановке. В результате программа печатает строку, свидетельствующую о неправильных вычислениях:
elem_cnt : 5 expecting: 10
Шаблоны функций предоставляют в наше распоряжение механизм, с помощью которого можно сохранить семантику определений и вызовов функций (инкапсуляция фрагмента кода в одном месте программы и гарантированно однократное вычисление аргументов), не принося в жертву сильную типизацию языка C++, как в случае применения макросов.
Шаблон дает алгоритм, используемый для автоматической генерации экземпляров функций с различными типами. Программист параметризует
все или только некоторые типы в интерфейсе функции (т.е. типы формальных параметров и возвращаемого значения), оставляя ее тело неизменным. Функция хорошо подходит на роль шаблона, если ее реализация остается инвариантной на некотором множестве экземпляров, различающихся типами данных, как, скажем, в случае min().
Так определяется шаблон функции min():
}
Если вместо макроса препроцессора min()
подставить в текст предыдущей программы этот шаблон, то результат будет правильным:
elem_cnt : 10 expecting: 10
(В стандартной библиотеке C++ есть шаблоны функций для многих часто используемых алгоритмов, например для min(). Эти алгоритмы описываются в главе 12. А в данной вводной главе мы приводим собственные упрощенные версии некоторых алгоритмов из стандартной библиотеки.)
Как объявление, так и определение шаблона функции всегда должны начинаться с ключевого слова template, за которым следует список разделенных запятыми идентификаторов, заключенный в угловые скобки '<' и '>', – список параметров шаблона, обязательно непустой. У шаблона могут быть параметры-типы, представляющие некоторый тип, и параметры-константы,
представляющие фиксированное константное выражение.
Параметр-тип состоит из ключевого слова class или ключевого слова typename, за которым следует идентификатор. Эти слова всегда обозначают, что последующее имя относится к встроенному или определенному пользователем типу. Имя параметра шаблона выбирает программист. В приведенном примере мы использовали имя Type, но могли выбрать и любое другое:
<
}
При конкретизации ( порождении конкретного экземпляра) шаблона вместо параметра-типа подставляется фактический встроенный или определенный пользователем тип. Любой из типов int, double, char*, vector или list
является допустимым аргументом шаблона.
Параметр-константа выглядит как обычное объявление. Он говорит о том, что вместо имени параметра должно быть подставлено значение константы из определения шаблона. Например, size – это параметр-константа, который представляет размер массива arr:
Type min( Type (&arr) [size] );
Вслед за списком параметров шаблона идет объявление или определение функции. Если не обращать внимания на присутствие параметров в виде спецификаторов типа или констант, то определение шаблона функции выглядит точно так же, как и для обычных функций:
}
В этом примере Type
определяет тип значения, возвращаемого функцией min(), тип параметра r_array и тип локальной переменной min_val; size задает размер массива r_array. В ходе работы программы при использовании функции min()
вместо Type
могут быть подставлены любые встроенные и определенные пользователем типы, а вместо size – те или иные константные выражения. (Напомним, что работать с функцией можно двояко: вызвать ее или взять ее адрес).
Процесс подстановки типов и значений вместо параметров называется конкретизацией шаблона. (Подробнее мы остановимся на этом в следующем разделе.)
Список параметров нашей функции min()
может показаться чересчур коротким. Как было сказано в разделе 7.3, когда параметром является массив, передается указатель на его первый элемент, первая же размерность фактического аргумента-массива внутри определения функции неизвестна. Чтобы обойти эту трудность, мы объявили первый параметр min() как ссылку на массив, а второй – как его размер. Недостаток подобного подхода в том, что при использовании шаблона с массивами одного и того же типа int, но разных размеров генерируются (или конкретизируются) различные экземпляры функции min().
Имя параметра разрешено употреблять внутри объявления или определения шаблона. Параметр-тип служит спецификатором типа; его можно использовать точно так же, как спецификатор любого встроенного или пользовательского типа, например в объявлении переменных или в операциях приведения типов. Параметр-константа применяется как константное значение – там, где требуются константные выражения, например для задания размера в объявлении массива или в качестве начального значения элемента перечисления.
}
Если в глобальной области видимости объявлен объект, функция или тип с тем же именем, что у параметра шаблона, то глобальное имя оказывается скрытым. В следующем примере тип переменной tmp не double, а тот, что у параметра шаблона Type:
}
Объект или тип, объявленные внутри определения шаблона функции, не могут иметь то же имя, что и какой-то из параметров:
}
Имя параметра-типа шаблона можно использовать для задания типа возвращаемого значения:
T1 min( T2, T3 );
В одном списке параметров некоторое имя разрешается употреблять только один раз. Например, следующее определение будет помечено как ошибка компиляции:
Type min( Type, Type );
Однако одно и то же имя можно многократно применять внутри объявления или определения шаблона:
Type min( Type, Type );
Type max( Type, Type );
Имена параметров в объявлении и определении не обязаны совпадать. Так, все три объявления min()
относятся к одному и тому же шаблону функции:
Type min( Type a, Type b ) { /* ... */ }
Количество появлений одного и того же параметра шаблона в списке параметров функции не ограничено. В следующем примере Type
используется для представления двух разных параметров:
Type sum( const vector &, Type );
Если шаблон функции имеет несколько параметров-типов, то каждому из них должно предшествовать ключевое слово class или typename:
T sum( T*, U );
В списке параметров шаблона функции ключевые слова typename и class
имеют одинаковый смысл и, следовательно, взаимозаменяемы. Любое из них может использоваться для объявления разных параметров-типов шаблона в одном и том же списке (как было продемонстрировано на примере шаблона функции minus()). Для обозначения параметра-типа более естественно, на первый взгляд, употреблять ключевое слово typename, а не class, ведь оно ясно указывает, что за ним следует имя типа. Однако это слово было добавлено в язык лишь недавно, как часть стандарта C++, поэтому в старых программах вы скорее всего встретите слово class. (Не говоря уже о том, что class
короче, чем typename, а человек по природе своей ленив.)
Ключевое слово typename
упрощает разбор определений шаблонов. (Мы лишь кратко остановимся на том, зачем оно понадобилось. Желающим узнать об этом подробнее рекомендуем обратиться к книге Страуструпа “Design and Evolution of C++”.)
При таком разборе компилятор должен отличать выражения-типы от тех, которые таковыми не являются; выявить это не всегда возможно. Например, если компилятор встречает в определении шаблона выражение Parm::name и если Parm – это параметр-тип, представляющий класс, то следует ли считать, что name
представляет член-тип класса Parm?
}
Компилятор не знает, является ли name
типом, поскольку определение класса, представленного параметром Parm, недоступно до момента конкретизации шаблона. Чтобы такое определение шаблона можно было разобрать, пользователь должен подсказать компилятору, какие выражения включают типы. Для этого служит ключевое слово typename. Например, если мы хотим, чтобы выражение Parm::name в шаблоне функции minus()
было именем типа и, следовательно, вся строка трактовалась как объявление указателя, то нужно модифицировать текст следующим образом:
}
Ключевое слово typename
используется также в списке параметров шаблона для указания того, что параметр является типом.
Шаблон функции можно объявлять как inline или extern – как и обычную функцию. Спецификатор помещается после списка параметров, а не перед словом template.
<
Type min( Array, int );
Упражнение 10.1
Определите, какие из данных определений шаблонов функций неправильны. Исправьте ошибки.
Ctype foo( Ctype a, Ctype b );
Упражнение 10.2
Какие из повторных объявлений шаблонов ошибочны? Почему?
void bar( C1, C2 );
Упражнение 10.3
Перепишите функцию putValues() из раздела 7.3.3 в виде шаблона. Параметризуйте его так, чтобы было два параметра шаблона (для типа элементов массива и для размера массива) и один параметр функции, являющийся ссылкой на массив. Напишите определение шаблона функции.
упоминался в разделе 6.17. В этой главе мы создадим упрощенный тип для знакомства с шаблонами классов.)
Необходимо, чтобы наш класс Queue
поддерживал следующие операции:
· добавить элемент в конец очереди:
void add( item );
· удалить элемент из начала очереди:
item remove();
· определить, пуста ли очередь:
bool is_empty();
· определить, заполнена ли очередь:
bool is_full();
Определение Queue
могло бы выглядеть так:
};
Чтобы создать классы Queue, способные хранить целые числа, комплексные числа и строки, программисту достаточно написать:
Queue qs;
Реализация Queue
представлена в следующих разделах с целью иллюстрации определения и применения шаблонов классов. В реализации используются две абстракции шаблона:
· сам шаблон класса Queue
предоставляет описанный выше открытый интерфейс и пару членов: front и back. Очередь реализуется с помощью связанного списка;
· шаблон класса QueueItem
представляет один узел связанного списка Queue. Каждый помещаемый в очередь элемент сохраняется в объекте QueueItem, который содержит два члена: value и next. Тип value
будет различным в каждом экземпляре класса Queue, а next – это всегда указатель на следующий объект QueueItem в очереди.
Прежде чем приступать к детальному изучению реализации этих шаблонов, рассмотрим, как они объявляются и определяются. Вот объявление шаблона класса QueueItem:
class QueueItem;
Как объявление, так и определение шаблона всегда начинаются с ключевого слова template. За ним следует заключенный в угловые скобки список параметров шаблона, разделенных запятыми. Список не бывает пустым. В нем могут быть параметры-типы, представляющие некоторый тип, и параметры-константы, представляющие некоторое константное выражение.
Параметр-тип шаблона состоит из ключевого слова class или typename (в списке параметров они эквивалентны), за которым следует идентификатор. (Ключевое слово typename не поддерживается компиляторами, написанными до принятия стандарта C++. В разделе 10.1 подробно объяснялось, зачем это слово было добавлено в язык.) Оба ключевых слова обозначают, что последующее имя параметра относится к встроенному или определенному пользователем типу. Например, в приведенном выше определении шаблона QueueItem
имеется один параметр-тип T. Допустимым фактическим аргументом для T
является любой встроенный или определенный пользователем тип, такой, как int, double, char*, complex или string.
У шаблона класса может быть несколько параметров-типов:
class Container;
Однако ключевое слово class или typename
должно предшествовать каждому. Следующее объявление ошибочно:
<
class collection;
Объявленный параметр-тип служит спецификатором типа в оставшейся части определения шаблона и употребляется точно так же, как любой встроенный или определенный пользователем тип в обычном определении класса. Например, параметр-тип можно использовать для объявления данных и функций-членов, членов вложенных классов и т.д.
Не являющийся типом параметр шаблона представляет собой обычное объявление. Он показывает, что следующее за ним имя – это потенциальное значение, употребляемое в определении шаблона в качестве константы. Так, шаблон класса Buffer
может иметь параметр-тип, представляющий типы элементов, хранящихся в буфере, и параметр-константу, содержащий его размер:
class Buffer;
За списком параметров шаблона следует определение или объявление класса. Шаблон определяется так же, как обычный класс, но с указанием параметров:
};
В этом примере Type
используется для обозначения типа члена item. По ходу выполнения программы вместо Type
могут быть подставлены различные встроенные или определенные пользователем типы. Такой процесс подстановки называется конкретизацией
шаблона.
Имя параметра шаблона можно употреблять после его объявления и до конца объявления или определения шаблона. Если в глобальной области видимости объявлена переменная с таким же именем, как у параметра шаблона, то это имя будет скрыто. В следующем примере тип item
равен не double, а типу параметра:
};
Член класса внутри определения шаблона не может быть одноименным его параметру:
<
};
Имя параметра шаблона может встречаться в списке только один раз. Поэтому следующее объявление компилятор помечает как ошибку:
class container;
Такое имя разрешается повторно использовать в объявлениях или определениях других шаблонов:
class Queue;
Имена параметров в опережающем объявлении и последующем определении одного и того же шаблона не обязаны совпадать. Например, все эти объявления QueueItem
относятся к одному шаблону класса:
class QueueItem { ... };
У параметров могут быть аргументы по умолчанию (это справедливо как для параметров-типов, так и для параметров-констант) – тип или значение, которые используются в том случае, когда при конкретизации шаблона фактический аргумент не указан. В качестве такого аргумента следует выбирать тип или значение, подходящее для большинства конкретизаций. Например, если при конкретизации шаблона класса Buffer не указан размер буфера, то по умолчанию принимается 1024:
class Buffer;
В последующих объявлениях шаблона могут быть заданы дополнительные аргументы по умолчанию. Как и в объявлениях функций, если для некоторого параметра задан такой аргумент, то он должен быть задан и для всех параметров, расположенных в списке правее (даже в другом объявлении того же шаблона):
class Buffer;
<
class Buffer;
(Отметим, что аргументы по умолчанию для параметров шаблонов не поддерживаются в компиляторах, реализованных до принятия стандарта C++. Чтобы примеры из этой книги, в частности из главы 12, компилировались большинством современных компиляторов, мы не использовали такие аргументы.)
Внутри определения шаблона его имя можно применять как спецификатор типа всюду, где допустимо употребление имени обычного класса. Вот более полная версия определения шаблона QueueItem:
};
Обратите внимание, что каждое появление имени QueueItem в определении шаблона – это сокращенная запись для
QueueItem
Такую сокращенную нотацию можно употреблять только внутри определения QueueItem (и, как мы покажем в следующих разделах, в определениях его членов, которые находятся вне определения шаблона класса). Если QueueItem
применяется как спецификатор типа в определении какого-либо другого шаблона, то необходимо задавать полный список параметров. В следующем примере шаблон класса используется в определении шаблона функции display. Здесь за именем шаблона класса QueueItem
должны идти параметры, т.е. QueueItem.
}
class Queue {
public:
Queue();
~Queue();
Type& remove();
void add( const Type & );
bool is_empty();
bool is_full();
private:
// ...
};
Вопрос в том, какой тип использовать вместо Type? Предположим, что мы решили реализовать класс Queue, заменив Type на int. Тогда Queue
может управлять коллекциями объектов типа int. Если бы понадобилось поместить в очередь объект другого типа, то его пришлось бы преобразовать в тип int, если же это невозможно, компилятор выдаст сообщение об ошибке:
qObj.add( str ); // ошибка: нет преобразования из string в int
Поскольку любой объект в коллекции имеет тип int, то язык C++ гарантирует, что в очередь можно поместить либо значение типа int, либо значение, преобразуемое в такой тип. Это подходит, если предстоит работа с очередями объектов только типа int. Если же класс Queue
Таким образом, используя в своей программе некоторую библиотеку, мы должны быть уверены, что имена глобальных сущностей нашей программы не совпадают с именами из библиотеки. Это нелегко, если мы работаем с библиотеками разных производителей, где определено много глобальных имен. Собирая программу с такими библиотеками, нельзя гарантировать, что имена глобальных сущностей не будут вступать в конфликт.
Обойти эту проблему, названную проблемой засорения области видимости глобального пространства имен, можно посредством очень длинных имен. Часто в качестве их префикса употребляется определенная последовательность символов. Например:
void inverse( cplusplus_primer_matrix & );
Однако у этого решения есть недостаток. Программа, написанная на С++, может содержать множество глобальных классов, функций и шаблонов, видимых в любой точке кода. Работать со слишком длинными идентификаторами для программистов утомительно.
Пространства имен помогают справиться с проблемой засорения более удобным способом. Автор библиотеки может задать собственное пространство и таким образом вынести используемые в библиотеке имена из глобальной области видимости:
}
cplusplus_primer
является пользовательским пространством имен
(в отличие от глобального пространства, которое неявно подразумевается и существует в любой программе).
Каждое такое пространство представляет собой отдельную область видимости. Оно может содержать вложенные определения пространств имен, а также объявления или определения функций, объектов, шаблонов и типов. Все сущности, объявленные внутри некоторого пространства имен, называются его членами. Каждое имя в пользовательском пространстве, как и в глобальном, должно быть уникальным в пределах этого пространства.
Однако в разных пользовательских пространствах могут встречаться члены с одинаковыми именами.
Имя члена пространства имен автоматически дополняется, или квалифицируется, именем этого пространства. Например, имя класса matrix, объявленное в пространстве cplusplus_primer, становится cplusplus_primer::matrix, а имя функции inverse()
превращается в cplusplus_primer::inverse().
Члены cplusplus_primer
могут использоваться в программе с помощью спецификации имени:
}
Если в другом пользовательском пространстве имен (скажем, DisneyFeatureAnimation) также существует класс matrix и функция inverse() и мы хотим использовать этот класс вместо объявленного в пространстве cplusplus_primer, то функцию func()
нужно модифицировать следующим образом:
}
Конечно, каждый раз указывать специфицированные имена типа
namespace_name::member_name
неудобно. Поэтому существуют механизмы, позволяющие облегчить использование пространств имен в программах. Это псевдонимы пространств имен, using-объявления и using-директивы. (Мы рассмотрим их в разделе 8.6.)
За идентификатором пространства имен следует блок в фигурных скобках, содержащий различные объявления. Любое объявление, допустимое в области видимости глобального пространства, может встречаться и в пользовательском: классы, переменные (вместе с инициализацией), функции (вместе со своими определениями), шаблоны.
Помещая объявление в пользовательское пространство, мы не меняем его семантики. Единственное отличие состоит в том, что имена, вводимые такими объявлениями, включают в себя имя пространства, внутри которого они объявлены. Например:
}
Именем класса, объявленного в пространстве cplusplus_primer, будет
cplusplus_primer::matrix
Именем функции
cplusplus_primer::inverse()
Именем константы
cplusplus_primer::pi
Имя класса, функции или константы расширяется именем пространства, в котором они объявлены. Такие имена называют квалифицированными.
Определение пространства имен не обязательно должно быть непрерывным. Например, предыдущее пространство могло быть определено таким образом:
}
Два приведенных примера эквивалентны: оба задают пространство имен cplusplus_primer, содержащее класс matrix, функцию inverse(), константу pi и operator+(). Определение пространства имен может состоять из нескольких соединенных частей.
Последовательность
namespace namespace_name {
задает новое пространство, если имя namespace_name не совпадает с одним из ранее объявленных. В противном случае новые объявления добавляются в старое пространство.
Возможность разбить пространство имен на несколько частей помогает при организации библиотеки. Ее исходный код легко разделить на интерфейсную часть и реализацию. Например:
}
Первая часть пространства имен содержит объявления и определения, служащие интерфейсом библиотеки: определения типов, констант, объявления функций. Во второй части находятся детали реализации, то есть определения функций.
Еще более полезной для организации исходного кода библиотеки является возможность разделить определение одного пространства имен на несколько файлов: эти определения также объединяются. Наша библиотека может быть устроена следующим образом:
}
Программа, использующая эту библиотеку, выглядит так:
}
Подобная организация программы обеспечивает модульность библиотеки, необходимую для сокрытия реализации от пользователей, в то же время позволяя без ошибок скомпилировать и связать файлы primer.C и user.C в одну программу.
вместе с определением шаблона QueueItem:
#endif
При использовании имени Queue
внутри определения шаблона класса Queue список параметров
можно опускать. Однако пропуск списка параметров шаблона QueueItem в определении шаблона Queue
недопустим. Так, объявление члена front является ошибкой:
}
Упражнение 16.1
Найдите ошибочные объявления (или пары объявлений) шаблонов классов:
class Container1;
class Container2;
class Container3 {};
class Container4 {};
class Container5;
class Container6;
Упражнение 16.2
Следующее определение шаблона List
некорректно. Как исправить ошибку?
};
ival + sh; sh + ival;
Операнды ch и sh
расширяются до типа int. При выполнении операции складываются два значения типа int. Расширение типа неявно выполняется компилятором и для пользователя прозрачно.
В этом разделе мы рассмотрим, как разработчик может определить собственные преобразования для объектов типа класса. Такие определенные пользователем преобразования также автоматически вызываются компилятором по мере необходимости. Чтобы показать, зачем они нужны, обратимся снова к классу SmallInt, введенному в разделе 10.9.
Напомним, что SmallInt
позволяет определять объекты, способные хранить значения из того же диапазона, что unsigned char, т.е. от 0 до 255, и перехватывает ошибки выхода за его границы. Во всех остальных отношениях этот класс ведет себя точно так же, как unsigned char.
Чтобы иметь возможность складывать объекты SmallInt с другими объектами того же класса или со значениями встроенных типов, а также вычитать их, реализуем шесть операторных функций:
};
Операторы-члены дают возможность складывать и вычитать два объекта SmallInt. Глобальные же операторы-друзья позволяют производить эти операции над объектами данного класса и объектами встроенных арифметических типов. Необходимо только шесть операторов, поскольку любой встроенный арифметический тип может быть приведен к типу int. Например, выражение
si + 3.14159
разрешается в два шага:
1. Константа 3.14159
типа double
преобразуется в целое число 3.
2. Вызывается operator+(const SmallInt &,int), который возвращает значение 6.
Если мы хотим поддержать битовые и логические операции, а также операции сравнения и составные операторы присваивания, то сколько же необходимо перегрузить операторов? Сразу и не сосчитаешь. Значительно удобнее автоматически преобразовать объект класса SmallInt в объект типа int.
В языке C++ имеется механизм, позволяющий в любом классе задать набор преобразований, применимых к его объектам. Для SmallInt мы определим приведение объекта к типу int. Вот его реализация:
};
Оператор int() – это конвертер, реализующий определенное пользователем преобразование, в данном случае приведение типа класса к заданному типу int. Определение конвертера описывает, что означает преобразование и какие действия компилятор должен выполнить для его применения. Для объекта SmallInt
смысл преобразования в int заключается в том, чтобы вернуть число типа int, хранящееся в члене value.
Теперь объект класса SmallInt
можно использовать всюду, где допустимо использование int. Если предположить, что перегруженных операторов больше нет и в SmallInt
определен конвертер в int, операция сложения
si + 3.14159
разрешается двумя шагами:
1. Вызывается конвертер класса SmallInt, который возвращает целое число 3.
2. Целое число 3 расширяется до 3.0 и складывается с константой двойной точности 3.14159, что дает 6.14159.
Такое поведение больше соответствует поведению операндов встроенных типов по сравнению с определенными ранее перегруженными операторами. Когда значение типа int
складывается со значением типа double, то выполняется сложение двух чисел типа double
(поскольку тип int
расширяется до double) и результатом будет число того же типа.
В этой программе иллюстрируется применение класса SmallInt:
}
Откомпилированная программа выдает следующие результаты:
Введите SmallInt, пожалуйста: 127
Прочитано значение 127
Оно равно 127
Введите SmallInt, пожалуйста (ctrl-d для выхода): 126
Оно меньше, чем 127
Введите SmallInt, пожалуйста (ctrl-d для выхода): 128
Оно больше, чем 127
Введите SmallInt, пожалуйста (ctrl-d для выхода): 256
*** Ошибка диапазона SmallInt: 256 ***
В реализацию класса SmallInt добавили поддержку новой функциональности:
};
Ниже приведены определения функций-членов, находящиеся вне тела класса:
}
is.size();
Разработчик способен оградить некоторые члены базового класса от эффектов неоткрытого наследования. Вот как, к примеру, открывается функция-член size() класса IntArray:
};
Еще одна причина для открытия отдельных членов заключается в том, что иногда необходимо разрешить доступ к защищенным членам закрыто унаследованного базового класса при последующем наследовании. Предположим, что пользователям нужен подтип стека PeekbackStack, который может динамически расти. Для этого классу, производному от PeekbackStack, понадобится доступ к защищенным элементам ia и _size класса IntArray:
};
Производный класс может лишь вернуть унаследованному члену исходный уровень доступа, но не повысить или понизить его по сравнению с указанным в базовом классе.
На практике множественное наследование очень часто применяется для того, чтобы унаследовать открытый интерфейс одного класса и закрытую реализацию другого. Например, в библиотеку классов Booch Components включена следующая реализация растущей очереди Queue
(см. также статью Майкла Вило (Michaeel Vilot) и Грейди Буча (Grady Booch) в [LIPPMAN96b]):
{ ... }
Закрытое наследование называют также наследованием реализации. Производный класс напрямую не поддерживает открытый интерфейс базового, но пользуется его реализацией, предоставляя свой собственный открытый интерфейс.
Чтобы показать, какие здесь возникают вопросы, реализуем класс PeekbackStack, который поддерживает выборку из стека с помощью метода peekback():
peekback( int index, type &value ) { ... }
где value
содержит элемент в позиции index, если peekback()
вернула true. Если же peekback()
возвращает false, то заданная аргументом index
позиция некорректна и в value помещается элемент из вершины стека.
В реализации PeekbackStack
возможны два типа ошибок:
· реализация абстракции PeekbackStack: некорректная реализация поведения класса;
· реализация представления данных: неправильное управление выделением и освобождением памяти, копированием объектов из стека и т.п.
Обычно стек реализуется либо как массив, либо как связанный список элементов (в стандартной библиотеке по умолчанию это делается на базе двусторонней очереди, хотя вместо нее можно использовать вектор, см. главу 6). Хотелось бы иметь гарантированно правильную (или, по крайней мере, хорошо протестированную и поддерживаемую) реализацию массива или списка, чтобы использовать ее в нашем классе PeekbackStack. Если она есть, то можно сосредоточиться на правильности поведения стека.
У нас есть класс IntArray, представленный в разделе 2.3 (мы временно откажемся от применения класса deque из стандартной библиотеки и от поддержки элементов, имеющих отличный от int
тип). Вопрос, таким образом, заключается в том, как лучше всего воспользоваться классом IntArray в нашей реализации PeekbackStack. Можно задействовать механизм наследования. (Отметим, что для этого нам придется модифицировать IntArray, сделав его члены защищенными, а не закрытыми.) Реализация выглядела бы так:
<
}
К сожалению, программа, которая работает с нашим новым классом PeekbackStack, может неправильно использовать открытый интерфейс базового IntArray:
is[0] = is[512];
Абстракция PeekbackStack
должна обеспечить доступ к элементам стека по принципу “последним пришел, первым ушел”. Однако наличие дополнительного интерфейса IntArray не позволяет гарантировать такое поведение.
Проблема в том, что открытое наследование описывается как отношение “ЯВЛЯЕТСЯ”. Но PeekbackStack не является разновидностью массива IntArray, а лишь включает его как часть своей реализации. Открытый интерфейс IntArray не должен входить в открытый интерфейс PeekbackStack.
Закрытое наследование от базового класса представляет собой вид наследования, который нельзя описать в терминах подтипов. В производном классе открытый интерфейс базового становится закрытым. Все показанные выше примеры использования объекта PeekbackStack
становятся допустимыми только внутри функций-членов и друзей производного класса.
В приведенном ранее определении PeekbackStack
достаточно заменить слово public в списке базовых классов на private. Внутри же самого определения класса public и private следует оставить на своих местах:
class PeekbackStack : private IntArray { ... };
·
если ошибка обнаруживается в точке объявления, то мы не можем продолжать компиляцию приложения, пока не исправим ее. Если же конфликтующее объявление – это часть библиотеки, для которой у нас нет исходного текста, то разрешение конфликта может оказаться нетривиальной задачей. Более того, возможно, в нашем коде никогда и не возникнет ситуации, когда эта ошибка проявляется, так что для нас она останется лишь потенциальной угрозой;
· с другой стороны, если ошибка не найдена вплоть до момента использования, то код может оказаться замусоренным ошибками, проявляющимися в самый неподходящий момент к удивлению программиста. При такой стратегии успешная компиляция говорит не об отсутствии семантических ошибок, а лишь о том, что программа не исполняет код, нарушающий семантические правила языка.
Выдача сообщения об ошибке в точке использования – это одна из форм отложенного вычисления, распространенного метода повышения производительности программ. Он часто применяется для того, чтобы отложить потенциально дорогую операцию выделения или инициализации ресурса до момента, когда в нем возникнет реальная необходимость. Если ресурс так и не понадобится, мы сэкономим на ненужных подготовительных операциях. Если же он потребуется, но не сразу, мы растянем инициализацию программы на более длительный период.
В C++ потенциальные ошибки “комбинирования”, связанные с перегруженными функциями, шаблонами и наследованием классов, обнаруживаются в точке использования, а не в точке объявления. (Мы полагаем, что это правильно, поскольку необходимость выявлять все возможные ошибки, которые можно допустить в результате комбинирования многочисленных компонентов, – пустая трата времени). Следовательно, для обнаружения и устранения латентных ошибок необходимо тщательно тестировать код. Подобные ошибки, возникающие при комбинировании двух или более больших компонентов, допустимы; однако в пределах одного компонента, такого, как иерархия классов Query, их быть не должно.
int (*)( const string &, const string & ) );
И в этом случае директива typedef
помогает сделать объявление sort() более понятным:
int sort( string*, string*, PFI2S );
Поскольку в большинстве случаев употребляется функция lexicoCompare, можно использовать значение параметра по умолчанию:
int sort( string*, string*, PFI2S = lexicoCompare );
Определение sort() выглядит следующим образом:
23 }
sort()
реализует алгоритм быстрой сортировки Хоара
(C.A.R.Hoare). Рассмотрим ее определение детально. Она сортирует элементы массива от s1 до s2. Это рекурсивная функция, которая вызывает сама себя для последовательно уменьшающихся подмассивов. Рекурсия окончится тогда, когда s1 и s2
укажут на один и тот же элемент или s1 будет располагаться после s2
(строка 5).
elem
( строка 6) является разделяющим элементом. Все элементы, меньшие чем elem, перемещаются влево от него, а большие– вправо. Теперь массив разбит на две части. sort()
рекурсивно вызывается для каждой из них (строки 20-21).
Цикл for(;;)
проводит разделение (строки 10-17). На каждой итерации цикла индекс low
увеличивается до первого элемента, большего или равного elem
(строка 11). Аналогично high уменьшается до последнего элемента, меньшего или равного elem
(строка 12). Когда low
становится равным или большим high, мы выходим из цикла, в противном случае нужно поменять местами значения элементов и начать новую итерацию (строки 14-16). Хотя элементы разделены, elem все еще остается первым в массиве. swap() в строке 19 ставит его на место до рекурсивного вызова sort() для двух частей массива.
Сравнение производится вызовом функции, на которую указывает compare
(строки 11-12). Чтобы поменять элементы массива местами, используется операция swap() с аргументами типа string, представленная в разделе 6.11.
Вот как выглядит main(), в которой применяется наша функция сортировки:
<
}
Результат работы программы:
"a"
"drizzle"
"falling"
"left"
"light"
"museum"
"the"
"they"
"was"
"when"
Параметр функции автоматически приводится к типу указателя на функцию:
void sort( string *, string *, functype );
sort() рассматривается компилятором как объявленная в виде
int (*)( const string &, const string & ) );
Два этих объявления sort()
эквивалентны.
Заметим, что, помимо использования в качестве параметра, указатель на функцию может быть еще и типом возвращаемого значения. Например:
int (*ff( int ))( int*, int );
ff()
объявляется как функция, имеющая один параметр типа int и возвращающая указатель на функцию типа
int (*)( int*, int );
И здесь использование директивы typedef делает объявление понятнее. Объявив PF с помощью typedef, мы видим, что ff()
возвращает указатель на функцию:
PF ff( int );
Типом возвращаемого значения функции не может быть тип функции. В этом случае выдается ошибка компиляции. Например, нельзя объявить ff() таким образом:
func ff( int ); // ошибка: тип возврата ff() - функция
void putValues( int[ 10 ] );
рассматривается компилятором так, как будто оно имеет вид
void putValues( int* );
Размер массива неважен при объявлении параметра. Все три приведенные записи эквивалентны:
void putValues( int[ 10 ] );
Передача массивов как указателей имеет следующие особенности:
·
изменение значения аргумента внутри функции затрагивает сам переданный объект, а не его локальную копию. Если такое поведение нежелательно, программист должен позаботиться о сохранении исходного значения. Можно также при объявлении функции указать, что она не должна изменять значение параметра, объявив этот параметр константой:
void putValues( const int[ 10 ] );
· размер массива не является частью типа параметра. Поэтому функция не знает реального размера передаваемого массива. Компилятор тоже не может это проверить. Рассмотрим пример:
// однако при выполнении возможна ошибка
При проверке типов параметров компилятор способен распознать, что в обоих случаях тип аргумента int*
соответствует объявлению функции. Однако контроль за тем, не является ли аргумент массивом, не производится.
По принятому соглашению C-строка является массивом символов, последний элемент которого равен нулю. Во всех остальных случаях при передаче массива в качестве параметра необходимо указывать его размер. Это относится и к массивам символов, внутри которых встречается 0. Обычно для такого указания используют дополнительный параметр функции. Например:
}
putValues()
печатает элементы массива в следующем формате:
( 10 )< 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 >
где 10 – это размер массива. Вот как выглядит реализация putValues(), в которой используется дополнительный параметр:
}
Другой способ сообщить функции размер массива-параметра – объявить параметр как ссылку. В этом случае размер становится частью типа, и компилятор может проверить аргумент в полной мере.
}
Поскольку размер массива теперь является частью типа параметра, новая версия putValues()
способна работать только с массивами из 10 элементов. Конечно, это ограничивает ее область применения, зато реализация значительно проще:
<
}
Еще один способ получить размер переданного массива в функции – использовать абстрактный контейнерный тип. (Такие типы были представлены в главе 6. В следующем подразделе мы поговорим об этом подробнее.)
Хотя две предыдущих реализации putValues()
правильны, они обладают серьезными недостатками. Так, первый вариант работает только с массивами типа int. Для типа double*
нужно писать другую функцию, для long* – еще одну и т.д. Второй вариант производит операции только над массивом из 10 элементов типа int. Для обработки массивов разного размера нужны дополнительные функции. Лучшим решением было бы использовать шаблон – функцию, или, скорее, обобщенную реализацию кода целого семейства функций, которые отличаются только типами обрабатываемых данных. Вот как можно сделать из первого варианта putValues()
шаблон, способный работать с массивами разных типов и размеров:
}
Параметры шаблона заключаются в угловые скобки. Ключевое слово class
означает, что идентификатор Type служит именем параметра, при конкретизации шаблона функции putValues() он заменяется на реальный тип – int, double, string и т.д. (В главе 10 мы продолжим разговор о шаблонах функций.)
Параметр может быть многомерным массивом. Для такого параметра должны быть заданы правые границы всех измерений, кроме первого. Например:
putValues( int matrix[][10], int rowSize );
Здесь matrix
объявляется как двумерный массив, который содержит десять столбцов и неизвестное число строк. Эквивалентным объявлением для matrix
будет:
int (*matrix)[10]
Многомерный массив передается как указатель на его нулевой элемент. В нашем случае тип matrix – указатель на массив из десяти элементов типа int. Как и для одномерного массива, граница первого измерения не учитывается при проверке типов. Если параметры являются многомерными массивами, то контролируются все измерения, кроме первого.
Заметим, что скобки вокруг *matrix
необходимы из-за более высокого приоритета операции взятия индекса. Инструкция
int *matrix[10];
объявляет matrix как массив из десяти указателей на int.
Как было сказано в разделе 3.6, ссылка может быть один раз инициализирована значением объекта, и впоследствии изменить ее нельзя. Указатель же в течение своей жизни способен адресовать разные объекты или не адресовать вообще.
Поскольку указатель может содержать, а может и не содержать адрес какого-либо объекта, перед его использованием функция должна проверить, не равен ли он нулю:
}
Параметр-ссылка не нуждается в этой проверке, так как всегда существует именуемый ею объект. Например:
}
Если параметр должен ссылаться на разные объекты во время выполнения функции или принимать нулевое значение (ни на что не ссылаться), нам следует использовать указатель.
Одна из важнейших сфер применения параметров-ссылок – эффективная реализация перегруженных операций. При этом использование операций остается простым и интуитивно понятным. (Подробнее данный вопрос рассматривается в главе 15.) Разберем маленький пример. Представим себе класс Matrix (матрица). Хорошо бы реализовать операции сложения и присваивания “привычным” способом:
c = a + b;
Эти операции реализуются с помощью перегруженных операторов – функций с немного необычным именем. Для оператора сложения такая функция будет называться operator+. Посмотрим, как ее определить:
<
}
При такой реализации сложение двух объектов типа Matrix выглядит вполне привычно:
a + b;
но, к сожалению, оказывается совершенно неэффективным. Заметим, что параметры у нас передаются по значению. Содержимое двух матриц будет копироваться в область активации функции operator+(), а поскольку объекты типа Matrix весьма велики, затраты времени и памяти на создание копий могут быть совершенно неприемлемыми.
Представим себе, что мы решили использовать указатели в качестве параметров, чтобы избежать этих затрат. Вот модифицированный код operator+():
}
Да, мы добились эффективной реализации, но зато теперь применение нашей операции вряд ли можно назвать интуитивно понятным. В качестве значений параметров-указателей требуется передавать адреса складываемых объектов. Поэтому для сложения двух матриц пришлось бы написать:
&a + &b; // допустимо, хотя и плохо
Хотя такая форма не может не вызвать критику, но все-таки два объекта сложить еще удается. А вот три уже крайне затруднительно:
&a + &b + &c;
Для того чтобы сложить три объекта, при подобной реализации нужно написать так:
&( &a + &b ) + &c;
Трудно ожидать, что кто-нибудь согласится писать такие выражения. К счастью, параметры-ссылки дают именно то решение, которое требуется. Если параметр объявлен как ссылка, функция получает его l-значение, а не копию. Лишнее копирование исключается. И тип фактического аргумента может быть Matrix – это упрощает операцию сложения, как и для встроенных типов. Вот схема перегруженного оператора сложения для класса Matrix:
}
При такой реализации сложение трех объектов Matrix выглядит вполне привычно:
a + b + c;
Ссылки были введены в С++ именно для того, чтобы удовлетворить двум требованиям: эффективная реализация и интуитивно понятное применение.
В каких случаях применение параметров-ссылок оправданно? Во-первых, тогда, когда без использования ссылок пришлось бы менять типы параметров на указатели (см. приведенную выше функцию swap()). Во-вторых, при необходимости вернуть из функции несколько значений. В-третьих, для передачи большого объекта типа класса. Рассмотрим два последних случая подробнее.
Как пример функции, использующей параметр-ссылку для возврата дополнительного значения, возьмем look_up(), которая будет искать заданную величину в векторе целых чисел. В случае успеха look_up()
вернет итератор, указывающий на найденный элемент, иначе– на элемент, расположенный за конечным. Если величина содержится в векторе несколько раз, итератор будет указывать на первое вхождение. Кроме того, дополнительный параметр-ссылка occurs
возвращает количество найденных элементов.
}
Третий случай, когда использование параметра-ссылки может быть полезно, – это большой объект типа класса в качестве аргумента. При передаче по значению объект будет копироваться целиком при каждом вызове функции, что для больших объектов может привести к потере эффективности. Используя параметр-ссылку, функция получает доступ к той области памяти, где размещен сам объект, без создания дополнительной копии.
Например:
}
Может возникнуть желание использовать параметр-ссылку, чтобы избежать создания копии большого объекта, но в то же время не дать вызываемой функции возможности изменять значение аргумента. Если параметр-ссылка не должен модифицироваться внутри функции, то стоит объявить его как ссылку на константу. В такой ситуации компилятор способен распознать и пресечь попытку непреднамеренного изменения значения аргумента.
В следующем примере нарушается константность параметра xx функции foo(). Поскольку параметр функции foo_bar() не является ссылкой на константу, то нет гарантии, что вызов foo_bar() не изменит значения аргумента. Компилятор сигнализирует об ошибке:
}
Для того чтобы программа компилировалась, мы должны изменить тип параметра foo_bar(). Подойдет любой из следующих двух вариантов:
extern int foo_bar( X ); // передача по значению
Вместо этого можно передать копию xx, которую позволено менять:
}
Параметр-ссылка может именовать любой встроенный тип данных. В частности, разрешается объявить параметр как ссылку на указатель, если программист хочет изменить значение самого указателя, а не объекта, который он адресует. Вот пример функции, обменивающей друг с другом значения двух указателей:
}
Объявление
int *&v1;
должно читаться справа налево: v1
является ссылкой на указатель на объект типа int. Модифицируем функцию main(), которая вызывала rswap(), для проверки работы ptrswap():
}
Вот результат работы программы:
Перед ptrswap(): pi: 10 pj: 20
После ptrswap(): pi: 20 pj: 10
Конечно, можно определить три константы для обозначения этих режимов:
const int append = 3;
и пользоваться этими константами:
open_file( "Phoenix_and_the_Crane", append );
Подобное решение допустимо, но не вполне приемлемо, поскольку мы не можем гарантировать, что аргумент, передаваемый в функцию open_file() равен только 1, 2 или 3.
Использование перечислимого типа решает данную проблему. Когда мы пишем:
enum open_modes{ input = 1, output, append };
мы определяем новый тип open_modes. Допустимые значения для объекта этого типа ограничены набором 1, 2 и 3, причем каждое из указанных значений имеет мнемоническое имя. Мы можем использовать имя этого нового типа для определения как объекта данного типа, так и типа формальных параметров функции:
void open_file( string file_name, open_modes om );
input, output и append являются элементами перечисления. Набор элементов перечисления задает допустимое множество значений для объекта данного типа. Переменная типа open_modes (в нашем примере) инициализируется одним из этих значений, ей также может быть присвоено любое из них. Например:
open_file( "Phoenix and the Crane", append );
Попытка присвоить переменной данного типа значение, отличное от одного из элементов перечисления (или передать его параметром в функцию), вызовет ошибку компиляции. Даже если попробовать передать целое значение, соответствующее одному из элементов перечисления, мы все равно получим ошибку:
open_file( "Jonah", 1 );
Есть способ определить переменную типа open_modes, присвоить ей значение одного из элементов перечисления и передать параметром в функцию:
<
om = append;
open_file( "TailTell", om );
Однако получить имена таких элементов невозможно. Если мы напишем оператор вывода:
cout << input << " " << om << endl;
то все равно получим:
1 3
Эта проблема решается, если определить строковый массив, в котором элемент с индексом, равным значению элемента перечисления, будет содержать его имя. Имея такой массив, мы сможем написать:
<< open_modes_table[ om ] << endl
Будет выведено:
input append
Кроме того, нельзя перебрать все значения перечисления:
// ...
Для определения перечисления служит ключевое слово enum, а имена элементов задаются в фигурных скобках, через запятую. По умолчанию первый из них равен 0, следующий – 1 и так далее. С помощью оператора присваивания это правило можно изменить. При этом каждый следующий элемент без явно указанного значения будет на 1 больше, чем элемент, идущий перед ним в списке. В нашем примере мы явно указали значение 1 для input, при этом output и append
будут равны 2 и 3. Вот еще один пример:
enum Forms{ share, spere, cylinder, polygon };
Целые значения, соответствующие разным элементам одного перечисления, не обязаны отличаться. Например:
enum Points { point2d=2, point2w, point3d=3, point3w=4 };
Объект, тип которого – перечисление, можно определять, использовать в выражениях и передавать в функцию как аргумент. Подобный объект инициализируется только значением одного из элементов перечисления, и только такое значение ему присваивается – явно или как значение другого объекта того же типа. Даже соответствующие допустимым элементам перечисления целые значения не могут быть ему присвоены:
}
Однако в арифметических выражениях перечисление может быть автоматически преобразовано в тип int. Например:
int chunk_size = array_size * pt2w;
Каждому параметру функции отводится место в данной области, причем его размер определяется типом параметра. При вызове функции память инициализируется значениями фактических аргументов.
Стандартным способом передачи аргументов является копирование их значений, т.е. передача по значению. При этом способе функция не получает доступа к реальным объектам, являющихся ее аргументами. Вместо этого она получает в стеке локальные копии этих объектов. Изменение значений копий никак не отражается на значениях самих объектов. Локальные копии теряются при выходе из функции.
Значения аргументов при передаче по значению не меняются. Следовательно, программист не должен заботиться о сохранении и восстановлении их значений при вызове функции. Без этого механизма любой вызов мог бы привести к нежелательному изменению аргументов, не объявленных константными явно. Передача по значению освобождает человека от лишних забот в наиболее типичной ситуации.
Однако такой способ передачи аргументов может не устраивать нас в следующих случаях:
· передача большого объекта типа класса. Временные и пространственные расходы на размещение и копирование такого объекта могут оказаться неприемлемыми для реальной программы;
· иногда значения аргументов должны быть модифицированы внутри функции. Например, swap()
должна обменять значения своих аргументов, что невозможно при передаче по значению:
<
}
swap()
обменивает значения локальных копий своих аргументов. Те же переменные, что были использованы в качестве аргументов при вызове, остаются неизменными. Это можно проиллюстрировать, написав небольшую программу:
}
Результат выполнения программы:
Перед swap(): i: 10 j: 20
После swap(): i: 10 j: 20
Достичь желаемого можно двумя способами. Первый – объявление параметров указателями. Вот как будет выглядеть реализация swap() в этом случае:
}
Функция main()
тоже нуждается в модификации. Вместо передачи самих объектов необходимо передавать их адреса:
pswap( &i, &j );
Теперь программа работает правильно:
Перед swap(): i: 10 j: 20
После swap(): i: 20 j: 10
Альтернативой может стать объявление параметров ссылками. В данном случае реализация swap()
выглядит так:
}
Вызов этой функции из main() аналогичен вызову первоначальной функции swap():
rswap( i, j );
Выполнив программу main(), мы снова получим верный результат.
Глобальный объект определен вне функции. Например:
}
Объект glob
является глобальным. (В главе 8 рассмотрение глобальных объектов и глобальной области видимости будет продолжено.) Главное достоинство и одновременно один из наиболее заметных недостатков такого объекта – доступность из любого места программы, поэтому его обычно используют для общения между разными модулями. Обратная сторона медали такова:
· функции, использующие глобальные объекты, зависят от этих объектов и их типов. Использовать такую функцию в другом контексте затруднительно;
· при модификации такой программы повышается вероятность ошибок. Даже для внесения локальных изменений необходимо понимание всей программы в целом;
· если глобальный объект получает неверное значение, ошибку нужно искать по всей программе. Отсутствует локализация;
· используя глобальные объекты, труднее писать рекурсивные функции (Рекурсия возникает тогда, когда функция вызывает сама себя. Мы рассмотрим это в разделе 7.5.);
· если используются потоки (threads), то для синхронизации доступа к глобальным объектам требуется писать дополнительный код. Отсутствие синхронизации – одна из распространенных ошибок при использовании потоков. (Пример использования потоков при программировании на С++ см. в статье “Distributing Object Computing in C++” (Steve Vinoski and Doug Schmidt) в [LIPPMAN96b].)
Можно сделать вывод, что для передачи информации между функциями предпочтительнее пользоваться параметрами и возвращаемыми значениями.
Вероятность ошибок при таком подходе возрастает с увеличением списка. Считается, что восемь параметров – это приемлемый максимум. В качестве альтернативы длинному списку можно использовать в качестве параметра класс, массив или контейнер. Он способен содержать группу значений.
Аналогично программа может возвращать только одно значение. Если же логика требует нескольких, некоторые параметры объявляются ссылками, чтобы функция могла непосредственно модифицировать значения соответствующих фактических аргументов и использовать эти параметры для возврата дополнительных значений, либо некоторый класс или контейнер, содержащий группу значений, объявляется типом, возвращаемым функцией.
Упражнение 7.9
Каковы две формы инструкции return? Объясните, в каких случаях следует использовать первую, а в каких вторую форму.
Упражнение 7.10
Найдите в данной функции потенциальную ошибку времени выполнения:
}
Упражнение 7.11
Каким способом вы вернули бы из функции несколько значений? Опишите достоинства и недостатки вашего подхода.
}
Поскольку каждый класс определяет собственную область видимости, функции, являющиеся членами двух разных классов, не перегружают друг друга. (Функции-члены класса описываются в главе 13. Разрешение перегрузки для функций-членов класса рассматривается в главе 15.)
Объявлять такие функции разрешается и внутри пространства имен. С каждым из них также связана отдельная область видимости, так что функции, объявленные в разных пространствах, не перегружают друг друга. Например:
}
Использование using-объявлений и using-директив помогает сделать члены пространства имен доступными в других областях видимости. Эти механизмы оказывают определенное влияние на объявления перегруженных функций. (Using-объявления и using-директивы рассматривались в разделе 8.6.)
Каким образом using-объявление сказывается на перегрузке функций? Напомним, что оно вводит псевдоним для члена пространства имен в ту область видимости, в которой это объявление встречается. Что делают такие объявления в следующей программе?
<
max( 35.5, 76.6 ); // вызывает libs_R_us::max( double, double )
Первое using-объявление вводит обе функции libs_R_us::max в глобальную область видимости. Теперь любую из функций max() можно вызвать внутри func(). По типам аргументов определяется, какую именно функцию вызывать. Второе using-объявление – это ошибка: в нем нельзя задавать список параметров. Функция libs_R_us::print()
объявляется только так:
using libs_R_us::print;
Using-объявление всегда делает доступными все
перегруженные функции с указанным именем. Такое ограничение гарантирует, что интерфейс пространства имен libs_R_us не будет нарушен. Ясно, что в случае вызова
print( 88 );
автор пространства имен ожидает, что будет вызвана функция libs_R_us::print(int). Если разрешить пользователю избирательно включать в область видимости лишь одну из нескольких перегруженных функций, то поведение программы становится непредсказуемым.
Что происходит, если using-объявление вводит в область видимости функцию с уже существующим именем? Эти функции выглядят так, как будто они объявлены прямо в том месте, где встречается using-объявление. Поэтому введенные функции участвуют в процессе разрешения имен всех перегруженных функций, присутствующих в данной области видимости:
}
Using-объявление добавляет в глобальную область видимости два объявления: для print(int) и для print(double). Они являются псевдонимами в пространстве libs_R_us и включаются в множество перегруженных функций с именем print, где уже находится глобальная print(const string &). При разрешении перегрузки print в fooBar
рассматриваются все три функции.
Если using-объявление вводит некоторую функцию в область видимости, в которой уже имеется функция с таким же именем и таким же списком параметров, это считается ошибкой. С помощью using-объявления нельзя задать псевдоним для функции print(int) в пространстве имен libs_R_us, если в глобальной области видимости уже есть print(int). Например:
}
Мы показали, как связаны using-объявления и перегруженные функции. Теперь рассмотрим особенности применения using-директивы. Using-директива приводит к тому, что члены пространства имен выглядят объявленными вне этого пространства, добавляя их в новую область видимости. Если в этой области уже есть функция с тем же именем, то происходит перегрузка. Например:
}
Это верно и в том случае, когда есть несколько using-директив. Одноименные функции, являющиеся членами разных пространств, включаются в одно и то множество:
}
}
Множество перегруженных функций с именем print в глобальной области видимости включает функции print(int), print(double) и print(long double). Все они рассматриваются в main() при разрешении перегрузки, хотя первоначально были определены в разных пространствах имен.
Итак, повторим, что перегруженные функции находятся в одной и той же области видимости. В частности, они оказываются там в результате применения using-объявлений и using-директив, делающих доступными имена из других областей.
}
На этом примере показан целый ряд проблем, имеющих отношение к возможным ошибочным состояниям входного потока:
· поток, чтение из которого невозможно из-за неправильного формата, переводится в состояние fail:
is.setstate( ios_base::failbit );
· операции вставки и извлечения из потока, находящегося в ошибочном состоянии, не работают:
while (( ch = is.get() ) != lbrace)
Инструкция зациклится, если объект istream будет находиться в ошибочном состоянии. Поэтому перед каждым обращением к get() проверяется отсутствие ошибки:
while ( is && ( ch = is.get() ) != lbrace)
Если объект istream не в “хорошем” состоянии, то его значение будет равно false. (О состояниях потока мы расскажем в разделе 20.7.)
Данная программа считывает объект класса WordCount, сохраненный оператором вывода из предыдущего раздела:
}
Выводится следующее:
<10> rosebud
<11,3> <11,8> <14,2> <34,6> <49,7> <67,5>
<81,2> <82,3> <91,4> <97,8>
Упражнение 20.9
Оператор ввода класса WordCount сам читает объекты класса Location. Вынесите этот код в отдельный оператор ввода класса Location.
Упражнение 20.10
Реализуйте оператор ввода для класса Date из упражнения 20.7 в разделе 20.4.
Упражнение 20.11
Реализуйте оператор ввода для класса CheckoutRecord из упражнения 20.8 в разделе 20.4.
}
Проектировщик должен решить, следует ли выводить завершающий символ новой строки. Лучше этого не делать: поскольку операторы вывода для встроенных типов такой символ не печатают, пользователь ожидает аналогичного поведения и от операторов в других классах. Определенный нами в классе WordCount оператор вывода можно использовать вместе с любыми другими операторами:
}
Программа печатает на терминале строки:
wd:
<12> sadness
Оператор вывода – это бинарный оператор, который возвращает ссылку на объект класса ostream. В общем случае структура определения перегруженного оператора вывода выглядит так:
}
Первый его аргумент – это ссылка на объект ostream, а второй – ссылка (обычно константная) на объект некоторого класса. Возвращается ссылка на ostream. Значением всегда является объект ostream, для которого оператор вызывался.
Поскольку первым аргументом является ссылка, оператор вывода должен быть определен как обычная функция, а не член класса. (Объяснение см. в разделе 15.1.) Если оператору необходим доступ к неоткрытым членам, то следует объявить его другом класса. (О друзьях говорилось в разделе 15.2.)
Пусть Location – это класс, в котором хранятся номера строки и колонки вхождения слова. Вот его определение:
}
Изменим определение класса WordCount, включив в него вектор occurList
объектов Location и объект word
класса string:
};
В классах string и Location
определен оператор вывода operator<<(). Так выглядит измененное определение оператора вывода в WordCount:
}
А вот небольшая программа для тестирования нового определения класса WordCount; позиции вхождений для простоты “зашиты” в код:
}
После компиляции и запуска программа выводит следующее:
Вхождения:
<10> rosebud
<11,3> <11,8> <14,2> <34,6> <49,7> <67,5>
<81,2> <82,3> <91,4> <97,8>
Полученный результат сохранен в файле output. Далее мы определим оператор ввода, с помощью которого прочитаем данные из этого файла.
Упражнение 20.7
Дано определение класса Date:
};
Напишите перегруженный оператор вывода даты в формате:
(a)
// полное название месяца
September 8th, 1997
(b)
9 / 8 / 97
(c) Какой формат лучше? Объясните.
(d) Должен ли оператор вывода Date
быть функцией-другом? Почему?
Упражнение 20.8
Определите оператор вывода для следующего класса CheckoutRecord:
};
};
В классе String
есть три набора перегруженных операторов. Первый – это набор операторов присваивания:
String& operator=( const char * );
Сначала идет копирующий оператор присваивания. (Подробно они обсуждались в разделе 14.7.) Следующий оператор поддерживает присваивание C-строки символов объекту типа String:
name = "Sherlock"; // использование оператора operator=( char * )
(Операторы присваивания, отличные от копирующих, мы рассмотрим в разделе 15.3.)
Во втором наборе есть всего один оператор – взятия индекса:
char& operator[]( int );
Он позволяет программе индексировать объекты класса String точно так же, как массивы объектов встроенного типа:
cout << "увы, что-то не так\n";
(Детально этот оператор описывается в разделе 15.4.)
В третьем наборе определены перегруженные операторы равенства для объектов класса String. Программа может проверить равенство двух таких объектов или объекта и C-строки:
bool operator==( const String & );
Перегруженные операторы позволяют использовать объекты типа класса с операторами, определенными в главе 4, и манипулировать ими так же интуитивно, как объектами встроенных типов. Например, желая определить операцию конкатенации двух объектов класса String, мы могли бы реализовать ее в виде функции-члена concat(). Но почему concat(), а не, скажем, append()? Выбранное нами имя логично и легко запоминается, но пользователь все же может забыть, как мы назвали функцию. Зачастую имя проще запомнить, если определить перегруженный оператор. К примеру, вместо concat() мы назвали бы новую операцию operator+=(). Такой оператор используется следующим образом:
}
Перегруженный оператор объявляется в теле класса точно так же, как обычная функция-член, только его имя состоит из ключевого слова operator, за которым следует один из множества предопределенных в языке C++ операторов (см. табл. 15.1). Так можно объявить operator+=() в классе String:
<
};
и определить его следующим образом:
}
Type min( Type, Type ); // #3
Следующее определение main()
иллюстрирует, как могут вызываться три объявленных таким образом функции:
}
Разумеется, тот факт, что три перегруженных шаблона функции успешно объявлены, не означает, что они могут быть также успешно вызваны. Такие шаблоны могут приводить к неоднозначности при вызове конкретизированного шаблона. Например, для следующего определения шаблона min5()
int min5( T, T ) { /* ... */ }
функция не конкретизируется по шаблону, если min5()
вызывается с аргументами разных типов; при этом процесс вывода заканчивается с ошибкой, поскольку из фактических аргументов функции выводятся два разных типа для T.
min5 ( i, ui );
Для разрешения второго вызова можно было бы перегрузить min5(), допустив два различных типа аргументов:
<
int min5( T, U );
При следующем обращении производится конкретизация этого шаблона функции:
min5( i, ui );
К сожалению, теперь стал неоднозначным предыдущий вызов:
min5( 1024, i );
Второе объявление min5()
допускает наличие у функции аргументов различных типов, но не требует этого. В нашем случае и T, и U
типа int. Оба объявления шаблонов могут быть конкретизированы вызовом, в котором два аргумента функции имеют один и тот же тип. Единственный способ указать, какой шаблон более предпочтителен, устранив тем самым неоднозначность, – явно задать его аргументы. (О явном задании аргументов шаблона см. раздел 10.4.) Например:
min5( 1024, i );
Однако в этом случае мы можем обойтись без перегрузки шаблона функции. Поскольку шаблон min5(T,U)
подходит для всех вызовов, для которых подходит min5(T,T), то одного объявления min5(T,U)
вполне достаточно, а объявление min5(T,T) можно удалить. Мы уже говорили в
главе 9, что, хотя перегрузка допускается, при проектировании таких функций надо быть внимательным и использовать ее только при необходимости. Те же соображения применимы и к определению перегруженных шаблонов.
В некоторых ситуациях неоднозначности при вызове не возникает, хотя по шаблону можно конкретизировать две разных функции. Если имеются следующие два шаблона для функции sum(), то предпочтение будет отдано первому даже тогда, когда конкретизированы могут быть оба:
int ival1 = sum( ia, 1024 );
Как это ни удивительно, такой вызов не приводит к неоднозначности. Шаблон конкретизируется из первого определения, так как выбирается наиболее специализированное определение. Поэтому для аргумента Type
принимается int, а не int*.
Для того чтобы один шаблон был более специализирован, чем другой, оба они должны иметь одни и те же имя и число параметров, а для параметров разных типов, как, скажем, T* и T в предыдущем примере, параметр в одном шаблоне должен быть способен принять более широкое множество фактических аргументов, чем соответствующий параметр в другом. Например, для шаблона sum(Type*, int) вместо первого формального параметра функции разрешается подставлять только фактические аргументы типа “указатель”. В то же время в шаблоне sum(Type, int) первому формальному параметру могут соответствовать фактические аргументы любого типа. Первый шаблон sum(Type*, int)
допускает более узкое множество аргументов, чем второй, т.е. он более специализирован, а следовательно, он и конкретизируется при вызове функции.
Catch-обработчик состоит из трех частей: ключевого слова catch, объявления одного типа или одного объекта, заключенного в круглые скобки (оно называется объявлением исключения), и составной инструкции. Если для обработки исключения выбрано некоторое catch-предложение, то выполняется эта составная инструкция. Рассмотрим catch-обработчики исключений pushOnFull и popOnEmpty в функции main()
более подробно:
}
В обоих catch-обработчиках есть объявление типа класса; в первом это pushOnFull, а во втором– popOnEmpty. Для обработки исключения выбирается тот обработчик, для которого типы в объявлении исключения и в возбужденном исключении совпадают. (В главе 19 мы увидим, что типы не обязаны совпадать точно: обработчик для базового класса подходит и для исключений с производными классами.) Например, когда функция-член pop()
класса iStack
возбуждает исключение popOnEmpty, то управление попадает во второй обработчик. После вывода сообщения об ошибке в cerr,
функция main()
возвращает код errorCode89.
А если catch-обработчики не содержат инструкции return, с какого места будет продолжено выполнение программы? После завершения обработчика выполнение возобновляется с инструкции, идущей за последним catch-обработчиком в списке. В нашем примере оно продолжается с инструкции return в функции main(). После того как catch-обработчик popOnEmpty выведет сообщение об ошибке, main()
вернет 0.
}
Говорят, что механизм обработки исключений в C++ невозвратный: после того как исключение обработано, управление не возобновляется с того места, где оно было возбуждено. В нашем примере управление не возвращается в функцию-член pop(), возбудившую исключение.
}
Если исключение возбуждено, то управление не попадет на инструкцию, где ресурс освобождается. Чтобы освободить ресурс, не пытаясь перехватить все возможные исключения (тем более, что мы не всегда знаем, какие именно исключения могут возникнуть), воспользуемся специальной конструкцией, позволяющей перехватывать любые исключения. Это не что иное, как предложение catch, в котором объявление исключения имеет вид (...) и куда управление попадает при любом исключении. Например:
}
Конструкция catch(...)
используется в сочетании с повторным возбуждением исключения. Захваченный ресурс освобождается внутри составной инструкции в catch-обработчике перед тем, как передать исключение по цепочке вложенных вызовов в результате повторного возбуждения:
}
Чтобы гарантировать освобождение ресурса в случае, когда выход из manip()
происходит в результате исключения, мы освобождаем его внутри catch(...) до того, как исключение будет передано дальше. Можно также управлять захватом и освобождением ресурса путем инкапсуляции в класс всей работы с ним. Тогда захват будет реализован в конструкторе, а освобождение – в автоматически вызываемом деструкторе. (С этим подходом мы познакомимся в главе 19.)
Предложение catch(...) используется самостоятельно или в сочетании с другими catch-обработчиками. В последнем случае следует позаботиться о правильной организации обработчиков, ассоциированных с try-блоком.
Catch-обработчики исследуются по очереди, в том порядке, в котором они записаны. Как только найден подходящий, просмотр прекращается. Следовательно, если предложение catch(...)
употребляется вместе с другими catch-обработчиками, то оно должно быть последним в списке, иначе компилятор выдаст сообщение об ошибке:
catch ( ... ) { } // должно быть последним в списке catch-обработчиков
Упражнение 11.4
Объясните, почему модель обработки исключений в C++ называется невозвратной.
Упражнение 11.5
Даны следующие объявления исключений. Напишите выражения throw, создающие объект-исключение, который может быть перехвачен указанными обработчиками:
catch( EXCPTYPE ) { }
Упражнение 11.6
Объясните, что происходит во время раскрутки стека.
Упражнение 11.7
Назовите две причины, по которым объявление исключения в предложении catch следует делать ссылкой.
Упражнение 11.8
На основе кода, написанного вами в упражнении 11.3, модифицируйте класс созданного исключения: неправильный индекс, использованный в операторе operator[](), должен сохраняться в объекте-исключении и затем выводиться catch-обработчиком. Измените программу так, чтобы operator[]() возбуждал при ее выполнении исключение.
int main() {
// a first solution
cout << "2 raised to the power of 10: ";
cout << 2 * 2 * 2 * 2 * 2 * 2 * 2 * 2 * 2 * 2;
cout << endl;
return 0;
}
Задача решена, хотя нам и пришлось неоднократно проверять, действительно ли 10 раз повторяется литерал 2. Мы не ошиблись в написании этой длинной последовательности двоек, и программа выдала правильный результат – 1024.
Но теперь нас попросили возвести 2 в 17 степень, а потом в 23. Чрезвычайно неудобно каждый раз модифицировать текст программы! И, что еще хуже, очень просто ошибиться, написав лишнюю двойку или пропустив ее... А что делать, если нужно напечатать таблицу степеней двойки от 0 до 15? 16 раз повторить две строки, имеющие общий вид:
cout << 2 * ... * 2;
где Х последовательно увеличивается на 1, а вместо отточия подставляется нужное число литералов?
Да, мы справились с задачей. Заказчик вряд ли будет вникать в детали, удовлетворившись полученным результатом. В реальной жизни такой подход достаточно часто срабатывает, более того, бывает оправдан: задача решена далеко не самым изящным способом, зато в желаемый срок. Искать более красивый и грамотный вариант может оказаться непрактичной тратой времени.
В данном случае “метод грубой силы” дает правильный ответ, но как же неприятно и скучно решать задачу подобным образом! Мы точно знаем, какие шаги нужно сделать, но сами эти шаги просты и однообразны.
Привлечение более сложных механизмов для той же задачи, как правило, значительно увеличивает время подготовительного этапа. Кроме того, чем более сложные механизмы применяются, тем больше вероятность ошибок. Но даже несмотря на неизбежные ошибки и неверные ходы, применение “высоких технологий” может принести выигрыш в скорости разработки, не говоря уже о том, что эти технологии значительно расширяют наши возможности. И – что интересно! – сам процесс решения может стать привлекательным.
Вернемся к нашему примеру и попробуем “ технологически усовершенствовать” его реализацию. Мы можем воспользоваться именованным объектом для хранения значения степени, в которую нужно возвести наше число. Кроме того, вместо повторяющейся последовательности литералов применим оператор цикла. Вот как это будет выглядеть:
}
value, pow, res и cnt – это переменные, которые позволяют хранить, модифицировать и извлекать значения. Оператор цикла for
повторяет строку вычисления результата pow раз.
Несомненно, мы создали гораздо более гибкую программу. Однако это все еще не функция. Чтобы получить настоящую функцию, которую можно использовать в любой программе для вычисления степени числа, нужно выделить общую часть вычислений, а конкретные значения задать параметрами.
{
for ( int res = 1; exp > 0; --exp )
res = res * val;
return res;
}
Теперь получить любую степень нужного числа не составит никакого труда. Вот как реализуется последняя наша задача – напечатать таблицу степеней двойки от 0 до 15:
int main()
{
int val = 2;
int exp = 15;
cout << "Степени 2\n";
for ( int cnt=0; cnt <= exp; ++cnt )
cout << cnt << ": "
<< pow( val, cnt ) << endl;
return 0;
}
Конечно, наша функция pow() все еще недостаточно обобщена и недостаточно надежна. Она не может оперировать вещественными числами, неправильно возводит числа в отрицательную степень – всегда возвращает 1. Результат возведения большого числа в большую степень может не поместиться в переменную типа int, и тогда будет возвращено некоторое случайное неправильное значение. Видите, как непросто, оказывается, писать функции, рассчитанные на широкое применение? Гораздо сложнее, чем реализовать конкретный алгоритм, направленный на решение конкретной задачи.
Ввод с терминала, называемый стандартным вводом, “привязан” к предопределенному объекту cin. Вывод на терминал, или стандартный вывод, привязан к объекту cout. Третий предопределенный объект, cerr, представляет собой стандартный вывод для ошибок. Обычно он используется для вывода сообщений об ошибках и предупреждений.
Для использования библиотеки ввода/вывода необходимо включить соответствующий заголовочный файл:
#include
Чтобы
значение поступило в стандартный вывод или в стандартный вывод для ошибок используется оператор <<:
cout << "\n";
Последовательность "\n"
представляет собой символ перехода на новую строку. Вместо "\n" мы можем использовать предопределенный манипулятор endl.
cout << endl;
Манипулятор endl не просто выводит данные (символ перехода на новую строку), но и производит сброс буфера вывода. (Предопределенные манипуляторы рассматриваются в главе 20.)
Операторы вывода можно сцеплять. Так, три строки в предыдущем примере заменяются одной:
cout << "сумма v1 и v2 = " << v1 + v2 << "\n";
Для чтения значения из стандартного ввода применяется оператор ввода (>>):
cin >> file_name;
Операторы ввода, как и операторы вывода, можно сцеплять:
// ...
cout << "Введите имя входного и выходного файлов: ";
cin >> ifile >> ofile;
Каким образом ввести заранее неизвестное число значений? Мы вернемся к этому вопросу в конце раздела 2.2, а пока скажем, что последовательность инструкций
while ( cin >> word )
// ...
считывает по одному слову из стандартного ввода до тех пор, пока не считаны все слова. Выражение
( cin >> word )
возвращает false, когда достигнут конец файла. (Подробнее об этом – в главе 20.) Вот пример простой законченной программы, считывающей по одному слову из cin и выводящей их в cout:
}
Вот первое предложение из произведения Джеймса Джойса “Пробуждение Финнегана”:
riverrun, past Eve and Adam's
Если запустить приведенную выше программу и набрать с клавиатуры данное предложение, мы увидим на экране терминала следующее:
Все слова прочитаны!
(В главе 6 мы рассмотрим вопрос о том, как убрать знаки препинания из вводимых слов.)
·
итератор чтения можно использовать для получения элементов из контейнера, но поддержка записи в контейнер не гарантируется. Такой итератор должен обеспечивать следующие операции (итераторы, поддерживающие также дополнительные операции, можно употреблять в качестве итераторов чтения при условии, что они удовлетворяют минимальным требованиям): сравнение двух итераторов на равенство и неравенство, префиксная и постфиксная форма инкремента итератора для адресации следующего элемента (оператор ++), чтение элемента с помощью оператора разыменования (*). Такого уровня поддержки требуют, в частности, алгоритмы find(), accumulate() и equal(). Любому алгоритму, которому необходим итератор чтения, можно передавать также и итераторы категорий, описанных в пунктах 3, 4 и 5;
· итератор записи можно представлять себе как противоположный по функциональности итератору чтения. Иными словами, его можно использовать для записи элементов контейнера, но поддержка чтения из контейнера не гарантируется. Такие итераторы обычно применяются в качестве третьего аргумента алгоритма (например, copy()) и указывают на позицию, с которой надо начинать копировать. Любому алгоритму, которому необходим итератор записи, можно передавать также и итераторы других категорий, перечисленных в пунктах 3, 4 и 5;
· однонаправленный итератор можно использовать для чтения и записи в контейнер, но только в одном направлении обхода (обход в обоих направлениях поддерживается итераторами следующей категории). К числу обобщенных алгоритмов, требующих как минимум однонаправленного итератора, относятся adjacent_find(), swap_range() и replace(). Конечно, любому алгоритму, которому необходим подобный итератор, можно передавать также и итераторы описанных ниже категорий;
· двунаправленный итератор может читать и записывать в контейнер, а также перемещаться по нему в обоих направлениях. Среди обобщенных алгоритмов, требующих как минимум двунаправленного итератора, выделяются place_merge(), next_permutation() и reverse();
· итератор с произвольным доступом, помимо всей функциональности, поддерживаемой двунаправленным итератором, обеспечивает доступ к любой позиции внутри контейнера за постоянное время. Подобные итераторы требуются таким обобщенным алгоритмам, как binary_search(), sort_heap() и nth-element().
Упражнение 12.6
Объясните, почему некорректны следующие примеры. Какие ошибки обнаруживаются во время компиляции?
(e) sort( ivec1.begin(), ivec3.end() );
Упражнение 12.7
Напишите программу, которая читает последовательность целых чисел из стандартного ввода с помощью потокового итератора чтения istream_iterator. Нечетные числа поместите в один файл посредством ostream_iterator, разделяя значения пробелом. Четные числа таким же образом запишите в другой файл, при этом каждое значение должно размещаться в отдельной строке.
Побитовые операции рассматривают операнды как упорядоченные наборы битов, каждый бит может иметь одно из двух значений – 0 или 1. Такие операции позволяют программисту манипулировать значениями отдельных битов. Объект, содержащий набор битов, иногда называют битовым вектором. Он позволяет компактно хранить набор флагов – переменных, принимающих значение “да” “нет”. Например, компиляторы зачастую помещают в битовые векторы спецификаторы типов, такие, как const и volatile. Библиотека iostream
использует эти векторы для хранения состояния формата вывода.
Как мы видели, в С++ существуют два способа работы со строками: использование C-строк и объектов типа string стандартной библиотеки – и два подхода к массивам: массивы встроенного типа и объект vector. При работе с битовыми векторами также можно применять подход, заимствованный из С, – использовать для представления такого вектора объект встроенного целого типа, обычно unsigned int, или
класс bitset
стандартной библиотеки С++. Этот класс инкапсулирует семантику вектора, предоставляя операции для манипулирования отдельными битами. Кроме того, он позволяет ответить на вопросы типа: есть ли “взведенные” биты (со значением 1) в векторе? Сколько битов “взведено”?
В общем случае предпочтительнее пользоваться классом bitset, однако, понимание работы с битовыми векторами на уровне встроенных типов данных очень полезно. В этом разделе мы рассмотрим применение встроенных типов для представления битовых векторов, а в следующем – класс bitset.
При использовании встроенных типов для представления битовых векторов можно пользоваться как знаковыми, так и беззнаковыми целыми типами, но мы настоятельно советуем пользоваться беззнаковыми: поведение побитовых операторов со знаковыми типами может различаться в разных реализациях компиляторов.
Побитовое НЕ (~) меняет значение каждого бита операнда. Бит, установленный в 1, меняет значение на 0 и наоборот.
Операторы сдвига (<<, >>) сдвигают биты в левом операнде на указанное правым операндом количество позиций. “Выталкиваемые наружу” биты пропадают, освобождающиеся биты (справа для сдвига влево, слева для сдвига вправо) заполняются нулями. Однако нужно иметь в виду, что для сдвига вправо заполнение левых битов нулями гарантируется только для беззнакового операнда, для знакового в некоторых реализациях возможно заполнение значением знакового (самого левого) бита.
Побитовое И (&) применяет операцию И ко всем битам своих операндов. Каждый бит левого операнда сравнивается с битом правого, находящимся в той же позиции. Если оба бита равны 1, то бит в данной позиции получает значение 1, в любом другом случае – 0. (Побитовое И (&) не надо путать с логическим И (&&),но, к сожалению, каждый программист хоть раз в жизни совершал подобную ошибку.)
Побитовое ИСКЛЮЧАЮЩЕЕ ИЛИ (^) сравнивает биты операндов. Соответствующий бит результата равен 1, если операнды различны (один равен 0, а другой 1). Если же оба операнда равны, результата равен 0.
Побитовое ИЛИ (|) применяет операцию логического сложения к каждому биту операндов. Бит в позиции результата получает значение 1, если хотя бы один из соответствующих битов операндов равен 1, и 0, если биты обоих операндов равны 0. (Побитовое ИЛИ не нужно смешивать с логическим ИЛИ.)
Рассмотрим простой пример. Пусть у нас есть класс из 30 студентов. Каждую неделю преподаватель проводит зачет, результат которого – сдал/не сдал. Итоги можно представить в виде битового вектора. (Заметим, что нумерация битов начинается с нуля, первый бит на самом деле является вторым по счету. Однако для удобства мы не будем использовать нулевой бит; таким образом, студенту номер 1 соответствует бит номер 1. В конце концов, наш преподаватель – не специалист в области программирования.)
unsigned int quiz1 = 0;
Нам нужно иметь возможность менять значение каждого бита и проверять это значение. Предположим, студент 27 сдал зачет. Бит 27 необходимо выставить в 1, не меняя значения других битов. Это можно сделать за два шага. Сначала нужно начать с числа, содержащего 1 в 27-м бите и 0 в остальных. Для этого используем операцию сдвига:
1 << 27;
Применив побитовую операцию ИЛИ к переменной quiz1 и нашей константе, получим нужный результат: значение 27-й бита станет равным значение 1, а другие биты останутся неизменными.
quiz1 |= 1<<27;
Теперь представим себе, что преподаватель перепроверил результаты теста и выяснил, что студент 27 зачет не сдал. Теперь нужно присвоить нуль 27-му биту, не трогая остальных. Сначала применим побитовое НЕ к предыдущей константе и получим число, в котором все биты, кроме 27-го, равны 1:
~(1<<27 );
Теперь побитово умножим (И) эту константу на quiz1 и получим нужный результат: 0 в 27-м бите и неизменные значения остальных.
quiz1 &= ~(1<<27);
Как проверить значение того же 27-го бита? Побитовое И дает true, если 27-й бит равен 1, и false, если 0:
bool hasPassed = quiz1 & (1<<27);
При использовании побитовых операций подобным образом очень легко допустить ошибку. Поэтому чаще всего такие операции инкапсулируются в макросы препроцессора или встроенные функции:
}
Вот пример использования:
has_passed_quiz[ index ] = bit_on( quiz1, index );
Раз уж мы начали инкапсулировать действия с битовым вектором в функции, следующим шагом нужно создать класс. Стандартная библиотека С++ включает такой класс bitset, его использование описано ниже.
Упражнение 4.12
Даны два целых числа:
unsigned int ui1 = 3, ui2 = 7;
Каков результат следующих выражений?
(a) ui1 & ui2 (c) uil | ui2
(b) ui1 && ui2 (d) uil || ui2
Упражнение 4.13
Используя пример функции bit_on(), создайте функции bit_turn_on()
(выставляет бит в 1), bit_turn_off()
(сбрасывает бит в 0), flip_bit()
(меняет значение на противоположное) и bit_off() (возвращает true, если бит равен 0). Напишите программу, использующую ваши функции.
Упражнение 4.14
В чем недостаток функций из предыдущего упражнения, использующих тип unsigned int? Их реализацию можно улучшить, используя определение типа с помощью typedef или механизм функций-шаблонов. Перепишите функцию bit_on(),применив сначала typedef, а затем механизм шаблонов.
Account newAcct( oldAcct );
называется почленной инициализацией по умолчанию. По умолчанию – потому, что она производится автоматически, независимо от того, есть явный конструктор или нет. Почленной – потому, что единицей инициализации является отдельный нестатический член, а не побитовая копия всего объекта класса.
Такую инициализацию проще всего представить, если считать, что компилятор создает специальный внутренний копирующий конструктор, где поочередно, в порядке объявления, инициализируются все нестатические члены. Если рассмотреть первое определение нашего класса Account:
};
то можно представить, что копирующий конструктор по умолчанию определен так:
}
Почленная инициализация одного объекта класса другим встречается в следующих ситуациях:
· явная инициализация одного объекта другим:
Account newAcct( oldAcct );
· передача объекта класса в качестве аргумента функции:
// ...
· передача объекта класса в качестве возвращаемого функцией значения:
}
· определение непустого последовательного контейнера:
vector< string > svec( 5 );
(В этом примере с помощью конструктора string по умолчанию создается один временный объект, который затем копируется в пять элементов вектора посредством копирующего конструктора string.)
· вставка объекта класса в контейнер:
svec.push_back( string( "pooh" ));
Для большинства определений реальных классов почленная инициализация по умолчанию не соответствует семантике класса. Чаще всего это случается, когда его член представляет собой указатель, который адресует освобождаемую деструктором память в хипе, как, например, в нашем Account.
В результате такой инициализации newAcct._name и oldAcct._name
указывают на одну и ту же C-строку. Если oldAcct выходит из области видимости и к нему применяется деструктор, то newAcct._name указывает на освобожденную область памяти. С другой стороны, если newAcct модифицирует строку, адресуемую _name, то она изменяется и для oldAcct. Подобные ошибки очень трудно найти.
Одно из решений псевдонимов указателей заключается в том, чтобы выделить область памяти для копии строки и инициализировать newAcct._name адресом этой области. Следовательно, почленную инициализацию по умолчанию для класса Account
нужно подавить за счет предоставления явного копирующего конструктора, который реализует правильную семантику инициализации.
Внутренняя семантика класса также может не соответствовать почленной инициализации по умолчанию. Ранее мы уже объясняли, что два разных объекта Account не должны иметь одинаковые номера счетов. Чтобы гарантировать такое поведение, мы должны подавить почленную инициализацию по умолчанию для класса Account. Вот как выглядит копирующий конструктор, решающий обе эти проблемы:
<
}
Альтернативой написанию копирующего конструктора является полный запрет почленной инициализации. Это можно сделать следующим образом:
1. Объявить копирующий конструктор закрытым членом. Это предотвратит почленную инициализацию всюду, кроме функций-членов и друзей класса.
2. Запретить почленную инициализацию в функциях-членах и друзьях класса, намеренно не предоставляя определения копирующего конструктора (однако объявить его так, как описано на шаге 1, все равно нужно). Язык не дает нам возможности ограничить доступ к закрытым членам класса со стороны функций-членов и друзей. Но если определение отсутствует, то любая попытка вызвать копирующий конструктор, законная с точки зрения компилятора, приведет к ошибке во время редактирования связей, поскольку не удастся найти определение символа.
Чтобы запретить почленную инициализацию, класс Account можно объявить так:
};
До сих пор мы не занимались явной обработкой почленной инициализации. Посмотрим, что происходит в нашей иерархии классов Query по умолчанию.
В абстрактном базовом классе Query
определены три нестатических члена:
};
Член _solution, если он установлен, адресует множество, память для которого выделена в хипе функцией-членом _vec2set(). Деструктор Query применяет к _solution
оператор delete.
Класс Query
должен предоставлять как явный копирующий конструктор, так и явный копирующий оператор присваивания. (Если вам это непонятно, перечитайте раздел 14.6.) Но сначала посмотрим, как почленное копирование по умолчанию происходит без них.
Производный класс NameQuery
содержит объект-член типа string и подобъект базового Query. Если есть объект folk класса NameQuery:
NameQuery folk( "folk" );
то инициализация music с помощью folk
NameQuery music = folk;
осуществляется так:
1. Компилятор проверяет, есть ли в NameQuery
явный копирующий конструктор. (Его нет. Поэтому необходимо применить почленную инициализацию по умолчанию.)
2. Далее компилятор проверяет, содержит ли объект NameQuery
подобъекты базового класса. (Да, в нем имеется подобъект Query.)
3. Компилятор проверяет, определен ли в классе Query
явный копирующий конструктор. (Нет, поэтому компилятор применит почленную инициализацию по умолчанию.)
4. Компилятор проверяет, содержит ли объект Query
подобъекты базового класса. (Нет.)
5. Компилятор просматривает все нестатические члены Query
На этот раз почленная инициализация music с помощью folk
корректна. Реализовывать явный копирующий конструктор в NameQuery нет необходимости.
Объект производного класса NotQuery
содержит подобъект базового Query и член _op
типа Query*, который указывает на операнд, размещенный в хипе. Деструктор NotQuery
применяет к этому операнду оператор delete.
Для класса NotQuery
почленная инициализация по умолчанию члена _op небезопасна, поэтому необходим явный копирующий конструктор. В его реализации используется виртуальная функция clone(), которую мы определили в предыдущем разделе.
{ _op = rhs._op->clone(); }
При почленной инициализации одного объекта класса NotQuery другим выполняются два шага:
1. Компилятор проверяет, определен ли в NotQuery
явный копирующий конструктор. Да, определен.
2. Этот конструктор вызывается для почленной инициализации.
Вот и все. Ответственность за правильную инициализацию подобъекта базового класса и нестатических членов возлагается на копирующий конструктор NotQuery. (Классы AndQuery и OrQuery
сходны с NotQuery, поэтому мы оставляем их в качестве упражнения для читателей.)
Почленное присваивание аналогично почленной инициализации. Если имеется явный копирующий оператор присваивания, то он вызывается для выполнения присваивания одного объекта класса другому. В противном случае применяется почленное присваивание по умолчанию.
Если базовый класс есть, то сначала с помощью копирующего оператора присваивания почленно присваивается подобъект данного класса, иначе такое присваивание рекурсивно применяется к базовым классам и членам подобъекта базового класса.
Просматриваются все нестатические члены в порядке их объявления. Если член не является объектом класса, то его значение справа от знака равенства копируется в значение соответствующего члена слева от знака равенства. Если же член является объектом класса, в котором определен явный копирующий оператор присваивания, то он и вызывается. В противном случае к базовым классам и членам объекта-члена применяется почленное присваивание по умолчанию.
Ниже предложена небольшая программа для тестирования данной реализации. Мы создаем или копируем объект, а затем распечатываем его.
}
После компиляции и запуска программа печатает следующее:
notQuery 1: ! alice
notQuery 2: ! alice
notQuery 3: ! emma
notQuery 3 присвоено значение nq2: ! alice
AndQuery : ! alice && emma
AndQuery 2: ! alice && emma
AndQuery 3: alice && emma
AndQuery 2 после присваивания: alice && emma
Упражнение 17.18
Реализуйте копирующие конструкторы в классах AndQuery и OrQuery.
Упражнение 17.19
Реализуйте копирующие операторы присваивания в классах AndQuery и OrQuery.
Упражнение 17.20
Что указывает на необходимость реализации явных копирующего конструктора и копирующего оператора присваивания?
Вот как выглядит копирующий оператор присваивания для нашего объекта Query. Еще раз отметим, что в этом месте необязательно копировать разрешающее множество, достаточно предотвратить копирование по умолчанию:
};
В классе NameQuery
явный копирующий оператор присваивания не нужен. Присваивание одного объекта NameQuery
другому выполняется в два шага:
1. Для присваивания подобъектов Query
двух объектов NameQuery
вызывается явный копирующий оператор присваивания класса Query.
2. Для присваивания членов string
вызывается явный копирующий оператор присваивания этого класса.
Для объектов NameQuery
вполне достаточно почленного присваивания по умолчанию.
В каждом из классов NotQuery, AndQuery и OrQuery для безопасного копирования операндов требуется явный копирующий оператор присваивания. Вот его реализация для NotQuery:
}
В отличие от копирующего конструктора, в копирующем операторе присваивания нет специальной части, через которую вызывается аналогичный оператор базового класса. Для этого используются две синтаксических конструкции: явный вызов, продемонстрированный выше, и явное приведение типа, как в следующем примере:
(*static_cast(this)) = rhs;
(Реализация копирующих операторов присваивания в классах AndQuery и OrQuery выглядит так же, поэтому мы оставим ее в качестве упражнения.)
newAcct = oldAcct;
по умолчанию присваивает каждому нестатическому члену newAcct значение соответственного члена oldAcct. Компилятор генерирует следующий копирующий оператор присваивания:
}
Как правило, если для класса не подходит почленная инициализация по умолчанию, то не подходит и почленное присваивание по умолчанию. Например, для первоначального определения класса Account, где член _name был объявлен как char*, такое присваивание не годится ни для _name, ни для _acct_nmbr.
Мы можем подавить его, если предоставим явный копирующий оператор присваивания, где будет реализована подходящая для класса семантика:
}
Здесь условная инструкция
if ( this != &rhs )
предотвращает присваивание объекта класса самому себе, что особенно неприятно в ситуации, когда копирующий оператор присваивания сначала освобождает некоторый ресурс, ассоциированный с объектом в левой части, чтобы назначить вместо него ресурс, ассоциированный с объектом в правой части. Рассмотрим копирующий оператор присваивания для класса Account:
<
}
Когда один объект класса присваивается другому, как, например, в инструкции:
newAcct = oldAcct;
выполняются следующие шаги:
1. Выясняется, есть ли в классе явный копирующий оператор присваивания.
2. Если есть, проверяются права доступа к нему, чтобы понять, можно ли его вызывать в данном месте программы.
3. Оператор вызывается для выполнения присваивания; если же он недоступен, компилятор выдает сообщение об ошибке.
4. Если явного оператора нет, выполняется почленное присваивание по умолчанию.
5. При почленном присваивании каждому члену встроенного или составного члена объекта в левой части присваивается значение соответственного члена объекта в правой части.
6. Для каждого члена, являющегося объектом класса, рекурсивно применяются шаги 1-6, пока не останутся только члены встроенных и составных типов.
Если мы снова модифицируем определение класса Account так, что _name
будет иметь тип string, то почленное присваивание по умолчанию
newAcct = oldAcct;
будет выполняться так же, как при создании компилятором следующего оператора присваивания:
}
Однако почленное присваивание по умолчанию для объектов класса Account не подходит из-за _acct_nmbr. Нужно реализовать явный копирующий оператор присваивания с учетом того, что _name – это объект класса string:
}
Чтобы запретить почленное копирование, мы поступаем так же, как и в случае почленной инициализации: объявляем оператор закрытым и не предоставляем его определения.
Копирующий конструктор и копирующий оператор присваивания обычно рассматривают вместе. Если необходим один, то, как правило, необходим и другой. Если запрещается один, то, вероятно, следует запретить и другой.
Упражнение 14.17
Реализуйте копирующий оператор присваивания для каждого из классов, определенных в упражнении 14.14 из раздела 14.6.
Упражнение 14.18
Нужен ли копирующий оператор присваивания для того класса, который вы выбрали в упражнении 14.3 из раздела 14.2? Если да, реализуйте его. В противном случае объясните, почему он не нужен.
NotQuery *pnq2 = new NotQuery( *pnq );
Если же у нас есть только указатель на абстрактный класс Query, то задача создания дубликата становится куда менее тривиальной:
// как получить дубликат pq?
Если бы позволялось объявить виртуальный экземпляр оператора new, то проблема была бы решена, поскольку автоматически вызывался бы нужный экземпляр. К сожалению, это невозможно: new – статическая функция-член, которая применяется к неструктурированной памяти еще до конструирования объекта класса (см. раздел 15.8).
Но хотя оператор new
нельзя сделать виртуальным, разрешается создать его суррогат, который будет выделять память из хипа и копировать туда объекты, – clone():
};
Вот как он может быть реализован в классе NameQuery:
};
Это работает правильно, если тип целевого указателя Query*:
Query *pq2 = pq->clone();
Если же его тип равен NameQuery*, нужно привести возвращенный указатель типа Query* назад к типу NameQuery*:
static_cast( pnq->clone() );
(Причина, по которой необходимо преобразование типа, объясняется в разделе 19.1.1.)
Как правило, тип значения, возвращаемого реализацией виртуальной функции в производном классе, должен совпадать с типом, возвращаемым ее реализацией в базовом. Исключение, о котором мы уже упоминали, призвано поддержать рассмотренную ситуацию. Если виртуальная функция в базовом классе возвращает значение некоторого типа класса (либо указатель или ссылку на тип класса), то ее реализация в производном может возвращать значение, тип которого является производным от этого класса с открытым типом наследования (то же относится к ссылкам и указателям):
};
Теперь pq2 и pnq2
можно инициализировать без явного приведения типов:
NameQuery *pnq2 = pnq->clone(); // правильно
Так выглядит реализация clone() в классе NotQuery:
};
Реализации в AndQuery и OrQuery
аналогичны. Чтобы эти реализации clone() работали правильно, в классах NotQuery, AndQuery и OrQuery должны быть явно определены копирующие конструкторы. (Мы займемся этим в разделе 17.6.)
·
фактический аргумент типа char, unsigned char или short
расширяется до типа int. Фактический аргумент типа unsigned short
расширяется до типа int, если машинный размер int
больше, чем размер short, и до типа unsigned int в противном случае;
· аргумент типа float
расширяется до типа double;
· аргумент перечислимого типа расширяется до первого из следующих типов, который способен представить все значения элементов перечисления: int, unsigned int, long, unsigned long;
· аргумент типа bool
расширяется до типа int.
Подобное расширение применяется, когда тип фактического аргумента совпадает с одним из только что перечисленных типов, а формальный параметр относится к соответствующему расширенному типу:
}
Символьный литерал имеет тип char. Он расширяется до int. Поскольку расширенный тип соответствует типу формального параметра функции manip(), мы говорим, что ее вызов требует расширения типа аргумента.
Рассмотрим следующий пример:
print( uc ); // print( int ); для uc требуется только расширение типа
Для аппаратной платформы, на которой unsigned char
занимает один байт памяти, а int – четыре байта, расширение преобразует unsigned char в int, так как с его помощью можно представить все значения типа unsigned char. Для такой машинной архитектуры из приведенного в примере множества перегруженных функций наилучшее соответствие аргументу типа unsigned char
обеспечивает print(int). Для двух других функций установление соответствия требует стандартного приведения.
Следующий пример иллюстрирует расширение фактического аргумента перечислимого типа:
}
Иногда расширение перечислений преподносит сюрпризы. Компиляторы часто выбирают представление перечисления в зависимости от значений его элементов. Предположим, что в вышеупомянутой архитектуре (один байт для char и четыре байта для int) определено такое перечисление:
enum e1 { a1, b1, c1 };
Поскольку есть всего три элемента: a1, b1 и c1 со значениями 0, 1 и 2
соответственно – и поскольку все эти значения можно представить типом
char, то компилятор, как правило, и выбирает char для представления типа e1. Рассмотрим, однако, перечисление e2 со следующим множеством элементов:
enum e2 { a2, b2, c2=0x80000000 };
Так как одна из констант имеет значение 0x80000000, то компилятор обязан выбрать для представления e2
такой тип, который достаточен для хранения значения 0x80000000, то есть unsigned int.
Итак, хотя и e1, и e2
являются перечислениями, их представления различаются. Из-за этого e1 и e2
расширяются до разных типов:
}
При первом обращении к format()
фактический аргумент расширяется до типа int, так как для представления типа e1
используется char, и, следовательно, вызывается перегруженная функция format(int). При втором обращении тип фактического аргумента e2
представлен типом unsigned int и аргумент расширяется до unsigned int, из-за чего вызывается перегруженная функция format(unsigned int). Поэтому следует помнить, что поведение двух перечислений по отношению к процессу разрешения перегрузки может быть различным и зависеть от значений элементов, определяющих, как происходит расширение типа.
1. преобразования целых типов: приведение от целого типа или перечисления к любому другому целому типу (исключая трансформации, которые выше были отнесены к категории расширения типов);
2. преобразования типов с плавающей точкой: приведение от любого типа с плавающей точкой к любому другому типу с плавающей точкой (исключая трансформации, которые выше были отнесены к категории расширения типов);
3. преобразования между целым типом и типом с плавающей точкой: приведение от любого типа с плавающей точкой к любому целому типу или наоборот;
4. преобразования указателей: приведение целого значения 0 к типу указателя или трансформация указателя любого типа в тип void*;
5. преобразования в тип bool: приведение от любого целого типа, типа с плавающей точкой, перечислимого типа или указательного типа к типу bool.
Вот несколько примеров:
}
Преобразования, относящиеся к группам 1, 2 и 3, потенциально опасны, так как целевой тип может и не обеспечивать представления всех значений исходного. Например, с помощью float
нельзя адекватно представить все значения типа int. Именно по этой причине трансформации, входящие в эти группы, отнесены к категории стандартных преобразований, а не расширений типов.
<
}
При вызове функции calc()
применяется стандартное преобразование из целого типа int в тип с плавающей точкой float. В зависимости от значения переменной i
может оказаться, что его нельзя сохранить в типе float без потери точности.
Предполагается, что все стандартные изменения требуют одного объема работы. Например, преобразование из char в unsigned char не более приоритетно, чем из char в double. Близость типов не принимается во внимание. Если две устоявших функции требуют для установления соответствия стандартной трансформации фактического аргумента, то вызов считается неоднозначным и помечается компилятором как ошибка. Например, если даны две перегруженные функции:
extern void manip( float );
то следующий вызов неоднозначен:
}
Константа 3.14 имеет тип double. С помощью того или иного стандартного преобразования соответствие может быть установлено с любой из перегруженных функций. Поскольку есть две трансформации, приводящие к цели, вызов считается неоднозначным. Ни одно преобразование не имеет преимущества над другим. Программист может разрешить неоднозначность либо путем явного приведения типа:
manip ( static_cast( 3.14 ) ); // manip( long )
либо используя суффикс, обозначающий, что константа принадлежит к типу float:
manip ( 3.14F ) ); // manip( float )
Вот еще несколько примеров неоднозначных вызовов, которые помечаются как ошибки, поскольку соответствуют нескольким перегруженным функциям:
<
}
Стандартные преобразования указателей иногда противоречат интуиции. В частности, значение 0
приводится к указателю на любой тип; полученный таким образом указатель называется нулевым. Значение 0
может быть представлено как константное выражение целого типа:
}
Константное выражение 0L
(значение 0
типа long int) и константное выражение 0x00
(шестнадцатеричное целое значение 0) имеют целый тип и потому могут быть преобразованы в нулевой указатель типа int*.
Но поскольку перечисления не относятся к целым типам, элемент, равный 0, не приводим к типу указателя:
set( zr ); // ошибка: zr нельзя преобразовать в тип int*
Вызов функции set()
является ошибкой, так как не существует преобразования между значением zr
элемента перечисления и формальным параметром типа int*, хотя zr
равно 0.
Следует отметить, что константное выражение 0 имеет тип int. Для его приведения к типу указателя требуется стандартное преобразование. Если в множестве перегруженных функций есть функция с формальным параметром типа int, то именно в ее пользу будет разрешена перегрузка в случае, когда фактический аргумент равен 0:
}
При вызове print(int)
имеет место точное соответствие, тогда как для вызова print(void*)
необходимо приведение значения 0 к типу указателя. Поскольку соответствие лучше преобразования, для разрешения этого вызова выбирается функция print(int). Обращение к set()
неоднозначно, так как 0
соответствует формальным параметрам обеих перегруженных функций за счет применения стандартной трансформации. Раз обе функции одинаково хороши, фиксируется неоднозначность.
Последнее из возможных преобразований указателя позволяет привести указатель любого типа к типу void*, поскольку void* – это родовой указатель на любой тип данных. Вот несколько примеров:
}
Только указатели на типы данных могут быть приведены к типу void* с помощью стандартного преобразования, с указателями на функции так поступать нельзя:
}
}
Перечислимый тип точно соответствует только определенным в нем элементам перечисления, а также объектам, которые объявлены как принадлежащие к этому типу:
}
Выше уже упоминалось, что фактический аргумент может точно соответствовать формальному параметру, даже если для приведения их типов необходимо некоторое тривиальное преобразование, первое из которых – преобразование l-значения в r-значение. Под l-значением понимается объект, удовлетворяющий следующим условиям:
· можно получить адрес объекта;
· можно получить значение объекта;
· это значение легко модифицировать (если только в объявлении объекта нет спецификатора const).
Напротив, r-значение – это выражение, значение которого вычисляется, или выражение, обозначающее временный объект, для которого нельзя получить адрес и значение которого нельзя модифицировать. Вот простой пример:
}
В первом операторе присваивания переменная lval – это l-значение, а литерал 5 – r-значение. Во втором операторе присваивания res – это l-значение, а временный объект, в котором хранится результат, возвращаемый функцией calc(), – это r-значение.
В некоторых ситуациях в контексте, где ожидается значение, можно использовать выражение, представляющее собой l-значение:
}
Здесь obj1 и obj2 – это l-значения. Однако для выполнения сложения в функции main() из переменных obj1 и obj2 извлекаются их значения. Действие, состоящее в извлечении значения объекта, представленного выражением вида l-значение, называется преобразованием l-значения в r-значение.
Когда функция ожидает аргумент, переданный по значению, то в случае, если аргумент является l-значением, выполняется его преобразование в r-значение:
}
Так как аргумент в вызове print(color)
передается по значению, то производится преобразование l-значения в r-значение для извлечения значения color и передачи его в функцию с прототипом print(string). Однако несмотря на то, что такое приведение имело место, считается, что фактический аргумент color
точно соответствует объявлению print(string).
При вызове функций не всегда требуется применять к аргументам подобное преобразование. Ссылка представляет собой l-значение; если у функции есть параметр-ссылка, то при вызове функция получает l-значение. Поэтому к фактическому аргументу, которому соответствует формальный параметр-ссылка, описанное преобразование не применяется. Например, пусть объявлена такая функция:
void print( list & );
В вызове ниже li – это l-значение, представляющее объект list, передаваемый функции print():
}
Сопоставление li с параметром-ссылкой считается точным соответствием.
Второе преобразование, при котором все же фиксируется точное соответствие, – это преобразование массива в указатель. Как уже отмечалось в разделе 7.3, параметр функции никогда не имеет тип массива, трансформируясь вместо этого в указатель на его первый элемент. Аналогично фактический аргумент типа массива из NT (где N – число элементов в массиве, а T – тип каждого элемента) всегда приводится к типу указателя на T. Такое преобразование типа фактического аргумента и называется преобразованием массива в указатель. Несмотря на это, считается, что фактический аргумент точно соответствует формальному параметру типа “указатель на T”. Например:
}
Перед вызовом функции putValues()
массив преобразуется в указатель, в результате чего фактический аргумент ai
(массив из трех целых) приводится к указателю на int. Хотя формальным параметром функции putValues() является указатель и фактический аргумент при вызове преобразован, между ними устанавливается точное соответствие.
При установлении точного соответствия допустимо также преобразование функции в указатель. (Оно упоминалось в разделе 7.9.) Как и параметр-массив, параметр-функция становится указателем на функцию. Фактический аргумент типа “функция” также автоматически приводится к типу указателя на функцию. Такое преобразование типа фактического аргумента и называется преобразованием функции в указатель. Хотя трансформация производится, считается, что фактический аргумент точно соответствует формальному параметру. Например:
}
Перед вызовом sort()
применяется преобразование функции в указатель, которое приводит аргумент lexicoCompare от типа “функция” к типу “указатель на функцию”. Хотя формальным параметром функции является указатель, а фактическим – имя функции и, следовательно, было произведено преобразование функции в указатель, считается, что фактический аргумент точно третьему формальному параметру функции sort().
Последнее из перечисленных выше – это преобразование спецификаторов. Оно относится только к указателям и заключается в добавлении спецификаторов const или volatile
(или обоих) к типу, который адресует данный указатель:
}
Перед вызовом функции is_equal()
фактические аргументы pi и parm
преобразуются из типа “указатель на int” в тип “указатель на const int”. Эта трансформация заключается в добавлении спецификатора const к адресуемому типу, поэтому относится к категории преобразований спецификаторов. Несмотря на то, что функция ожидает получить два указателя на const int, а фактические аргументы являются указателями на int, считается, что точное соответствие между формальными и фактическими параметрами функции is_equal()
установлено.
Преобразование спецификаторов применимо только к типу, который адресует указатель. Оно не употребляется в случае, когда формальный параметр имеет спецификатор const или volatile, а фактический аргумент – нет.
}
Хотя формальный параметр функции takeCI()
имеет тип const int, а вызывается она с аргументом ii типа int, преобразование спецификаторов не производится: есть точное соответствие между фактическим аргументом и формальным параметром.
Все сказанное верно и для случая, когда аргумент является указателем, а спецификаторы const или volatile
относятся к этому указателю:
}
Спецификатор const при формальном параметре функции init() относится к самому указателю, а не к типу, который он адресует. Поэтому компилятор при анализе преобразований, которые должны быть применены к фактическому аргументу, не учитывает этот спецификатор. К аргументу pi не применяется преобразование спецификатора: считается, что этот аргумент и формальный параметр точно соответствуют друг другу.
Первые три из рассмотренных преобразований (l-значения в r-значение, массива в указатель и функции в указатель) часто называют трансформациями l-значений. (В разделе 9.4 мы увидим, что хотя и трансформации l-значений, и преобразования спецификаторов относятся к категории преобразований, не нарушающих точного соответствия, его степень считается выше в случае, когда необходима лишь первая трансформация. В следующем разделе мы поговорим об этом несколько подробнее.)
Точное соответствие можно установить принудительно, воспользовавшись явным приведением типов. Например, если есть две перегруженные функции:
extern void ff(void *);
то вызов
ff( 0xffbc ); // вызывается ff(int)
будет точно соответствовать ff(int), хотя литерал 0xffbc
записан в виде шестнадцатеричной константы. Программист может заставить компилятор вызвать функцию ff(void *), если явно выполнит операцию приведения типа:
ff( reinterpret_cast(0xffbc) ); // вызывается ff(void*)
Если к фактическому аргументу применяется такое приведение, то он приобретает тип, в который преобразуется. Явные приведения типов помогают в управлении процессом разрешения перегрузки. Например, если при разрешении перегрузки получается неоднозначный результат (фактические аргументы одинаково хорошо соответствуют двум или более устоявшим функциям), то для устранения неоднозначности можно применить явное приведение типа, заставив компилятор выбрать конкретную функцию.
int count = word_count[ "wrinkles" ];
Однако этот способ работает так, как надо, только при условии, что запрашиваемый ключ действительно содержится в отображении. Иначе оператор взятия индекса поместит в отображение элемент с таким ключом. В данном случае в word_count
занесется пара
string( "wrinkles" ), 0
Класс map
предоставляет две операции для того, чтобы выяснить, содержится ли в нем определенное значение ключа.
· count(keyValue): функция-член count()
возвращает количество элементов с данным ключом. (Для отображения оно равно только 0 или 1). Если count()
вернула 1, мы можем смело использовать индексацию:
count = word_count[ "wrinkles" ];
· find(keyValue): функция-член find()
возвращает итератор, указывающий на элемент, если ключ найден, и итератор end() в противном случае. Например:
count = (*it).second;
Значением итератора является указатель на объект pair, в котором first
содержит ключ, а second – значение. (Мы вернемся к этому в следующем подразделе.)
// добавим отсутствующее слово
// объявление cplusplus_primer::inverse() в use2.C
Объявление cplusplus::inverse() в primer.h
ссылается на одну и ту же функцию в обоих исходных файлах use1.C и use2.C.
Член пространства имен является глобальной сущностью, хотя его имя квалифицировано. Требование ПОО (правило одного определения, см. раздел 8.2) распространяется и на него. Чтобы удовлетворить этому требованию, программы, в которых используются пространства имен, обычно организуют следующим образом:
1. Объявления функций и объектов, являющихся членами пространства имен, помещают в заголовочный файл, который включается в каждый исходный файл, где они используются.
}
2. Определения этих членов помещают в исходный файл, содержащий реализацию:
}
Для объявления объекта без его определения используется ключевое слово extern, как и в случае такого объявления в глобальной области видимости.
}
первой будет выполнена инструкция readIn(), за ней sort(), compact() и наконец print().
Однако представим себе ситуацию, когда количество продаж невелико: оно равно 1 или даже 0. Вряд ли стоит вызывать функции sort() и compact() для такого случая. Но вывести результат все-таки нужно, поэтому функцию print() следует вызывать в любом случае. Для этого случая мы можем использовать условную инструкцию if. Нам придется переписать функцию readIn() так, чтобы она возвращала количество прочитанных записей:
}
Первая инструкция if
обеспечивает условное выполнение блока программы: функции sort() и compact()
вызываются только в том случае, если count больше 1. Согласно второй инструкции if на терминал выводится сообщение “Продаж не было”, если условие истинно, т.е. значение count
равно 0. Если же это условие ложно, производится вызов функции print(). (Детальное описание инструкции if
приводится в разделе 5.3.)
Другим распространенным способом непоследовательного выполнения программы является итерация, или инструкция цикла. Такая инструкция предписывает повторять блок программы до тех пор, пока некоторое условие не изменится с true на false. Например:
}
В этом надуманном примере цикл while выполняется пять раз, до тех пор пока переменная iterations не получит значение 5 и переменная continue_loop не станет равной false. Инструкция
iterations++;
увеличивает значение переменной iterations на единицу. (Инструкции цикла детально рассматриваются в главе 5.)
(плюшевый мишка) есть два виртуальных базовых: непосредственный– ToyAnimal
(игрушечное животное) и экземпляр ZooAnimal, от которого унаследован класс Bear:
{ ... };
Эта иерархия изображена на рис. 18.5, где виртуальное наследование показано пунктирной стрелкой, а невиртуальное – сплошной.
Character ZooAnimal ToyAnimal
BookCharacter Bear
TeddyBear
¾¾> невиртуальное наследование
- - - -> виртуальноe наследование
Рис. 18.5. Иерархия виртуального наследования класса TeddyBear
Непосредственные базовые классы просматриваются в порядке их объявления при поиске среди них виртуальных. В нашем примере сначала анализируется поддерево наследования BookCharacter, затем Bear и наконец ToyAnimal. Каждое поддерево обходится в глубину, т.е. поиск начинается с корневого класса и продвигается вниз. Так, для поддерева BookCharacter сначала просматривается Character, а затем BookCharacter. Для поддерева Bear
– ZooAnimal, а потом Bear.
При описанном алгоритме поиска порядок вызова конструкторов виртуальных базовых классов для TeddyBear
таков: ZooAnimal, потом ToyAnimal.
После того как вызваны конструкторы виртуальных базовых классов , настает черед конструкторов невиртуальных, которые вызываются в порядке объявления: BookCharacter, затем Bear. Перед выполнением конструктора BookCharacter вызывается конструктор его базового класса Character.
Type value = iA[ index ];
find()
возвращает индекс первого вхождения значения find_val или -1, если значение в массиве не найдено. Этот код некорректен, поскольку в нем не проверяется, что не была возвращена -1. Поскольку -1
находится за границей массива, то каждая инициализация value
может привести к ошибке. Поэтому мы создадим подтип Array, который будет контролировать выход за границы массива, – Array_RC и поместим его определение в заголовочный файл Array_RC.h:
#endif
Внутри определения производного класса каждая ссылка на спецификатор типа шаблона базового должна быть квалифицирована списком формальных параметров:
: Array( sz ) {}
Такая запись неправильна:
Array_RC( int sz = ArraySize ) : Array( sz ) {}
Единственное отличие поведения класса Array_RC от базового состоит в том, что оператор взятия индекса контролирует выход за границы массива. Во всех остальных отношениях можно воспользоваться уже имеющейся реализацией шаблона класса Array. Напомним, однако, что конструкторы не наследуются, поэтому в Array_RC
определен собственный набор из трех конструкторов. Мы сделали класс Array_RC
виртуальным наследником класса Array, поскольку предвидели необходимость множественного наследования.
Вот полная реализация функций-членов Array_RC, находящаяся в файле Array_RC.C
(определения функций класса Array помещены в заголовочный файл Array.C, поскольку мы пользуемся моделью конкретизации шаблонов с включением, описанной в разделе 16.18):
}
Мы квалифицировали обращения к членам базового класса Array, например к _size, чтобы предотвратить просмотр Array до момента конкретизации шаблона:
Array::_size;
Мы достигаем этого, включая в обращение параметр шаблона. Таким образом, имена в определении Array_RC
разрешаются тогда, когда определяется шаблон (за исключением имен, явно зависящих от его параметра). Если встречается неквалифицированное имя _size, то компилятор должен найти его определение, если только это имя не зависит явно от параметра шаблона. Мы сделали имя _size зависящим от параметра шаблона, предварив его именем базового класса Array. Теперь компилятор не будет пытаться разрешить имя _size до момента конкретизации шаблона. (В определении класса Array_Sort мы приведем другие примеры использования подобных приемов.)
Каждая конкретизация Array_RC
порождает экземпляр класса Array. Например:
Array_RC sa;
конкретизирует параметром string как шаблон Array_RC, так и шаблон Array. Приведенная ниже программа вызывает try_array() (реализацию см. в разделе 16.13), передавая ей объекты подтипа Array_RC. Если все сделано правильно, то выходы за границы массивы будут замечены:
}
После компиляции и запуска программа печатает следующее:
конкретизация шаблона класса Array_RC
try_array: начальные значения массива
( 10 )< 12, 7, 14, 9, 128, 17
6, 3, 27, 5 >
try_array: после присваиваний
( 10 )< 128, 7, 14, 9, 128, 128
6, 3, 27, 3 >
try_array: почленная инициализация
( 10 )< 12, 7, 14, 9, 128, 128
6, 3, 27, 3 >
try_array: после почленного копирования
( 10 )< 12, 7, 128, 9, 128, 128
6, 3, 27, 3 >
try_array: после вызова grow
( 10 )< 12, 7, 128, 9, 128, 128
6, 3, 27, 3, 0, 0
0, 0, 0, 0 >
искомое значение: 5 возвращенный индекс: -1
Assertion failed: ix >= 0 && ix < _size
#endif
Array_Sort
включает дополнительный член – dirty_bit. Если он установлен в true, то не гарантируется, что массив по-прежнему отсортирован. Предоставляется также ряд вспомогательных функций доступа: is_dirty() возвращает значение dirty_bit; set_bit()
устанавливает dirty_bit в true; clear_bit() сбрасывает dirty_bit в false; check_bit()
пересортировывает массив, если dirty_bit равно true, после чего сбрасывает его в false. Все операции, которые потенциально могут перевести массив в неотсортированное состояние, вызывают set_bit().
При каждом обращении к шаблону Array
Array_Sort( const Array_Sort &as )
а не
Array_Sort( // ошибка: это не спецификатор типа
поскольку второе вхождение Array_Sort
синтаксически является именем функции, а не спецификатором типа.
Есть две причины, по которым правильна такая запись:
sort( 0, _size );
а не просто
as.check_bit();
Первая причина связана с типизацией: check_bit() – это неконстантная функция-член, которая модифицирует объект класса. В качестве аргумента передается ссылка на константный объект. Применение check_bit() к аргументу as
нарушает его константность и потому воспринимается компилятором как ошибка.
Вторая причина: копирующий конструктор рассматривает массив, ассоциированный с as, только для того, чтобы выяснить, нуждается ли вновь созданный объект класса Array_Sort в сортировке. Напомним, однако, что член dirty_bit нового объекта еще не инициализирован. К началу выполнения тела конструктора Array_Sort
инициализированы только члены ia и _size, унаследованные от класса Array. Этот конструктор должен с помощью clear_bit() задать начальные значения дополнительных членов и, вызвав sort(), обеспечить специальное поведение подтипа. Конструктор Array_Sort
можно было бы инициализировать и по-другому:
}
Ниже приведена реализация функции-члена grow().1
Наша стратегия состоит в том, чтобы воспользоваться имеющейся в базовом классе Array
реализацией для выделения дополнительной памяти, а затем пересортировать элементы и сбросить dirty_bit:
<
Tigger >
try_array: после присваиваний
( 7 )< Eeyore, Gopher, Owl, Piglet, Pooh, Pooh
Pooh >
try_array: почленная инициализация
( 7 )< Eeyore, Gopher, Owl, Piglet, Pooh, Pooh
Pooh >
try_array: после почленного копирования
( 7 )< Eeyore, Piglet, Owl, Piglet, Pooh, Pooh
Pooh >
try_array: после вызова grow
( 7 )<, , , , Eeyore, Owl
Piglet, Piglet, Pooh, Pooh, Pooh >
искомое значение: Tigger возвращенный индекс: -1
Memory fault (coredump)
После почленного копирования массив не
отсортирован, поскольку виртуальная функция вызывалась через объект, а не через указатель или ссылку. Как было сказано в разделе 17.5, в таком случае вызывается экземпляр функции из класса именно этого объекта, а не того подтипа, который может находиться в переменной. Поэтому функция sort()
никогда не будет вызвана через объект Array. (Разумеется, мы реализовали такое поведение только в целях демонстрации.)
}
Так выглядит реализация двоичного поиска в функции-члене find() класса Array_Sort:
}
Протестируем нашу реализацию класса Array_Sort с помощью функции try_array(). Показанная ниже программа тестирует шаблон этого класса для конкретизаций типами int и string:
}
При конкретизации типом string
после компиляции и запуска программа печатает следующий текст (обратите внимание, что попытка вывести элемент с индексом -1
заканчивается крахом):
конкретизация класса Array_Sort
try_array: начальные значения массива
( 7 )< Eeyore, Gopher, Heffalump, Owl, Piglet, Pooh
Для нашей поисковой системы мы построим набор стоп-слов– слов, имеющих семантически нейтральное значение (артикли, союзы, предлоги), таких, как the, and, into, with, but и т.д. (это улучшает качество системы, однако мы уже не сможем найти первое предложение из знаменитого монолога Гамлета: “To be or not to be?”). Прежде чем добавлять слово к word_map, проверим, не содержится ли оно в списке стоп-слов. Если содержится, проигнорируем его.
поддерживает итераторные операции с классом istream или одним из производных от него, например ifstream для работы с потоком ввода из файла. Аналогично ostream_iterator
поддерживает итераторные операции с классом ostream или одним из производных от него, например ofstream для работы с потоком вывода в файл. Для использования любого из этих итераторов следует включить заголовочный файл
#include
В следующей программе мы пользуемся потоковым итератором чтения для получения из стандартного ввода последовательности целых чисел в вектор, а затем применяем потоковый итератор записи в качестве целевого в обобщенном алгоритме unique_copy():
}
throw;
которая вновь генерирует объект-исключение. Повторное возбуждение возможно только внутри составной инструкции, являющейся частью catch-обработчика:
}
При повторном возбуждении новый объект-исключение не создается. Это имеет значение, если catch-обработчик модифицирует объект, прежде чем возбудить исключение повторно. В следующем фрагменте исходный объект-исключение не изменяется. Почему?
}
Так как eObj не является ссылкой, то catch-обработчик получает копию объекта-исключения, так что любые модификации eObj
относятся к локальной копии и не отражаются на исходном объекте-исключении, передаваемом при повторном возбуждении. Таким образом, переданный далее объект по-прежнему имеет тип zeroOp.
Чтобы модифицировать исходный объект-исключение, в объявлении исключения внутри catch-обработчика должна фигурировать ссылка:
}
Теперь eObj
ссылается на объект-исключение, созданный выражением throw, так что все изменения относятся непосредственно к исходному объекту. Поэтому при повторном возбуждении исключения далее передается модифицированный объект.
Таким образом, другая причина для объявления ссылки в catch-обработчике заключается в том, что сделанные внутри обработчика модификации объекта-исключения в таком случае будут видны при повторном возбуждении исключения. (Третья причина будет рассмотрена в разделе 19.2, где мы расскажем, как catch-обработчик вызывает виртуальные функции класса.)
#include
Например, объект-функция, поддерживающий сложение, – это шаблон класса с именем plus. Для определения экземпляра, способного складывать два целых числа, нужно написать:
plus< int > intAdd;
Для выполнения операции сложения мы применяем перегруженный оператор вызова к intAdd
точно так же, как и к классу AddImage в предыдущем разделе:
int sum = intAdd( ival1, ival2 );
Реализация шаблона класса plus
вызывает оператор сложения, ассоциированный с типом своего параметра – int. Этот и другие предопределенные объекты-функции применяются прежде всего в качестве аргументов обобщенных алгоритмов и обычно замещают подразумеваемую по умолчанию операцию. Например, по умолчанию алгоритм sort() располагает элементы контейнера в порядке возрастания с помощью оператора “меньше” базового типа. Для сортировки по убыванию мы передаем предопределенный шаблон класса greater, который вызывает оператор “больше”:
sort( svec.begin(), svec.end(), greater() );
Предопределенные объекты-функции перечислены в следующих разделах и разбиты на категории: арифметические, логические и сравнительные. Применение каждого из них иллюстрируется как в качестве именованного, так и в качестве безымянного объекта, передаваемого функции. Мы пользуемся следующими определениями объектов, включая и определение простого класса (перегрузка операторов подробно рассматривается в главе 15):
double dval1, dval2, dres;
Кроме того, мы определяем два шаблона функций, которым передаем различные безымянные объекты-функции:
{ return fob( val1, val2 ); }
·
точное соответствие. Тип фактического аргумента точно соответствует типу формального параметра. Например, если в множестве перегруженных функций print()
есть такие:
void print( char );
то каждый из следующих трех вызовов дает точное соответствие:
print( a ); // соответствует print( unsigned int );
· соответствие с
преобразованием типа. Тип фактического аргумента не соответствует типу формального параметра, но может быть преобразован в него:
ff( 0 ); // аргумент типа int приводится к типу char
· отсутствие соответствия. Тип фактического аргумента не может быть приведен к типу формального параметра в объявлении функции, поскольку необходимого преобразования не существует. Для каждого из следующих двух вызовов функции print() соответствия нет:
print( si ); // ошибка: нет соответствия
Для установления точного соответствия тип фактического аргумента необязательно должен совпадать с типом формального параметра. К аргументу могут быть применены некоторые тривиальные преобразования, а именно:
· преобразование l-значения в r-значение;
· преобразование массива в указатель;
· преобразование функции в указатель;
· преобразования спецификаторов.
(Подробнее они рассмотрены ниже.)
Категория соответствия с преобразованием типа является наиболее сложной. Необходимо рассмотреть несколько видов такого приведения: расширение типов (promotions), стандартные преобразования и определенные пользователем преобразования. (Расширения типов и стандартные преобразования изучаются в этой главе. Определенные пользователем преобразования будут представлены позднее, после детального рассмотрения классов; они
выполняются конвертером, функцией-членом, которая позволяет определить в классе собственный набор “стандартных” трансформаций. В главе 15 мы познакомимся с такими конвертерами и с тем, как они влияют на разрешение перегрузки функций.)
При выборе лучшей из устоявших функций для данного вызова компилятор ищет функцию, для которой применяемые к фактическим аргументам преобразования являются “наилучшими”. Преобразования типов ранжируются следующим образом: точное соответствие лучше расширения типа, расширение типа лучше стандартного преобразования, а оно, в свою очередь, лучше определенного пользователем преобразования. Мы еще вернемся к ранжированию в разделе 9.4, а пока на простых примерах покажем, как оно помогает выбрать наиболее подходящую функцию.
ival = 3.541 + 3;
В результате ival
получит значение 6. Вот что происходит: мы складываем литералы разных типов – 3.541
типа double и 3
типа int. C++ не может непосредственно сложить подобные операнды, сначала ему нужно привести их к одному типу. Для этого существуют правила преобразования арифметических типов. Общий принцип таков: перейти от операнда меньшего типа к большему, чтобы не потерять точность вычислений.
В нашем случае целое значение 3 трансформируется в тип double, и только после этого производится сложение. Такое преобразование выполняется независимо от желания программиста, поэтому оно получило название неявного преобразования типов.
Результат сложения двух чисел типа double тоже имеет тип double. Значение равно 6.541. Теперь его нужно присвоить переменной ival. Типы переменной и результата 6.541 не совпадают, следовательно, тип этого значения приводится к типу переменной слева от знака равенства. В нашем случае это int. Преобразование double в int
производится автоматически, отбрасыванием дробной части (а не округлением). Таким образом, 6.541
превращается в 6, и этот результат присваивается переменной ival. Поскольку при таком преобразовании может быть потеряна точность, большинство компиляторов выдают предупреждение.
Так как компилятор не округляет числа при преобразовании double в int, при необходимости мы должны позаботиться об этом сами. Например:
ival += dva1 + 0.5; // преобразование с округлением
При желании мы можем произвести явное преобразование типов:
ival = static_cast< int >( 3.541 ) + 3;
В этом примере мы явно даем указание компилятору привести величину 3.541 к типу int, а не следовать правилам по умолчанию.
В этом разделе мы детально обсудим вопросы и неявного (как в первом примере), и явного преобразования типов (как во втором).
(см. главу 16), модифицированного так, чтобы он стал конкретным базовым классом. Перед тем как приступать к реализации, поговорим о взаимосвязях между шаблонами классов и наследованием.
Конкретизированный экземпляр такого шаблона может выступать в роли явного базового класса:
class IntStack : private Array {};
Разрешается также произвести его от не шаблонного базового класса:
class Derived : public Base {};
Шаблон может выступать одновременно в роли базового и производного классов:
class Array_RC : public virtual Array {};
В первом примере конкретизированный типом int шаблон Array
служит закрытым базовым классом для не шаблонного IntStack. Во втором примере не шаблонный Base служит базовым для любого класса, конкретизированного из шаблона Derived. В третьем примере любой конкретизированный из шаблона Array_RC класс является производным от класса, конкретизированного из шаблона Array. Так, инструкция
Array_RC ia;
конкретизирует экземпляры шаблонов Array и Array_RC.
Кроме того, сам параметр-шаблон может служить базовым классом [MURRAY93]:
class Persistent : public Type { ... };
в данном примере определяется производный устойчивый (persistent) подтип для любого конкретизированного типа. Как отмечает Мюррей (Murray), на Type
налагается неявное ограничение: он должен быть типом класса. Например, инструкция
Persistent< int > pi; // ошибка
приводит к ошибке компиляции, поскольку встроенный тип не может быть объектом наследования.
Шаблон, выступающий в роли базового класса, должен квалифицироваться полным списком параметров. Если имеется определение:
template class Base {};
то необходимо писать:
class Derived : public Base {};
Такая запись неправильна:
class Derived : public Base {};
В следующем разделе шаблон Array, определенный в главе 16, выступает в роли виртуального базового класса для подтипа Array, контролирующего выход за границы массива; для отсортированного подтипа Array; для подтипа Array, который обладает обоими указанными свойствами. Однако первоначальное определение шаблона класса Array для наследования не подходит:
·
все его члены и вспомогательные функции объявлены закрытыми, а не защищенными;
· ни одна из зависящих от типа функций-членов, скажем оператор взятия индекса, не объявлена виртуальной.
Означает ли это, что наша первоначальная реализация была неправильной? Нет. Она была верной на том уровне понимания, которым мы тогда обладали. При реализации шаблона класса Array мы еще не осознали необходимость специализированных подтипов. Теперь, однако, определение шаблона придется изменить так (реализации функций-членов при этом останутся теми же):
отсутствует.
Численные алгоритмы
Следующие четыре алгоритма реализуют численные операции с контейнером. Для их использования необходимо включить заголовочный файлaccumulate(), partial_sum(), inner_product(), adjacent_difference()
Чисто виртуальные функции
С точки зрения кодирования основная задача, стоящая перед нами в связи с поддержкой пользовательских запросов,– это реализация зависимых от типа операций для каждого из возможных операторов. Для этого мы определили четыре конкретных типа классов: AndQuery, OrQuery и т.д. Однако с точки зрения проектирования наша цель – инкапсулировать обработку каждого вида запроса, спрятать за не зависящим от типа интерфейсом. Это позволит построить ядро приложения, которое не потребует изменений при добавлении или удалении типов.Чтобы добиться этого, определим абстрактный тип класса Query. При этом мы не будем программировать разные типы пользовательских запросов, а лишь абстрактные операции, применимые к ним:
void doit_and_bedone( vector< Query* > *pvec ) { vector it = pvec->begin(), end_it = pvec->end(); for ( ; it != end_it; ++it ) { Query *pq = *it; cout << "обрабатывается " << *pq << endl; pq->eval(); pq->display(); delete pq; }
| |
}
Такое определение позволяет добавлять неограниченное число типов запросов без необходимости изменять или даже перекомпилировать ядро системы, но при условии, что открытый интерфейс нашего абстрактного базового класса Query
достаточен для поддержки новых запросов.
Проектируя открытый интерфейс Query, мы определим множество операций, достаточное для поддержки всех существующих и будущих типов запросов, хотя на практике нам вряд ли удастся это гарантировать. Предоставление общего интерфейса для тех запросов, о которых мы уже знаем, – вполне реальная задача, но любое заявление, претендующее на более широкую поддержку, следует рассматривать с долей скептицизма.
Поскольку Query – абстрактный класс, объекты которого в приложении не создаются, то никакой разумной реализации виртуальных функций в нем самом мы предложить не можем. Это лишь названия, которые должны быть замещены в производных классах. Напрямую вызывать их мы не будем.
Язык обладает синтаксической конструкцией, обозначающей, что некоторая виртуальная функция предоставляет интерфейс, который должен быть замещен в производных подтипах, но вызываться непосредственно не может. Это чисто виртуальные функции. Объявляются они следующим образом:
class Query { public: // объявляется чисто виртуальная функция virtual ostream& print( ostream&=cout ) const = 0; // ... |
Заметьте, что за объявлением функции следует присваивание нуля.
Класс, содержащий (или наследующий) одну или несколько таких функций, распознается компилятором как абстрактный базовый класс. Попытка создать независимый объект абстрактного класса приводит к ошибке компиляции. (Ошибкой является также вызов чисто виртуальной функции с помощью механизма виртуализации.) Например:
// В классе Query объявлены одна или несколько виртуальных функций, // поэтому программист не может создавать независимые объекты // класса Query // правильно: подобъект Query в составе NameQuery Query *pq = new NameQuery( "Nostromo" ); // ошибка: оператор new создает объект класса Query |
Абстрактный базовый класс может существовать только как подобъект в составе объекта некоторого производного от него класса. Это именно та семантика, которая нужна нам для базового Query.
Читаем текстовый файл
Первая наша задача – прочитать текстовый файл, в котором будет производиться поиск. Нам нужно сохранить следующую информацию: само слово, номер строки и позицию в строке, где слово встречается.Как получить одну строку текста? Стандартная библиотека предоставляет для этого функцию getline():
istream& |
getline( istream &is, string str, char delimiter );
getline()берет из входного потока все символы, включая пробелы, и помещает их в объект типа string, до тех пор пока не встретится символ delimiter, не будет достигнут конец файла или количество полученных символов не станет равным величине, возвращаемой функцией-членом max_size()класса string.
Мы будем помещать каждую такую строку в вектор.
Мы вынесли код, читающий файл, в функцию, названную retrieve_text(). В объекте типа pair
дополнительно сохраняется размер и номер самой длинной строки. (Полный текст программы приводится в разделе 6.14.)
Вот реализация функции ввода файла:[15]
// возвращаемое значение - указатель на строковый вектор vector retrieve_text() { string file_name; cout << "please enter file name: "; cin >> file_name; // откроем файл для ввода ... ifstream 1nfile( file_name.c_str(), ios::in ); if ( ! infile ) { cerr << "oops! unable to open file " << file_name << " -- bailing out!\n"; exit( -1 ); } else cout << '\n'; vector new vector string textime; typedef pair stats maxline; int linenum = 0; while ( getline( infile, textline, '\n' )) { cout << "line read: " << textline << '\n'; if ( maxline.first < textline.size() ) { maxline.first = textline.size() ; maxline.second = linenum; } 1ines_of_text->push_back( textline ); linenum++; } return lines_of_text;
| |
}
Вот как выглядит вывод программы (размер страницы книги недостаточен, чтобы расположить напечатанные строки во всю длину, поэтому мы сделали в тексте отступы, показывающие, где реально заканчивалась строка):
please enter file name: a1ice_emma
line read: Alice Emma has long flowing red hair. Her Daddy says
line read: when the wind blows through her hair, it looks
almost alive,
line read: like a fiery bird in flight. A beautiful fiery bird,
he tells her,
line read: magical but untamed. "Daddy, shush, there is no such
thing, "
line read: she tells him, at the same time wanting him to tell
her more.
line read: Shyly, she asks, "I mean. Daddy, is there?"
number of lines: 6
maximum length: 66
longest line: like a fiery bird in flight. A beautiful fiery
bird, he tells her,
После того как все строки текста сохранены, нужно разбить их на слова. Сначала мы отбросим знаки препинания. Например, возьмем строку из части “Anna Livia Plurrabelle” романа “Finnegans Wake”.
"For every tale there's a telling,
and that's the he and she of it."
В приведенном фрагменте есть следующие знаки препинания:
"For
there's
telling,
that's
it."
А хотелось бы получить:
For
there
telling
that
it
Можно возразить, что
there's
должно превратиться в
there is
но мы-то движемся в другом направлении: следующий шаг – это отбрасывание семантически нейтральных слов, таких, как is, that, and, it и т.д. Так что для данной строчки из “Finnegans Wake” только два слова являются значимыми: tale и telling, и только по этим словам будет выполняться поиск. (Мы реализуем набор стоп-слов с помощью контейнерного типа set, который подробно рассматривается в следующем разделе.)
После удаления знаков препинания нам необходимо превратить все прописные буквы в строчные, чтобы избежать проблем с поиском в таких, например, строках:
Home is where the heart is.
A home is where they have to let you in.
Несомненно, запрос слова home
должен найти обе строки.
Мы должны также обеспечить минимальную поддержку учета словоформ: отбрасывать окончания слов, чтобы слова dog и dogs, love, loving и loved рассматривались системой как одинаковые.
В следующем разделе мы вернемся к описанию стандартного класса string и рассмотрим многочисленные операции над строками, которые он поддерживает, в контексте дальнейшей разработки нашей поисковой системы.
Члены и не члены класса
Рассмотрим операторы равенства в нашем классе String более внимательно. Первый оператор позволяет устанавливать равенство двух объектов, а второй – объекта и C-строки:#include "String.h" int main() { String flower; // что-нибудь записать в переменную flower if ( flower == "lily" ) // правильно // ... else if ( "tulip" == flower ) // ошибка // ... |
}
При первом использовании оператора равенства в main() вызывается перегруженный operator==(const char *)
класса String. Однако на второй инструкции if компилятор выдает сообщение об ошибке. В чем дело?
Перегруженный оператор, являющийся членом некоторого класса, применяется только тогда, когда левым операндом служит объект этого класса. Поскольку во втором случае левый операнд не принадлежит к классу String, компилятор пытается найти такой встроенный оператор, для которого левым операндом может быть C-строка, а правым – объект класса String. Разумеется, его не существует, поэтому компилятор говорит об ошибке.
Но можно же создать объект класса String из C-строки с помощью конструктора класса. Почему компилятор не выполнит неявно такое преобразование:
if ( String( "tulip" ) == flower ) //правильно: вызывается оператор-член
Причина в его неэффективности. Перегруженные операторы не требуют, чтобы оба операнда имели один и тот же тип. К примеру, в классе Text определяются следующие операторы равенства:
class Text { public: Text( const char * = 0 ); Text( const Text & ); // набор перегруженных операторов равенства bool operator==( const char * ) const; bool operator==( const String & ) const; bool operator==( const Text & ) const; // ... |
};
и выражение в main()
можно переписать так:
if ( Text( "tulip" ) == flower ) // вызывается Text::operator==()
Следовательно, чтобы найти подходящий для сравнения оператор равенства, компилятору придется просмотреть все определения классов в поисках конструктора, способного привести левый операнд к некоторому типу класса. Затем для каждого из таких типов нужно проверить все ассоциированные с ним перегруженные операторы равенства, чтобы понять, может ли хоть один из них выполнить сравнение. А после этого компилятор должен решить, какая из найденных комбинаций конструктора и оператора равенства (если таковые нашлись) лучше всего соответствует операнду в правой части! Если потребовать от компилятора выполнения всех этих действий, то время трансляции программ C++ резко возрастет. Вместо этого компилятор просматривает только перегруженные операторы, определенные как члены класса левого операнда (и его базовых классов, как мы покажем в главе 19).
Разрешается, однако, определять перегруженные операторы, не являющиеся членами класса. При анализе строки в main(), вызвавшей ошибку компиляции, подобные операторы принимались во внимание. Таким образом, сравнение, в котором C-строка стоит в левой части, можно сделать корректным, если заменить операторы равенства, являющиеся членами класса String, на операторы равенства, объявленные в области видимости пространства имен:
bool operator==( const String &, const String & ); |
Обратите внимание, что эти глобальные перегруженные операторы имеют на один параметр больше, чем операторы-члены. Если оператор является членом класса, то первым параметром неявно передается указатель this. То есть для операторов-членов выражение
flower == "lily"
переписывается компилятором в виде:
flower.operator==( "lily" )
и на левый операнд flower в определении перегруженного оператора-члена можно сослаться с помощью this. (Указатель this
введен в разделе 13.4.) В случае глобального перегруженного оператора параметр, представляющий левый операнд, должен быть задан явно.
Тогда выражение
flower == "lily"
вызывает оператор
bool operator==( const String &, const char * );
Непонятно, какой оператор вызывается для второго случая использования оператора равенства:
"tulip" == flower
Мы ведь не определили такой перегруженный оператор:
bool operator==( const char *, const String & );
Но это необязательно. Когда перегруженный оператор является функцией в пространстве имен, то как для первого, так и для второго его параметра (для левого и правого операндов) рассматриваются возможные преобразования, т.е. компилятор интерпретирует второе использование оператора равенства как
operator==( String("tulip"), flower );
и вызывает для выполнения сравнения следующий перегруженный оператор:
bool operator==( const String &, const String & );
Но тогда зачем мы предоставили второй перегруженный оператор:
bool operator==( const String &, const char * );
Преобразование типа из C-строки в класс String
может быть применено и к правому операнду. Функция main()
будет компилироваться без ошибок, если просто определить в пространстве имен перегруженный оператор, принимающий два операнда String:
bool operator==( const String &, const String & );
Предоставлять ли только этот оператор или еще два:
bool operator==( const char *, const String & ); |
зависит от того, насколько велики затраты на преобразование из C-строки в String во время выполнения, то есть от “стоимости” дополнительных вызовов конструктора в программах, пользующихся нашим классом String. Если оператор равенства будет часто использоваться для сравнения C-строк и объектов , то лучше предоставить все три варианта. (Мы вернемся к вопросу эффективности в разделе, посвященном друзьям.
Подробнее о приведении к типу класса с помощью конструкторов мы расскажем в разделе 15.9; в разделе 15.10 речь пойдет о разрешении перегрузки функций с помощью описанных преобразований, а в разделе 15.12 – о разрешении перегрузки операторов.)
Итак, на основе чего принимается решение, делать ли оператор членом класса или членом пространства имен? В некоторых случаях у программиста просто нет выбора:
· если перегруженный оператор является членом класса, то он вызывается лишь при условии, что левым операндом служит член этого класса. Если же левый операнд имеет другой тип, оператор обязан быть членом пространства имен;
· язык требует, чтобы операторы присваивания ("="), взятия индекса ("[]"), вызова ("()") и доступа к членам по стрелке ("->") были определены как члены класса. В противном случае выдается сообщение об ошибке компиляции:
// ошибка: должен быть членом класса |
(Подробнее оператор присваивания рассматривается в разделе 15.3, взятия индекса – в разделе 15.4, вызова – в разделе 15.5, а оператор доступа к члену по стрелке – в разделе 15.6.)
В остальных случаях решение принимает проектировщик класса. Симметричные операторы, например оператор равенства, лучше определять в пространстве имен, если членом класса может быть любой операнд (как в String).
Прежде чем закончить этот подраздел, определим операторы равенства для класса String в пространстве имен:
bool operator==( const String &str1, const String &str2 ) { if ( str1.size() != str2.size() ) return false; return strcmp( str1.c_str(), str2.c_str() ) ? false : true ; } inline bool operator==( const String &str, const char *s ) { return strcmp( str.c_str(), s ) ? false : true ; |
Что такое переменная
Переменная, или объект– это именованная область памяти, к которой мы имеем доступ из программы; туда можно помещать значения и затем извлекать их. Каждая переменная С++ имеет определенный тип, который характеризует размер и расположение этой области памяти, диапазон значений, которые она может хранить, и набор операций, применимых к этой переменной. Вот пример определения пяти объектов разных типов:int student_count; double salary; bool on_loan; strins street_address; |
char delimiter;
Переменная, как и литерал, имеет определенный тип и хранит свое значение в некоторой области памяти. Адресуемость – вот чего не хватает литералу. С переменной ассоциируются две величины:
· собственно значение, или r-значение (от read value – значение для чтения), которое хранится в этой области памяти и присуще как переменной, так и литералу;
· значение адреса области памяти, ассоциированной с переменной, или l-значение (от location value – значение местоположения) – место, где хранится r-значение; присуще только объекту.
В выражении
ch = ch - '0';
переменная ch
находится и слева и справа от символа операции присваивания. Справа расположено значение для чтения (ch и символьный литерал '0'): ассоциированные с переменной данные считываются из соответствующей области памяти. Слева – значение местоположения: в область памяти, соотнесенную с переменной ch, помещается результат вычитания. В общем случае левый операнд операции присваивания должен быть l-значением. Мы не можем написать следующие выражения:
// ошибки компиляции: значения слева не являются l-значениями // ошибка: литерал - не l-значение 0 = 1; // ошибка: арифметическое выражение - не l-значение |
salary + salary * 0.10 = new_salary;
Оператор определения переменной выделяет для нее память. Поскольку объект имеет только одну ассоциированную с ним область памяти, такой оператор может встретиться в программе только один раз. Если же переменная, определенная в одном исходном файле, должна быть использована в другом, появляются проблемы. Например:
// файл module0.C |
string fileName;
// ... присвоить fileName значение
// файл module1.C
// использует объект fileName
// увы, не компилируется:
// fileName не определен в module1.C
ifstream input_file( fileName );
С++ требует, чтобы объект был известен до первого обращения к нему. Это вызвано необходимостью гарантировать правильность использования объекта в соответствии с его типом. В нашем примере модуль module1.C
вызовет ошибку компиляции, поскольку переменная fileName не определена в нем. Чтобы избежать этой ошибки, мы должны сообщить компилятору об уже определенной переменной fileName. Это делается с помощью инструкции объявления
переменной:
// файл module1.C |
// fileName объявляется, то есть программа получает
// информацию об этом объекте без вторичного его определения
extern string fileName;
ifstream input_file( fileName )
Объявление переменной сообщает компилятору, что объект с данным именем, имеющий данный тип, определен где-то в программе. Память под переменную при ее объявлении не отводится. (Ключевое слово extern рассматривается в разделе 8.2.)
Программа может содержать сколько угодно объявлений одной и той же переменной, но определить ее можно только один раз. Такие объявления удобно помещать в заголовочные файлы, включая их в те модули, которые этого требуют. Так мы можем хранить информацию об объектах в одном месте и обеспечить удобство ее модификации в случае надобности. (Более подробно о заголовочных файлах мы поговорим в разделе 8.2.)
Что такое выражение?
Выражение состоит из одного или более операндов, в простейшем случае – из одного литерала или объекта. Результатом такого выражения является r-значение его операнда. Например:void mumble() { 3.14159; "melancholia"; upperBound; |
}
Результатом вычисления выражения 3.14159
станет 3.14159
типа double, выражения "melancholia" – адрес первого элемента строки типа const char*. Значение выражения upperBound – это значение объекта upperBound, а его типом будет тип самого объекта.
Более общим случаем выражения является один или более операндов и некоторая операция, применяемая к ним:
salary + raise ivec[ size/2 ] * delta |
first_name + " " + 1ast_name
Операции обозначаются соответствующими знаками. В первом примере сложение применяется к salary и raise. Во втором выражении size
делится на 2. Частное используется как индекс для массива ivec. Получившийся в результате операции взятия индекса элемент массива умножается на delta. В третьем примере два строковых объекта конкатенируются между собой и со строковым литералом, создавая новый строковый объект.
Операции, применяемые к одному операнду, называются унарными
(например,
взятие адреса (&) и разыменование (*)), а применяемые к двум операндам – бинарными. Один и тот же символ может обозначать разные операции в зависимости от того, унарна она или бинарна. Так, в выражении
*ptr
* представляет собой унарную операцию разыменования. Значением этого выражения является значение объекта, адрес которого содержится в ptr. Если же написать:
var1 * var2
то звездочка будет обозначать бинарную операцию умножения.
Результатом вычисления выражения всегда, если не оговорено противное, является r-значение. Тип результата арифметического выражения определяется типами операндов. Если операнды имеют разные типы, производится преобразование типов в соответствии с предопределенным набором правил. (Мы детально рассмотрим эти правила в разделе 4.14.)
Выражение может являться составным, то есть объединять в себе несколько подвыражений. Вот, например, выражение, проверяющее на неравенство нулю указатель и объект, на который он указывает (если он на что-то указывает)[7]:
ptr != 0 && *ptr != 0
Выражение состоит из трех подвыражений: проверку указателя ptr, разыменования ptr и проверку результата разыменования. Если ptr определен как
int ival = 1024; |
то результатом разыменования будет 1024 и оба сравнения дадут истину. Результатом всего выражения также будет истина (оператор &&
обозначает логическое И).
Если посмотреть на этот пример внимательно, можно заметить, что порядок выполнения операций очень важен. Скажем, если бы операция разыменования ptr
производилась до его сравнения с 0, в случае нулевого значения ptr это скорее всего вызвало бы крах программы. В случае операции И порядок действий строго определен: сначала оценивается левый операнд, и если его значение равно false, правый операнд не вычисляется вовсе. Порядок выполнения операций определяется их приоритетами, не всегда очевидными, что вызывает у начинающих программистов на С и С++ множество ошибок. Приоритеты будут приведены в разделе 4.13, а пока мы расскажем обо всех операциях, определенных в С++, начиная с наиболее привычных.
Данные-члены
Данные-члены класса объявляются так же, как переменные. Например, у класса Screenмогут быть следующие данные-члены:
#include class Screen { string _screen; // string( _height * _width ) string::size_type _cursor; // текущее положение на экране short _height; // число строк short _width; // число колонок
| |
};
Поскольку мы решили использовать строки для внутреннего представления объекта класса Screen, то член _screen
имеет тип string. Член _cursor– это смещение в строке, он применяется для указания текущей позиции на экране. Для него использован переносимый тип string::size_type.
(Тип size_type
рассматривался в разделе 6.8.)
Необязательно объявлять два члена типа short по отдельности. Вот объявление класса Screen, эквивалентное приведенному выше:
class Screen { /* * _ screen адресует строку размером _height * _width * _cursor указывает текущую позицию на экране * _height и _width - соответственно число строк и колонок */ string _screen; string::size_type _cursor; short _height, _width; |
};
Член класса может иметь любой тип:
class StackScreen { int topStack; void (*handler)(); // указатель на функцию vector
| |
};
Описанные данные-члены называются нестатическими. Класс может иметь также и статические
данные-члены. (У них есть особые свойства, которые мы рассмотрим в разделе 13.5.)
Объявления данных-членов очень похожи на объявления переменных в области видимости блока или пространства имен. Однако их, за исключением статических членов, нельзя явно инициализировать в теле класса:
class First { int memi = 0; // ошибка double memd = 0.0; // ошибка |
};
Данные-члены класса инициализируются с помощью конструктора класса. (Мы рассказывали о конструкторах в разделе 2.3; более подробно они рассматриваются в главе 14.)
Деструктор класса
Одна из целей, стоящих перед конструктором,– обеспечить автоматическое выделение ресурса. Мы уже видели в примере с классом Account конструктор, где с помощью оператора newвыделяется память для массива символов и присваивается уникальный номер счету. Можно также представить ситуацию, когда нужно получить монопольный доступ к разделяемой памяти или к критической секции потока. Для этого необходима симметричная операция, обеспечивающая автоматическое освобождение памяти или возврат ресурса по завершении времени жизни объекта, – деструктор. Деструктор – это специальная определяемая пользователем функция-член, которая автоматически вызывается, когда объект выходит из области видимости или когда к указателю на объект применяется операция delete. Имя этой функции образовано из имени класса с предшествующим символом “тильда” (~). Деструктор не возвращает значения и не принимает никаких параметров, а следовательно, не может быть перегружен. Хотя разрешается определять несколько таких функций-членов, лишь одна из них будет применяться ко всем объектам класса. Вот, например, деструктор для нашего класса Account:
class Account { public: Account(); explicit Account( const char*, double=0.0 ); Account( const Account& ); ~Account(); // ... private: char *_name; unsigned int _acct_nmbr; double _balance; }; inline Account::~Account() { delete [] _name; return_acct_number( _acct_nnmbr ); |
}
Обратите внимание, что в нашем деструкторе не сбрасываются значения членов:
inline Account::~Account() { // необходимо delete [] _name; return_acct_number( _acct_nnmbr ); // необязательно _name = 0; _balance = 0.0; _acct_nmbr = 0; |
}
Делать это необязательно, поскольку отведенная под члены объекта память все равно будет освобождена. Рассмотрим следующий класс:
class Point3d { public: // ... private: float x, y, z; |
};
Конструктор здесь необходим для инициализации членов, представляющих координаты точки. Нужен ли деструктор? Нет. Для объекта класса Point3d не требуется освобождать ресурсы: память выделяется и освобождается компилятором автоматически в начале и в конце его жизни.
В общем случае, если члены класса имеют простые значения, скажем, координаты точки, то деструктор не нужен. Не для каждого класса необходим деструктор, даже если у него есть один или более конструкторов. Основной целью деструктора является освобождения ресурсов, выделенных либо в конструкторе, либо во время жизни объекта, например освобождение замка или памяти, выделенной оператором new.
Но функции деструктора не ограничены только освобождением ресурсов. Он может реализовывать любую операцию, которая по замыслу проектировщика класса должна быть выполнена сразу по окончании использования объекта. Так, широко распространенным приемом для измерения производительности программы является определение класса Timer, в конструкторе которого запускается та или иная форма программного таймера. Деструктор останавливает таймер и выводит результаты замеров. Объект данного класса можно условно определять в критических участках программы, которые мы хотим профилировать, таким образом:
{ // начало критического участка программы #ifdef PROFILE Timer t; #endif // критический участок // t уничтожается автоматически // отображается затраченное время ... |
Чтобы убедиться в том, что мы понимаем поведение деструктора (да и конструктора тоже), разберем следующий пример:
(1) #include "Account.h" (2) Account global( "James Joyce" ); (3) int main() (4) { (5) Account local( "Anna Livia Plurabelle", 10000 ); (6) Account &loc_ref = global; (7) Account *pact = 0; (8) (9) { (10) Account local_too( "Stephen Hero" ); (11) pact = new Account( "Stephen Dedalus" ); (12) } (13) (14) delete pact; |
(15) }
Сколько здесь вызывается конструкторов? Четыре: один для глобального объекта global в строке (2); по одному для каждого из локальных объектов local и local_too в строках (5) и (10) соответственно, и один для объекта, распределенного в хипе, в строке (11). Ни объявление ссылки loc_ref на объект в строке (6), ни объявление указателя pact в строке (7) не приводят к вызову конструктора. Ссылка – это псевдоним для уже сконструированного объекта, в данном случае для global. Указатель также лишь адресует объект, созданный ранее (в данном случае распределенный в хипе, строка (11)), или не адресует никакого объекта (строка (7)).
Аналогично вызываются четыре деструктора: для глобального объекта global, объявленного в строке (2), для двух локальных объектов и для объекта в хипе при вызове delete в строке (14). Однако в программе нет инструкции, с которой можно связать вызов деструктора. Компилятор просто вставляет эти вызовы за последним использованием объекта, но перед закрытием соответствующей области видимости.
Конструкторы и деструкторы глобальных объектов вызываются на стадиях инициализации и завершения выполнения программы. Хотя такие объекты нормально ведут себя при использовании в том файле, где они определены, но их применение в ситуации, когда производятся ссылки через границы файлов, становится в C++ серьезной проблемой.4
Деструктор не вызывается, когда из области видимости выходит ссылка или указатель на объект (сам объект при этом остается).
С++ с помощью внутренних механизмов препятствует применению оператора delete к указателю, не адресующему никакого объекта, так что соответствующие проверки кода необязательны:
// необязательно: неявно выполняется компилятором |
Всякий раз, когда внутри функции этот оператор применяется к отдельному объекту, размещенному в хипе, лучше использовать объект класса auto_ptr, а не обычный указатель (см. обсуждение класса auto_ptr в разделе 8.4). Это особенно важно потому, что пропущенный вызов delete
(скажем, в случае, когда возбуждается исключение) ведет не только к утечке памяти, но и к пропуску вызова деструктора. Ниже приводится пример программы, переписанной с использованием auto_ptr (она слегка модифицирована, так как объект класса auto_ptr
может быть явно переустановлен для адресации другого объекта только присваиванием его другому auto_ptr):
#include #include "Account.h" Account global( "James Joyce" ); int main() { Account local( "Anna Livia Plurabelle", 10000 ); Account &loc_ref = global; auto_ptr { Account local_too( "Stephen Hero" ); } // объект auto_ptr уничтожается здесь |
Деструкторы
Когда заканчивается время жизни объекта производного класса, автоматически вызываются деструкторы производного и базового классов (если они определены), а также деструкторы всех объектов-членов. Например, если имеется объект класса NameQuery:NameQuery nq( "hyperion" );
то порядок вызова деструкторов следующий: сначала деструктор NameQuery, затем деструктор string для члена _name и наконец деструктор базового класса. В общем случае эта последовательность противоположна порядку вызова конструкторов.
Вот деструкторы нашего базового Query и производных от него (все они объявлены открытыми членами соответствующих классов):
inline Query:: ~Query(){ delete _solution; } inline NotQuery:: ~NotQuery(){ delete _op; } inline OrQuery:: ~OrQuery(){ delete _lop; delete _rop; } inline AndQuery:: |
~AndQuery(){ delete _lop; delete _rop; }
Отметим два аспекта:
· мы не предоставляем явного деструктора NameQuery, потому что никаких специальных действий по очистке его объекта предпринимать не нужно. Деструкторы базового класса и класса string для члена _name
вызываются автоматически;
· в деструкторах производных классов оператор delete
применяется к указателю типа Query*. Чтобы вызвать не деструктор Query, а деструктор класса того объекта, который фактически адресуется этим указателем, мы должны объявить деструктор базового Query
виртуальным. (Более подробно о виртуальных функциях вообще и о виртуальных деструкторах в частности мы поговорим в следующем разделе.)
В нашей реализации неявно подразумевалось, что память для операндов, указатели на которые имеются в объектах классов NotQuery, OrQuery и AndQuery, выделена из хипа. Именно поэтому в деструкторах мы применяли к этим указателям оператор delete. Но язык не позволяет обеспечить истинность такого предположения, так как в нем нет различий между адресами в хипе и вне его. С этой точки зрения наша реализация не застрахована от ошибок.
В разделе 17. 7 мы инкапсулируем выделение памяти и конструирование объектов иерархии Query в управляющий класс UserQuery. Это гарантирует выполнение нашего предположения. На уровне программы в целом следует перегрузить операторы new и delete для классов иерархии. Например, можно поступить следующим образом. Оператор new устанавливает в объекте флажок, говорящий, что память для него выделена из хипа. Перегруженный оператор delete
проверяет этот флажок: если он есть, то память освобождается с помощью стандартного оператора delete.
Упражнение 17.7
Идентифицируйте конструкторы и деструкторы базового и производных классов для той иерархии, которую вы выбрали в упражнении 17.2 (раздел 17.1).
Упражнение 17.8
Измените реализацию класса OrQuery
так, чтобы он был производным от BinaryQuery.
Упражнение 17.9
Найдите ошибку в следующем определении класса:
class Object { public: virtual ~Object(); virtual string isA(); protected: string _isA; private: Object( string s ) : _isA( s ) {} |
Упражнение 17.10
Дано определение базового класса:
class ConcreteBase { public: explicit ConcreteBase( int ); virtual ostream& print( ostream& ); virtual ~Base(); static int object_count(); protected: int _id; static int _object_count; |
Что неправильно в следующих фрагментах:
(a) class C1 : public ConcreteBase { public: C1( int val ) : _id( _object_count++ ) {} // ... |
(b) class C2 : public C1 { public: C2( int val ) : ConcreteBase( val ), C1( val ) {} // ... |
(c) class C3 : public C2 { public: C3( int val ) : C2( val ), _object_count( val ) {} // ... |
(d) class C4 : public ConcreteBase { public: C4( int val ) : ConcreteBase ( _id+val ){} // ... |
Упражнение 17.11
В первоначальном определении языка C++ порядок следования инициализаторов в списке инициализации членов определял порядок вызова конструкторов. Принцип, который действует сейчас, был принят в 1986 году. Как вы думаете, почему была изменена исходная спецификация?
Детали разрешения перегрузки функций
В разделе 9.2 мы уже упоминали, что процесс разрешения перегрузки функций состоит из трех шагов:1. Установить множество функций-кандидатов для разрешения данного вызова, а также свойства списка фактических аргументов.
2. Отобрать из множества кандидатов устоявшие функции – те, которые могут быть вызваны с данным списком фактических аргументов при учете их числа и типов.
3. Выбрать функцию, лучше всего соответствующую вызову, подвергнув ранжированию преобразования, которые необходимо применить к фактическим аргументам, чтобы привести их в соответствие с формальными параметрами устоявшей функции.
Теперь мы готовы к тому, чтобы изучить эти шаги более детально.
Динамически размещаемые объекты
Время жизни глобальных и локальных объектов четко определено. Программист неспособен хоть как-то изменить его. Однако иногда необходимо иметь объекты, временем жизни которых можно управлять. Выделение памяти под них и ее освобождение зависят от действий выполняющейся программы. Например, можно отвести память под текст сообщения об ошибке только в том случае, если ошибка действительно имела место. Если программа выдает несколько таких сообщений, размер выделяемой строки будет разным в зависимости от длины текста, т.е. подчиняется типу ошибки, произошедшей во время исполнения программы.Третий вид объектов позволяет программисту полностью управлять выделением и освобождением памяти. Такие объекты называют динамически
размещаемыми
или, для краткости, просто динамическими. Динамический объект “живет” в пуле свободной памяти, называемой хипом. Программист создает его с помощью оператора new, а уничтожает с помощью оператора delete. Динамически размещаться может как единичный объект, так и массив объектов. Размер массива, размещаемого в хипе, разрешается задавать во время выполнения.
В этом разделе, посвященном динамическим объектам, мы рассмотрим три формы оператора new: для размещения единичного объекта, для размещения массива и третью форму, называемую оператором размещения new (placement new expression). Когда хип исчерпан, этот оператор возбуждает исключение. (Разговор об исключениях будет продолжен в главе 11. В главе 15 мы расскажем об операторах new и delete
применительно к классам.)
Динамическое создание и уничтожение единичных объектов
Оператор newсостоит их ключевого слова new, за которым следует спецификатор типа. Этот спецификатор может относиться к встроенным типам или к типам классов. Например:
new int;
размещает в хипе один объект типа int. Аналогично в результате выполнения инструкции
new iStack;
там появится один объект класса iStack.
Сам по себе оператор new не слишком полезен. Как можно реально воспользоваться созданным объектом? Одним из аспектов работы с памятью из хипа является то, что размещаемые в ней объекты не имеют имени. Оператор new
возвращает не сам объект, а указатель на него. Все манипуляции с этим объектом производятся косвенно через указатели:
int *pi = new int;
Здесь оператор new
создает один объект типа int, на который ссылается указатель pi. Выделение памяти из хипа во время выполнения программы называется динамическим выделением. Мы говорим, что память, адресуемая указателем pi, выделена динамически.
Второй аспект, относящийся к использованию хипа, состоит в том, что эта память не инициализируется. Она содержит “мусор”, оставшийся после предыдущей работы. Проверка условия:
if ( *pi == 0 )
вероятно, даст false, поскольку объект, на который указывает pi, содержит случайную последовательность битов. Следовательно, объекты, создаваемые с помощью оператора new, рекомендуется инициализировать. Программист может инициализировать объект типа int из предыдущего примера следующим образом:
int *pi = new int( 0 );
Константа в скобках задает начальное значение для создаваемого объекта; теперь pi
ссылается на объект типа int, имеющий значение 0. Выражение в скобках называется инициализатором. Это может быть любое выражение (не обязательно константа), возвращающее значение, приводимое к типу int.
Оператор new
выполняет следующую последовательность действий: выделяет из хипа память для объекта, затем инициализирует его значением, стоящим в скобках. Для выделения памяти вызывается библиотечная функция new(). Предыдущий оператор приблизительно эквивалентен следующей последовательности инструкций:
int ival = 0; // создаем объект типа int и инициализируем его 0 |
не считая, конечно, того, что объект, адресуемый pi, создается библиотечной функцией new() и размещается в хипе. Аналогично
iStack *ps = new iStack( 512 );
создает объект типа iStack на 512 элементов. В случае объекта класса значение или значения в скобках передаются соответствующему конструктору, который вызывается в случае успешного выделения памяти. (Динамическое создание объектов классов более подробно рассматривается в разделе 15.8. Оставшаяся часть данного раздела посвящена созданию объектов встроенных типов.)
Описанные операторы new
могут вызывать одну проблему: хип, к сожалению, является конечным ресурсом, и в некоторой точке выполнения программы мы можем исчерпать его. Если функция new() не может выделить затребованного количества памяти, она возбуждает исключение bad_alloc.
(Обработка исключений рассматривается в главе 11.)
Время жизни объекта, на который указывает pi, заканчивается при освобождении памяти, где этот объект размещен. Это происходит, когда pi
передается оператору delete. Например,
delete pi;
освобождает память, на которую ссылается pi, завершая время жизни объекта типа int. Программист управляет окончанием жизни объекта, используя оператор delete в нужном месте программы. Этот оператор вызывает библиотечную функцию delete(), которая возвращает выделенную память в хип. Поскольку хип конечен, очень важно возвращать ее своевременно.
Глядя на предыдущий пример, вы можете спросить: а что случится, если значение pi по какой-либо причине было нулевым? Не следует ли переписать этот код таким образом:
// необходимо ли это? if ( pi != 0 ) |
Нет. Язык С++ гарантирует, что оператор delete не будет вызывать функцию delete() в случае нулевого операнда. Следовательно, проверка на 0
необязательна. (Если вы явно добавите такую проверку, в большинстве реализаций она фактически будет выполнена дважды.)
Важно понимать разницу между временем жизни указателя pi и объекта, который он адресует. Сам объект pi является глобальным и объявлен в глобальной области видимости. Следовательно, память под него выделяется до выполнения программы и сохраняется за ним до ее завершения. Совсем не так определяется время жизни адресуемого указателем pi
объекта, который создается с помощью оператора new во время выполнения. Область памяти, на которую указывает pi, выделена динамически, следовательно, pi является указателем на динамически размещенный объект типа int. Когда в программе встретится оператор delete, эта память будет освобождена. Однако память, отведенная самому указателю pi, не освобождается, а ее содержимое не изменяется. После выполнения delete
объект pi
становится висячим указателем, то есть ссылается на область памяти, не принадлежащую программе. Такой указатель служит источником трудно обнаруживаемых ошибок, поэтому сразу после уничтожения объекта ему полезно присвоить 0, обозначив таким образом, что указатель больше ни на что не ссылается.
Оператор delete
может использоваться только по отношению к указателю, который содержит адрес области памяти, выделенной в результате выполнения оператора new. Попытка применить delete к указателю, не ссылающемуся на такую память, приведет к непредсказуемому поведению программы. Однако, как было сказано выше, этот оператор можно применять к нулевому указателю.
Ниже приведены примеры опасных и безопасных операторов delete:
void f() { int i; string str = "dwarves"; int *pi = &i; short *ps = 0; double *pd = new doub1e(33); delete str; // плохо: str не является динамическим объектом delete pi; // плохо: pi ссылается на локальный объект delete ps; // безопасно delete pd; // безопасно |
Вот три основные ошибки, связанные с динамическим выделением памяти:
· не освободить выделенную память. В таком случае память не возвращается в хип. Эта ошибка получила название утечки
памяти;
· дважды применить оператор delete к одной и той же области памяти. Такое бывает, когда два указателя получают адрес одного и того же динамически размещенного объекта. В результате подобной ошибки мы вполне можем удалить нужный объект. Действительно, память, освобожденная с помощью одного из адресующих ее указателей, возвращается в хип и затем выделяется под другой объект. Затем оператор delete применяется ко второму указателю, адресовавшему старый объект, а удаляется при этом новый;
· изменять объект после его удаления. Такое часто случается, поскольку указатель, к которому применяется оператор delete, не обнуляется.
Эти ошибки при работе с динамически выделяемой памятью гораздо легче допустить, нежели обнаружить и исправить. Для того чтобы помочь программисту, стандартная библиотека С++ представляет класс auto_ptr. Мы рассмотрим его в следующем подразделе. После этого мы покажем, как динамически размещать и уничтожать массивы, используя вторую форму операторов new и delete.
Динамическое создание и уничтожение константных объектов
Программист способен создать объект в хипе и запретить изменение его значения после инициализации. Этого можно достичь, объявляя объект константным. Для этого применяется следующая форма оператора new:const int *pci = new const int(1024);
Константный динамический объект имеет несколько особенностей. Во-первых, он должен быть инициализирован, иначе компилятор сигнализирует об ошибке (кроме случая, когда объект принадлежит к типу класса, имеющего конструктор по умолчанию; в такой ситуации инициализатор можно опустить).
Во-вторых, указатель, возвращаемый выражением new, должен адресовать константу. В предыдущем примере pci
служит указателем на const int.
Константность динамически созданного объекта подразумевает, что значение, полученное при инициализации, в дальнейшем не может быть изменено. Но поскольку объект динамический, временем его жизни управляет оператор delete. Например:
delete pci;
Хотя операнд оператора delete
имеет тип указателя на const int, эта инструкция является корректной и освобождает область памяти, на которую ссылается pci.
Невозможно создать динамический массив константных элементов встроенного типа потому, что, как мы отмечали выше, элементы такого массива нельзя проинициализировать в операторе new. Следующая инструкция приводит к ошибке компиляции:
const int *pci = new const int[100]; // ошибка
Динамическое создание и уничтожение массивов
Оператор newможет выделить из хипа память для размещения массива. В этом случае после спецификатора типа в квадратных скобках указывается размер массива. Он может быть задан сколь угодно сложным выражением. new
возвращает указатель на первый элемент массива. Например:
// создание единственного объекта типа int // с начальным значением 1024 int *pi = new int( 1024 ); // создание массива из 1024 элементов // элементы не инициализируются int *pia = new int[ 1024 ]; // создание двумерного массива из 4x1024 элементов |
int (*pia2)[ 1024 ] = new int[ 4 ][ 1024 ];
pi содержит адрес единственного элемента типа int, инициализированного значением 1024; pia – адрес первого элемента массива из 1024
элементов; pia2 – адрес начала массива, содержащего четыре массива по 1024
элемента, т.е. pia2
адресует 4096
элементов.
В общем случае массив, размещаемый в хипе, не может быть инициализирован. (В разделе 15.8 мы покажем, как с помощью конструктора по умолчанию присвоить начальное значение динамическому массиву объектов типа класса.) Задавать инициализатор при выделении оператором new памяти под массив не разрешается. Массиву элементов встроенного типа, размещенному в хипе, начальные значения присваиваются с помощью цикла for:
for (int index = 0; index < 1024; ++index ) |
pia[ index ] = 0;
Основное преимущество динамического массива состоит в том, что количество элементов в его первом измерении не обязано быть константой, т.е. может не быть известным во время компиляции. Для массивов, определяемых в локальной или глобальной области видимости, это не так: здесь размер задавать необходимо.
Например, если указатель в ходе выполнения программы ссылается на разные C-строки, то область памяти под текущую строку обычно выделяется динамически и ее размер определяется в зависимости от длины строки. Как правило, это более эффективно, чем создавать массив фиксированного размера, способный вместить самую длинную строку: ведь все остальные строки могут быть значительно короче. Более того, программа может аварийно завершиться, если длина хотя бы одной из строк превысит отведенный лимит.
Оператор new
допустимо использовать для задания первого измерения массива с помощью значения, вычисляемого во время выполнения. Предположим, у нас есть следующие C-строки:
const char *noerr = "success"; // ... const char *err189 = "Error: a function declaration must " |
Размер создаваемого с помощью оператора new
массива может быть задан значением, вычисляемым во время выполнения:
#include const char *errorTxt; if (errorFound) errorTxt = errl89; else errorTxt = noerr; int dimension = strlen( errorTxt ) + 1; char *strl = new char[ dimension ]; // копируем текст ошибки в strl |
dimension
разрешается заменить выражением:
// обычная для С++ идиома, // иногда удивляющая начинающих программистов |
Единица, прибавляемая к значению, которое возвращает strlen(), необходима для учета завершающего нулевого символа в C-строке. Отсутствие этой единицы – весьма распространенная ошибка, которую достаточно трудно обнаружить, поскольку она проявляет себя косвенно: происходит затирание какой-либо другой области программы. Почему? Большинство функций, которые обрабатывают массивы, представляющие собой С-строки символов, пробегают по элементам, пока не встретят завершающий нуль.
Если в конце строки нуля нет, то возможно чтение или запись в случайную область памяти. Избежать подобных проблем позволяет класс string из стандартной библиотеки С++.
Отметим, что только первое измерение массива, создаваемого с помощью оператора new, может быть задано значением, вычисляемым во время выполнения. Остальные измерения должны задаваться константами, известными во время компиляции. Например:
int getDim(); // создание двумерного массива int (*pia3)[ 1024 ] = new int[ getDim() ][ 1024 ]; // правильно // ошибка: второе измерение задано не константой |
int **pia4 = new int[ 4 ][ getDim() ];
Оператор delete для уничтожения массива имеет следующую форму:
delete[] str1;
Пустые квадратные скобки необходимы. Они говорят компилятору, что указатель адресует массив, а не единичный элемент. Поскольку тип str1 – указатель на char, без этих скобок компилятор не поймет, что удалять следует целый массив.
Отсутствие скобок не является синтаксической ошибкой, но правильность выполнения программы не гарантируется (это особенно справедливо для массивов, которые содержат объекты классов, имеющих деструкторы, как это будет показано в разделе 14.4).
Чтобы избежать проблем, связанных с управлением динамически выделяемой памятью для массивов, рекомендуется пользоваться контейнерными типами из стандартной библиотеки, такими, как vector, list или string. Они управляют памятью автоматически. (Тип string был представлен в разделе 3.4, тип vector – в разделе 3.10. Подробное описание контейнерных типов см. в главе 6.)
Динамическое выделение памяти и указатели
Прежде чем углубиться в объектно-ориентированную разработку, нам придется сделать небольшое отступление о работе с памятью в программе на С++. Мы не сможем написать сколько-нибудь сложную программу, не умея выделять память во время выполнения и обращаться к ней.В С++ объекты могут быть размещены либо статически – во время компиляции, либо динамически – во время выполнения программы, путем вызова функций из стандартной библиотеки. Основная разница в использовании этих методов – в их эффективности и гибкости. Статическое размещение более эффективно, так как выделение памяти происходит до
выполнения программы, однако оно гораздо менее гибко, потому что мы должны заранее знать тип и размер размещаемого объекта. К примеру, совсем не просто разместить содержимое некоторого текстового файла в статическом массиве строк: нам нужно заранее знать его размер. Задачи, в которых нужно хранить и обрабатывать заранее неизвестное число элементов, обычно требуют динамического выделения памяти.
До сих пор во всех наших примерах использовалось статическое выделение памяти. Скажем, определение переменной ival
int ival = 1024;
заставляет компилятор выделить в памяти область, достаточную для хранения переменной типа int, связать с этой областью имя ival и поместить туда значение 1024. Все это делается на этапе компиляции, до выполнения программы.
С объектом ival
ассоциируются две величины: собственно значение переменной, 1024 в данном случае, и адрес той области памяти, где хранится это значение. Мы можем обращаться к любой из этих двух величин. Когда мы пишем:
int ival2 = ival + 1;
то обращаемся к значению, содержащемуся в переменной ival: прибавляем к нему 1 и инициализируем переменную ival2 этим новым значением, 1025. Каким же образом обратиться к адресу, по которому размещена переменная?
С++ имеет встроенный тип “указатель”, который используется для хранения адресов объектов. Чтобы объявить указатель, содержащий адрес переменной ival, мы должны написать:
int *pint; // указатель на объект типа int
int *pint = new int(1024);
Здесь оператор new
выделяет память под безымянный объект типа int, инициализирует его значением 1024 и возвращает адрес созданного объекта. Этот адрес используется для инициализации указателя pint. Все действия над таким безымянным объектом производятся путем разыменовывания данного указателя, т.к. явно манипулировать динамическим объектом невозможно.
Вторая форма оператора new
выделяет память под массив заданного размера, состоящий из элементов определенного типа:
int *pia = new int[4];
В этом примере память выделяется под массив из четырех элементов типа int. К сожалению, данная форма оператора new не позволяет инициализировать элементы массива.
Некоторую путаницу вносит то, что обе формы оператора new возвращают одинаковый указатель, в нашем примере это указатель на целое. И pint, и pia
объявлены совершенно одинаково, однако pint указывает на единственный объект типа int, а pia – на первый элемент массива из четырех объектов типа int.
Когда динамический объект больше не нужен, мы должны явным образом освободить отведенную под него память. Это делается с помощью оператора delete, имеющего, как и new, две формы – для единичного объекта и для массива:
// освобождение единичного объекта delete pint; // освобождение массива |
Что случится, если мы забудем освободить выделенную память? Память будет расходоваться впустую, она окажется неиспользуемой, однако возвратить ее системе нельзя, поскольку у нас нет указателя на нее. Такое явление получило специальное название утечка памяти. В конце концов программа аварийно завершится из-за нехватки памяти (если, конечно, она будет работать достаточно долго). Небольшая утечка трудно поддается обнаружению, но существуют утилиты, помогающие это сделать.
Наш сжатый обзор динамического выделения памяти и использования указателей, наверное, больше породил вопросов, чем дал ответов. В разделе 8.4 затронутые проблемы будут освещены во всех подробностях. Однако мы не могли обойтись без этого отступления, так как класс Array, который мы собираемся спроектировать в последующих разделах, основан на использовании динамически выделяемой памяти.
Упражнение 2.3
Объясните разницу между четырьмя объектами:
(a) int ival = 1024; (b) int *pi = &ival; (c) int *pi2 = new int(1024); |
Упражнение 2.4
Что делает следующий фрагмент кода? В чем состоит логическая ошибка? (Отметим, что операция взятия индекса ([]) правильно применена к указателю pia. Объяснение этому факту можно найти в разделе 3.9.2.)
int *pi = new int(10); int *pia = new int[10]; while ( *pi < 10 ) { pia[*pi] = *pi; *pi = *pi + 1; } delete pi; |
Директива extern "C" и перегруженные функции A
В разделе 7.7 мы видели, что директиву связывания extern "C"можно использовать в программе на C++ для того, чтобы указать, что некоторый объект находится в части, написанной на языке C. Как эта директива влияет на объявления перегруженных функций? Могут ли в одном и том же множестве находиться функции, написанные как на C++, так и на C?
В директиве связывания разрешается задать только одну из множества перегруженных функций. Например, следующая программа некорректна:
// ошибка: для двух перегруженных функций указана директива extern "C" extern "C" void print( const char* ); |
extern "C" void print( int );
Приведенный ниже пример перегруженной функции calc() иллюстрирует типичное применение директивы extern "C":
class SmallInt ( /* ... */ ); class BigNum ( /* ... */ ); // написанная на C функция может быть вызвана как из программы, // написанной на C, так и из программы, написанной на C++. // функции C++ обрабатывают параметры, являющиеся классами extern "C" double calc( double ); extern SmallInt calc( const SmallInt& ); |
extern BigNum calc( const BigNum& );
Написанная на C функция calc()
может быть вызвана как из C, так и из программы на C++. Остальные две функции принимают в качестве параметра класс и, следовательно, их допустимо использовать только в программе на C++. Порядок следования объявлений несуществен.
Директива связывания не имеет значения при решении, какую функцию вызывать; важны только типы параметров. Выбирается та функция, которая лучше всего соответствует типам переданных аргументов:
Smallint si = 8; int main() { calc( 34 ); // вызывается C-функция calc( double ) calc( si ); // вызывается функция C++ calc( const SmallInt & ) // ... return 0; |
}
Директива связывания extern "C" A
Если программист хочет использовать функцию, написанную на другом языке, в частности на С, то компилятору нужно указать, что при вызове требуются несколько иные условия. Скажем, имя функции или порядок передачи аргументов различаются в зависимости от языка программирования.Показать, что функция написана на другом языке, можно с помощью директивы связывания в форме простой либо составной инструкции:
// директива связывания в форме простой инструкции extern "C" void exit(int); // директива связывания в форме составной инструкции extern "C" { int printf( const char* ... ); int scanf( const char* ... ); } // директива связывания в форме составной инструкции extern "C" { #include
| |
}
Первая форма такой директивы состоит из ключевого слова extern, за которым следует строковый литерал, а за ним – “обычное” объявление функции. Хотя функция написана на другом языке, проверка типов вызова выполняется полностью. Несколько объявлений функций могут быть помещены в фигурные скобки составной инструкции директивы связывания – второй формы этой директивы. Скобки отмечают те объявления, к которым она относится, не ограничивая их видимости, как в случае обычной составной инструкции. Составная инструкция extern "C" в предыдущем примере говорит только о том, что функции printf() и scanf()
написаны на языке С. Во всех остальных отношениях эти объявления работают точно так же, как если бы они были расположены вне инструкции.
Если в фигурные скобки составной директивы связывания помещается директива препроцессора #include, все объявленные во включаемом заголовочном файле функции рассматриваются как написанные на языке, указанном в этой директиве. В предыдущем примере все функции из заголовочного файла cmath написаны на языке С.
Директива связывания не может появиться внутри тела функции. Следующий фрагмент кода вызывает ошибку компиляции:
int main() { // ошибка: директива связывания не может появиться // внутри тела функции extern "C" double sqrt( double ); double getValue(); //правильно double result = sqrt ( getValue() ); //... return 0; |
}
Если мы переместим директиву так, чтобы она оказалась вне тела main(), программа откомпилируется правильно:
extern "C" double sqrt( double ); int main() { double getValue(); //правильно double result = sqrt ( getValue() ); //... return 0; |
Однако более подходящее место для директивы связывания – заголовочный файл, где находится объявление функции, описывающее ее интерфейс.
Как сделать С++ функцию доступной для программы на С? Директива extern "C"
поможет и в этом:
// функция calc() может быть вызвана из программы на C |
Если в одном файле имеется несколько объявлений функции, то директива связывания может быть указана при каждом из них или только при первом – в этом случае она распространяется и на все последующие объявления. Например:
// ---- myMath.h ---- extern "C" double calc( double ); // ---- myMath.C ---- // объявление calc() в myMath.h #include "myMath.h" // определение функции extern "C" calc() // функция calc() может быть вызвана из программы на C |
В данном разделе мы видели примеры директивы связывания extern "C" только для языка С. Это единственный внешний язык, поддержку которого гарантирует стандарт С++. Конкретная реализация может поддерживать связь и с другими языками. Например, extern "Ada" для функций, написанных на языке Ada; extern "FORTRAN" для языка FORTRAN и т.д. Мы описали один из случаев использования ключевого слова extern в С++. В разделе 8.2 мы покажем, что это слово имеет и другое назначение в объявлениях функций и объектов.
Упражнение 7.14
exit(), printf(), malloc(), strcpy() и strlen() являются функциями из библиотеки С. Модифицируйте приведенную ниже С-программу так, чтобы она компилировалась и связывалась в С++.
const char *str = "hello"; void *malloc( int ); char *strcpy( char *, const char * ); int printf( const char *, ... ); int exit( int ); int strlen( const char * ); int main() { /* программа на языке С */ char* s = malloc( strlen(str)+l ); strcpy( s, str ); printf( "%s, world\n", s ); exit( 0 ); |
Директива typedef
Директива typedefпозволяет задать синоним для встроенного либо пользовательского типа данных. Например:
typedef double wages; typedef vector typedef vec_int test_scores; typedef bool in_attendance;
| |
typedef int *Pint;
Имена, определенные с помощью директивы typedef, можно использовать точно так же, как спецификаторы типов:
// double hourly, weekly; wages hourly, weekly; // vector vec_int vecl( 10 ); // vector const int c1ass_size = 34; test_scores test0( c1ass_size ); // vector< bool > attendance; vector< in_attendance > attendance( c1ass_size ); // int *table[ 10 ];
| |
Pint table [ 10 ];
Эта директива начинается с ключевого слова typedef, за которым идет спецификатор типа, и заканчивается идентификатором, который становится синонимом для указанного типа.
Для чего используются имена, определенные с помощью директивы typedef? Применяя мнемонические имена для типов данных, можно сделать программу более легкой для восприятия. Кроме того, принято употреблять такие имена для сложных составных типов, в противном случае воспринимаемых с трудом (см. пример в разделе 3.14), для объявления указателей на функции и функции-члены класса (см. раздел 13.6).
Ниже приводится пример вопроса, на который почти все дают неверный ответ. Ошибка вызвана непониманием директивы typedef как простой текстовой макроподстановки. Дано определение:
typedef char *cstring;
Каков тип переменной cstr в следующем объявлении:
extern const cstring cstr;
Ответ, который кажется очевидным:
const char *cstr
Однако это неверно. Спецификатор const
относится к cstr, поэтому правильный ответ – константный указатель на char:
char *const
cstr;
Директивы препроцессора
Заголовочные файлы включаются в текст программы с помощью директивы препроцессора #include. Директивы препроцессора начинаются со знака “диез” (#), который должен быть самым первым символом строки. Программа, которая обрабатывает эти директивы, называется препроцессором(в современных компиляторах препроцессор обычно является частью самого компилятора).
Директива #include
включает в программу содержимое указанного файла. Имя файла может быть указано двумя способами:
#include
| |
#include "my_file.h"
Если имя файла заключено в угловые скобки (<>), считается, что нам нужен некий стандартный заголовочный файл, и компилятор ищет этот файл в предопределенных местах. (Способ определения этих мест сильно различается для разных платформ и реализаций.) Двойные кавычки означают, что заголовочный файл – пользовательский, и его поиск начинается с того каталога, где находится исходный текст программы.
Заголовочный файл также может содержать директивы #include. Поэтому иногда трудно понять, какие же конкретно заголовочные файлы включены в данный исходный текст, и некоторые заголовочные файлы могут оказаться включенными несколько раз. Избежать этого позволяют условные директивы препроцессора. Рассмотрим пример:
#ifndef BOOKSTORE_H
#define BOOKSTORE_H /* содержимое файла bookstore.h */ |
#endif
Условная директива
#ifndef проверяет, не было ли значение BOOKSTORE_H
определено ранее. (BOOKSTORE_H – это константа препроцессора; такие константы принято писать заглавными буквами.) Препроцессор обрабатывает следующие строки вплоть до директивы #endif. В противном случае он пропускает строки от #ifndef до # endif.
Директива
#define BOOKSTORE_H
определяет константу препроцессора BOOKSTORE_H. Поместив эту директиву непосредственно после директивы #ifndef, мы можем гарантировать, что содержательная часть заголовочного файла bookstore.h
будет включена в исходный текст только один раз, сколько бы раз ни включался в текст сам этот файл.
Другим распространенным примером применения условных директив препроцессора является включение в текст программы отладочной информации. Например:
int main() { #ifdef DEBUG cout << "Начало выполнения main()\n"; #endif string word; vector while ( cin >> word ) { #ifdef DEBUG cout << "Прочитано слово: " << word << "\n"; #endif text.push_back(word); } // ... |
Если константа DEBUG не определена, результирующий текст программы будет выглядеть так:
int main() |
string word;
vector
while ( cin >> word )
{
text.push_back(word);
}
// ...
}
В противном случае мы получим:
int main() |
cout << "Начало выполнения main()\n";
string word;
vector
while ( cin >> word )
{
cout << "Прочитано слово: " << word << "\n";
text.push_back(word);
}
// ...
}
Константа препроцессора может быть определена в командной строке при вызове компилятора с помощью опции -D (в различных реализациях эта опция может называться по-разному). Для UNIX-систем вызов компилятора с определением препроцессорной константы DEBUG
выглядит следующим образом:
$ CC -DDEBUG main.C
Есть константы, которые автоматически определяются компилятором. Например, мы можем узнать, компилируем ли мы С++ или С программу. Для С++ программы автоматически определяется константа __cplusplus
(два подчеркивания). Для стандартного С определяется __STDC__. Естественно, обе константы не могут быть определены одновременно. Пример:
#idfef __cplusplus // компиляция С++ программы extern "C"; // extern "C" объясняется в главе 7 #endif |
Другими полезными предопределенными константами (в данном случае лучше сказать переменными) препроцессора являются __LINE__ и __FILE__. Переменная __LINE__
содержит номер текущей компилируемой строки, а __FILE__ – имя компилируемого файла. Вот пример их использования:
if ( element_count == 0 ) |
<< " Строка: " << __LINE__
<< "element_count не может быть 0";
Две константы __DATE__ и __TIME__
содержат дату и время компиляции.
Стандартная библиотека С предоставляет полезный макрос assert(), который проверяет некоторое условие и в случае, если оно не выполняется, выдает диагностическое сообщение и аварийно завершает программу. Мы будем часто пользоваться этим полезным макросом в последующих примерах программ. Для его применения следует включить в программу директиву
#include
assert.h – это заголовочный файл стандартной библиотеки С. Программа на C++ может ссылаться на заголовочный файл как по его имени, принятому в C, так и по имени, принятому в C++. В стандартной библиотеке С++ этот файл носит имя cassert. Имя заголовочного файла в библиотеке С++ отличается от имени соответствующего файла для С отсутствием расширения .h и подставленной спереди буквой c
(выше уже упоминалось, что в заголовочных файлах для C++ расширения не употребляются, поскольку они могут зависеть от реализации).
Эффект от использования директивы препроцессора #include зависит от типа заголовочного файла. Инструкция
#include
включает в текст программы содержимое файла cassert. Но поскольку все имена, используемые в стандартной библиотеке С++, определены в пространстве std, имя assert()
будет невидимо до тех пор, пока мы явно не сделаем его видимым с помощью следующей using-директивы:
using namespace std;
Если же мы включаем в программу заголовочный файл для библиотеки С
#include
то надобность в using-директиве отпадает: имя assert() будет видно и так[2]. (Пространства имен используются разработчиками библиотек для предотвращения засорения глобального пространства имен. В разделе 8.5 эта тема рассматривается более подробно.)
Дополнительные операции со строками
Вторая форма функции-члена erase()принимает в качестве параметров два итератора, ограничивающих удаляемую подстроку. Например, превратим
string name( "AnnaLiviaPlurabelle" );
в строку "Annabelle":
typedef string::size_type size_type; size_type startPos = name.find( 'L' ) size_type endPos = name.find_1ast_of( 'b' ); name.erase( name.begin()+startPos, |
name.begin()+endPos );
Символ, на который указывает второй итератор, не входит в удаляемую подстроку.
Для третьей формы параметром является только один итератор; эта форма удаляет все символы, начиная с указанной позиции до конца строки. Например:
name.erase( name. begin()+4 );
оставляет строку "Anna".
Функция-член insert()
позволяет вставить в заданную позицию строки другую строку или символ. Общая форма выглядит так:
string_object.insert( position, new_string );
position
обозначает позицию, перед которой производится вставка. new_string
может быть объектом класса string, C-строкой или символом:
string string_object( "Missisippi" ); string::size_type pos = string_object.find( "isi" ); |
string_object.insert( pos+1, 's' );
Можно выделить для вставки подстроку из new_string:
string new_string ( "AnnaBelle Lee" ); string_object += ' '; // добавим пробел // найдем начальную и конечную позицию в new_string pos = new_string.find( 'B' ); string::size_type posEnd = new_string.find( ' ' ); string_object.insert( string_object.size(), // позиция вставки new_string, pos, // начало подстроки в new_string posEnd // конец подстроки new_string |
)
string_object
получает значение "Mississippi Belle". Если мы хотим вставить все символы new_string, начиная с pos, последний параметр нужно опустить.
Пусть есть две строки:
string sl( "Mississippi" ); |
string s2( "Annabelle" );
Как получить третью строку со значением "Miss Anna"?
Можно использовать функции-члены assign() и append():
string s3; // скопируем первые 4 символа s1 |
s3 теперь содержит значение "Miss".
// добавим пробел |
Теперь s3 содержит "Miss ".
// добавим 4 первых символа s2 |
s3 получила значение "Miss Anna". То же самое можно сделать короче:
s3.assign(s1,4).append(' ').append(s2,4);
Другая форма функции-члена assign()
имеет три параметра: второй обозначает позицию начала, а третий – длину. Позиции нумеруются с 0. Вот как, скажем, извлечь "belle" из "Annabelle":
string beauty; // присвоим beauty значение "belle" |
Вместо этих параметров мы можем использовать пару итераторов:
// присвоим beauty значение "belle" |
В следующем примере две строки содержат названия текущего проекта и проекта, находящегося в отложенном состоянии. Они должны периодически обмениваться значениями, поскольку работа идет то над одним, то над другим. Например:
string current_project( "C++ Primer, 3rd Edition" ); |
Функция-член swap()
позволяет обменять значения двух строк с помощью вызова
current_project.swap( pending_project );
Для строки
string first_novel( "V" );
операция взятия индекса
char ch = first_novel[ 1 ];
возвратит неопределенное значение: длина строки first_novel равна 1, и единственное правильное значение индекса – 0. Такая операция взятия индекса не обеспечивает проверку правильности параметра, но мы всегда можем сделать это сами с помощью функции-члена size():
int elem_count( const string &word, char elem ) { int occurs = 0; // не надо больше проверять ix for ( int ix=0; ix < word.size(); ++-ix ) if ( word[ ix ] == elem ) ++occurs; return occurs; |
}
Там, где это невозможно или нежелательно, например:
void mumble( const string &st, int index ) { // возможна ошибка char ch = st[ index ]; // ... |
следует воспользоваться функцией at(), которая делает то же, что и операция взятия индекса, но с проверкой. Если индекс выходит за границу, возбуждается исключение out_of_range:
void mumble( const string &st, int index ) { try { char ch = st.at( index ); // ... } catch ( std::out_of_range ){...} // ... |
Строки можно сравнивать лексикографически. Например:
string cobol_program_crash( "abend" ); |
Строка cobol_program_crash
лексикографически меньше, чем cplus_program_crash: сопоставление производится по первому отличающемуся символу, а буква e в латинском алфавите идет раньше, чем o. Операция сравнения выполняется функцией-членом compare(). Вызов
sl.compare( s2 );
возвращает одно из трех значений:
· если s1
больше, чем s2, то положительное;
· если s1
меньше, чем s2, то отрицательное;
· если s1
равно s2, то 0.
Например,
cobol_program_crash.compare( cplus_program_crash );
вернет отрицательное значение, а
cplus_program_crash.compare( cobol_program_crash );
положительное. Перегруженные операции сравнения (<, >, !=, ==, <=, >=) являются более компактной записью функции compare().
Шесть вариантов функции-члена compare() позволяют выделить сравниваемые подстроки в одном или обоих операндах. (Примеры вызовов приводились в предыдущем разделе.)
Функция-член replace()
дает десять способов заменить одну подстроку на другую (их длины не обязаны совпадать). В двух основных формах replace() первые два аргумента задают заменяемую подстроку: в первом варианте в виде начальной позиции и длины, во втором – в виде пары итераторов на ее начало и конец. Вот пример первого варианта:
string sentence( " An ADT provides both interface and implementation." ); string::size_type position = sentence.find_1ast_of( 'A' ); string::size_type length = 3; // заменяем ADT на Abstract Data Type |
position
представляет собой начальную позицию, а length – длину заменяемой подстроки. Третий аргумент является подставляемой строкой. Его можно задать несколькими способами. Допустим, как объект string:
string new_str( "Abstract Data Type" ); |
Следующий пример иллюстрирует выделение подстроки в new_str:
#include typedef string::size_type size_type; // найдем позицию трех букв size_type posA = new_str.find( 'A' ); size_type posD = new_str.find( 'D' ); size_type posT = new_str.find( 'T' ); // нашли: заменим T на "Type" sentence.replace( position+2, 1, new_str, posT, 4 ); // нашли: заменим D на "Data " sentence.replace( position+1, 1, new_str, posD, 5 ); // нашли: заменим A на "Abstract " |
Еще один вариант позволяет заменить подстроку на один символ, повторенный заданное количество раз:
string hmm( "Some celebrate Java as the successor to C++." ); string:: size_type position = hmm.find( 'J' ); // заменим Java на xxxx |
В данном примере используется указатель на символьный массив и длина вставляемой подстроки:
const char *lang = "EiffelAda95JavaModula3"; int index[] = { 0, 6, 11, 15, 22 }; string ahhem( "C++ is the language for today's power programmers." ); |
А здесь мы используем пару итераторов:
string sentence( "An ADT provides both interface and implementation." ); // указывает на 'A' в ADT string: iterator start = sentence. begin()+3; // заменяем ADT на Abstract Data Type |
sentence.repiace( start, start+3, "Abstract Data Type" );
Оставшиеся четыре варианта допускают задание заменяющей строки как объекта типа string, символа, повторяющегося N раз, пары итераторов и C-строки.
Вот и все, что мы хотели сказать об операциях со строками. Для более полной информации обращайтесь к определению стандарта С++ [ISO-C++97].
Упражнение 6.18
Напишите программу, которая с помощью функций-членов assign() и append() из строк
string quote1( "When lilacs last in the dooryard bloom'd" ); |
составит предложение
"The child is in the dooryard"
Упражнение 6.19
Напишите функцию:
string generate_salutation( string generic1, string lastname, string generic2, string::size_type pos, |
которая в строке
string generic1( "Dear Ms Daisy:" );
заменяет Daisy и Ms
(миссис). Вместо Daisy
подставляется параметр lastname, а вместо Ms
подстрока
string generic2( "MrsMsMissPeople" );
длины length, начинающаяся с pos.
Например, вызов
string lastName( "AnnaP" ); string greetings = |
вернет строку:
Dear Miss AnnaP:
Дополнительные операторы ввода/вывода
Иногда необходимо прочитать из входного потока последовательность не интерпретируемых байтов, а типов данных, таких, как char, int, string и т.д. Функция-член get() класса istreamчитает по одному байту, а функция getline() читает строку, завершающуюся либо символом перехода на новую строку, либо каким-то иным символом, определяемым пользователем. У функции-члена get() есть три формы:
·
get(char& ch) читает из входного потока один символ (в том числе и пустой) и помещает его в ch. Она возвращает объект iostream, для которого была вызвана. Например, следующая программа собирает статистику о входном потоке, а затем копирует входной поток в выходной:
#include int main() { char ch; int tab_cnt = 0, nl_cnt = 0, space_cnt = 0, period_cnt = 0, comma_cnt = 0; while ( cin.get(ch)) { switch( ch ) { case ' ': space_cnt++; break; case '\t': tab_cnt++; break; case '\n': nl_cnt++; break; case '.': period_cnt++; break; case ',': comma_cnt++; break; } cout.put(ch); } cout << "\nнаша статистика:\n\t" << "пробелов: " << space_cnt << '\t' << "символов новой строки: " << nl_cnt << '\t' << "табуляций: " << tab_cnt << "\n\t" << "точек: " << period_cnt << '\t' << "запятых: " << comma_cnt << endl;
| |
}
Функция-член put()
класса ostream
дает альтернативный метод вывода символа в выходной поток: put()
принимает аргумент типа char и возвращает объект класса ostream, для которого была вызвана.
После компиляции и запуска программа печатает следующий результат:
Alice Emma has long flowing red hair. Her Daddy says
when the wind blows through her hair, it looks almost alive,
like a fiery bird in flight. A beautiful fiery bird, he tells her,
magical but untamed. "Daddy, shush, there is no such creature,"
she tells him, at the same time wanting him to tell her more.
Shyly, she asks, "I mean, Daddy, is there?"
наша статистика:
пробелов: 59 символов новой строки: 6 табуляций: 0
точек: 4 запятых: 12
· вторая форма get()
также читает из входного потока по одному символу, но возвращает не поток istream, а значение прочитанного символа. Тип возвращаемого значения равен int, а не char, поскольку необходимо возвращать еще и признак конца файла, который обычно равен -1, чтобы отличаться от кодов реальных символов. Для проверки на конец файла мы сравниваем полученное значение с константой EOF, определенной в заголовочном файле iostream. Переменная, в которой сохраняется значение, возвращенное get(), должна быть объявлена как int, чтобы в ней можно было представить не только код любого символа, но и EOF:
#include int main() { int ch; // альтернатива: // while ( ch = cin.get() && ch != EOF ) while (( ch = cin.get()) != EOF ) cout.put( ch ); return 0; |
При использовании любой из этих форм get() для чтения данной последовательности нужно семь итераций:
a b c
d
Читаются следующие символы: ('a', пробел, 'b', пробел, 'c', символ новой строки, 'd'). На восьмой итерации читается EOF. Оператор ввода (>>) по умолчанию пропускает пустые символы, поэтому на ту же последовательность потребуется четыре итерации, на которых возвращаются символы: 'a', 'b', 'c', 'd'. А вот следующая форма get()
может прочесть всю последовательность всего за две итерации;
· сигнатура третьей формы get()
такова:
get(char *sink, streamsize size, char delimiter='\n')
sink – это массив, в который помещаются символы. size – это максимальное число символов, читаемых из потока istream. delimiter – это символ-ограничитель, при обнаружении которого чтение прекращается. Сам ограничитель не читается, а оставляется в потоке и будет прочитан следующим. Программисты часто забывают удалить его из потока перед вторым обращением к get(). Чтобы избежать этой ошибки, в показанной ниже программе мы воспользовались функцией-членом ignore()
класса istream. По умолчанию ограничителем является символ новой строки.
Символы читаются из потока, пока одно из следующих условий не окажется истинным. Как только это случится, в очередную позицию массива помещается двоичный нуль.
· прочитано size-1
символов;
· встретился конец файла;
· встретился символ-ограничитель (еще раз напомним, что он остается в потоке и будет считан следующим).
Эта форма get()
возвращает объект istream, для которого была вызвана (функция-член gcount() позволяет узнать количество прочитанных символов). Вот простой пример ее применения:
#include int main() { const int max_line = 1024; char line[ max_line ]; while ( cin.get( line, max_line )) { // читается не больше max_line - 1 символов, // чтобы оставить место для нуля int get_count = cin.gcount(); cout << "фактически прочитано символов: " << get_count << endl; // что-то сделать со строкой // если встретился символ новой строки, // удалить его, прежде чем приступать к чтению следующей if ( get_count < max_line-1 ) cin.ignore(); } |
Если на вход этой программы подать текст о юной Алисе Эмме, то результат будет выглядеть так:
фактически прочитано символов: 52
фактически прочитано символов: 60
фактически прочитано символов: 66
фактически прочитано символов: 63
фактически прочитано символов: 61
фактически прочитано символов: 43
Чтобы еще раз протестировать поведение программы, мы создали строку, содержащую больше max_line
символов, и поместили ее в начало текста. Получили:
фактически прочитано символов: 1023
фактически прочитано символов: 528
фактически прочитано символов: 52
фактически прочитано символов: 60
фактически прочитано символов: 66
фактически прочитано символов: 63
фактически прочитано символов: 61
фактически прочитано символов: 43
По умолчанию ignore()
читает и удаляет один символ из потока, для которого вызвана, но можно и явно задать ограничитель и количество пропускаемых символов. В общем виде ее сигнатура такова:
ignore( streamsize length = 1, int delim = traits::eof )
ignore()
читает и отбрасывает length
символов из потока или все символы до ограничителя включительно или до конца файла и возвращает объект istream, для которого вызвана.
Мы рекомендуем пользоваться функцией getline(), а не get(), поскольку она автоматически удаляет ограничитель из потока. Сигнатура getline()
такая же, как у get() с тремя аргументами (и возвращает она тоже объект istream, для которого вызвана):
getline(char *sink, streamsize size, char delimiter='\n')
Поскольку и getline(), и get() с тремя аргументами могут читать size символов или меньше, то часто нужно “спросить” у объекта istream, сколько символов было фактически прочитано. Это позволяет сделать функция-член gcount(): она возвращает число символов, прочитанных при последнем обращении к get() или getline().
Функция-член write()
класса ostream
дает альтернативный метод вывода массива символов. Вместо того чтобы выводить символы до завершающего нуля, она выводит указанное число символов, включая и внутренние нули, если таковые имеются. Вот ее сигнатура:
write( const char *sink, streamsize length )
Здесь length
определяет, сколько символов выводить. write()
возвращает объект класса ostream, для которого она вызвана.
Парной для функции write() из класса ostream
является функция read() из класса istream с такой сигнатурой:
read( char* addr, streamsize size )
read()
читает size
соседних байт из входного потока и помещает их, начиная с адреса addr. Функция gcount()
возвращает число байт, прочитанных при последнем обращении к read(). В свою очередь read() возвращает объект класса istream, для которого она вызвана. Вот пример использования getline(), gcount() и write():
#include int main() { const int lineSize = 1024; int lcnt = 0; // сколько строк прочитано int max = -1; // длина самой длинной строки char inBuf[ lineSize ]; // читается до конца строки, но не более 1024 символов while (cin.getline( inBuf, lineSize )) { // сколько символов фактически прочитано int readin = cin.gcount(); // статистика: счетчик строк, самая длинная строка ++lcnt; if ( readin > max ) max = readin; cout << "Строка #" << lcnt << "\tПрочитано символов: " << readin << endl; cout.write( inBuf, readin).put('\n').put('\n'); } cout << "Всего прочитано строк: " << lcnt << endl; cout << "Самая длинная строка: " << max << endl; |
Когда на вход было подано несколько фраз из романа Германа Мелвилла “Моби Дик”, программа напечатала следующее:
Строка #1 Прочитано символов: 45
Call me Ishmael. Some years ago, never mind
Строка #2 Прочитано символов: 46
how long precisely, having little or no money
Строка #3 Прочитано символов: 48
in my purse, and nothing particular to interest
Строка #4 Прочитано символов: 51
me on shore, I thought I would sail about a little
Строка #5 Прочитано символов: 47
and see the watery part of the world. It is a
Строка #6 Прочитано символов: 43
way I have of driving off the spleen, and
Строка #7 Прочитано символов: 28
regulating the circulation.
Всего прочитано строк: 7
Самая длинная строка: 51
Функция-член getline()
класса istream
поддерживает только ввод в массив символов. Однако в стандартной библиотеке есть обычная функция getline(), которая помещает символы в объект класса string:
getline( istream &is, string str, char delimiter );
Эта функция читает не более str::max_size()-1
символов. Если входная последовательность длиннее, то операция завершается неудачно и объект переводится в ошибочное состояние. В противном случае ввод прекращается, когда прочитан ограничитель (он удаляется из потока, но в строку не помещается) либо достигнут конец файла.
Вот еще три необходимые нам функции-члена класса istream:
// возвращает символ в поток putback( char class ); // устанавливает "указатель на следующий символ потока istream на один символ назад unget(); // возвращает следующий символ (или EOF), // но не извлекает его из потока |
Следующий фрагмент иллюстрирует использование некоторых из них:
char ch, next, lookahead; while ( cin.get( ch )) { switch (ch) { case '/': // это комментарий? посмотрим с помощью peek() // если да, пропустить остаток строки next = cin.peek(); if ( next == '/' ) cin.ignore( lineSize, '\n' ); break; case '>': // проверка на лексему >>= next = cin.peek(); if ( next == '>' ) { lookahead = cin.get(); next = cin.peek(); if ( next != '=' ) cin.putback( lookahead ); } // ... |
Упражнение 20.4
Прочитайте из стандартного ввода следующую последовательность символов, включая все пустые, и скопируйте каждый символ на стандартный вывод (эхо-копирование):
a b c
d e
f
Упражнение 20.5
Прочитайте фразу “riverrun, from bend of bay to swerve of shore” сначала как последовательность из девяти строк, а затем как одну строку.
Упражнение 20.6
С помощью функций getline() и gcount()
прочитайте последовательность строк из стандартного ввода и найдите самую длинную (не забудьте, что строку, прочитанную за несколько обращений к getline(), нужно считать одной).
Доступ к членам базового класса
Объект производного класса фактически построен из нескольких частей. Каждый базовый класс вносит свою долю в виде подобъекта, составленного из нестатических данных-членов этого класса. Объект производного класса построен из подобъектов, соответствующих каждому из его базовых, а также из части, включающей нестатические члены самого производного класса. Так, наш объект NameQuery состоит из подобъекта Query, содержащего члены _loc и _solution, и части, принадлежащей NameQuery,– она содержит только член _name.Внутри производного класса к членам, унаследованным из базового, можно обращаться напрямую, как к его собственным. (Глубина цепочки наследования не увеличивает затраты времени и не лимитирует доступ к ним.) Например:
void NameQuery:: display_partial_solution( ostream &os ) { os << _name << " is found in " << (_solution ? _solution->size() : 0) << " lines of text\n"; |
}
Это касается и доступа к унаследованным функциям-членам базового класса: мы вызываем их так, как если бы они были членами производного – либо через его объект:
NameQuery nq( "Frost" ); // вызывается NameQuery::eval() nq.eval(); // вызывается Query::display() |
nq.display();
либо непосредственно из тела другой (или той же самой) функции-члена:
void NameQuery:: match_count() { if ( ! _solution ) // вызывается Query::_vec2set() _solution = _vec2set( &_loc ); return _solution->size(); |
}
Однако прямой доступ из производного класса к членам базового запрещен, если имя последнего скрыто в производном классе:
class Diffident { public: // ... protected: int _mumble; // ... }; class Shy : public Diffident { public: // ... protected: // имя Diffident::_mumble скрыто string _mumble; // ... |
};
В области видимости Shy
употребление неквалифицированного имени _mumble разрешается в пользу члена _mumble
класса Shy
(объекта string), даже если такое использование в данном контексте недопустимо:
void Shy:: turn_eyes_down() { // ... _mumble = "excuse me"; // правильно // ошибка: int Diffident::_mumble скрыто _mumble = -1; |
Некоторые компиляторы помечают это как ошибку типизации. Для доступа к члену базового класса, имя которого скрыто в производном, необходимо квалифицировать имя члена базового класса именем самого этого класса с помощью оператора разрешения области видимости. Так выглядит правильная реализация функции-члена turn_eyes_down():
void Shy:: turn_eyes_down() { // ... _mumble = "excuse me"; // правильно // правильно: имя члена базового класса квалифицировано Diffident::_mumble = -1; |
Функции-члены базового и производного классов не составляют множество перегруженных функций:
class Diffident { public: void mumble( int softness ); // ... }; class Shy : public Diffident { public: // скрывает видимость функции-члена Diffident::_mumble, // а не перегружает ее void mumble( string whatYaSay ); void print( int soft, string words ); // ... |
Вызов функции-члена базового класса из производного в этом случае приводит к ошибке компиляции:
Shy simon; // правильно: Shy::mumble( string ) simon.mumble( "pardon me" ); // ошибка: ожидался первый аргумент типа string // Diffident::mumble( int ) невидима |
Хотя к членам базового класса можно обращаться напрямую, они сохраняют область видимости класса, в котором определены. А чтобы функции перегружали друг друга, они должны находиться в одной и той же области видимости. Если бы это было не так, следующие два экземпляра невиртуальной функции-члена turn_aside()
class Diffident { public: void turn_aside( ); // ... }; class Shy : public Diffident { public: // скрывает видимость // Diffident::turn_aside() void turn_aside(); // ... |
};
привели бы к ошибке повторного определения, так как их сигнатуры одинаковы. Однако запись правильна, поскольку каждая функция находится в области видимости того класса, в котором определена.
А если нам действительно нужен набор перегруженных функций-членов базового и производного классов? Написать в производном классе небольшую встроенную заглушку для вызова экземпляра из базового? Это возможно:
class Shy : public Diffident { public: // один из способов реализовать множество перегруженных // членов базового и производного классов void mumble( string whatYaSay ); void mumble( int softness ) { Diffident::mumble( softness ); } // ... |
Но в стандартном C++ тот же результат достигается посредством using-объявления:
class Shy : public Diffident { public: // в стандартном C++ using-объявление // создает множество перегруженных // членов базового и производного классов void mumble( string whatYaSay ); using Diffident::mumble; // ... |
По сути дела, using-объявление вводит каждый именованный член базового класса в область видимости производного. Поэтому такой член теперь входит в множество перегруженных функций, ассоциированных с именем функции-члена производного класса. (В ее using-объявлении нельзя указать список параметров, только имя. Это означает, что если некоторая функция уже перегружена в базовом классе, то в область видимости производного класса попадут все перегруженные экземпляры и, следовательно, добавить только одну из них невозможно.)
Обратим внимание на степень доступности защищенных членов базового класса. Когда мы пишем:
class Query { public: const vector // ... protected: vector // ... |
то имеем в виду, что класс, производный от Query, может напрямую обратиться к члену _loc, тогда как во всей остальной программе для этого необходимо пользоваться открытой функцией доступа. Однако объект производного класса имеет доступ только к защищенному члену _loc входящего в него
подобъекта, относящегося к базовому классу. Объект производного класса неспособен обратиться к защищенным членам другого независимого объекта базового класса:
bool NameQuery:: compare( const Query *pquery ) { // правильно: защищенный член подобъекта Query int myMatches = _loc.size(); // ошибка: нет прав доступа к защищенному члену // независимого объекта Query int itsMatches = pquery->_loc.size(); return myMatches == itsMatches; |
У объекта NameQuery
есть доступ к защищенным членам только одного объекта Query – подобъекта самого себя. Прямое обращение к ним из производного класса осуществляется через неявный указатель this (см. раздел 13.4). Первая реакция на ошибку компиляции – переписать функцию compare() с использованием открытой функции-члена location():
bool NameQuery:: compare( const Query *pquery ) { // правильно: защищенный член подобъекта Query int myMatches = _loc.size(); // правильно: используется открытый метод доступа int itsMatches = pquery->locations()->size(); return myMatches == itsMatches; |
Однако проблема заключается в неправильном проектировании. Поскольку _loc – это член базового класса Query, то место compare()
среди членов базового, а не производного класса. Во многих случаях подобные проблемы могут быть решены путем переноса некоторой операции в тот класс, где находится недоступный член, как в приведенном примере.
Этот вид ограничения доступа не распространяется на доступ изнутри класса к другим объектам того же класса:
bool NameQuery:: compare( const NameQuery *pname ) { int myMatches = _loc.size(); // правильно int itsMatches = name->_loc.size(); // тоже правильно return myMatches == itsMatches; |
Производный класс может напрямую обращаться к защищенным членам базового в других объектах того же класса, что и он сам, равно как и к защищенным и закрытым членам других объектов своего класса.
Рассмотрим инициализацию указателя на базовый Query адресом объекта производного NameQuery:
Query *pb = new NameQuery( "sprite" );
При вызове виртуальной функции, определенной в базовом классе Query, например:
pb->eval(); // вызывается NameQuery::eval()
вызывается функция из NameQuery. За исключением вызова виртуальной функции, объявленной в Query и переопределенной в NameQuery, другого способа напрямую добраться до членов класса NameQuery
через указатель pb не существует:
(a) если в Query и NameQuery
объявлены некоторые невиртуальные функции-члены с одинаковым именем, то через pb
всегда вызывается экземпляр из Query;
(b) если в Query и NameQuery
объявлены одноименные члены, то через pb обращение происходит к члену класса Query;
(c) если в NameQuery
имеется виртуальная функция, отсутствующая в Query, скажем suffix(), то попытка вызвать ее через pb приводит к ошибке компиляции:
// ошибка: suffix() - не член класса Query |
· Обращение к члену или невиртуальной функции-члену класса NameQuery
через pb
тоже вызывает ошибку компиляции:
// ошибка: _name - не член класса Query |
Квалификация имени члена в этом случае не помогает:
// ошибка: у класса Query нет базового класса NameQuery |
В C++ с помощью указателя на базовый класс можно работать только с данными и функциями-членами, включая виртуальные, которые объявлены (или унаследованы) в самом этом классе, независимо от того, какой фактический объект адресуется указателем. Объявление функции-члена виртуальной откладывает решение вопроса о том, какой экземпляр функции вызвать, до выяснения (во время выполнения программы) фактического типа объекта, адресуемого pb.
Такой подход может показаться недостаточно гибким, но у него есть два весомых преимущества:
· поиск виртуальной функции-члена во время выполнения никогда не закончится неудачно из-за того, что фактический тип класса не существует. В таком случае программа просто не смогла бы откомпилироваться;
· механизм виртуализации можно оптимизировать. Часто вызов такой функции оказывается не дороже, чем косвенный вызов функции по указателю (детально этот вопрос рассмотрен в [LIPPMAN96a]).
В базовом классе Query
определен статический член _text_file:
static vector
Создается ли при порождении класса NameQuery
второй экземпляр _text_file, уникальный именно для него? Нет. Все объекты производного класса ссылаются на тот же самый, единственный разделяемый статический член. Сколько бы ни было производных классов, существует лишь один экземпляр _text_file. Можно обратиться к нему через объект производного класса с помощью синтаксиса доступа:
nameQueryObject._text_file; // правильно
Наконец, если производный класс хочет получить доступ к закрытым членам своего базового класса напрямую, то он должен быть объявлен другом базового:
class Query { friend class NameQuery; public: // ... |
Теперь объект NameQuery
может обращаться не только к закрытым членам своего подобъекта, соответствующего базовому классу, но и к закрытым и защищенным членам любых объектов Query.
А если мы произведем от NameQuery
класс StringQuery? Он будет поддерживать сокращенную форму запроса AndQuery, и вместо
beautiful && fiery && bird
можно будет написать:
"beautiful fiery bird"
Унаследует ли StringQuery от класса NameQuery
дружественные отношения с Query? Нет. Отношение дружественности не наследуется. Производный класс не становится другом класса, который объявил своим другом один из базовых. Если производному классу требуется стать другом одного или более классов, то эти классы должны предоставить ему соответствующие права явно. Например, у класса StringQuery нет никаких специальных прав доступа по отношению к Query. Если расширенный доступ необходим, то Query должен разрешить его явно.
Упражнение 17.6
Даны следующие определения базового и производных классов:
class Base { public: foo( int ); // ... protected: int _bar; double _foo_bar; }; class Derived : public Base { public: foo( string ); bool bar( Base *pb ); void foobar(); // ... protected: string _bar; |
Исправьте ошибки в каждом из следующих фрагментов кода:
Derived d; d.foo( 1024 ); |
(c) bool Derived::bar( Base *pb ) |
Доступ к членам класса
Говорят, что определение функции-члена принадлежит области видимости класса независимо от того, находится ли оно вне или внутри его тела. Отсюда следуют два вывода:· в определении функции-члена могут быть обращения к любым членам класса, открытым или закрытым, и это не нарушает ограничений доступа;
· когда функция-член обращается к членам класса, операторы доступа “точка” и “стрелка” не необходимы.
Например:
#include void Screen::copy( const Screen &sobj ) { // если этот объект и объект sobj - одно и то же, // копирование излишне // мы анализируем указатель this (см. раздел 13.4) if ( this != &sobj ) { _height = sobj._height; _width = sobj._width; _cursor = 0; // создаем новую строку; // ее содержимое такое же, как sobj._screen _screen = sobj._screen; }
| |
}
Хотя _screen, _height, _width и _cursor
являются закрытыми членами класса Screen, функция-член copy()
работает с ними напрямую. Если при обращении к члену отсутствует оператор доступа, то считается, что речь идет о члене того класса, для которого функция-член вызвана. Если вызвать copy() следующим образом:
#include "Screen.h" int main() { Screen s1; // Установить s1 Screen s2; s2.copy(s1); // ... |
}
то параметр sobj
внутри определения copy()
соотносится с объектом s1 из функции main(). Функция-член copy() вызвана для объекта s2, стоящего перед оператором “точка”. Для такого вызова члены _screen, _height, _width и _cursor, при обращении к которым внутри определения этой функции нет оператора доступа, – это члены объекта s2. В следующем разделе мы рассмотрим доступ к членам класса внутри определения функции-члена более подробно и, в частности, покажем, как для поддержки такого доступа применяется указатель this.
Доступ к членам
Часто бывает так, что внутреннее представление типа класса изменяется в последующих версиях программы. Допустим, опрос пользователей нашего класса Screenпоказал, что для его объектов всегда задается размер экрана 80 ´ 24. В таком случае было бы желательно заменить внутреннее представление экрана менее гибким, но более эффективным:
class Screen { public: // функции-члены private: // инициализация статических членов (см. 13.5) static const int _height = 24; static const int _width = 80; string _screen; string::size_type _cursor; |
};
Прежняя реализация функций-членов (то, как они манипулируют данными-членами класса) больше не годится, ее нужно переписать. Но это не означает, что должен измениться и интерфейс функций-членов (список формальных параметров и тип возвращаемого значения).
Если бы данные-члены класса Screen
были открыты и доступны любой функции внутри программы, как отразилось бы на пользователях изменение внутреннего представления этого класса?
·
все функции, которые напрямую обращались к данным-членам старого представления, перестали бы работать. Следовательно, пришлось бы отыскивать и изменять соответствующие части кода;
· так как интерфейс не изменился, то коды, манипулировавшие объектами класса Screen только через функции-члены, не пришлось бы модифицировать. Но поскольку сами функции-члены все же изменились, программу пришлось бы откомпилировать заново.
Сокрытие информации – это формальный механизм, предотвращающий прямой доступ к внутреннему представлению типа класса из функций программы. Ограничение доступа к членам задается с помощью секций тела класса, помеченных ключевыми словами public, private и protected – спецификаторами доступа. Члены, объявленные в секции public, называются открытыми, а объявленные в секциях private и protected
соответственно закрытыми или защищенными.
· открытый член
доступен из любого места программы. Класс, скрывающий информацию, оставляет открытыми только функции-члены, определяющие операции, с помощью которых внешняя программа может манипулировать его объектами;
· закрытый член
доступен только функциям-членам и друзьям
класса. Класс, который хочет скрыть информацию, объявляет свои данные-члены закрытыми;
· защищенный член
ведет себя как открытый по отношению к производному классу и как закрытый по отношению к остальной части программы. (В главе 2 мы видели пример использования защищенных членов в классе IntArray. Детально они рассматриваются в главе 17, где вводится понятие наследования.)
В следующем определении класса Screen
указаны секции public и private:
class Screen { public: void home() { _cursor = 0; } char get() { return _screen[_cursor]; } char get( int, int ); void move( int, int ); // ... private: string _screen; string::size_type _cursor; short _height, _width; |
Согласно принятому соглашению, сначала объявляются открытые члены класса. (Обсуждение того, почему в старых программах C++ сначала шли закрытые члены и почему этот стиль еще кое-где сохранился, см. в книге [LIPPMAN96a].) В теле класса может быть несколько секций public, protected и private. Каждая секция продолжается либо до метки следующей секции, либо до закрывающей фигурной скобки. Если спецификатор доступа не указан, то секция, непосредственно следующая за открывающей скобкой, по умолчанию считается private.
Друзья
Иногда удобно разрешить некоторым функциям доступ к закрытым членам класса. Механизм друзейпозволяет классу разрешать доступ к своим неоткрытым членам.
Объявление друга начинается с ключевого слова friend и может встречаться только внутри определения класса. Так как друзья не являются членами класса, то не имеет значения, в какой секции они объявлены. В примере ниже мы сгруппировали все подобные объявления сразу после заголовка класса:
class Screen { friend istream& operator>>( istream&, Screen& ); friend ostream& operator<<( ostream&, const Screen& ); public: // ... оставшаяся часть класса Screen |
};
Операторы ввода и вывода теперь могут напрямую обращаться к закрытым членам класса Screen. Простая реализация оператора вывода выглядит следующим образом:
#include ostream& operator<<( ostream& os, const Screen& s ) { // правильно: можно обращаться к _height, _width и _screen os << "<" << s._height << "," << s._width << ">"; os << s._screen; return os;
| |
}
Другом может быть функция из пространства имен, функция-член другого класса или даже целый класс. В последнем случае всем его функциям-членам предоставляется доступ к неоткрытым членам класса, объявляющего дружественные отношения. (В разделе 15.2 друзья обсуждаются более подробно.)
Рассмотрим еще раз перегруженные операторы равенства для класса String, определенные в области видимости пространства имен. Оператор равенства для двух объектов String
выглядит следующим образом:
bool operator==( const String &str1, const String &str2 ) { if ( str1.size() != str2.size() ) return false; return strcmp( str1.c_str(), str2.c_str() ) ? false : true; |
}
Сравните это определение с определением того же оператора как функции-члена:
bool String::operator==( const String &rhs ) const { if ( _size != rhs._size ) return false; return strcmp( _string, rhs._string ) ? false : true; |
}
Нам пришлось модифицировать способ обращения к закрытым членам класса String. Поскольку новый оператор равенства– это глобальная функция, а не функция-член, у него нет доступа к закрытым членам класса String. Для получения размера объекта String и лежащей в его основе C-строки символов используются функции-члены size() и c_str().
Альтернативной реализацией является объявление глобальных операторов равенства друзьями класса String. Если функция или оператор объявлены таким образом, им предоставляется доступ к неоткрытым членам.
Объявление друга (оно начинается с ключевого слова friend) встречается только внутри определения класса. Поскольку друзья не являются членами класса, объявляющего дружественные отношения, то безразлично, в какой из секций – public, private или protected – они объявлены. В примере ниже мы решили поместить все подобные объявления сразу после заголовка класса:
class String { friend bool operator==( const String &, const String & ); friend bool operator==( const char *, const String & ); friend bool operator==( const String &, const char * ); public: // ... остальная часть класса String |
};
В этих трех строчках три перегруженных оператора сравнения, принадлежащие глобальной области видимости, объявляются друзьями класса String, а следовательно, в их определениях можно напрямую обращаться к закрытым членам данного класса:
Еще раз о разрешении перегрузки функций
В главе 9 подробно описывалось, как разрешается вызов перегруженной функции. Если фактические аргументы при вызове имеют тип класса, указателя на тип класса или указателя на члены класса, то на роль возможных кандидатов претендует большее число функций. Следовательно, наличие таких аргументов оказывает влияние на первый шаг процедуры разрешения перегрузки – отбор множества функций-кандидатов.На третьем шаге этой процедуры выбирается наилучшее соответствие. При этом ранжируются преобразования типов фактических аргументов в типы формальных параметров функции. Если аргументы и параметры имеют тип класса, то в множество возможных преобразований следует включать и последовательности определенных пользователем преобразований, также подвергая их ранжированию.
В этом разделе мы детально рассмотрим, как фактические аргументы и формальные параметры типа класса влияют на отбор функций-кандидатов и как последовательности определенных пользователем преобразований сказываются на выборе наилучшей из устоявших функции.
Еще раз об итераторах
Следующая реализация шаблона функции не компилируется. Можете ли вы сказать, почему?// в таком виде это не компилируется template < typename type > int count( const vector< type > &vec, type value ) { int count = 0; vector< type >::iterator iter = vec.begin(); while ( iter != vec.end() ) if ( *iter == value ) ++count; return count; |
}
Проблема в том, что у ссылки vec
есть спецификатор const, а мы пытаемся связать с ней итератор без такого спецификатора. Если бы это было разрешено, то ничто не помешало бы нам модифицировать с помощью этого итератора элементы вектора. Для предотвращения подобной ситуации язык требует, чтобы итератор, связанный с const-вектором, был константным. Мы можем сделать это следующим образом:
// правильно: это компилируется без ошибок |
vector< type>::const_iterator iter = vec.begin();
Требование, чтобы с const-контейнером был связан только константный итератор, аналогично требованию о том, чтобы const-массив адресовался только константным указателем. В обоих случаях это вызвано необходимостью гарантировать, что содержимое const-контейнера не будет изменено.
Операции begin() и end()
перегружены и возвращают константный или неконстантный итератор в зависимости от наличия спецификатора const в объявлении контейнера. Если дана такая пара объявлений:
vector< int > vec0; |
const vector< int > vec1;
то при обращениях к begin() и end() для vec0
будет возвращен неконстантный, а для vec1 – константный итератор:
vector< int >::iterator iter0 = vec0.begin(); |
vector< int >::const_iterator iter1 = vec1.begin();
Разумеется, присваивание константному итератору неконстантного разрешено всегда. Например:
// правильно: инициализация константного итератора неконстантным |
vector< int >::const_iterator iter2 = vec0.begin();
Файловый ввод/вывод
Библиотека iostreamподдерживает и файловый ввод/вывод. Все операции, применимые в стандартному вводу и выводу, могут быть также применены к файлам. Чтобы использовать файл для ввода или вывода, мы должны включить еще один заголовочный файл:
#include
Перед тем как открыть файл для вывода, необходимо объявить объект типа ofstream:
ofstream outfile("name-of-file");
Проверить, удалось ли нам
открыть файл, можно следующим образом:
if ( ! outfile ) // false, если файл не открыт |
cerr << "Ошибка открытия файла.\n"
Так же открывается файл и для ввода, только он имеет тип ifstream:
ifstream infile("name-of-file"); |
if ( ! infile ) // false, если файл не открыт
cerr << "Ошибка открытия файла.\n"
Ниже приводится текст простой программы, которая читает файл с именем in_file и выводит все прочитанные из этого файла слова, разделяя их пробелом, в другой файл, названный out_file.
#include #include #include int main() { ifstream infile("in_file"); ofstream outfile("out_file"); if ( ! infile ) { cerr << "Ошибка открытия входного файла.\n" return -1; } if ( ! outfile ) { cerr << "Ошибка открытия выходного файла.\n" return -2; }
| |
string word;
while ( infile >> word )
outfile << word << ' ';
return 0;
}
В главе 20 библиотека ввода/вывода будет рассмотрена подробно. А в следующих разделах мы увидим, как можно создавать новые типы данных, используя механизм классов и шаблонов.
Функции-члены класса
Функции-члены реализуют набор операций, применимых к объектам класса. Например, для Screenтакой набор состоит из следующих объявленных в нем функций-членов:
class Screen { public: void home() { _cursor = 0; } char get() { return _screen[_cursor]; } char get( int, int ); void move( int, int ); bool checkRange( int, int ); int height() { return _height; } int width() { return _width; } // ... |
};
Хотя у любого объекта класса есть собственная копия всех данных-членов, каждая функция-член существует в единственном экземпляре:
Screen myScreen, groupScreen; myScreen.home(); |
groupScreen.home();
При вызове функции home() для объекта myScreen
происходит обращение к его члену _cursor. Когда же эта функция вызывается для объекта groupScreen, то она обращается к члену _cursor именно этого объекта, причем сама функция home() одна и та же. Как же может одна функция-член обращаться к данным-членам разных объектов? Для этого применяется указатель this, рассматриваемый в следующем разделе.
Функции-члены шаблонов классов
Как и для обычных классов, функция-член шаблона класса может быть определена либо внутри определения шаблона (и тогда называется встроенной), либо вне его. Мы уже встречались со встроенными функциями-членами при рассмотрении шаблона Queue. Например, конструктор Queueявляется встроенным, так как определен внутри определения шаблона класса:
template class Queue { // ... public: // встроенный конструктор Queue() : front( 0 ), back( 0 ) { } // ...
| |
};
При определении функции-члена шаблона вне определения самого шаблона следует применять специальный синтаксис для обозначения того, членом какого именно шаблона является функция. Определению функции-члена должно предшествовать ключевое слово template, за которым следуют параметры шаблона. Так, конструктор Queue
можно определить следующим образом:
template class Queue { public: Queue(); private: // ... }; template inline Queue
| |
Queue( ) { front = back = 0; }
За первым вхождением Queue
(перед оператором ::) следует список параметров, показывающий, какому шаблону принадлежит данная функция-член. Второе вхождение Queue в определение конструктора (после оператора ::) содержит имя функции-члена, за которым может следовать список параметров шаблона, хотя это и необязательно. После имени функции идет ее определение;. в нем могут быть ссылки на параметр шаблона Type всюду, где в определении обычной функции использовалось бы имя типа.
Функция-член шаблона класса сама является шаблоном. Стандарт C++ требует, чтобы она конкретизировалась только при вызове либо при взятии ее адреса. (Некоторые более старые компиляторы конкретизируют такие функции одновременно с конкретизацией самого шаблона класса.) При конкретизации функции-члена используется тип того объекта, для которого функция вызвана:
Queue
Объект qs
имеет тип Queue
Функция-член шаблона конкретизируется только при реальном использовании в программе (т.е. при вызове или взятии ее адреса). От того, в какой именно момент конкретизируется функция-член, зависит разрешение имен в ее определении (см. раздел 16.11) и объявление ее специализации (см. раздел 16.9).
Функции-члены шаблонов Queue и QueueItem
Чтобы понять, как определяются и используются функции-члены шаблонов классов, продолжим изучение шаблонов Queue и QueueItem:template class Queue { public: Queue() : front( 0 ), back ( 0 ) { } ~Queue(); Type& remove(); void add( const Type & ); bool is_empty() const { return front == 0; } private: QueueItem QueueItem
| |
};
Деструктор, а также функции-члены remove() и add()
определены не в теле шаблона, а вне его. Деструктор Queue
опустошает очередь:
template Queue { while (! is_empty() ) remove();
| |
}
Функция-член Queue
помещает новый элемент в конец очереди:
template void Queue { // создать новый объект QueueItem QueueItem new QueueItem if ( is_empty() ) front = back = pt; else { back->next = pt; back = pt; }
| |
}
Функция-член Queue
возвращает значение элемента, находящегося в начале очереди, и удаляет сам элемент.
#include #include template Type Queue { if ( is_empty() ) { cerr << "remove() вызвана для пустой очереди\n"; exit( -1 ); } QueueItem front = front->next; Type retval = pt->item; delete pt; return retval;
| |
}
Мы поместили определения функций-членов в заголовочный файл Queue.h, включив его в каждый файл, где возможны конкретизации функций. (Обоснование этого решения, а также рассмотрение более общих вопросов, касающихся модели компиляции шаблонов, мы отложим до раздела 16.8.)
В следующей программе иллюстрируется использование и конкретизация функции-члена шаблона Queue:
#include #include "Queue.h" int main() { // конкретизируется класс Queue // оператор new требует, чтобы Queue Queue int ival; for ( ival = 0; ival < 10; ++ival ) // конкретизируется функция-член add() p_qi->add( ival ); int err_cnt = 0; for ( ival = 0; ival < 10; ++ival ) { // конкретизируется функция-член remove() int qval = p_qi->remove(); if ( ival != qval ) err_cnt++; } if ( !err_cnt ) cout << "!! queue executed ok\n"; else cerr << "?? queue errors: " << err_cnt << endl; return 0;
| |
}
После компиляции и запуска программа выводит следующую строку:
!! queue executed ok
Упражнение 16.5
Используя шаблон класса Screen, определенный в разделе 16.2, реализуйте функции-члены Screen
(см. разделы 13.3, 13.4 и 13.6) в виде функций-членов шаблона.
Функции-члены со спецификаторами const и volatile
Любая попытка модифицировать константный объект из программы обычно помечается компилятором как ошибка. Например:const char blank = ' '; |
blank = '\n'; // ошибка
Однако объект класса, как правило, не модифицируется программой напрямую. Вместо этого вызывается та или иная открытая функция-член. Чтобы не было “покушений” на константность объекта, компилятор должен различать безопасные (те, которые не изменяют объект) и небезопасные (те, которые пытаются это сделать) функции-члены:
const Screen blankScreen; blankScreen.display(); // читает объект класса |
blankScreen.set( '*' ); // ошибка: модифицирует объект класса
Проектировщик класса может указать, какие функции-члены не модифицируют объект, объявив их константными с помощью спецификатора const:
class Screen { public: char get() const { return _screen[_cursor]; } // ... |
};
Для класса, объявленного как const, могут быть вызваны только те функции-члены, которые также объявлены со спецификатором const. Ключевое слово const
помещается между списком параметров и телом функции-члена. Для константной функции-члена, определенной вне тела класса, это слово должно присутствовать как в объявлении, так и в определении:
class Screen { public: bool isEqual( char ch ) const; // ... private: string::size_type _cursor; string _screen; // ... }; bool Screen::isEqual( char ch ) const { return ch == _screen[_cursor]; |
}
Запрещено объявлять константную функцию-член, которая модифицирует члены класса. Например, в следующем упрощенном определении:
class Screen { public: int ok() const { return _cursor; } void error( int ival ) const { _cursor = ival; } // ... private: string::size_type _cursor; // ... |
};
определение функции-члена ok()
корректно, так как она не изменяет значения _cursor. В определении же error()
значение _cursor
изменяется, поэтому такая функция-член не может быть объявлена константной и компилятор выдает сообщение об ошибке:
error: cannot modify a data member within a const member function
ошибка: не могу модифицировать данные-члены внутри константной функции-члена
Если класс будет интенсивно использоваться, лучше объявить его функции-члены, не модифицирующие данных, константными. Однако наличие спецификатора const в объявлении функции-члена не предотвращает все возможные изменения. Такое объявление гарантирует лишь, что функции-члены не смогут изменять данные-члены, но если класс содержит указатели, то адресуемые ими объекты могут быть модифицированы константной функцией, не вызывая ошибки компиляции. Это часто приводит в недоумение начинающих программистов. Например:
#include class Text { public: void bad( const string &parm ) const; private: char *_text; }; void Text::bad( const string &parm ) const { _text = parm.c_str(); // ошибка: нельзя модифицировать _text for ( int ix = 0; ix < parm.size(); ++ix ) _text[ix] = parm[ix]; // плохой стиль, но не ошибка |
Модифицировать _text
нельзя, но это объект типа char*, и символы, на которые он указывает, можно изменить внутри константной функции-члена класса Text. Функция-член bad()
демонстрирует плохой стиль программирования. Константность функции-члена не гарантирует, что объекты внутри класса останутся неизменными после ее вызова, причем компилятор не поможет обнаружить такую ситуацию.
Константную функцию-член можно перегружать неконстантной функцией с тем же списком параметров:
class Screen { public: char get(int x, int y); char get(int x, int y) const; // ... |
В этом случае наличие спецификатора const у объекта класса определяет, какая из двух функций будет вызвана:
int main() { const Screen cs; Screen s; char ch = cs.get(0,0); // вызывает константную функцию-член ch = s.get(0,0); // вызывает неконстантную функцию-член |
Хотя конструкторы и деструкторы не являются константными функциями-членами, они все же могут вызываться для константных объектов. Объект становится константным после того, как конструктор проинициализирует его, и перестает быть таковым, как только вызывается деструктор. Таким образом, объект со спецификатором const
трактуется как константный с момента завершения работы конструктора и до вызова деструктора.
Функцию-член можно также объявить со спецификатором volatile (он был введен в разделе 3.13). Объект класса объявляется как volatile, если его значение изменяется способом, который не обнаруживается компилятором (например, если это структура данных, представляющая порт ввода/вывода). Для таких объектов вызываются только функции-члены с тем же спецификатором, конструкторы и деструкторы:
class Screen { public: char poll() volatile; // ... }; |
Функции-члены
Пользователям, по-видимому, понадобится широкий набор операций над объектами типа Screen: возможность перемещать курсор, проверять и устанавливать области экрана и рассчитывать его реальные размеры во время выполнения, а также копировать один объект в другой. Все эти операции можно реализовать с помощью функций-членов.Функции-члены класса объявляются в его теле. Это объявление выглядит точно так же, как объявление функции в области видимости пространства имен. (Напомним, что глобальная область видимости – это тоже область видимости пространства имен. Глобальные функции рассматривались в разделе 8.2, а пространства имен – в разделе 8.5.) Например:
class Screen { public: void home(); void move( int, int ); char get(); char get( int, int ); void checkRange( int, int ); // ... |
};
Определение функции-члена также можно поместить внутрь тела класса:
class Screen { public: // определения функций home() и get() void home() { _cursor = 0; } char get() { return _screen[_cursor]; } // ... |
};
home() перемещает курсор в левый верхний угол экрана; get()
возвращает символ, находящийся в текущей позиции курсора.
Функции-члены отличаются от обычных функций следующим:
· функция-член объявлена в области видимости своего класса, следовательно, ее имя не видно за пределами этой области. К функции-члену можно обратиться с помощью одного из операторов доступа к членам – точки (.) или стрелки (->):
ptrScreen->home(); |
myScreen.home();
(в разделе 13.9 область видимости класса обсуждается более детально);
· функции-члены имеют право доступа как к открытым, так и к закрытым членам класса, тогда как обычным функциям доступны лишь открытые. Конечно, функции-члены одного класса, как правило, не имеют доступа к данным-членам другого класса.
Функция-член может быть перегруженной (перегруженные функции рассматриваются в главе 9). Однако она способна перегружать лишь другую функцию-член своего класса. По отношению к функциям, объявленным в других классах или пространствах имен, функция-член находится в отдельной области видимости и, следовательно, не может перегружать их. Например, объявление get(int, int) перегружает лишь get() из того же класса Screen:
class Screen { public: // объявления перегруженных функций-членов get() char get() { return _screen[_cursor]; } char get( int, int ); // ... |
};
(Подробнее мы остановимся на функциях-членах класса в разделе 13.3.)
Функции-кандидаты для вызова функции в области видимости класса
Когда вызов функции видаcalc(t)
встречается в области видимости класса (например, внутри функции-члена), то первая часть множества кандидатов, описанного в предыдущем подразделе (т.е. множество, включающее объявления функций, видимых в точке вызова), может содержать не только функции-члены класса. Для построения такого множества применяется разрешение имени. (Эта тема детально разбиралась в разделах 13.9 – 13.12.)
Рассмотрим пример:
namespace NS { struct myClass { void k( int ); static void k( char* ); void mf(); }; int k( double ); }; void h(char); void NS::myClass::mf() { h('a'); // вызывается глобальная h( char ) k(4); // вызывается myClass::k( int ) |
}
Как отмечалось в разделе 13.11, квалификаторы NS::myClass:: просматриваются в обратном порядке: сначала поиск видимого объявления для имени, использованного в определении функции-члена mf(), ведется в классе myClass, а затем – в пространстве имен NS. Рассмотрим первый вызов:
h( 'a' );
При разрешении имени h() в определении функции-члена mf() сначала просматриваются функции-члены myClass. Поскольку функции-члена с таким именем в области видимости этого класса нет, то далее поиск идет в пространстве имен NS. Функции h()нет и там, поэтому мы переходим в глобальную область видимости. Результат – глобальная функция h(char), единственная функция-кандидат, видимая в точке вызова.
Как только найдено подходящее объявление, поиск прекращается. Следовательно, множество содержит только те функции, объявления которых находятся в областях видимости, где разрешение имени завершилось успешно. Это можно наблюдать на примере построения множества кандидатов для вызова
k( 4 );
Сначала поиск ведется в области видимости класса myClass. При этом найдены две функции-члена k(int) и k(char*). Поскольку множество кандидатов содержит лишь функции, объявленные в той области, где разрешение успешно завершилось, то пространство имен NS не просматривается и функция k(double) в данное множество не включается.
Если обнаруживается, что вызов неоднозначен, поскольку в множестве нет наиболее подходящей функции, то компилятор выдает сообщение об ошибке. Поиск кандидатов, лучше соответствующих фактическим аргументам, в объемлющих областях видимости не производится.
Функции-кандидаты
Функцией-кандидатом называется функция, имеющая то же имя, что и вызванная. Кандидаты отыскиваются двумя способами:·
объявление функции видимо в точке вызова. В следующем примере
void f(); void f( int ); void f( double, double = 3.4 ); void f( char*, char* ); int main() { f( 5.6 ); // для разрешения этого вызова есть четыре кандидата return 0; |
}
все четыре функции f()
удовлетворяют этому условию. Поэтому множество кандидатов содержит четыре элемента;
· если тип фактического аргумента объявлен внутри некоторого пространства имен, то функции-члены этого пространства, имеющие то же имя, что и вызванная функция, добавляются в множество кандидатов:
namespace NS { class C { /* ... */ }; void takeC( C& ); } // тип cobj - это класс C, объявленный в пространстве имен NS NS::C obj; int main() { // в точке вызова не видна ни одна из функций takeC() takeC( cobj); // правильно: вызывается NS::takeC( C& ), // потому что аргумент имеет тип NS::C, следовательно, // принимается во внимание функция takeC(), // объявленная в пространстве имен NS return 0; |
}
Таким образом, совокупность кандидатов является объединением множества функций, видимых в точке вызова, и множества функций, объявленных в том же пространстве имен, к которому принадлежат типы фактических аргументов.
При идентификации множества перегруженных функций, видимых в точке вызова, применимы уже рассмотренные ранее правила.
Функция, объявленная во вложенной области видимости, скрывает, а не перегружает одноименную функцию во внешней области. В такой ситуации кандидатами будут только функции из во вложенной области, т.е. такие, которые не скрыты при вызове. В следующем примере функциями-кандидатами, видимыми в точке вызова, являются format(double) и format(char*):
Функцией-кандидатом называется функция с тем же именем, что и вызванная. Предположим, что имеется такой вызов:
SmallInt si(15); |
add( si, 566 );
Функция-кандидат должна иметь имя add. Какие из объявлений add()
принимаются во внимание? Те, которые видимы в точке вызова.
Например, обе функции add(), объявленные в глобальной области видимости, будут кандидатами для следующего вызова:
const matrix& add( const matrix &, int ); double add( double, double ); int main() { SmallInt si(15); add( si, 566 ); // ... |
}
Рассмотрение функций, чьи объявления видны в точке вызова, производится не только для вызовов с аргументами типа класса. Однако для них поиск объявлений проводится еще в двух областях видимости:
· если фактический аргумент – это объект типа класса, указатель или ссылка на тип класса либо указатель на член класса и этот тип объявлен в пользовательском пространстве имен, то к множеству функций-кандидатов добавляются функции, объявленные в этом же пространстве и имеющие то же имя, что и вызванная:
namespace NS { class SmallInt { /* ... */ }; class String { /* ... */ }; String add( const String &, const String & ); } int main() { // si имеет тип class SmallInt: // класс объявлен в пространстве имен NS NS::SmallInt si(15); add( si, 566 ); // NS::add() - функция-кандидат return 0; |
}
Аргумент si
имеет тип SmallInt, т.е. тип класса, объявленного в пространстве имен NS. Поэтому к множеству функций-кандидатов добавляется add(const String &, const String &), объявленная в этом пространстве имен;
· если фактический аргумент – это объект типа класса, указатель или ссылка на класс либо указатель на член класса и у этого класса есть друзья, имеющие то же имя, что и вызванная функция, то они добавляются к множеству функций-кандидатов:
namespace NS { class SmallInt { friend SmallInt add( SmallInt, int ) { /* ... */ } }; } int main() { NS::SmallInt si(15); add( si, 566 ); // функция-друг add() - кандидат return 0; |
Наследование влияет на первый шаг процедуры разрешения перегрузки функции – формирование множества кандидатов для данного вызова, причем это влияние может быть различным в зависимости от того, рассматривается ли вызов обычной функции вида
func( args );
или функции-члена с помощью операторов доступа “точка” или “стрелка”:
object.memfunc( args ); |
pointer->memfunc( args );
В данном разделе мы изучим оба случая.
Если аргумент обычной функции имеет тип класса, ссылки или указателя на тип класса, и класс определен в пространстве имен, то кандидатами будут все одноименные функции, объявленные в этом пространстве, даже если они невидимы в точке вызова (подробнее об этом говорилось в разделе 15.10). Если аргумент при наследовании имеет тип класса, ссылки или указателя на тип класса, и у этого класса есть базовые, то в множество кандидатов добавляются также функции, объявленные в тех пространствах имен, где определены базовые классы. Например:
namespace NS { class ZooAnimal { /* ... */ }; void display( const ZooAnimal& ); } // базовый класс Bear объявлен в пространстве имен NS class Bear : public NS::ZooAnimal { }; int main() { Bear baloo; display( baloo ); return 0; |
}
Аргумент baloo
имеет тип класса Bear. Кандидатами для вызова display() будут не только функции, объявления которых видимы в точке ее вызова, но также и те, что объявлены в пространствах имен, в которых объявлены класс Bear и его базовый класс ZooAnimal. Поэтому в множество кандидатов добавляется функция display(const ZooAnimal&), объявленная в пространстве имен NS.
Если аргумент имеет тип класса и в определении этого класса объявлены функции-друзья с тем же именем, что и вызванная функция, то эти друзья также будут кандидатами, даже если их объявления не видны в точке вызова (см. раздел 15.10). Если аргумент при наследовании имеет тип класса, у которого есть базовые, то в множество кандидатов добавляются одноименные функции-друзья каждого из них. Предположим, что в предыдущем примере display() объявлена как функция-друг ZooAnimal:
Рассмотрим два вида вызовов функции-члена:
mc.mf( arg ); |
pmc->mf( arg );
где mc – выражение типа myClass, а pmc – выражение типа “указатель на тип myClass”. Множество кандидатов для обоих вызовов составлено из функций, найденных в области видимости класса myClass при поиске объявления mf().
Аналогично для вызова функции вида
myClass::mf( arg );
множество кандидатов также состоит из функций, найденных в области видимости класса myClass при поиске объявления mf(). Например:
class myClass { public: void mf( double ); void mf( char, char = '\n' ); static void mf( int* ); // ... }; int main() { myClass mc; int iobj; mc.mf( iobj ); |
}
Кандидатами для вызова функции в main()
являются все три функции-члена mf(), объявленные в myClass:
void mf( double ); void mf( char, char = '\n' ); |
static void mf( int* );
Если бы в myClass не было объявлено ни одной функции-члена с именем mf(), то множество кандидатов оказалось бы пустым. (На самом деле рассматривались бы также и функции из базовых классов. О том, как они попадают в это множество, мы поговорим в разделе 19.3.) Если для вызова функции не оказывается кандидатов, компилятор выдает сообщение об ошибке.
Функции
Мы рассмотрели, как объявлять переменные (глава 3), как писать выражения (глава 4) и инструкции (глава 5). Здесь мы покажем, как группировать эти компоненты в определения функций, чтобы облегчить их многократное использование внутри программы. Мы увидим, как объявлять и определять функции и как вызывать их, рассмотрим различные виды передаваемых параметров и обсудим особенности использования каждого вида. Мы расскажем также о различных видах значений, которые может вернуть функция. Будут представлены четыре специальных случая применения функций: встроенные (inline), рекурсивные, написанные на других языках и объявленные директивами связывания, а также функция main(). В завершение главы мы разберем более сложное понятие – указатель на функцию.Функция main(): разбор параметров командной строки
При запуске программы мы, как правило, передаем ей информацию в командной строке. Например, можно написатьprog -d -o of lie dataO
Фактические параметры являются аргументами функции main() и могут быть получены из массива C-строк с именем argv; мы покажем, как их использовать.
Во всех предыдущих примерах определение main()
содержало пустой список:
int main() { ... }
Развернутая сигнатура main()
позволяет получить доступ к параметрам, которые были заданы пользователем в командной строке:
int main( int argc, char *argv[] ){...}
argc
содержит их количество, а argv – C-строки, представляющие собой отдельные значения (в командной строке они разделяются пробелами). Скажем, при запуске команды
prog -d -o ofile data0
argc
получает значение 5, а argv
включает следующие строки:
argv[ 0 ] = "prog";
argv[ 1 ] = "-d";
argv[ 2 ] = "-o";
argv[ 3 ] = "ofile";
argv[ 4 ] = "dataO";
В argv[0]
всегда входит имя команды (программы). Элементы с индексами от 1 до argc-1
служат параметрами.
Посмотрим, как можно извлечь и использовать значения, помещенные в argv. Пусть программа из нашего примера вызывается таким образом:
prog [-d] [-h] [-v] [-o output_file] [-l limit_value] file_name |
[ file_name [file_name [ ... ]]]
Параметры в квадратных скобках являются необязательными. Вот, например, запуск программы с их минимальным количеством – одним лишь именем файла:
prog chap1.doc
Но можно запускать и так:
prog -l 1024 -o chap1-2.out chapl.doc chap2.doc prog d chap3.doc |
prog -l 512 -d chap4.doc
При разборе параметров командной строки выполняются следующие основные шаги:
1. По очереди извлечь каждый параметр из argv. Мы используем для этого цикл for с начальным индексом 1
(пропуская, таким образом, имя программы):
for ( int ix = 1; ix < argc; ++ix ) { char *pchar = argv[ ix ]; // ... |
}
2. Определить тип параметра. Если строка начинается с дефиса (-), это одна из опций { h, d, v, l, o}. В противном случае это может быть либо значение, ассоциированное с опцией (максимальный размер для -l, имя выходного файла для -o), либо имя входного файла. Чтобы определить, начинается ли строка с дефиса, используем инструкцию switch:
switch ( pchar[ 0 ] ) { case '-': { // -h, -d, -v, -l, -o } default: { // обработаем максимальный размер для опции -1 // имя выходного файла для -o // имена входных файлов ... } |
Реализуем обработку двух случаев пункта 2.
Если строка начинается с дефиса, мы используем switch по следующему символу для определения конкретной опции. Вот общая схема этой части программы:
case '-': { switch( pchar[ 1 ] ) { case 'd': // обработка опции debug break; case 'v': // обработка опции version break; case 'h': // обработка опции help break; case 'o': // приготовимся обработать выходной файл break; case 'l': // приготовимся обработать макс.размер break; default: // неопознанная опция: // сообщить об ошибке и завершить выполнение } |
Опция -d
задает необходимость отладки. Ее обработка заключается в присваивании переменной с объявлением
bool debug_on = false;
значения true:
case 'd': debug_on = true; |
В нашу программу может входить код следующего вида:
if ( debug_on ) |
Опция -v
выводит номер версии программы и завершает исполнение:
case 'v': cout << program_name << "::" << program_version << endl; |
Опция -h
запрашивает информацию о синтаксисе запуска и завершает исполнение. Вывод сообщения и выход из программы выполняется функцией usage():
case 'h': // break не нужен: usage() вызывает exit() |
Опция -o
сигнализирует о том, что следующая строка содержит имя выходного файла. Аналогично опция -l
говорит, что за ней указан максимальный размер. Как нам обработать эти ситуации?
Если в строке параметра нет дефиса, возможны три варианта: параметр содержит имя выходного файла, максимальный размер или имя входного файла. Чтобы различать эти случаи, присвоим true
переменным, отражающим внутреннее состояние:
// если ofi1e_on==true, // следующий параметр - имя выходного файла bool ofi1e_on = false; // если ofi1e_on==true, // следующий параметр - максимальный размер |
Вот обработка опций -l и -o в нашей инструкции switch:
case 'l': limit_on = true; break; case 'o': ofile_on = true; |
Встретив строку, не начинающуюся с дефиса, мы с помощью переменных состояния можем узнать ее содержание:
// обработаем максимальный размер для опции -1 // имя выходного файла для -o // имена входных файлов ... default: { // ofile_on включена, если -o встречалась if ( ofile_on ) { // обработаем имя выходного файла // выключим ofile_on } else if ( limit_on ) { // если -l встречалась // обработаем максимальный размер // выключим limit_on } else { // обработаем имя входного файла } |
Если аргумент является именем выходного файла, сохраним это имя и выключим ofile_on:
if ( ofile_on ) { ofile_on = false; ofile = pchar; |
Если аргумент задает максимальный размер, мы должны преобразовать строку встроенного типа в представляемое ею число. Сделаем это с помощью стандартной функции atoi(), которая принимает строку в качестве аргумента и возвращает int
(также существует функция atof(), возвращающая double). Для использования atoi()
включим заголовочный файл ctype.h. Нужно проверить, что значение максимального размера неотрицательно и выключить limit_on:
// int limit; else if ( limit_on ) { limit_on = false; limit = atoi( pchar ); if ( limit < 0 ) { cerr << program_name << "::" << program_version << " : error: " << "negative value for limit.\n\n"; usage( -2 ); } |
Если обе переменных состояния равны false, у нас есть имя входного файла. Сохраним его в векторе строк:
else |
file_names.push_back( string( pchar ));
При обработке параметров командной строки важен способ реакции на неверные опции. Мы решили, что задание отрицательной величины в качестве максимального размера будет фатальной ошибкой. Это приемлемо или нет в зависимости от ситуации. Также можно распознать эту ситуацию как ошибочную, выдать предупреждение и использовать ноль или какое-либо другое значение по умолчанию.
Слабость нашей реализации становится понятной, если пользователь небрежно относится к пробелам, разделяющим параметры. Скажем, ни одна из следующих двух строк не будет обработана:
prog - d dataOl |
(Оба случая мы оставим для упражнений в конце раздела.)
Вот полный текст нашей программы. (Мы добавили инструкции печати для трассировки выполнения.)
#include #include #include #include const char *const program_name = "comline"; const char *const program_version = "version 0.01 (08/07/97)"; inline void usage( int exit_value = 0 ) { // печатает отформатированное сообщение о порядке вызова // и завершает программу с кодом exit_value ... cerr << "порядок вызова:\n" << program_name << " " << "[-d] [-h] [-v] \n\t" << "[-o output_file] [-l limit] \n\t" << "file_name\n\t[file_name [file_name [ ... ]]]\n\n" << "где [] указывает на необязательность опции:\n\n\t" << "-h: справка.\n\t\t" << "печать этого сообщения и выход\n\n\t" << "-v: версия.\n\t\t" << "печать информации о версии программы и выход\n\n\t" << "-d: отладка.\n\t\t включает отладочную печать\n\n\t" << "-l limit\n\t\t" << "limit должен быть неотрицательным целым числом\n\n\t" << "-o ofile\n\t\t" << "файл, в который выводится результат\n\t\t" << "по умолчанию результат записывается на стандартный вывод\n\n" << "file_name\n\t\t" << "имя подлежащего обработке файла\n\t\t" << "должно быть задано хотя бы одно имя --\n\t\t" << "но максимальное число не ограничено\n\n" << "примеры:\n\t\t" << "$command chapter7.doc\n\t\t" << "$command -d -l 1024 -o test_7_8 " << "chapter7.doc chapter8.doc\n\n"; exit( exit_value ); } int main( int argc, char* argv[] ) { bool debug_on = false; bool ofile_on = false; bool limit_on = false; int limit = -1; string ofile; vector cout << "демонстрация обработки параметров в командной строке:\n" << "argc: " << argc << endl; for ( int ix = 1; ix < argc; ++ix ) { cout << "argv[ " << ix << " ]: " << argv[ ix ] << endl; char *pchar = argv[ ix ]; switch ( pchar[ 0 ] ) { case '-': { cout << "встретился \'-\'\n"; switch( pchar[ 1 ] ) { case 'd': cout << "встретилась -d: " << "отладочная печать включена\n"; debug_on = true; break; case 'v': cout << "встретилась -v: " << "выводится информация о версии\n"; cout << program_name << " :: " << program_version << endl; return 0; case 'h': cout << "встретилась -h: " << "справка\n"; // break не нужен: usage() завершает программу usage(); case 'o': cout << "встретилась -o: выходной файл\n"; ofile_on = true; break; case 'l': cout << "встретилась -l: " << "ограничение ресурса\n"; limit_on = true; break; default: cerr << program_name << " : ошибка : " << "неопознанная опция: - " << pchar << "\n\n"; // break не нужен: usage() завершает программу usage( -1 ); } break; } default: // либо имя файла cout << "default: параметр без дефиса: " << pchar << endl; if ( ofile_on ) { ofile_on = false; ofile = pchar; } else if ( limit_on ) { limit_on = false; limit = atoi( pchar ); if ( limit < 0 ) { cerr << program_name << " : ошибка : " << "отрицательное значение limit.\n\n"; usage( -2 ); } } else file_names.push_back( string( pchar )); break; } } if ( file_names.empty() ) { cerr << program_name << " : ошибка : " << "не задан ни один входной файл.\n\n"; usage( -3 ); } if ( limit != -1 ) cout << "Заданное пользователем значение limit: " << limit << endl; if ( ! ofile.empty() ) cout << "Заданный пользователем выходной файл: " << ofile << endl; cout << (file_names.size() == 1 ? "Файл, " : "Файлы, ") << "подлежащий(е) обработке:\n"; for ( int inx = 0; inx < file_names.size(); ++inx ) cout << "\t" << file_names[ inx ] << endl; |
a.out -d -l 1024 -o test_7_8 chapter7.doc chapters.doc
Вот трассировка обработки параметров командной строки:
демонстрация обработки параметров в командной строке:
argc: 8
argv[ 1 ]: -d
встретился '-'
встретилась -d: отладочная печать включена
argv[ 2 ]: -l
встретился '-'
встретилась -l: ограничение ресурса
argv[ 3 ]: 1024
default: параметр без дефиса: 1024
argv[ 4 ]: -o
встретился '-'
встретилась -o: выходной файл
argv[ 5 ]: test_7_8
default: параметр без дефиса: test_7_8
argv[ 6 ]: chapter7.doc
default: параметр без дефиса: chapter7.doc
argv[ 7 ]: chapter8.doc
default: параметр без дефиса: chapter8.doc
Заданное пользователем значение limit: 1024
Заданный пользователем выходной файл: test_7_8
Файлы, подлежащий(е) обработке:
chapter7.doc
chapter8.doc
Глобальные объекты и функции
Объявление функции в глобальной области видимости вводит глобальную функцию, а объявление переменной – глобальный объект. Глобальный объект существует на протяжении всего времени выполнения программы. Время жизни глобального объекта начинается с момента запуска программы и заканчивается с ее завершением.Для того чтобы глобальную функцию можно было вызвать или взять ее адрес, она должна иметь определение. Любой глобальный объект, используемый в программе, должен быть определен, причем только один раз. Встроенные функции могут определяться несколько раз, если только все определения совпадают. Такое требование единственности или точного совпадения получило название правила одного определения (ПОО). В этом разделе мы покажем, как следует вводить глобальные объекты и функции в программе, чтобы ПОО соблюдалось.
Готовим сцену
Прежде чем детально описывать множественное и виртуальное наследование, покажем, зачем оно нужно. Наш первый пример взят из области трехмерной компьютерной графики. Но сначала познакомимся с предметной областью.В компьютере сцена представляется графом сцены, который содержит информацию о геометрии (трехмерные модели), один или более источников освещения (иначе сцена будет погружена во тьму), камеру (без нее мы не можем смотреть на сцену) и несколько трансформационных узлов, с помощью которых позиционируются элементы.
Процесс применения источников освещения и камеры к геометрической модели для получения двумерного изображения, отображаемого на дисплее, называется рендерингом. В алгоритме рендеринга учитываются два основных аспекта: природа источника освещения сцены и свойства материалов поверхностей объектов, такие, как цвет, шероховатость и прозрачность. Ясно, что перышки на белоснежных крыльях феи выглядят совершенно не так, как капающие из ее глаз слезы, хотя те и другие освещены одним и тем же серебристым светом.
Добавление объектов к сцене, их перемещение, игра с источниками освещения и геометрией– работа компьютерного художника. Наша задача – предоставить интерактивную поддержку для манипуляций с графом сцены на экране. Предположим, что в текущей версии своего инструмента мы решили воспользоваться каркасом приложений Open Inventor для C++ (см. [WERNECKE94]), но с помощью подтипизации расширили его, создав собственные абстракции нужных нам классов. Например, Open Inventor располагает тремя встроенными источниками освещения, производными от абстрактного базового класса SoLight:
class SoSpotLight : public SoLight { ... } class SoPointLight : public SoLight { ... } |
class SoDirectionalLight : public SoLight { ... }
Префикс So
служит для того, чтобы дать уникальные имена сущностям, которые в области компьютерной графики весьма распространены (данный каркас приложений проектировался еще до появления пространств имен). Точечный источник (point light) – это источник света, излучающий, как солнце, во всех направлениях. Направленный источник (directional light) – источник света, излучающий в одном направлении. Прожектор
(spotlight) – источник, испускающий узконаправленный конический пучок, как обычный театральный прожектор.
По умолчанию Open Inventor осуществляет рендеринг графа сцены на экране с помощью библиотеки OpenGL (см. [NEIDER93]). Для интерактивного отображения этого достаточно, но почти все изображения, сгенерированные для киноиндустрии, сделаны с помощью средства RenderMan (см. [UPSTILL90]). Чтобы добавить поддержку такого алгоритма рендеринга мы, в частности, должны реализовать собственные специальные подтипы источников освещения:
class RiSpotLight : public SoSpotLight { ... } class RiPointLight : public SoPointLight { ... } |
Новые подтипы содержат дополнительную информацию, необходимую для рендеринга с помощью RenderMan. При этом базовые классы Open Inventor по-прежнему позволяют выполнять рендеринг с помощью OpenGL. Неприятности начинаются, когда возникает необходимость расширить поддержку теней.
В RenderMan направленный источник и прожектор поддерживают отбрасывание тени (поэтому мы называем их источниками освещения, дающими тень, – SCLS), а точечный – нет. Общий алгоритм требует, чтобы мы обошли все источники освещения на сцене и составили карту теней для каждого включенного SCLS. Проблема в том, что источники освещения хранятся в графе сцены как полиморфные объекты класса SoLight. Хотя мы можем инкапсулировать общие данные и необходимые операции в класс SCLS, непонятно, как включить его в существующую иерархию классов Open Inventor.
В поддереве с корнем SoLight в иерархии Open Inventor нет такого класса, из которого можно было бы произвести с помощью одиночного наследования класс SCLS так, чтобы в дальнейшем уже от него произвести SdRiSpotLight и SdRiDirectionalLight. Если не пользоваться множественным наследованием, лучшее, что можно сделать, – это сравнить член класса SCLS с каждым возможным типом SCLS-источника и вызвать соответствующую операцию:
SoLight *plight = next_scene_light(); if ( RiDirectionalLight *pdilite = dynamic_cast pdilite->scls.cast_shadow_map(); else if ( RiSpotLight *pslite = dynamic_cast pslite->scls.cast_shadow_map(); |
// и так далее
(Оператор dynamic_cast – это часть механизма идентификации типов во время выполнения (RTTI). Он позволяет опросить тип объекта, адресованного полиморфным указателем или ссылкой. Подробно RTTI будет обсуждаться в главе 19.)
Пользуясь множественным наследованием, мы можем инкапсулировать подтипы SCLS, защитив наш код от изменений при добавлении или удалении источника освещения (см. рис. 18.1).
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |

RPointLight RSpotLight RDirectionalLight
Рис. 18.1. Множественное наследование источников освещения
|
class RiDirectionalLight : public SoDirectionalLight, public SCLS { ... }; class RiSpotLight : public SoSpotLight, public SCLS { ... }; // ... SoLight *plight = next_scene_light(); if ( SCLS *pscls = dynamic_cast |
Это решение несовершенно. Если бы у нас был доступ к исходным текстам Open Inventor, то можно было бы избежать множественного наследования, добавив к SoLight
член-указатель на SCLS и поддержку операции cast_shadow_map():
|
class SoLight : public SoNode { public: void cast_shadow_map() { if ( _scls ) _scls->cast_shadow_map(); } // ... protected: SCLS *_scls; }; // ... SdSoLight *plight = next_scene_light(); |
Самое распространенное приложение, где используется множественное (и виртуальное) наследование, – это потоковая библиотека ввода/вывода в стандартном C++. Два основных видимых пользователю класса этой библиотеки – istream
(для ввода) и ostream
(для вывода). В число их общих атрибутов входят:
· информация о форматировании (представляется ли целое число в десятичной, восьмеричной или шестнадцатеричной системе счисления, число с плавающей точкой – в нотации с фиксированной точкой или в научной нотации и т.д.);
· информация о состоянии (находится ли потоковый объект в нормальном или ошибочном состоянии и т.д.);
· информация о параметрах локализации (отображается ли в начале даты день или месяц и т.д.);
· буфер, где хранятся данные, которые нужно прочитать или записать.
Эти общие атрибуты вынесены в абстрактный базовый класс ios, для которого istream и ostream
являются производными.
Класс iostream – наш второй пример множественного наследования. Он предоставляет поддержку для чтения и записи в один и тот же файл; его предками являются классы istream и ostream. К сожалению, по умолчанию он также унаследует два различных экземпляра базового класса ios, а нам это не нужно.
изображена на рис. 18.2.
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
Рис. 18.2. Иерархия виртуального наследования iostream (упрощенная)
Еще один реальный пример виртуального и множественного наследования дают распределенные объектные вычисления. Подробное рассмотрение этой темы см. в серии статей Дугласа Шмидта (Douglas Schmidt) и Стива Виноски (Steve Vinoski) в [LIPPMAN96b].
В данной главе мы рассмотрим использование и поведение механизмов виртуального и множественного наследования. В другой нашей книге, “Inside the C++ Object Model”, описаны более сложные вопросы производительности и дизайна этого аспекта языка.
Для последующего обсуждения мы выбрали иерархию животных в зоопарке. Наши животные существуют на разных уровнях абстракции. Есть, конечно, особи, имеющие свои имена: Линь-Линь, Маугли или Балу. Каждое животное принадлежит к какому-то виду; скажем, Линь-Линь – это гигантская панда.
Виды в свою очередь входят в семейства. Так, гигантская панда – член семейства медведей, хотя, как мы увидим в разделе 18.5, по этому поводу в зоологии долго велись бурные дискуссии. Каждое семейство – член животного мира, в нашем случае ограниченного территорией зоопарка.
На каждом уровне абстракции имеются данные и операции, необходимые для поддержки все более и более широкого круга пользователей. Например, абстрактный класс ZooAnimal
хранит информацию, общую для всех животных в зоопарке, и предоставляет открытый интерфейс для всех возможных запросов.
Помимо классов, описывающих животных, есть и вспомогательные классы, инкапсулирующие различные абстракции иного рода, например “животные, находящиеся под угрозой вымирания”. Наша реализация класса Panda множественно наследует от Bear
(медведь) и Endangered
(вымирающие).
Идентификация членов иерархии
В разделе 2.4 мы уже упоминали о том, что в объектном проектировании обычно есть один разработчик, который конструирует и реализует класс, и много пользователей, применяющих предоставленный открытый интерфейс. Это разделение ответственности отразилось в концепции открытого и закрытого доступа к членам класса.Когда используется наследование, у класса оказывается множество разработчиков. Во-первых, тот, кто предоставил реализацию базового класса (и, возможно, некоторых производных от него), а во-вторых, те, кто разрабатывал производные классы на различных уровнях иерархии. Этот род деятельности тоже относится к проектированию. Разработчик подтипа часто (хотя и не всегда) должен иметь доступ к реализации базового класса. Чтобы разрешить такой вид доступа, но все же предотвратить неограниченный доступ к деталям реализации класса, вводится дополнительный уровень доступа– protected (защищенный). Данные и функции-члены, помещенные в секцию protected
некоторого класса, остаются недоступными вызывающей программе, но обращение к ним из производных классов разрешено. (Все находящееся в секции private
базового класса доступно только ему, но не производным.)
Критерии помещения того или иного члена в секцию public одинаковы как для объектного, так и для объектно-ориентированного проектирования. Меняется только точка зрения на то, следует ли объявлять член закрытым или защищенным. Член базового класса объявляется закрытым, если мы не хотим, чтобы производные классы имели к нему прямой доступ; и защищенным, если его семантика такова, что для эффективной реализации производного класса может потребоваться прямой доступ к нему. При проектировании класса, который предполагается использовать в качестве базового, надо также принимать во внимание особенности функций, зависящих от типа, – виртуальных функций в иерархии классов.
На следующем шаге проектирования иерархии классов Query следует ответить на такие вопросы:
(a) Какие операции следует предоставить в открытом интерфейсе иерархии классов Query?
(b) Какие из них следует объявить виртуальными?
(c) Какие дополнительные операции могут потребоваться производным классам?
(d) Какие данные-члены следует объявить в нашем абстрактном базовом классе Query?
(e) Какие данные-члены могут потребоваться производным классам?
К сожалению, однозначно ответить на эти вопросы невозможно. Как мы увидим, процесс объектно-ориентированного проектирования по своей природе итеративен, эволюционирующая иерархия классов требует и добавлений, и модификаций. В оставшейся части этого раздела мы будем постепенно уточнять иерархию классов Query.
Идентификация типов во время выполнения
RTTI позволяет программам, которые манипулируют объектами через указатели или ссылки на базовые классы, получить истинный производный тип адресуемого объекта. Для поддержки RTTI в языке C++ есть два оператора:· оператор dynamic_cast
поддерживает преобразования типов во время выполнения, обеспечивая безопасную навигацию по иерархии классов. Он позволяет трансформировать указатель на базовый класс в указатель на производный от него, а также преобразовать l-значение, ссылающееся на базовый класс, в ссылку на производный, но только в том случае, если это завершится успешно;
· оператор typeid
позволяет получить фактический производный тип объекта, адресованного указателем или ссылкой.
Однако для получения информации о типе производного класса операнд любого из операторов dynamic_cast или typeid
должен иметь тип класса, в котором есть хотя бы одна виртуальная функция. Таким образом, операторы RTTI – это события времени выполнения для классов с виртуальными функциями и события времени компиляции для всех остальных типов. В данном разделе мы более подробно познакомимся с их возможностями.
Использование RTTI оказывается необходимым при реализации таких приложений, как отладчики или объектные базы данных, когда тип объектов, которыми манипулирует программа, становится известен только во время выполнения путем исследования RTTI-информации, хранящейся вместе с типами объектов. Однако лучше пользоваться статической системой типов C++, поскольку она безопаснее и эффективнее.
Иерархия классов исключений в стандартной библиотеке C++
В начале этого раздела мы определили иерархию классов исключений, с помощью которой наша программа сообщает об аномальных ситуациях. В стандартной библиотеке C++ есть аналогичная иерархия, предназначенная для извещения о проблемах при выполнении функций из самой стандартной библиотеки. Эти классы исключений вы можете использовать в своих программах непосредственно или создать производные от них классы для описания собственных специфических исключений.Корневой класс исключения в стандартной иерархии называется exception. Он определен в стандартном заголовочном файле
имеет следующий интерфейс:
namespace std { class exception public: exception() throw(); exception( const exception & ) throw(); exception& operator=( const exception & ) throw(); virtual ~exception() throw(); virtual const char* what() const throw(); }; |
}
Как и всякий другой класс из стандартной библиотеки C++, exception помещен в пространство имен std, чтобы не засорять глобальное пространство имен программы.
Первые четыре функции-члена в определении класса – это конструктор по умолчанию, копирующий конструктор, копирующий оператор присваивания и деструктор. Поскольку все они открыты, любая программа может свободно создавать и копировать объекты-исключения, а также присваивать им значения. Деструктор объявлен виртуальным, чтобы сделать возможным дальнейшее наследование классу exception.
Самой интересной в этом списке является виртуальная функция what(), которая возвращает C-строку с текстовым описанием возбужденного исключения. Классы, производные от exception, могут заместить what()
собственной версией, которая лучше характеризует объект-исключение.
Отметим, что все функции в определении класса exception имеют пустую спецификацию throw(), т.е. не возбуждают никаких исключений. Программа может манипулировать объектами-исключениями (к примеру, внутри catch-обработчиков типа exception), не опасаясь, что функции создания, копирования и уничтожения этих объектов возбудят исключения.
Помимо корневого exception, в стандартной библиотеке есть и другие классы, которые допустимо использовать в программе для извещения об ошибках, обычно подразделяемых на две больших категории: логические ошибки и ошибки времени выполнения.
Логические ошибки обусловлены нарушением внутренней логики программы, например логических предусловий или инвариантов класса. Предполагается, что их можно найти и предотвратить еще до начала выполнения программы. В стандартной библиотеке определены следующие такие ошибки:
namespace std { class logic_error : public exception { // логическая ошибка public: explicit logic_error( const string &what_arg ); }; class invalid_argument : public logic_error { // неверный аргумент public: explicit invalid_argument( const string &what_arg ); }; class out_of_range : public logic_error { // вне диапазона public: explicit out_of_range( const string &what_arg ); }; class length_error : public logic_error { // неверная длина public: explicit length_error( const string &what_arg ); }; class domain_error : public logic_error { // вне допустимой области public: explicit domain_error( const string &what_arg ); }; |
Функция может возбудить исключение invalid_argument, если получит аргумент с некорректным значением; в конкретной ситуации, когда значение аргумента выходит за пределы допустимого диапазона, разрешается возбудить исключение out_of_range, а length_error
используется для оповещения о попытке создать объект, длина которого превышает максимально возможную.
Ошибки времени выполнения, напротив, вызваны событием, с самой программой не связанным. Предполагается, что их нельзя обнаружить, пока программа не начала работать. В стандартной библиотеке определены следующие такие ошибки:
namespace std { class runtime_error : public exception { // ошибка времени выполнения public: explicit runtime_error( const string &what_arg ); }; class range_error : public runtime_error { // ошибка диапазона public: explicit range_error( const string &what_arg ); }; class overflow_error : public runtime_error { // переполнение public: explicit overflow_error( const string &what_arg ); }; class underflow_error : public runtime_error { // потеря значимости public: explicit underflow_error( const string &what_arg ); }; |
Упражнение 19.5
Какие исключения могут возбуждать следующие функции:
#include (a) void operate() throw( logic_error ); (b) int mathErr( int ) throw( underflow_error, overflow_error ); |
Упражнение 19.6
Объясните, как механизм обработки исключений в C++ поддерживает технику программирования “захват ресурса – это инициализация; освобождение ресурса – это уничтожение”.
Упражнение 19.7
Исправьте ошибку в списке catch-обработчиков для данного try-блока:
#include int main() { try { // использование функций из стандартной библиотеки } catch( exception ) { } catch( runtime_error &re ) { } catch( overflow_error eobj ) { } |
Упражнение 19.8
Дана программа на C++:
int main() { // использование стандартной библиотеки |
Модифицируйте main()
так, чтобы она перехватывала все исключения, возбуждаемые функциями стандартной библиотеки. Обработчики должны печатать сообщение об ошибке, ассоциированное с исключением, а затем вызывать функцию abort() (она определена в заголовочном файле
}
Функция может возбудить исключение range_error, чтобы сообщить об ошибке во внутренних вычислениях. Исключение overflow_error
говорит об ошибке арифметического переполнения, а underflow_error – о потере значимости.
Класс exception
является базовым и для класса исключения bad_alloc, которое возбуждает оператор new(), когда ему не удается выделить запрошенный объем памяти (см. раздел 8.4), и для класса исключения bad_cast, возбуждаемого в ситуации, когда ссылочный вариант оператора dynamic_cast не может быть выполнен (см. раздел 19.1).
Переопределим оператор operator[] в шаблоне Array из раздела 16.12 так, чтобы он возбуждал исключение типа range_error, если индекс массива Array
выходит за границы:
#include #include template class Array { public: // ... elemType& operator[]( int ix ) const { if ( ix < 0 || ix >= _size ) { string eObj = "ошибка: вне диапазона в Array throw out_of_range( eObj ); } return _ia[ix]; } // ... private: int _size; elemType *_ia; |
Для использования предопределенных классов исключений в программу необходимо включить заголовочный файл
типа string. Эту информацию можно извлечь в обработчике с помощью функции-члена what():
int main() { try { // функция main() такая же, как в разделе 16.2 } catch ( const out_of_range &excep ) { // печатается: // ошибка: вне диапазона в Array cerr << excep.what() << "\n"; return -1; } |
В данной реализации выход индекса за пределы массива в функции try_array() приводит к тому, что оператор взятия индекса operator[]()
класса Array
возбуждает исключение типа out_of_range, которое перехватывается в main().
Имена перегруженных операторов
Перегружать можно только предопределенные операторы языка C++ (см. табл. 15.1).Таблица 15.1. Перегружаемые операторы
| + | - | * | / | % | ^ | & | | | ~ | |||||||||
| ! | , | = | < | > | <= | >= | ++ | -- | |||||||||
| << | >> | == | != | && | || | += | -= | /= | |||||||||
| %= | ^= | &= | |= | *= | <<= | >>= | [] | () | |||||||||
| -> | ->* | new | new[] | delete | delete[] |
Проектировщик класса не вправе объявить перегруженным оператор с другим именем. Так, при попытке объявить оператор ** для возведения в степень компилятор выдаст сообщение об ошибке.
Следующие четыре оператора языка C++ не могут быть перегружены:
| // неперегружаемые операторы |
:: .* . ?:
Предопределенное назначение оператора нельзя изменить для встроенных типов. Например, не разрешается переопределить встроенный оператор сложения целых чисел так, чтобы он проверял результат на переполнение.
| // ошибка: нельзя переопределить встроенный оператор сложения int |
int operator+( int, int );
Нельзя также определять дополнительные операторы для встроенных типов данных, например добавить к множеству встроенных операций operator+ для сложения двух массивов.
Перегруженный оператор определяется исключительно для операндов типа класса или перечисления и может быть объявлен только как член класса или пространства имен, принимая хотя бы один параметр типа класса или перечисления (переданный по значению или по ссылке).
Предопределенные приоритеты операторов (см. раздел 4.13) изменить нельзя. Независимо от типа класса и реализации оператора в инструкции
x == y + z;
всегда сначала выполняется operator+, а затем operator==; однако помощью скобок порядок можно изменить.
Предопределенная арность операторов также должна быть сохранена. К примеру, унарный логический оператор НЕ нельзя определить как бинарный оператор для двух объектов класса String. Следующая реализация некорректна и приведет к ошибке компиляции:
| // некорректно: ! - это унарный оператор bool operator!( const String &s1, const String &s2 ) { return ( strcmp( s1.c_str(), s2.c_str() ) != 0 ); |
}
Для встроенных типов четыре предопределенных оператора ("+", "-", "*" и "&") используются либо как унарные, либо как бинарные. В любом из этих качеств они могут быть перегружены.
Для всех перегруженных операторов, за исключением operator(), недопустимы аргументы по умолчанию.
Имя переменной
Имя переменной, или идентификатор, может состоять из латинских букв, цифр и символа подчеркивания. Прописные и строчные буквы в именах различаются. Язык С++ не ограничивает длину идентификатора, однако пользоваться слишком длинными именами типа gosh_this_is_an_impossibly_name_to_typeнеудобно.
Некоторые слова являются ключевыми в С++ и не могут быть использованы в качестве идентификаторов; в таблице 3.1 приведен их полный список.
Таблица 3.1. Ключевые слова C++
| asm | auto | bool | break | case | |||||
| catch | char | class | const | const_cast | |||||
| continue | default | delete | do | double | |||||
| dynamic_cast | else | enum | explicit | export | |||||
| extern | false | float | for | friend | |||||
| goto | if | inline | int | long | |||||
| mutable | namespace | new | operator | private | |||||
| protected | public | register | reinterpret_cast | return | |||||
| short | signed | sizeof | static | static_cast | |||||
| struct | switch | template | this | throw | |||||
| true | try | typedef | typeid | typename | |||||
| union | unsigned | using | virtual | void | |||||
| volatile | wchar_t | while |
Чтобы текст вашей программы был более понятным, мы рекомендуем придерживаться общепринятых соглашений об именах объектов:
· имя переменной обычно пишется строчными буквами, например index
(для сравнения: Index – это имя типа, а INDEX – константа, определенная с помощью директивы препроцессора #define);
· идентификатор должен нести какой-либо смысл, поясняя назначение объекта в программе, например: birth_date или salary;
если такое имя состоит из нескольких слов, как, например, birth_date, то принято либо разделять слова символом подчеркивания (birth_date), либо писать каждое следующее слово с большой буквы (birthDate). Замечено, что программисты, привыкшие к ОбъектноОриентированномуПодходу предпочитают выделять слова заглавными буквами, в то время как те_кто_много_писал_на_С используют символ подчеркивания. Какой из двух способов лучше – вопрос вкуса.
Инициализация члена, являющегося объектом класса
Что произойдет, если в объявлении _nameзаменить C-строку на тип класса string? Как это повлияет на почленную инициализацию по умолчанию? Как надо будет изменить явный копирующий конструктор? Мы ответим на эти вопросы в данном подразделе.
При почленной инициализации по умолчанию исследуется каждый член. Если он принадлежит к встроенному или составному типу, то такая инициализация применяется непосредственно. Например, в первоначальном определении класса Account
член _name
инициализируется непосредственно, так как это указатель:
newAcct._name = oldAcct._name;
Члены, являющиеся объектами классов, обрабатываются по-другому. В инструкции
Account newAcct( oldAcct );
оба объекта распознаются как экземпляры Account. Если у этого класса есть явный копирующий конструктор, то он и применяется для задания начального значения, в противном случае выполняется почленная инициализация по умолчанию.
Таким образом, если обнаруживается член-объект класса, то описанный выше процесс применяется рекурсивно. У класса есть явный копирующий конструктор? Если да, вызвать его для задания начального значения члена-объекта класса. Иначе применить к этому члену почленную инициализацию по умолчанию. Если все члены этого класса принадлежат к встроенным или составным типам, то каждый инициализируется непосредственно и процесс на этом завершается. Если же некоторые члены сами являются объектами классов, то алгоритм применяется к ним рекурсивно, пока не останется ничего, кроме встроенных и составных типов.
В нашем примере у класса string
есть явный копирующий конструктор, поэтому _name инициализируется с помощью его вызова. Копирующий конструктор по умолчанию для класса Account
выглядит следующим образом (хотя явно он не определен):
inline Account:: Account( const Account &rhs ) { _acct_nmbr = rhs._acct_nmbr; _balance = rhs._balance; // Псевдокод на C++ // иллюстрирует вызов копирующего конструктора // для члена, являющегося объектом класса _name.string::string( rhs._name ); |
}
Теперь почленная инициализация по умолчанию для класса Account корректно обрабатывает выделение и освобождение памяти для _name, но все еще неверно копирует номер счета, поэтому приходится кодировать явный копирующий конструктор. Однако приведенный ниже фрагмент не совсем правилен. Можете ли вы сказать, почему?
// не совсем правильно... inline Account:: Account( const Account &rhs ) { _name = rhs._name; _balance = rhs._balance; _acct_nmbr = get_unique_acct_nmbr(); |
Эта реализация ошибочна, поскольку в ней не различаются инициализация и присваивание. В результате вместо вызова копирующего конструктора string мы вызываем конструктор string по умолчанию на фазе неявной инициализации и копирующий оператор присваивания string – в теле конструктора. Исправить это несложно:
inline Account:: Account( const Account &rhs ) : _name( rhs._name ) { _balance = rhs._balance; _acct_nmbr = get_unique_acct_nmbr(); |
Самое главное – понять, что такое исправление необходимо. (Обе реализации приводят к тому, что в _name
копируется значение из rhs._name, но в первой одна и та же работа выполняется дважды.) Общее эвристическое правило состоит в том, чтобы по возможности инициализировать все члены-объекты классов в списке инициализации членов.
Упражнение 14.13
Для какого определения класса скорее всего понадобится копирующий конструктор?
1. Представление Point3w, содержащее четыре числа с плавающей точкой.
2. Класс matrix, в котором память для хранения матрицы выделяется динамически в конструкторе и освобождается в деструкторе.
3. Класс payroll
(платежная ведомость), где каждому объекту приписывается уникальный идентификатор.
4. Класс word
(слово), содержащий объект класса string и вектор, в котором хранятся пары (номер строки, смещение в строке).
Упражнение 14.14
Реализуйте для каждого из данных классов копирующий конструктор, конструктор по умолчанию и деструктор.
(a) class BinStrTreeNode { public: // ... private: string _value; int _count; BinStrTreeNode *_leftchild; BinStrTreeNode *_rightchild; |
(b) class BinStrTree { public: // ... private: BinStrTreeNode *_root; |
(c) class iMatrix { public: // ... private: int _rows; int _cols; int *_matrix; |
(d) class theBigMix { public: // ... private: BinStrTree _bst; iMatrix _im; string _name; vectorMfloat> *_pvec; |
Упражнение 14.15
Нужен ли копирующий конструктор для того класса, который вы выбрали в упражнении 14.3 из раздела 14.2? Если нет, объясните почему. Если да, реализуйте его.
Упражнение 14.16
Идентифицируйте в следующем фрагменте программы все места, где происходит почленная инициализация:
Point global; Point foo_bar( Point arg ) { Point local = arg; Point *heap = new Point( global ); *heap = local; Point pa[ 4 ] = { local, *heap }; return *heap; |
Инициализация и присваивание
Вспомним, что имя массива без указания индекса элемента интерпретируется как адрес первого элемента. Аналогично имя функции без следующих за ним скобок интерпретируется как указатель на функцию. Например, при вычислении выраженияlexicoCompare;
получается указатель типа
int (*)( const string &, const string & );
Применение оператора взятия адреса к имени функции также дает указатель того же типа, например lexicoCompare и &lexicoCompare. Указатель на функцию инициализируется следующим образом:
int (*pfi)( const string &, const string & ) = lexicoCompare; |
int (*pfi2)( const string &, const string & ) = &lexicoCompare;
Ему можно присвоить значение:
pfi = lexicoCompare; |
pfi2 = pfi;
Инициализация и присваивание корректны только тогда, когда список параметров и тип значения, которое возвращает функция, адресованная указателем в левой части операции присваивания, в точности соответствуют списку параметров и типу значения, возвращаемого функцией или указателем в правой части. В противном случае выдается сообщение об ошибке компиляции. Никаких неявных преобразований типов для указателей на функции не производится. Например:
int calc( int, int ); int (*pfi2s)( const string &, const string & ) = 0; int (*pfi2i)( int, int ) = 0; int main() { pfi2i = calc; // правильно pri2s = calc; // ошибка: несовпадение типов pfi2s = pfi2i; // ошибка: несовпадение типов return 0; |
}
Такой указатель можно инициализировать нулем или присвоить ему нулевое значение, в этом случае он не адресует функцию.
Инициализация класса
Рассмотрим следующее определение класса:class Data { public: int ival; char *ptr; |
};
Чтобы безопасно пользоваться объектом класса, необходимо правильно инициализировать его члены. Однако смысл этого действия для разных классов различен. Например, может ли ival
содержать отрицательное значение или нуль? Каковы правильные начальные значения обоих членов класса? Мы не ответим на эти вопросы, не понимая абстракции, представляемой классом. Если с его помощью описываются служащие компании, то ptr, вероятно, указывает на фамилию служащего, а ival – его уникальный номер. Тогда отрицательное или нулевое значения ошибочны. Если же класс представляет текущую температуру в городе, то допустимы любые значения ival. Возможно также, что класс Data представляет строку со счетчиком ссылок: в таком случае ival
содержит текущее число ссылок на строку по адресу ptr. При такой абстракции ival
инициализируется значением 1; как только значение становится равным 0, объект класса уничтожается.
Мнемонические имена класса и обоих его членов сделали бы, конечно, его назначение более понятным для читателя программы, но не дали бы никакой дополнительной информации компилятору. Чтобы компилятор понимал наши намерения, мы должны предоставить одну или несколько перегруженных функций инициализации – конструкторов. Подходящий конструктор выбирается в зависимости от множества начальных значений, указанных при определении объекта. Например, любая из приведенных ниже инструкций представляет корректную инициализацию объекта класса Data:
Data dat01( "Venus and the Graces", 107925 ); Data dat02( "about" ); Data dat03( 107925 ); |
Data dat04;
Бывают ситуации (как в случае с dat04), когда нам нужен объект класса, но его начальные значения мы еще не знаем. Возможно, они станут известны позже. Однако начальное значение задать необходимо, хотя бы такое, которое показывает, что разумное начальное значение еще не присвоено. Другими словами, инициализация объекта иногда сводится к тому, чтобы показать, что он еще не
инициализирован. Большинство классов предоставляют специальный конструктор по умолчанию, для которого не требуется задавать начальных значений. Как правило, он инициализирует объект таким образом, чтобы позже можно было понять, что реальной инициализации еще не проводилось.
Обязан ли наш класс Data
иметь конструктор? Нет, поскольку все его члены открыты. Унаследованный из языка C механизм поддерживает явную инициализацию, аналогичную используемой при инициализации массивов:
int main() { // local1.ival = 0; local1.ptr = 0 Data local1 = { 0, 0 }; // local2.ival = 1024; // local3.ptr = "Anna Livia Plurabelle" Data.local2 - { 1024, "Anna Livia Plurabelle" }; // ... |
Значения присваиваются позиционно, на основе порядка, в котором объявляются данные-члены. Следующий пример приводит к ошибке компиляции, так как ival
объявлен перед ptr:
// ошибка: ival = "Anna Livia Plurabelle"; // ptr = 1024 |
Явная инициализация имеет два основных недостатка. Во-первых, она может быть применена лишь для объектов классов, все члены которых открыты (т.е. эта инициализация не поддерживает инкапсуляции данных и абстрактных типов – их не было в языке C, откуда она заимствована). А во-вторых, такая форма требует вмешательства программиста, что увеличивает вероятность появления ошибок (забыл включить список инициализации или перепутал порядок следования инициализаторов в нем).
Так нужно ли применять явную инициализацию вместо конструкторов? Да. Для некоторых приложений более эффективно использовать список для инициализации больших структур постоянными значениями. К примеру, мы можем таким образом построить палитру цветов или включить в текст программы фиксированные координаты вершин и значения в узлах сложной геометрической модели. В подобных случаях инициализация выполняется во время загрузки, что сокращает затраты времени на запуск конструктора, даже если он определен как встроенный. Это особенно удобно при работе с глобальными объектами1[O.A.4] .
Однако в общем случае предпочтительным методом инициализации является конструктор, который гарантированно будет вызван компилятором для каждого объекта до его первого использования. В следующем разделе мы познакомимся с конструкторами детально.
Инициализация массива, распределенного из хипа A
По умолчанию инициализация массива объектов, распределенного из хипа, проходит в два этапа: выделение памяти для массива, к каждому элементу которого применяется конструктор по умолчанию, если он определен, и последующее присваивание значения каждому элементу.Чтобы свести инициализацию к одному шагу, программист должен вмешаться и поддержать следующую семантику: задать начальные значения для всех или некоторых элементов массива и гарантировать применение конструктора по умолчанию для тех элементов, начальные значения которых не заданы. Ниже приведено одно из возможных программных решений, где используется оператор размещения new:
#include #include #include #include #include "Accounts.h" typedef pair /* init_heap_array() * объявлена как статическая функция-член * обеспечивает выделение памяти из хипа и инициализацию * массива объектов * init_values: пары начальных значений элементов массива * elem_count: число элементов в массиве * если 0, то размером массива считается размер вектора * init_values */ Account* Account:: init_heap_array( vector vector { vector vec_size = init_value.size(); if ( vec_size == 0 && elem_count == 0 ) return 0; // размер массива равен либо elem_count, // либо, если elem_count == 0, размеру вектора ... size_t elems = elem_count ? elem_count : vec_size(); // получить блок памяти для размещения массива char *p = new char[sizeof(Account)*elems]; // по отдельности инициализировать каждый элемент массива int offset = sizeof( Account ); for ( int ix = 0; ix < elems; ++ix ) { // смещение ix-ого элемента // если пара начальных значений задана, // передать ее конструктору; // в противном случае вызвать конструктор по умолчанию if ( ix < vec_size ) new( p+offset*ix ) Account( init_values[ix].first, init_values[ix].second ); else new( p+offset*ix ) Account; } // отлично: элементы распределены и инициализированы; // вернуть указатель на первый элемент return (Account*)p;
| |
}
Необходимо заранее выделить блок памяти, достаточный для хранения запрошенного массива, как массив байт, чтобы избежать применения к каждому элементу конструктора по умолчанию. Это делается в такой инструкции:
char *p = new char[sizeof(Account)*elems];
Далее программа в цикле обходит этот блок, присваивая на каждой итерации переменной p
адрес следующего элемента и вызывая либо конструктор с двумя параметрами, если задана пара начальных значений, либо конструктор по умолчанию:
for ( int ix = 0; ix < elems; ++ix ) { if ( ix < vec_size ) new( p+offset*ix ) Account( init_values[ix].first, init_values[ix].second ); else new( p+offset*ix ) Account; |
В разделе 14.3 говорилось, что оператор размещения new
позволяет применить конструктор класса к уже выделенной области памяти. В данном случае мы используем new для поочередного применения конструктора класса Account к каждому из выделенных элементов массива. Поскольку при создании инициализированного массива мы подменили стандартный механизм выделения памяти, то должны сами позаботиться о ее освобождении. Оператор delete
работать не будет:
delete [] ps;
Почему? Потому что ps (мы предполагаем, что эта переменная была инициализирована вызовом init_heap_array()) указывает на блок памяти, полученный не с помощью стандартного оператора new, поэтому число элементов в массиве компилятору неизвестно. Так что всю работу придется сделать самим:
void Account:: dealloc_heap_array( Account *ps, size_t elems ) { for ( int ix = 0; ix < elems; ++ix ) ps[ix].Account::~Account(); delete [] reinterpret_cast |
Если в функции инициализации мы пользовались арифметическими операциями над указателями для доступа к элементам:
new( p+offset*ix ) Account;
то здесь мы обращаемся к ним, задавая индекс в массиве ps:
ps[ix].Account::~Account();
Хотя и ps, и p адресуют одну и ту же область памяти, ps объявлен как указатель на объект класса Account, а p – как указатель на char. Индексирование p
дало бы ix-й байт, а не ix-й объект класса Account. Поскольку с p
ассоциирован не тот тип, что нужно, арифметические операции над указателями приходится программировать самостоятельно.
Мы объявляем обе функции статическими членами класса:
typedef pair
class Account { public: // ... static Account* init_heap_array( vector vector static void dealloc_heap_array( Account*, size_t ); // ... |
Инициализация, присваивание и уничтожение класса
В этой главе мы детально изучим автоматическую инициализацию, присваивание и уничтожение объектов классов в программе. Для поддержки инициализации служит конструктор– определенная проектировщиком функция (возможно, перегруженная), которая автоматически применяется к каждому объекту класса перед его первым использованием. Парная по отношению к конструктору функция, деструктор, автоматически применяется к каждому объекту класса по окончании его использования и предназначена для освобождения ресурсов, захваченных либо в конструкторе класса, либо на протяжении его жизни.По умолчанию как инициализация, так и присваивание одного объекта класса другому выполняются почленно, т.е. путем последовательного копирования всех членов. Хотя этого обычно достаточно, при некоторых обстоятельствах такая семантика оказывается неадекватной. Тогда проектировщик класса должен предоставить специальный копирующий конструктор и копирующий оператор присваивания. Самое сложное в поддержке этих функций-членов – понять, что они должны быть написаны.
Инструкции объявления
В С++ определение объекта, напримерint ival;
рассматривается как инструкция объявления (хотя в данном случае более правильно было бы сказать определения). Ее можно использовать в любом месте программы, где разрешено употреблять инструкции. В следующем примере объявления помечены комментарием //#n, где n– порядковый номер.
#include #include #include int main() { string fileName; // #1 cout << "Введите имя файла: "; cin >> fileName; if ( fileName.empty() ) { // странный случай cerr << "Пустое имя файла. Завершение работы.\n"; return -1; } ifstream inFile( fileName.c_str() ); // #2 if ( ! inFile ) { cerr << "Невозможно открыть файл.\n"; return -2; } string inBuf; // #3 vector< string > text; // #4 while ( inFile >> inBuf ) { for ( int ix = 0; ix < inBuf .size(); ++ix ) // #5 // можно обойтись без ch, // но мы использовали его для иллюстрации if (( char ch = inBuf[ix] )=='.'){ // #6 ch = '_'; inBuf[ix] = ch; } text.push_back( inBuf ); } if ( text.empty() ) return 0; // одна инструкция объявления, // определяющая сразу два объекта vector iend = text.end(); while ( iter != -iend ) { cout << *iter << '\n'; ++iter; } return 0;
| |
}
Программа содержит семь инструкций объявления и восемь определений объектов. Объявления действуют локально; переменная объявляется непосредственно перед первым использованием объекта.
В 70-е годы философия программирования уделяла особое внимание тому, чтобы определения всех объектов находились в начале программы или блока, перед исполняемыми инструкциями. (В С, например, определение переменной не является инструкцией и обязано располагаться в начале блока.) В некотором смысле это была реакция на идиому использования переменных без предварительного объявления, чреватую ошибками. Такую идиому поддерживал, например, FORTRAN.
Поскольку в С++ объявление является обычной инструкцией, ему разрешено появляться в любом месте программы, где допустимо употребление инструкции, что дает возможность использовать локальные объявления.
Необходимо ли это? Для встроенных типов данных применение локальных объявлений является скорее вопросом вкуса. Язык их поощряет , разрешая объявлять переменные внутри условных частей инструкций if, if-else, switch, while, for. Те программисты, которые любят этот стиль, верят, что таким образом делают свои программы более понятными.
Локальные объявления становятся необходимостью, когда мы используем объекты классов, имеющие конструкторы и деструкторы. Если мы помещаем все объявления в начало блока или функции, происходят две неприятные вещи:
· конструкторы всех объектов вызываются перед исполнением первой инструкции блока. Применение локальных объявлений позволяет “размазать” расходы на инициализацию по всему блоку;
· что более важно, блок или функция могут завершиться до того, как будут действительно использованы все объявленные в начале объекты. Скажем, наша программа из предыдущего примера имеет два аварийных выхода: при вводе пользователем пустого имени файла и при
невозможности открыть файл с заданным именем. При этом последующие инструкции функции уже не выполняются. Если бы объекты inBuf и next были объявлены в начале блока, конструкторы и деструкторы этих объектов в случае ненормального завершения функции вызывались бы совершенно напрасно.
Инструкция объявления может состоять из одного или более определений. Например, в нашей программе мы определяем два итератора вектора в одной инструкции:
// одна инструкция объявления, // определяющая сразу два объекта vector |
Эквивалентная пара, определяющая по одному объекту, выглядит так:
vector |
Хотя определение одного или нескольких объектов в одном предложении является скорее вопросом вкуса, в некоторых случаях – например, при одновременном определении объектов, указателей и ссылок – это может спровоцировать появление ошибок. Скажем, в следующей инструкции не совсем ясно, действительно ли программист хотел определить указатель и объект или просто забыл поставить звездочку перед вторым идентификатором (используемые имена переменных наводят на второе предположение):
// то ли хотел определить программист? |
Эквивалентная пара инструкций не позволит допустить такую ошибку:
string *ptr1; |
В наших примерах мы обычно группируем определения объектов в инструкции по сходству употребления. Например, в следующей паре
int aCnt=0, eCnt=0, iCnt=0, oCnt=0, uCnt=0; |
первая инструкция объявляет пять очень похожих по назначению объектов – счетчиков пяти гласных латинского алфавита. Счетчики для подсчета символов и слов определяются во второй инструкции. Хотя такой подход нам кажется естественным и удобным, нет никаких причин считать его хоть чем-то лучше других.
Упражнение 5.1
Представьте себе, что вы являетесь руководителем программного проекта и хотите, чтобы применение инструкций объявления было унифицировано. Сформулируйте правила использования объявлений объектов для вашего проекта.
Упражнение 5.2
Представьте себе, что вы только что присоединились к проекту из предыдущего упражнения. Вы совершенно не согласны не только с конкретными правилами использования инструкций объявления, но и вообще с навязыванием каких-либо правил для этого. Объясните свою позицию.
Инструкции
Мельчайшей независимой частью С++ программы является инструкция. Она соответствует предложению естественного языка, но завершается точкой с запятой (;), а не точкой. Выражение С++ (например, ival + 5) становится простой инструкцией, если после него поставить точку с запятой. Составная инструкция– это последовательность простых, заключенная в фигурные скобки. По умолчанию инструкции выполняются в порядке записи. Как правило, последовательного выполнения недостаточно для решения реальных задач. Специальные управляющие конструкции позволяют менять порядок действий в зависимости от некоторых условий и повторять составную инструкцию определенное количество раз. Инструкции if, if-else и switchобеспечивают условное выполнение. Повторение обеспечивается инструкциями цикла while, do-while и for.
Инструкция break
Инструкция breakостанавливает циклы for, while, do while и блока switch. Выполнение программы продолжается с инструкции, следующей за закрывающей фигурной скобкой цикла или блока. Например, данная функция ищет в массиве целых чисел определенное значение. Если это значение найдено, функция сообщает его индекс, в противном случае она возвращает -1. Вот как выглядит реализация функции:
// возвращается индекс элемента или -1 int search( int *ia, int size, int value ) { // проверка что ia != 0 и size > 0 ... int loc = -1; for ( int ix = 0; ix < size; ++ix ) { if ( value == ia[ ix ] ) { // нашли! // запомним индекс и выйдем из цикла 1oc = ix; break; } } // конец цикла // сюда попадаем по break ... return 1oc; |
}
В этом примере break
прекращает выполнение цикла for и передает управление инструкции, следующей за этим циклом, – в нашем случае return. Заметим, что break
выводит из блока, относящегося к инструкции for, а не if, хотя является частью составной инструкции, соответствующей if. Использование break
внутри блока if, не входящего в цикл или в switch, является синтаксической ошибкой:
// ошибка: неверное использование break if ( ptr ) { if ( *ptr == "quit" ) break; // ... |
}
Если эта инструкция используется внутри вложенных циклов или инструкций switch, она завершает выполнение того внутреннего блока, в котором находится. Цикл или switch, включающий тот цикл или switch, из которого мы вышли с помощью break, продолжает выполняться. Например:
white ( cin >> inBuf ) { switch( inBuf[ 0 ] ) { case '-': for ( int ix = 1; ix < inBuf.size(); ++ix ) { if ( inBuf[ ix ] == ' ' ) break; // #1 // ... // ... } break; // #2 case '+': // ... } |
}
Инструкция break, помеченная // #1, завершает выполнение цикла for внутри ветви case '-'
блока switch, но не сам switch. Аналогично break // #2
завершает выполнение блока switch, но не цикла while, в который тот входит.
Инструкция цикла for
Как мы видели, выполнение программы часто состоит в повторении последовательности инструкций – до тех пор, пока некоторое условие остается истинным. Например, мы читаем и обрабатываем записи файла, пока не дойдем до его конца, перебираем элементы массива, пока индекс не станет равным размерности массива минус 1, и т.д. В С++ предусмотрено три инструкции для организации циклов, в частности for и while, которые начинаются проверкой условия. Такая проверка означает, что цикл может закончиться без выполнения связанной с ним простой или составной инструкции. Третий тип цикла, do while, гарантирует, что тело будет выполнено как минимум один раз: условие цикла проверяется по его завершении. (В этом разделе мы детально рассмотрим цикл for; в разделе 5.6 разберем while, а в разделе 5.7 – do while.)Цикл for
обычно используется для обработки структур данных, имеющих фиксированную длину, таких, как массив или вектор:
#include int main() { int ia[ 10 ]; for ( int ix = 0; ix < 10; ++-ix ) ia[ ix ] = ix; vector vector for ( ; iter != ivec.end(); ++iter ) *iter *= 2; return 0;
| |
}
Синтаксис цикла for
следующий:
for (инструкция-инициализации; условие; выражение ) |
инструкция
инструкция-инициализации
может быть либо выражением, либо инструкцией объявления. Обычно она используется для инициализации переменной значением, которое увеличивается в ходе выполнения цикла. Если такая инициализация не нужна или выполняется где-то в другом месте, эту инструкцию можно заменить пустой (см. второй из приведенных ниже примеров). Вот примеры правильного использования инструкции-инициализации:
// index и iter определены в другом месте for ( index =0; ... for ( ; /* пустая инструкция */ ... for ( iter = ivec.begin(); ... for ( int 1o = 0,hi = max; ... |
for ( char *ptr = getStr(); ...
условие служит для управления циклом. Пока условие при вычислении дает true, инструкция
продолжает выполняться. Выполняемая в цикле инструкция
может быть как простой, так и составной. Если же самое первое вычисление условия дает false, инструкция не выполняется ни разу. Правильные условия
можно записать так:
(... index < arraySize; ... ) (... iter != ivec.end(); ... ) (... *stl++ = *st2++; ... ) |
Выражение вычисляется после выполнения инструкции на каждой итерации цикла. Обычно его используют для модификации переменной, инициализированной в инструкции-инициализации. Если самое первое вычисление условия дает false, выражение не выполняется ни разу. Правильные выражения выглядят таким образом:
( ... ...; ++-index ) ( ... ...; ptr = ptr->next ) ( ... ...; ++i, --j, ++cnt ) |
Для приведенного ниже цикла for
const int sz = 24; int ia[ sz ]; vector for ( int ix = 0; ix < sz; ++ix ) { ivec[ ix ] = ix; ia[ ix ]= ix; |
порядок вычислений будет следующим:
1. инструкция-инициализации
выполняется один раз перед началом цикла. В данном примере объявляется переменная ix, которая инициализируется значением 0.
2. Вычисляется условие. Если оно равно true, выполняется составная инструкция тела цикла. В нашем примере, пока ix меньше sz, значение ix
присваивается элементам ivec[ix] и ia[ix]. Когда значением условия
станет false, выполнение цикла прекратится. Если самое первое вычисление условия
даст false, составная инструкция
выполняться не будет.
3. Вычисляется выражение. Как правило, его используют для модификации переменной, фигурирующей в инструкции-инициализации и проверяемой в условии. В нашем примере ix увеличивается на 1.
Эти три шага представляют собой полную итерацию цикла for. Теперь шаги 2 и 3 будут повторяться до тех пор, пока условие не станет равным false, т.е. ix
окажется равным или большим sz.
В инструкции-инициализации можно определить несколько объектов, однако все они должны быть одного типа, так как инструкция объявления допускается только одна:
for ( int ival = 0, *pi = &ia, &ri = val; ival < size; ++iva1, ++pi, ++ri ) |
Объявление объекта в условии гораздо труднее правильно использовать: такое объявление должно хотя бы раз дать значение false, иначе выполнение цикла никогда не прекратится. Вот пример, хотя и несколько надуманный:
#include int main() { for ( int ix = 0; bool done = ix == 10; ++ix ) cout << "ix: " << ix << endl; |
Видимость всех объектов, определенных внутри круглых скобок инструкции for, ограничена телом цикла. Например, проверка iter после цикла вызовет ошибку компиляции[8]:[O.A.2]
int main() { string word; vector< string > text; // ... for ( vector< string >::iterator iter = text.begin(), iter_end = text.end(); iter != text.end(); ++iter ) { if ( *iter == word ) break; // ... } // ошибка: iter и iter_end невидимы if ( iter != iter_end ) |
Упражнение 5.8
Допущены ли ошибки в нижеследующих циклах for? Если да, то какие?
(a) for ( int *ptr = &ia, ix = 0; ix < size && ptr != ia+size; ++ix, ++ptr ) // ... |
(b) for ( ; ; ) { if ( some_condition ) break; // ... |
(c) for ( int ix = 0; ix < sz; ++ix ) // ... if ( ix != sz ) // ... (d) int ix; for ( ix < sz; ++ix ) // ... (e) for ( int ix = 0; ix < sz; ++ix, ++ sz ) |
Упражнение 5.9
Представьте, что вам поручено придумать общий стиль использования цикла for в вашем проекте. Объясните и проиллюстрируйте примерами правила использования каждой из трех частей цикла.
Упражнение 5.10
Дано объявление функции:
bool is_equa1( const vector |
Напишите тело функции, определяющей равенство двух векторов. Для векторов разной длины сравнивайте только то количество элементов, которое соответствует меньшему из двух. Например, векторы (0,1,1,2) и (0,1,1,2,3,5,8) считаются равными. Длину векторов можно узнать с помощью функций v1.size() и v2.size().
Инструкция continue
Инструкция continueзавершает текущую итерацию цикла и передает управление на вычисление условия, после чего цикл может продолжиться. В отличие от инструкции break, завершающей выполнение всего цикла, инструкция continue
завершает выполнение только текущей итерации. Например, следующий фрагмент программы читает из входного потока по одному слову. Если слово начинается с символа подчеркивания, оно обрабатывается, в противном случае программа переходит к новому слову.
while ( cin >> inBuf ) { if ( inBuf[0] '= '_' ) continue; // завершение итерации // обработка слова ... |
}
Инструкция continue
может быть использована только внутри цикла.
Инструкция do while
Представим, что нам надо написать программу, переводящую мили в километры. Структура программы выглядит так:int val; bool more = true; // фиктивное значение, нужное для // начала цикла while ( more ) { val = getValue(); val = convertValue(val); printValue(val); more = doMore(); |
}
Проблема заключается в том, что условие вычисляется в теле цикла. for и while требуют, чтобы значение условия равнялось true до первого вхождения в цикл, иначе тело не выполнится ни разу. Это означает, что мы должны обеспечить такое условие до начала работы цикла. Альтернативой может служить использование do while, гарантирующего выполнение тела цикла хотя бы один раз. Синтаксис цикла do while
таков:
do инструкция |
while ( условие );
инструкция
выполняется до первой проверки условия. Если вычисление условия
дает false, цикл останавливается. Вот как выглядит предыдущий пример с использованием цикла do while:
do { val = getValue(); val = convertValue(val); printValue(val); |
} while doMore();
В отличие от остальных инструкций циклов, do while не разрешает объявлять объекты в своей части условия. Мы не можем написать:
// ошибка: объявление переменной // в условии не разрешается do { // ... mumble( foo ); |
} while ( int foo = get_foo() ) // ошибка
потому что до условной части инструкции do while мы дойдем только после первого выполнения тела цикла.
Упражнение 5.14
Какие ошибки допущены в следующих циклах do while:
(a)
do string rsp; int vail, va12; cout << "Введите два числа: "; c-in >> vail >> va12; cout << "Сумма " << vail << " и " << va12 << " = " << vail + va12 << "\n\n" << "Продолжить? [да][нет] "; cin >> rsp; while ( rsp[0] != 'n' ); (b) do { // ... } while ( int iva1 = get_response() ); (c) do { int ival = get_response(); if ( iva1 == some_value() ) break; } while ( iva1 ); if ( !iva1 ) |
// ...
Упражнение 5.15
Напишите небольшую программу, которая запрашивает у пользователя две строки и печатает результат лексикографического сравнения этих строк (строка считается меньшей, если идет раньше при сортировке по алфавиту). Пусть она повторяет эти действия, пока пользователь не даст команду закончить. Используйте тип string, сравнение строк и цикл do while.
Инструкция goto
Инструкция gotoобеспечивает безусловный переход к другой инструкции внутри той же функции, поэтому современная практика программирования выступает против ее применения.
Синтаксис goto
следующий:
goto метка;
где метка– определенный пользователем идентификатор. Метка ставится перед инструкцией, на которую можно перейти с помощью goto, и должна заканчиваться двоеточием. Нельзя ставить метку непосредственно перед закрывающей фигурной скобкой. Если же это необходимо, их следует разделить пустой инструкцией:
end: ; // пустая инструкция |
}
Переход через инструкцию объявления в том же блоке с помощью goto
невозможен. Например, данная функция вызывает ошибку компиляции:
int oops_in_error() { // mumble ... goto end; // ошибка: переход через объявление int ix = 10; // ... код, использующий ix end: ; |
}
Правильная реализация функции помещает объявление ix и использующие его инструкции во вложенный блок:
int oops_in_error() { // mumble ... goto end; { // правильно: объявление во вложенном блоке int ix = 10; // ... код, использующий ix } end: ; |
}
Причина такого ограничения та же, что и для объявлений внутри блока switch: компилятор должен гарантировать, что для объявленного объекта конструктор и деструктор либо выполняются вместе, либо ни один из них не выполняется. Это и достигается заключением объявления во вложенный блок.
Переход назад через объявление, однако, не считается ошибкой. Почему? Перепрыгнуть через инициализацию объекта нельзя, но проинициализировать один и тот же объект несколько раз вполне допустимо, хотя это может привести к снижению эффективности. Например:
// переход назад через объявление не считается ошибкой. void mumble ( int max_size ) { begin: int sz = get_size(); if ( sz <= 0 ) { // выдать предупреждение ... goto end; } else if ( sz > max_size ) // получить новое значение sz goto begin; { // правильно: переход через целый блок int ia = new int[ sz ]; doit( ia, sz ) ; delete [] ia; } end: ; |
}
Использование инструкции goto
резко критикуется во всех современных языках программирования. Ее применение приводит к тому, что ход выполнения программы становится трудно понять и, следовательно, такую программу трудно модифицировать. В большинстве случаев goto
можно заменить на инструкции if или циклы. Если вы все-таки решили использовать goto, не перескакивайте через большой фрагмент кода, чтобы можно было легко найти начало и конец вашего перехода.
Инструкция if
Инструкция ifобеспечивает выполнение или пропуск инструкции или блока в зависимости от условия. Ее синтаксис таков:
if ( условие ) |
инструкция
условие
заключается в круглые скобки. Оно может быть выражением, как в этом примере:
if(a+b>c) { ... }
или инструкцией объявления с инициализацией:
if ( int ival = compute_value() ){...}
Область видимости объекта, объявленного в условной части, ограничивается ассоциированной с if
инструкцией или блоком. Например, такой код вызывает ошибку компиляции:
if ( int ival = compute_value() ) { // область видимости ival // ограничена этим блоком } // ошибка: ival невидим |
if ( ! ival ) ...
Попробуем для иллюстрации применения инструкции if реализовать функцию min(), возвращающую наименьший элемент вектора. Заодно наша функция будет подсчитывать число элементов, равных минимуму. Для каждого элемента вектора мы должны проделать следующее:
1. Сравнить элемент с текущим значением минимума.
2. Если элемент меньше, присвоить текущему минимуму значение элемента и сбросить счетчик в 1.
3. Если элемент равен текущему минимуму, увеличить счетчик на 1.
4. В противном случае ничего не делать.
5. После проверки последнего элемента вернуть значение минимума и счетчика.
Необходимо использовать две инструкции if:
if ( minVal > ivec[ i ] )...// новое значение minVal |
if ( minVal == ivec[ i ] )...// одинаковые значения
Довольно часто программист забывает использовать фигурные скобки, если нужно выполнить несколько инструкций в зависимости от условия:
if ( minVal > ivec[ i ] ) minVal = ivec[ i ]; |
occurs = 1; // не относится к if!
Такую ошибку трудно увидеть, поскольку отступы в записи подразумевают, что и minVal=ivec[i], и occurs=1
входят в одну инструкцию if. На самом же деле инструкция
occurs = 1;
не является частью if и выполняется безусловно, всегда сбрасывая occurs в 1. Вот как должна быть составлена правильная if-инструкция (точное положение открывающей фигурной скобки является поводом для бесконечных споров):
if ( minVal > ivec[ i ] ) { minVal = ivec[ i ]; occurs = 1; |
Вторая инструкция if
выглядит так:
if ( minVal == ivec [ i ] ) |
Заметим, что порядок следования инструкций в этом примере крайне важен. Если мы будем сравнивать minVal
именно в такой последовательности, наша функция всегда будет ошибаться на 1:
if ( minVal > ivec[ i ] ) { minVal = ivec[ i ]; occurs = 1; } // если minVal только что получила новое значение, // то occurs будет на единицу больше, чем нужно if ( minVal == ivec[ i ] ) |
Выполнение второго сравнения не обязательно: один и тот же элемент не может одновременно быть и меньше и равен minVal. Поэтому появляется необходимость выбора одного из двух блоков в зависимости от условия, что реализуется инструкцией if-else, второй формой if-инструкции. Ее синтаксис выглядит таким образом:
if ( условие ) инструкция1 else |
инструкция1
выполняется, если условие истинно, иначе переходим к инструкция2. Например:
if ( minVal == ivec[ i ] ) ++occurs; else if ( minVal > ivec[ i ] ) { minVal = ivec[ i ]; occurs = 1; |
Здесь инструкция2
сама является if-инструкцией. Если minVal
меньше ivec[i], никаких действий не производится.
В следующем примере выполняется одна из трех инструкций:
if ( minVal < ivec[ i ] ) {} // пустая инструкция else if ( minVal > ivec[ i ] ) { minVal = ivec[ i ]; occurs = 1; } else // minVal == ivec[ i ] |
Составные инструкции if-else
могут служить источником неоднозначного толкования, если частей else
больше, чем частей if. К какому из if
отнести данную часть else? (Эту проблему иногда называют проблемой висячего else). Например:
if ( minVal <= ivec[ i ] ) if ( minVal == ivec[ i ] ) ++occurs; else { minVal = ivec[ i ]; occurs = 1; |
Судя по отступам, программист предполагает, что else относится к самому первому, внешнему if. Однако в С++ неоднозначность висячих else разрешается соотнесением их с последним встретившимся if. Таким образом, в действительности предыдущий фрагмент означает следующее:
if ( minVal <= ivec[ i ] ) { if ( minVal == ivec[ i ] ) ++occurs; else { minVal = ivec[ i ]; occurs = 1; } |
Одним из способов разрешения данной проблемы является заключение внутреннего if в фигурные скобки:
if ( minVal <= ivec[ i ] ) { if ( minVal == ivec[ i ] ) ++occurs; } else { minVal = ivec[ i ]; occurs = 1; |
В некоторых стилях программирования рекомендуется всегда употреблять фигурные скобки при использовании инструкций if-else, чтобы не допустить возможности неправильной интерпретации кода.
Вот первый вариант функции min(). Второй аргумент функции будет возвращать количество вхождений минимального значения в вектор. Для перебора элементов массива используется цикл for. Но мы допустили ошибку в логике программы. Сможете ли вы заметить ее?
#include int min( const vector { int minVal = 0; occurs = 0; int size = ivec.size(); for ( int ix = 0; ix < size; ++ix ) { if ( minVal == ivec[ ix ] ) ++occurs; else if ( minVal > ivec[ ix ] ) { minVal = ivec[ ix ]; occurs = 1; } } return minVal; |
Обычно функция возвращает только одно значение. Однако согласно нашей спецификации в точке вызова должно быть известно не только само минимальное значение, но и количество его вхождений в вектор. Для возврата второго значения мы использовали параметр типа
ссылка. (Параметры-ссылки рассматриваются в разделе 7.3.) Любое присваивание значения ссылке occurs
изменяет значение переменной, на которую она ссылается:
int main() { int occur_cnt = 0; vector< int > ivec; // occur_cnt получает значение occurs // из функции min() int minval = min( ivec, occur_cnt ); // ... |
Альтернативой использованию параметра-ссылки является применение объекта класса pair, представленного в разделе 3.14. Функция min() могла бы возвращать два значения в одной паре:
// альтернативная реализация // с помощью пары #include #include typedef pair min_va1_pair min( const vector { int minVal = 0; int occurs = 0; // то же самое ... return make_pair( minVal, occurs ); |
К сожалению, и эта реализация содержит ошибку. Где же она? Правильно: мы инициализировали minVal
нулем, поэтому, если минимальный элемент вектора больше нуля, наша реализация вернет нулевое значение минимума и нулевое значение количества вхождений.
Программу можно изменить, инициализировав minVal первым элементом вектора:
int minVal = ivec[0];
Теперь функция работает правильно. Однако в ней выполняются некоторые лишние действия, снижающие ее эффективность.
// исправленная версия min() // оставляющая возможность для оптимизации ... int minVal = ivec[0]; occurs = 0; int size = ivec.size(); for ( int ix = 0; ix < size; ++ix ) { if ( minVal == ivec[ ix ] ) ++occurs; |
Поскольку ix
инициализируется нулем, на первой итерации цикла значение первого элемента сравнивается с самим собой. Можно инициализировать ix
единицей и избежать ненужного выполнения первой итерации. Однако при оптимизации кода мы допустили другую ошибку (наверное, стоило все оставить как было!). Сможете ли вы ее обнаружить?
// оптимизированная версия min(), // к сожалению, содержащая ошибку... int minVal = ivec[0]; occurs = 0; int size = ivec.size(); for ( int ix = 1; ix < size; ++ix ) { if ( minVal == ivec[ ix ] ) ++occurs; |
Если ivec[0]
окажется минимальным элементом, переменная occurs не получит значения 1. Конечно, исправить это очень просто, но сначала надо найти ошибку:
int minVal = ivec[0]; |
К сожалению, подобного рода недосмотры встречаются не так уж редко: программисты тоже люди и могут ошибаться. Важно понимать, что это неизбежно, и быть готовым тщательно тестировать и анализировать свои программы.
Вот окончательная версия функции min() и программа main(), проверяющая ее работу:
#include #include int min( const vector< int > &ivec, int &occurs ) { int minVal = ivec[ 0 ]; occurs = 1; int size = ivec.size(); for ( int ix = 1; ix < size; ++ix ) { if ( minVal == ivec[ ix ] ) ++occurs; else if ( minVal > ivec[ ix ] ){ minVal = ivec[ ix ]; occurs = 1; } } return minVal; } int main() { int ia[] = { 9,1,7,1,4,8,1,3,7,2,6,1,5,1 }; vector int occurs = 0; int minVal = min( ivec, occurs ); cout << "Минимальное значение: " << minVal << " встречается: " << occurs << " раз.\n"; return 0; |
Результат работы программы:
Минимальное значение: 1 встречается: 5 раз.
В некоторых случаях вместо инструкции if-else можно использовать более краткое и выразительное условное выражение. Например, следующую реализацию функции min():
template inline const valueType& min( valueType &vall, valueType &va12 ) { if ( vall < va12 ) return vall; return va12; |
можно переписать так:
template inline const valueType& min( valueType &vall, valueType &va12 ) { return ( vall < va12 ) ? vall : va12; |
Длинные цепочки инструкций if-else, подобные приведенной ниже, трудны для восприятия и, таким образом, являются потенциальным источником ошибок.
if ( ch == 'a' || ch == 'A' ) ++aCnt; else if ( ch == 'e' || ch == 'E' ) ++eCnt; else if ( ch == 'i' || ch == 'I' ) ++iCnt; else if ( ch == 'o' || ch == '0' ) ++oCnt; else if ( ch == 'u' || ch == 'U' ) |
В качестве альтернативы таким цепочкам С++ предоставляет инструкцию switch. Это тема следующего раздела.
Упражнение 5.3
Исправьте ошибки в примерах:
(a) if ( ivall != iva12 ) ivall = iva12 else ivall = iva12 = 0; (b) if ( ivat < minval ) minvat = ival; occurs = 1; (c) if ( int ival = get_value()) cout << "ival = " << ival << endl; if ( ! ival ) cout << "ival = 0\n"; (d) if ( ival = 0 ) ival = get_value(); (e) if ( iva1 == 0 ) |
Упражнение 5.4
Преобразуйте тип параметра occurs
функции min(), сделав его не ссылкой, а простым объектом. Запустите программу. Как изменилось ее поведение?
Инструкция switch
Длинные цепочки инструкций if-else, наподобие приведенной в конце предыдущего раздела, трудны для восприятия и потому являются потенциальным источником ошибок. Модифицируя такой код, легко сопоставить, например, разные else и if. Альтернативный метод выбора одного их взаимоисключающих условий предлагает инструкция switch.Для иллюстрации инструкции switch
рассмотрим следующую задачу. Нам надо подсчитать, сколько раз встречается каждая из гласных букв в указанном отрывке текста. (Общеизвестно, что буква e– наиболее часто встречающаяся гласная в английском языке.) Вот алгоритм программы:
1. Считывать по одному символу из входного потока, пока они не кончатся.
2. Сравнить каждый символ с набором гласных.
3. Если символ равен одной из гласных, прибавить 1 к ее счетчику.
4. Напечатать результат.
Написанная программа была запущена, в качестве контрольного текста использовался раздел из оригинала данной книги. Результаты подтвердили, что буква e действительно самая частая:
aCnt: 394
eCnt: 721
iCnt: 461
oCnt: 349
uCnt: 186
Инструкция switch
состоит из следующих частей:
· ключевого слова switch, за которым в круглых скобках идет выражение, являющееся условием:
char ch; while ( cm >> ch ) |
switch( ch )
· набора меток case, состоящих из ключевого слова case и константного выражения, с которым сравнивается условие. В данном случае каждая метка представляет одну из гласных латинского алфавита:
case 'a': case 'e': case 'i': case 'o': |
case 'u':
· последовательности инструкций, соотносимых с метками case. В нашем примере с каждой меткой будет сопоставлена инструкция, увеличивающая значение соответствующего счетчика;
· необязательной метки default, которая является аналогом части else инструкции if-else. Инструкции, соответствующие этой метке, выполняются, если условие не отвечает ни одной из меток case. Например, мы можем подсчитать суммарное количество встретившихся символов, не являющихся гласными буквами:
default: // любой символ, не являющийся гласной |
Константное выражение в метке case
должно принадлежать к целому типу, поэтому следующие строки ошибочны:
// неверные значения меток case 3.14: // не целое |
Кроме того, две разные метки не могут иметь одинаковое значение.
Выражение условия в инструкции switch
может быть сколь угодно сложным, в том числе включать вызовы функций. Результат вычисления условия сравнивается с метками case, пока не будет найдено равное значение или не выяснится, что такого значения нет. Если метка обнаружена, выполнение будет продолжено с первой инструкции после нее, если же нет, то с первой инструкции после метки default (при ее наличии) или после всей составной инструкции switch.
В отличие от if-else
инструкции, следующие за найденной меткой, выполняются друг за другом, проходя все нижестоящие метки case и метку default. Об этом часто забывают. Например, данная реализация нашей программы выполняется совершенно не так, как хотелось бы:
#include int main() { char ch; int aCnt=0, eCnt=0, iCnt=0, oCnt=0, uCnt=0; while ( cin >> ch ) // Внимание! неверная реализация! switch ( ch ) { case 'a': ++aCnt; case 'e': ++eCnt; case 'i': ++iCnt; case 'o': ++oCnt; case 'u': ++uCnt; } cout << "Встретилась a: \t" << aCnt << '\n' << "Встретилась e: \t" << eCnt << '\n' << "Встретилась i: \t" << iCnt << '\n' << "Встретилась o: \t" << oCnt << '\n' << "Встретилась u: \t" << uCnt << '\n'; |
}
Если значение ch
равно i, выполнение начинается с инструкции после case 'i' и iCnt
возрастет на 1. Однако следующие ниже инструкции, ++oCnt и ++uCnt, также выполняются, увеличивая значения и этих переменных. Если же переменная ch
равна a, изменятся все пять счетчиков.
Программист должен явно дать указание компьютеру прервать последовательное выполнение инструкций в определенном месте switch, вставив break. В абсолютном большинстве случаев за каждой метке case
должен следовать соответствующий break.
break
прерывает выполнение switch и передает управление инструкции, следующей за закрывающей фигурной скобкой, – в данном случае производится вывод. Вот как это должно выглядеть:
switch ( ch ) { case 'a': ++aCnt; break; case 'e': ++eCnt; break; case 'i': ++iCnt; break; case 'o': ++oCnt; break; case 'u': ++uCnt; break; |
Если почему-либо нужно, чтобы одна из секций не заканчивалась инструкцией break, то желательно написать в этом месте разумный комментарий. Программа создается не только для машин, но и для людей, и необходимо сделать ее как можно более понятной для читателя. Программист, изучающий чужой текст, не должен
сомневаться, было ли нестандартное использование языка намеренным или ошибочным.
При каком условии программист может отказаться от инструкции break и позволить программе провалиться сквозь несколько меток case? Одним из таких случаев является необходимость выполнить одни и те же действия для двух или более меток. Это может понадобиться потому, что с case
всегда связано только одно значение. Предположим, мы не хотим подсчитывать, сколько раз встретилась каждая гласная в отдельности, нас интересует только суммарное количество всех встретившихся гласных. Это можно сделать так:
int vowelCnt = 0; // ... switch ( ch ) { // любой из символов a,e,1,o,u // увеличит значение vowelCnt case 'a': case 'e': case 'i': case 'o': case 'u': ++vowe1Cnt; break; |
}
Некоторые программисты подчеркивают осознанность своих действий тем, что предпочитают в таком случае писать метки на одной строке:
switch ( ch ) { // допустимый синтаксис case 'a': case 'e': case 'i': case 'o': case 'u': ++vowe1Cnt; break; |
В данной реализации все еще осталась одна проблема: как будут восприняты слова типа
UNIX
Наша программа не понимает заглавных букв, поэтому заглавные U и I не будут отнесены к гласным. Исправить ситуацию можно следующим образом:
switch ( ch ) { case 'a': case 'A': ++aCnt; break; case 'e': case 'E': ++eCnt; break; case 'i': case 'I': ++iCnt; break; case 'o': case 'O': ++oCnt; break; case 'u': case 'U': ++uCnt; break; |
Метка default
является аналогом части else инструкции if-else. Инструкции, соответствующие default, выполняются, если условие не отвечает ни одной из меток case. Например, добавим к нашей программе подсчет суммарного количества согласных:
#include #include int main() { char ch; int aCnt=0, eCnt=0, iCnt=0, oCnt=0, uCnt=0, consonantCount=0; while ( cin >> ch ) switch ( ch ) { case 'a': case 'A': ++aCnt; break; case 'e': case 'E': ++eCnt; break; case 'i': case 'I': ++iCnt; break; case 'o': case 'O': ++oCnt; break; case 'u': case 'U': ++uCnt; break; default: if ( isa1pha( ch ) ) ++consonantCnt; break; } cout << "Встретилась a: \t" << aCnt << '\n' << "Встретилась e: \t" << eCnt << '\n' << "Встретилась i: \t" << iCnt << '\n' << "Встретилась o: \t" << oCnt << '\n' << "Встретилась u: \t" << uCnt << '\n' << "Встретилось согласных: \t" << consonantCnt << '\n'; |
}
isalpha() – функция стандартной библиотеки С; она возвращает true, если ее аргумент является буквой. isalpha() объявлена в заголовочном файле ctype.h. (Функции из ctype.h мы будем рассматривать в главе 6.)
Хотя оператор break
функционально не нужен после последней метки в инструкции switch, лучше его все-таки ставить. Причина проста: если мы впоследствии захотим добавить еще одну метку после case, то с большой вероятностью забудем вписать недостающий break.
Условная часть инструкции switch
может содержать объявление, как в следующем примере:
switch( int ival = get_response() )
ival
инициализируется значением, получаемым от get_response(), и это значение сравнивается со значениями меток case. Переменная ival видна внутри блока switch, но не вне его.
Помещать же инструкцию объявления внутри тела блока switch не разрешается. Данный фрагмент кода не будет пропущен компилятором:
case illegal_definition: // ошибка: объявление не может // употребляться в этом месте string file_name = get_file_name(); // ... |
Если бы разрешалось объявлять переменную таким образом, то ее было бы видно во всем блоке switch, однако инициализируется она только в том случае, если выполнение прошло через данную метку case.
Мы можем употребить в этом месте составную инструкцию, тогда объявление переменной file_name
будет синтаксически правильным. Использование блока гарантирует, что объявленная переменная видна только внутри него, а в этом контексте она заведомо инициализирована. Вот как выглядит правильный текст:
case ok: { // ок string file_name = get_file_name(); // ... |
}
Упражнение 5.5
Модифицируйте программу из данного раздела так, чтобы она подсчитывала не только буквы, но и встретившиеся пробелы, символы табуляции и новой строки.
Упражнение 5.6
Модифицируйте программу из данного раздела так, чтобы она подсчитывала также количество встретившихся двухсимвольных последовательностей ff, fl и fi.
Упражнение 5.7
Найдите и исправьте ошибки в следующих примерах:
(a)
switch ( ival ) { case 'a': aCnt++; case 'e': eCnt++; default: iouCnt++; |
(b)
switch ( ival ) { case 1: int ix = get_value(); ivec[ ix ] = ival; break; default: ix = ivec.sizeQ-1; ivec[ ix ] = ival; |
(c)
switch ( ival ) { case 1, 3, 5, 7, 9: oddcnt++; break; case 2, 4, 6, 8, 10: evencnt++; break; |
(d)
int iva1=512 jva1=1024, kva1=4096; int bufsize; // ... switch( swt ) { case ival: bufsize = ival * sizeof( int ); break; case jval: bufsize = jval * sizeof( int ); break; case kval: bufsize = kval * sizeof( int ); break; |
(e)
enum { illustrator = 1, photoshop, photostyler = 2 }; switch ( ival ) { case illustrator: --i11us_1icense; break; case photoshop: --pshop_1icense; break; case photostyler: --psty1er_license; |
}
Инструкция while
Синтаксис инструкции whileследующий:
while ( условие ) |
инструкция
Пока значением условия
является true, инструкция
выполняется в такой последовательности:
1.
Вычислить условие.
2. Выполнить инструкцию,
если условие
истинно.
3. Если самое первое вычисление условия
дает false, инструкция не выполняется.
Условием
может быть любое выражение:
bool quit = false; // ... while ( ! quit ) { // ... quit = do_something(); } string word; |
while ( cin >> word ){ ... }
или объявление с инициализацией:
while ( symbol *ptr = search( name )) { // что-то сделать |
}
В последнем случае ptr
видим только в блоке, соответствующем инструкции while, как это было и для инструкций for и switch.
Вот пример цикла while, обходящего множество элементов, адресуемых двумя указателями:
int sumit( int *parray_begin, int *parray_end ) { int sum = 0; if ( ! parray_begin || ! parray_end ) return sum; while ( parray_begin != parray_end ) // прибавить к sum // и увеличить указатель sum += *parray_begin++; return sum; } int ia[6] = { 0, 1, 2, 3, 4, 5 }; int main() { int sum = sumit( &ia[0], &ia[ 6 ] ); // ... |
}
Для того чтобы функция sumit()
выполнялась правильно, оба указателя должны адресовать элементы одного и того же массива (parray_end
может указывать на элемент, следующий за последним). В противном случае sumit() будет возвращать бессмысленную величину. Увы, С++ не гарантирует, что два указателя адресуют один и тот же массив. Как мы увидим в главе 12, стандартные универсальные алгоритмы реализованы подобным же образом, они принимают параметрами указатели на первый и последний элементы массива.
Упражнение 5.11
Какие ошибки допущены в следующих циклах while:
(a) string bufString, word; while ( cin >> bufString >> word ) |
// ...
(b) while ( vector // ... (c) while ( ptr = 0 ) ptr = find_a_value(); (d) while ( bool status = find( word )) { word = get_next_word(); if ( word.empty() ) break; // ... } if ( ! status ) |
Упражнение 5.12
while
обычно применяется для циклов, выполняющихся, пока некоторое условие истинно, например, читать следующее значение, пока не
будет достигнут конец файла. for обычно рассматривается как пошаговый цикл: индекс пробегает по определенному диапазону значений. Напишите по одному типичному примеру for и while, а затем измените их, используя цикл другого типа. Если бы вам нужно было выбрать для постоянной работы только один из этих типов, какой бы вы выбрали? Почему?
Упражнение 5.13
Напишите функцию, читающую последовательность строк из стандартного ввода до тех пор, пока одно и то же слово не встретится два раза подряд либо все слова не будут обработаны. Для чтения слов используйте while; при обнаружении повтора слова завершите цикл с помощью инструкции break. Если повторяющееся слово найдено, напечатайте его. В противном случае напечатайте сообщение о том, что слова не повторялись.
Исключения и наследование
Обработка исключений – это стандартное языковое средство для реакции на аномальное поведение программы во время выполнения. C++ поддерживает единообразный синтаксис и стиль обработки исключений, а также способы тонкой настройки этого механизма в специальных ситуациях. Основы его поддержки в языке C++ описаны в главе 11, где показано, как программа может возбудить исключение, передать управление его обработчику (если таковой существует) и как обработчики исключений ассоциируются с try-блоками.Возможности механизма обработки исключений становятся больше, если в качестве исключений использовать иерархии классов. В этом разделе мы расскажем, как писать программы, которые умеют возбуждать и обрабатывать исключения, принадлежащие таким иерархиям.
Исключения и вопросы проектирования
С обработкой исключений в программах C++ связано несколько вопросов. Хотя поддержка такой обработки встроена в язык, не стоит использовать ее везде. Обычно она применяется для обмена информацией об ошибках между независимо разработанными частями программы. Например, автор некоторой библиотеки может с помощью исключений сообщать пользователям об ошибках. Если библиотечная функция обнаруживает аномальную ситуацию, которую не способна обработать самостоятельно, она может возбудить исключение для уведомления вызывающей программы.В нашем примере в библиотеке определен класс iStack и его функции-члены. Разумно предположить, что программист, кодировавший main(), где используется эта библиотека, не разрабатывал ее. Функции-члены класса iStack могут обнаружить, что операция pop()
вызвана, когда стек пуст, или что операция push() вызвана, когда стек полон; однако разработчик библиотеки ничего не знал о программе, пользующейся его функциями, так что не мог разрешить проблему локально. Не сумев обработать ошибку внутри функций-членов, мы решили возбуждать исключения, чтобы известить вызывающую программу.
Хотя C++ поддерживает исключения, следует применять и другие методы обработки ошибок (например, возврат кода ошибки) – там, где это более уместно. Однозначного ответа на вопрос: “Когда ошибку следует трактовать как исключение?” не существует. Ответственность за решение о том, что считать исключительной ситуацией, возлагается на разработчика. Исключения – это часть интерфейса библиотеки, и решение о том, какие исключения она возбуждает, – важный аспект ее дизайна. Если библиотека предназначена для использования в программах, которые не должны аварийно завершаться ни при каких обстоятельствах, то она обязана разбираться с аномалиями сама либо извещать о них вызывающую программу, передавая ей управление. Решение о том, какие ошибки следует обрабатывать как исключения, – трудная часть работы по проектированию библиотеки.
В нашем примере с классом iStack
вопрос, должна ли функция push() возбуждать исключение, если стек полон, является спорным. Альтернативная и, по мнению многих, лучшая реализация push() – локальное решение проблемы: увеличение размера стека при его заполнении. В конце концов, единственное ограничение – это объем доступной программе памяти. Наше решение о возбуждении исключения при попытке поместить значение в полный стек, по-видимому, непродуманно. Можно переделать функцию-член push(), чтобы она в такой ситуации наращивала стек:
void iStack::push( int value ) { // если стек полон, увеличить размер вектора if ( full() ) _stack.resize( 2 * _stack.size() ); _stack[ _top++ ] = value; |
Аналогично следует ли функции pop()
возбуждать исключение при попытке извлечь значение из пустого стека? Интересно отметить, что класс stack из стандартной библиотеки C++ (он рассматривался в главе 6) не возбуждает исключения в такой ситуации. Вместо этого постулируется, что поведение программы при попытке выполнения подобной операции не определено. Разрешить программе продолжать работу при обнаружении некорректного состояния признали возможным. Мы уже упоминали, что в разных библиотеках определены разные исключения. Не существует пригодного для всех случаев ответа на вопрос, что такое исключение.
Не все программы должны беспокоиться по поводу исключений, возбуждаемых библиотечными функциями. Хотя есть системы, для которых простой недопустим и которые, следовательно, должны обрабатывать все исключительные ситуации, не к каждой программе предъявляются такие требования. Обработка исключений предназначена в первую очередь для реализации отказоустойчивых систем. В этом случае решение о том, должна ли программа обрабатывать все исключения, возбуждаемые библиотеками, или может закончить выполнение аварийно, – это трудная часть процесса проектирования.
Еще один аспект проектирования программ заключается в том, что обработка исключений обычно структурирована. Как правило, программа строится из компонентов, и каждый компонент решает сам, какие исключения обрабатывать локально, а какие передавать на верхние уровни. Что мы понимаем под компонентом? Например, система анализа текстовых запросов, рассмотренная в главе 6, может быть разбита на три компонента, или слоя. Первый слой – это стандартная библиотека C++, которая обеспечивает базовые операции над строками, отображениями и т.д. Второй слой – это сама система анализа текстовых запросов, где определены такие функции, как string_caps() и suffix_text(), манипулирующие текстами и использующие стандартную библиотеку как основу. Третий слой – это программа, которая применяет нашу систему. Каждый компонент строится независимо и должен принимать решения о том, какие исключительные ситуации обрабатывать локально, а какие передавать на более высокий уровень.
Не все функции должны уметь обрабатывать исключения. Обычно try-блоки и ассоциированные с ними catch-обработчики применяются в функциях, являющихся точками входа в компонент. Catch-обработчики проектируются так, чтобы перехватывать те исключения, которые не должны попасть на верхние уровни программы. Для этого также используются спецификации исключений (см. раздел 11.4).
Мы расскажем о других аспектах проектирования программ, использующих исключения, в главе 19, после знакомства с классами и иерархиями классов.
12
Исключения, определенные как иерархии классов
В главе 11 мы использовали два типа класса для описания исключений, возбуждаемых функциями-членами нашего класса iStack:class popOnEmpty { ... }; |
class pushOnFull { ... };
В реальных программах на C++ типы классов, представляющих исключения, чаще всего организуются в группы, или иерархии. Как могла бы выглядеть вся иерархия для этих классов?
Мы можем определить базовый класс Excp, которому наследуют оба наши класса исключений. Он инкапсулирует данные и функции-члены, общие для обоих производных:
class Excp { ... }; class popOnEmpty : public Excp { ... }; |
class pushOnFull : public Excp { ... };
Одной из операцией, которые предоставляет базовый класс, является вывод сообщения об ошибке. Эта возможность используется обоими классами, стоящими ниже в иерархии:
class Excp { public: // напечатать сообщение об ошибке static void print( string msg ) { cerr << msg << endl; } |
};
Иерархию классов исключений разрешается развивать и дальше. От Excp
можно произвести другие классы для более точного описания исключений, обнаруживаемых программой:
class Excp { ... }; class stackExcp : public Excp { ... }; class popOnEmpty : public stackExcp { ... }; |
class pushOnFull : public stackExcp { ... };
class mathExcp : public Excp ( ... }; class zeroOp : public mathExcp { ... }; |
class divideByZero : public mathExcp { ... };
Последующие уточнения позволяют более детально идентифицировать аномальные ситуации в работе программы. Дополнительные классы исключений организуются как слои. По мере углубления иерархии каждый новый слой описывает все более специфичные исключения. Например, первый, самый общий слой в приведенной выше иерархии представлен классом Excp. Второй специализирует Excp, выделяя из него два подкласса: stackExcp (для исключений при работе с нашим iStack) и mathExcp
(для исключений, возбуждаемых функциями из математической библиотеки). Третий, самый специализированный слой данной иерархии уточняет классы исключений: popOnEmpty и pushOnFull
определяют два вида исключений работы со стеком, а ZeroOp и divideByZero – два вида исключений математических операций.
В последующих разделах мы рассмотрим, как возбуждаются и обрабатываются исключения, представленные классами в нашей иерархии.
Использование членов пространства имен А
Использование квалифицированных имен при каждом обращении к членам пространств может стать обременительным, особенно если имена пространств достаточно длинны. Если бы удалось сделать их короче, то такие имена проще было бы читать и набивать. Однако употребление коротких имен увеличивает риск их совпадения с другими, поэтому желательно, чтобы в библиотеках применялись пространства с длинными именами.К счастью, существуют механизмы, облегчающие использование членов пространств имен в программах. Псевдонимы пространства имен, using-объявления и using-директивы помогают преодолеть неудобства работы с очень длинными именами.
Использование исключений
Исключениями называют аномальные ситуации, возникающие во время исполнения программы: невозможность открыть нужный файл или получить необходимое количество памяти, использование выходящего за границы индекса для какого-либо массива. Обработка такого рода исключений, как правило, плохо интегрируется в основной алгоритм программы, и программисты вынуждены изобретать разные способы корректной обработки исключения, стараясь в то же время не слишком усложнить программу добавлением всевозможных проверок и дополнительных ветвей алгоритма.С++ предоставляет стандартный способ реакции на исключения. Благодаря вынесению в отдельную часть программы кода, ответственного за проверку и обработку ошибок, значительно облегчается восприятие текста программы и сокращается ее размер. Единый синтаксис и стиль обработки исключений можно, тем не менее, приспособить к самым разнообразным нуждам и запросам.
Механизм исключений делится на две основные части:
точка программы, в которой произошло исключение. Определение того факта, что при выполнении возникла какая-либо ошибка, влечет за собой возбуждение
исключения. Для этого в С++ предусмотрен специальный оператор throw. Возбуждение исключения в случае невозможности открыть некоторый файл выглядит следующим образом:
if ( !infile ) { string errMsg("Невозможно открыть файл: "); errMsg += fileName; throw errMsg; |
}
Место программы, в котором исключение обрабатывается. При возбуждении исключения нормальное выполнение программы приостанавливается и управление передается обработчику исключения. Поиск нужного обработчика часто включает в себя раскрутку так называемого стека вызовов программы. После обработки исключения выполнение программы возобновляется, но не с того места, где произошло исключение, а с точки, следующей за обработчиком. Для определения обработчика исключения в С++ используется ключевое слово catch. Вот как может выглядеть обработчик для примера из предыдущего абзаца:
catch (string exceptionMsg) {
log_message (exceptionMsg);
return false;
}
Каждый catch-обработчик ассоциирован с исключениями, возникающими в блоке операторов, который непосредственно предшествует обработчику и помечен ключевым словом try. Одному try-блоку могут соответствовать несколько catch-предложений, каждое из которых относится к определенному виду исключений. Приведем пример:
int* stats (const int *ia, int size) |
int *pstats = new int [4];
try {
pstats[0] = sum_it (ia,size);
pstats[1] = min_val (ia,size);
pstats[2] = max_val (ia,size);
}
catch (string exceptionMsg) {
// код обработчика
}
catch (const statsException &statsExcp) {
// код обработчика
}
pstats [3] = pstats[0] / size;
do_something (pstats);
return pstats;
}
В данном примере в теле функции stats() три оператора заключены в try-блок, а четыре – нет. Из этих четырех операторов два способны возбудить исключения.
1) int *pstats = new int [4];
Выполнение оператора new
может окончиться неудачей. Стандартная библиотека С++ предусматривает возбуждение исключения bad_alloc в случае невозможности выделить нужное количество памяти. Поскольку в примере не предусмотрен обработчик исключения bad_alloc, при его возбуждении выполнение программы закончится аварийно.
2) do_something (pstats);
Мы не знаем реализации функции do_something(). Любая инструкция этой функции, или функции, вызванной из этой функции, или функции, вызванной из функции, вызванной этой функцией, и так далее, потенциально может возбудить исключение. Если в реализации функции do_something и вызываемых из нее предусмотрен обработчик такого исключения, то выполнение stats()
продолжится обычным образом. Если же такого обработчика нет, выполнение программы аварийно завершится.
Необходимо заметить, что, хотя оператор
pstats [3] = pstats[0] / size;
может привести к делению на ноль, в стандартной библиотеке не предусмотрен такой тип исключения.
Обратимся теперь к инструкциям, объединенным в try-блок. Если в одной из вызываемых в этом блоке функций – sum_it(), min_val() или max_val() –произойдет исключение, управление будет передано на обработчик,
следующий за try-блоком и перехватывающий именно это исключение. Ни инструкция, возбудившая исключение, ни следующие за ней инструкции в try-блоке выполнены не будут. Представим себе, что при вызове функции sum_it() возбуждено исключение:
throw string ("Ошибка: adump27832");
Выполнение функции sum_it()
прервется, операторы, следующие в try-блоке за вызовом этой функции, также не будут выполнены, и pstats[0] не будет инициализирована. Вместо этого возбуждается исключительное состояние и исследуются два catch-обработчика. В нашем случае выполняется catch с параметром типа string:
catch (string exceptionMsg) { // код обработчика |
После выполнения управление будет передано инструкции, следующей за последним catch-обработчиком, относящимся к данному try-блоку. В нашем случае это
pstats [3] = pstats[0] / size;
(Конечно, обработчик сам может возбуждать исключения, в том числе – того же типа. В такой ситуации будет продолжено выполнение catch-предложений, определенных в программе, вызвавшей функцию stats().)
Вот пример:
catch (string exceptionMsg) { |
cerr << "stats(): исключение: "
<< exceptionMsg
<< endl;
delete [] pstats;
return 0;
}
В таком случае выполнение вернется в функцию, вызвавшую stats(). Будем считать, что разработчик программы предусмотрел проверку возвращаемого функцией stats()
значения и корректную реакцию на нулевое значение.
Функция stats()
умеет реагировать на два типа исключений: string и statsException. Исключение любого другого типа игнорируется, и управление передается в вызвавшую функцию, а если и в ней не найдется обработчика, – то в функцию более высокого уровня, и так до функции main().При отсутствии обработчика и там, программа аварийно завершится.
Возможно задание специального обработчика, который реагирует на любой тип исключения. Синтаксис его таков:
catch (...) { // обрабатывает любое исключение, // однако ему недоступен объект, переданный // в обработчик в инструкции throw |
}
( Детально обработка исключительных ситуаций рассматривается в главах 11 и 19.)
Упражнение 2.18
Какие ошибочные ситуации могут возникнуть во время выполнения следующей функции:
int *alloc_and_init (string file_name)
{
ifstream infile (file_name)
int elem_cnt;
infile >> elem_cnt;
int *pi = allocate_array(elem_cnt);
int elem;
int index=0;
while (cin >> elem)
pi[index++] = elem;
sort_array(pi,elem_cnt);
register_data(pi);
return pi;
}
Упражнение 2.19
В предыдущем примере вызываемые функции allocate_array(), sort_array() и register_data()
могут возбуждать исключения типов noMem, int и string соответственно. Перепишите функцию alloc_and_init(), вставив соответствующие блоки try и catch для обработки этих исключений. Пусть обработчики просто выводят в cerr сообщение об ошибке.
Упражнение 2.20
Усовершенствуйте функцию alloc_and_init()
так, чтобы она сама возбуждала исключение в случае возникновения всех возможных ошибок (это могут быть исключения, относящиеся к вызываемым функциям allocate_array(), sort_array() и register_data() и какими-то еще операторами внутри функции alloc_and_init()). Пусть это исключение имеет тип string и строка, передаваемая обработчику, содержит описание ошибки.
Использование обобщенных алгоритмов
Допустим, мы задумали написать книжку для детей и хотим понять, какой словарный состав наиболее подходит для такой цели. Чтобы ответить на этот вопрос, нужно прочитать несколько детских книг, сохранить текст в отдельных векторах строк (см. раздел 6.7) и подвергнуть его следующей обработке:1.
Создать копию каждого вектора.
2. Слить все векторы в один.
3. Отсортировать его в алфавитном порядке.
4. Удалить все дубликаты.
5. Снова отсортировать, но уже по длине слов.
6. Подсчитать число слов, длина которых больше шести знаков (предполагается, что длина – это некоторая мера сложности, по крайней мере, в терминах словаря).
7. Удалить семантически нейтральные слова (например, союзы and (и), if (если), or (или), but (но) и т.д.).
8. Напечатать получившийся вектор.
На первый взгляд, задача на целую главу. Но с помощью обобщенных алгоритмов мы решим ее в рамках одного подраздела.
Аргументом нашей функции является вектор из векторов строк. Мы принимаем указатель на него, проверяя, не является ли он нулевым:
#include #include typedef vector void process_vocab( vector { if ( ! pvec ) { // выдать предупредительное сообщение return; } // ...
| |
}
Нужно создать один вектор, включающий все элементы исходных векторов. Это делается с помощью обобщенного алгоритма copy()
(для его использования необходимо включить заголовочные файлы algorithm и iterator):
#include #include void process_vocab( vector { // ... vector< string > texts; vector for ( ; iter != pvec->end(); ++iter ) copy( (*iter).begin(), (*iter).end(), back_inserter( texts )); // ...
| |
}
Первыми двумя аргументами алгоритма copy() являются итераторы, ограничивающие диапазон подлежащих копированию элементов. Третий аргумент – это итератор, указывающий на место, куда надо копировать элементы. back_inserter
называется адаптером итератора; он позволяет вставлять элементы в конец вектора, переданного ему в качестве аргумента. (Подробнее мы рассмотрим адаптеры итераторов в разделе 12.4.).
Алгоритм unique()
удаляет из контейнера дубликаты, расположенные рядом. Если дана последовательность 01123211, то результатом будет 012321, а не 0123. Чтобы получить вторую последовательность, необходимо сначала отсортировать вектор с помощью алгоритма sort(); тогда из последовательности 01111223
получится 0123. (Хотя на самом деле получится 01231223.)
unique() не изменяет размер контейнера. Вместо этого каждый уникальный элемент помещается в очередную свободную позицию, начиная с первой. В нашем примере физический результат – это последовательность 01231223; остаток 1223 – это, так сказать, “отходы” алгоритма. unique() возвращает итератор, указывающий на начало этого остатка. Как правило, этот итератор затем передается алгоритму erase() для удаления ненужных элементов. (Поскольку встроенный массив не поддерживает операции erase(), то семейство алгоритмов unique() в меньшей степени подходит для работы с ним.) Вот соответствующий фрагмент функции:
void process_vocab( vector { // ... // отсортировать вектор texts sort( texts.begin(), texts.end() ); // удалить дубликаты vector it = unique( texts.begin(), texts.end() ); texts.erase( it, texts.end() ); // ... |
Ниже приведен результат печати вектора texts, объединяющего два небольших текстовых файла, после применения sort(), но до применения unique():
a a a a alice alive almost
alternately ancient and and and and and and
and as asks at at beautiful becomes bird
bird blows blue bounded but by calling coat
daddy daddy daddy dark darkened darkening distant each
either emma eternity falls fear fiery fiery flight
flowing for grow hair hair has he heaven,
held her her her her him him home
houses i immeasurable immensity in in in in
inexpressibly is is is it it it its
journeying lands leave leave life like long looks
magical mean more night, no not not not
now now of of on one one one
passion puts quite red rises row same says
she she shush shyly sight sky so so
star star still stone such tell tells tells
that that the the the the the the
the there there thing through time to to
to to trees unravel untamed wanting watch what
when wind with with you you you you
your your
После применения unique() и последующего вызова erase()
вектор texts
выглядит следующим образом:
a alice alive almost alternately ancient
and as asks at beautiful becomes bird blows
blue bounded but by calling coat daddy dark
darkened darkening distant each either emma eternity falls
fear fiery flight flowing for grow hair has
he heaven, held her him home houses i
immeasurable immensity in inexpressibly is it its journeying
lands leave life like long looks magical mean
more night, no not now of on one
passion puts quite red rises row same says
she shush shyly sight sky so star still
stone such tell tells that the there thing
through time to trees unravel untamed wanting watch
what when wind with you your
Следующая наша задача – отсортировать строки по длине. Для этого мы воспользуемся не алгоритмом sort(), а алгоритмом stable_sort(), который сохраняет относительные положения равных элементов. В результате для элементов равной длины сохраняется алфавитный порядок. Для сортировки по длине мы применим собственную операцию сравнения “меньше”. Один из возможных способов таков:
bool less_than( const string & s1, const string & s2 ) { return s1.size() < s1.size(); } void process_vocab( vector { // ... // отсортировать элементы вектора texts по длине, // сохранив также прежний порядок stable_sort( texts.begin(), texts.end(), less_than ); // ... |
}
Нужный результат при этом достигается, но эффективность существенно ниже, чем хотелось бы. less_than()
реализована в виде одной инструкции. Обычно она вызывается как встроенная (inline) функция. Но, передавая указатель на нее, мы не даем компилятору сделать ее встроенной. Способ, позволяющий добиться этого, –применение объекта-функции:
// объект-функция - операция реализована с помощью перегрузки // оператора operator() class LessThan { public: bool operator()( const string & s1, const string & s2 ) { return s1.size() < s2.size(); } |
Объект-функция – это класс, в котором перегружен оператор вызова operator(). В теле этого оператора и реализуется логика функции, в данном случае сравнение “меньше”. Определение оператора вызова выглядит странно из-за двух пар скобок. Запись
operator()
говорит компилятору, что мы перегружаем оператор вызова. Вторая пара скобок
( const string & s1, const string & s2 )
задает передаваемые ему формальные параметры. Если сравнить это определение с предыдущим определением функции less_than(), мы увидим, что, за исключением замены less_than на operator(), они совпадают.
Объект-функция определяется так же, как обычный объект класса (правда, в данном случае нам не понадобился конструктор: нет членов, подлежащих инициализации):
LessThan lt;
Для вызова экземпляра перегруженного оператора мы применяем оператор вызова к нашему объекту класса, передавая необходимые аргументы. Например:
string st1( "shakespeare" ); string st2( "marlowe" ); // вызывается lt.operator()( st1, st2 ); |
Ниже показана исправленная функция process_vocab(), в которой алгоритму stable_sort()
передается безымянный объект-функция LessThan():
void process_vocab( vector { // ... stable_sort( texts.begin(), texts.end(), LessThan() ); // ... |
Внутри stable_sort()
перегруженный оператор вызова подставляется в текст программы как встроенная функция. (В качестве третьего аргумента stable_sort() может принимать как указатель на функцию less_than(), так и объект класса LessThan, поскольку аргументом является параметр-тип шаблона. Подробнее об объектах-функциях мы расскажем в разделе 12.3.)
Вот результат применения stable_sort() к вектору texts:
a i
as at by he in is it no
of on so to and but for has
her him its not now one red row
she sky the you asks bird blue coat
dark each emma fear grow hair held home
life like long mean more puts same says
star such tell that time what when wind
with your alice alive blows daddy falls fiery
lands leave looks quite rises shush shyly sight
still stone tells there thing trees watch almost
either flight houses night, ancient becomes bounded calling
distant flowing heaven, magical passion through unravel untamed
wanting darkened eternity beautiful darkening immensity journeying alternately
immeasurable inexpressibly
Подсчитать число слов, длина которых больше шести символов, можно с помощью обобщенного алгоритма count_if() и еще одного объекта-функции – GreaterThan. Этот объект чуть сложнее, так как позволяет пользователю задать размер, с которым производится сравнение. Мы сохраняем размер в члене класса и инициализируем его с помощью конструктора (по умолчанию – значением 6):
#include class GreaterThan { public: GreaterThan( int size = 6 ) : _size( size ){} int size() { return _size; } bool operator()( const string & s1 ) { return s1.size() > 6; } private: int _size; |
Использовать его можно так:
void process_vocab( vector { // ... // подсчитать число строк, длина которых больше 6 int cnt = count_if( texts.begin(), texts.end(), GreaterThan() ); cout << "Number of words greater than length six are " << cnt << endl; // ... |
}
Этот фрагмент программы выводит такую строку:
Number of words greater than length six are 22
Алгоритм remove()
ведет себя аналогично unique(): он тоже не изменяет размер контейнера, а просто разделяет элементы на те, что следует оставить (копируя их по очереди в начало контейнера), и те, что следует удалить (перемещая их в конец контейнера). Вот как можно воспользоваться им для исключения из коллекции слов, которые мы не хотим сохранять:
void process_vocab( vector { // ... static string rw[] = { "and", "if", "or", "but", "the" }; vector< string > remove_words( rw, rw+5 ); vector< string >::iterator it2 = remove_words.begin(); for ( ; it2 != remove_words.end(); ++it2 ) { // просто для демонстрации другой формы count() int cnt = count( texts.begin(), texts.end(), *it2 ); cout << cnt << " instances removed: " << (*it2) << endl; texts.erase( remove(texts.begin(),texts.end(),*it2 ), texts.end() ); } // ... |
Результат применения remove():
1 instances removed: and
0 instances removed: if
0 instances removed: or
1 instances removed: but
1 instances removed: the
Теперь нам нужно распечатать содержимое вектора. Можно обойти все элементы и вывести каждый по очереди, но, поскольку при этом обобщенные алгоритмы не используются, мы считаем такое решение неподходящим. Вместо этого проиллюстрируем работу алгоритма for_each() для вывода всех элементов вектора. for_each() применяет указатель на функцию или объект-функцию к каждому элементу контейнера из диапазона, ограниченного парой итераторов. В нашем случае объект-функция PrintElem копирует один элемент в стандартный вывод:
class PrintElem { public: PrintElem( int lineLen = 8 ) : _line_length( lineLen ), _cnt( 0 ) {} void operator()( const string &elem ) { ++_cnt; if ( _cnt % _line_length == 0 ) { cout << '\n'; } cout << elem << " "; } private: int _line_length; int _cnt; |
};
void process_vocab( vector { // ... for_each( texts.begin(), texts.end(), PrintElem() ); |
Вот и все. Мы получили законченную программу, для чего пришлось лишь последовательно записать обращения к нескольким обобщенным алгоритмам. Для удобства мы приводим ниже полный листинг вместе с функцией main() для ее тестирования (здесь используются специальные типы итераторов, которые будут обсуждаться только в разделе 12.4). Мы привели текст реально исполнявшегося кода, который не полностью удовлетворяет стандарту C++. В частности, в нашем распоряжении были лишь устаревшие реализации алгоритмов count() и count_if(), которые не возвращают результат, а требуют передачи дополнительного аргумента для вычисленного значения. Кроме того, библиотека iostream
отражает предшествующую принятию стандарта реализацию, в которой требуется заголовочный файл iostream.h.
#include #include #include #include // предшествующий принятию стандарта синтаксис #include class GreaterThan { public: GreaterThan( int size = 6 ) : _size( sz ){} int size() { return _size; } bool operator()(const string &s1) { return s1.size() > _size; } private: int _size; }; class PrintElem { public: PrintElem( int lineLen = 8 ) : _line_length( lineLen ), _cnt( 0 ) {} void operator()( const string &elem ) { ++_cnt; if ( _cnt % _line_length == 0 ) { cout << '\n'; } cout << elem << " "; } private: int _line_length; int _cnt; |
class LessThan { public: bool operator()( const string & s1, const string & s2 ) { return s1.size() < s2.size(); } }; typedef vector void process_vocab( vector { if ( ! pvec ) { // вывести предупредительное сообщение return; } vector< string, allocator > texts; vector for ( iter = pvec->begin() ; iter != pvec->end(); ++iter ) copy( (*iter).begin(), (*iter).end(), back_inserter( texts )); // отсортировать вектор texts sort( texts.begin(), texts.end() ); // теперь посмотрим, что получилось for_each( texts.begin(), texts.end(), PrintElem() ); cout << "\n\n"; // разделить части выведенного текста // удалить дубликаты vector it = unique( texts.begin(), texts.end() ); texts.erase( it, texts.end() ); // посмотрим, что осталось for_each( texts.begin(), texts.end(), PrintElem() ); cout << "\n\n"; // отсортировать элементы // stable_sort сохраняет относительный порядок равных элементов stable_sort( texts.begin(), texts.end(), LessThan() ); for_each( texts.begin(), texts.end(), PrintElem() ); cout << "\n\n"; // подсчитать число строк, длина которых больше 6 int cnt = 0; // устаревшая форма count - в стандарте используется другая count_if( texts.begin(), texts.end(), GreaterThan(), cnt ); cout << "Number of words greater than length six are " << cnt << endl; static string rw[] = { "and", "if", "or", "but", "the" }; vector vector for ( ; it2 != remove_words.end(); ++it2 ) { int cnt = 0; // устаревшая форма count - в стандарте используется другая count( texts.begin(), texts.end(), *it2, cnt ); cout << cnt << " instances removed: " << (*it2) << endl; texts.erase( remove(texts.begin(),texts.end(),*it2), texts.end() ); } cout << "\n\n"; for_each( texts.begin(), texts.end(), PrintElem() ); } // difference_type - это тип, с помощью которого можно хранить результат // вычитания двух итераторов одного и того же контейнера // - в данном случае вектора строк ... // обычно это предполагается по умолчанию typedef vector // предшествующий принятию стандарта синтаксис для #include main() { vector vector string t1fn, t2fn; // запросить у пользователя имена входных файлов ... // в реальной программе надо бы выполнить проверку cout << "text file #1: "; cin >> t1fn; cout << "text file #2: "; cin >> t2fn; // открыть файлы ifstream infile1( t1fn.c_str()); ifstream infile2( t2fn.c_str()); // специальная форма итератора // обычно diff_type подразумевается по умолчанию ... istream_iterator< string, diff_type > input_set1( infile1 ), eos; istream_iterator< string, diff_type > input_set2( infile2 ); // специальная форма итератора copy( input_set1, eos, back_inserter( t1 )); copy( input_set2, eos, back_inserter( t2 )); sample.push_back( t1 ); sample.push_back( t2 ); process_vocab( &sample ); |
}
Упражнение 12.2
Длина слова – не единственная и, вероятно, не лучшая мера трудности текста. Другой возможный критерий – это длина предложения. Напишите программу, которая читает текст из файла либо со стандартного ввода, строит вектор строк для каждого предложения и передает его алгоритму count(). Выведите предложения в порядке сложности. Любопытный способ сделать это – сохранить каждое предложение как одну большую строку во втором векторе строк, а затем передать этот вектор алгоритму sort()
вместе с объектом-функцией, который считает, что чем строка короче, тем она меньше. (Более подробно с описанием конкретного обобщенного алгоритма, а также с иллюстрацией его применения вы может ознакомиться в Приложении, где все алгоритмы перечислены в алфавитном порядке.)
Упражнение 12.3
Более надежную оценку уровня трудности текста дает анализ структурной сложности предложений. Пусть каждой запятой присваивается 1 балл, каждому двоеточию или точке с запятой – 2 балла, а каждому тире – 3 балла. Модифицируйте программу из упражнения 12.2 так, чтобы она подсчитывала сложность каждого предложения. Воспользуйтесь алгоритмом count_if() для нахождения каждого из знаков препинания в векторе предложений. Выведите предложения в порядке сложности.
Использование пространства имен
Предположим, что мы хотим предоставить в общее пользование наш класс Array, разработанный в предыдущих примерах. Однако не мы одни занимались этой проблемой; возможно, кем-то где-то, скажем, в одном из подразделений компании Intel был создан одноименный класс. Из-за того что имена этих классов совпадают, потенциальные пользователи не могут задействовать оба класса одновременно, они должны выбрать один из них. Эта проблема решается добавлением к имени класса некоторой строки, идентифицирующей его разработчиков, скажем,class Cplusplus_Primer_Third_Edition_Array { ... };
Конечно, это тоже не гарантирует уникальность имени, но с большой вероятностью избавит пользователя от данной проблемы. Как, однако, неудобно пользоваться столь длинными именами!
Стандарт С++ предлагает для решения проблемы совпадения имен механизм, называемый пространством имен. Каждый производитель программного обеспечения может заключить свои классы, функции и другие объекты в свое собственное пространство имен. Вот как выглядит, например, объявление нашего класса Array:
namespace Cplusplus_Primer_3E { template
| |
}
Ключевое слово namespace
задает пространство имен, определяющее видимость нашего класса и названное в данном случае Cplusplus_Primer_3E. Предположим, что у нас есть классы от других разработчиков, помещенные в другие пространства имен:
namespace IBM_Canada_Laboratory {
template
class Matrix { ... };
}
namespace Disney_Feature_Animation {
class Point { ... };
template
}
По умолчанию в программе видны объекты, объявленные без явного указания пространства имен; они относятся к глобальному пространству имен. Для того чтобы обратиться к объекту из другого пространства, нужно использовать его квалифицированное имя, которое состоит из идентификатора пространства имен и идентификатора объекта, разделенных оператором разрешения области видимости (::). Вот как выглядят обращения к объектам приведенных выше примеров:
Cplusplus_Primer_3E::Array |
Disney_Feature_Animation::Point origin(5000,5000);
Для удобства использования можно назначать псевдонимы
пространствам имен. Псевдоним выбирают коротким и легким для запоминания. Например:
// псевдонимы
namespace LIB = IBM_Canada_Laboratory;
namespace DFA = Disney_Feature_Animation;
int main()
{
LIB::Array
}
Псевдонимы употребляются и для того, чтобы скрыть использование пространств имен. Заменив псевдоним, мы можем сменить набор задействованных функций и классов, причем во всем остальном код программы останется таким же. Исправив только одну строчку в приведенном выше примере, мы получим определение уже совсем другого массива:
namespace LIB = Cplusplus_Primer_3E;
int main()
{
LIB::Array
}
Конечно, чтобы это стало возможным, необходимо точное совпадение интерфейсов классов и функций, объявленных в этих пространствах имен. Представим, что класс Array из Disney_Feature_Animation не имеет конструктора с одним параметром – размером. Тогда следующий код вызовет ошибку:
namespace LIB = Disney_Feature_Animation;
int main()
{
LIB::Array
}
Еще более удобным является способ использования простого, неквалифицированного имени для обращения к объектам, определенным в некотором пространстве имен. Для этого существует директива using:
#include "IBM_Canada_Laboratory.h"
using namespace IBM_Canada_Laboratory;
int main()
{
// IBM_Canada_Laboratory::Matrix
Matrix mat(4,4);
// IBM_Canada_Laboratory::Array
Array
// ...
}
Пространство имен IBM_Canada_Laboratory
становится видимым в программе. Можно сделать видимым не все пространство, а отдельные имена внутри него (селективная директива using):
#include "IBM_Canada_Laboratory.h"
using namespace IBM_Canada_Laboratory::Matrix;
// видимым становится только Matrix
int main()
{
// IBM_Canada_Laboratory::Matrix
Matrix mat(4,4);
// Ошибка: IBM_Canada_Laboratory::Array невидим
Array
// ...
}
Как мы уже упоминали, все компоненты стандартной библиотеки С++ объявлены внутри пространства имен std. Поэтому простого включения заголовочного файла недостаточно, чтобы напрямую пользоваться стандартными функциями и классами:
#include
// ошибка: string невидим
string current_chapter = "Обзор С++";
Необходимо использовать директиву using:
#include
using namespace std;
// Ok: видим string
string current_chapter = "Обзор С++";
Заметим, однако, что таким образом мы возвращаемся к проблеме “засорения” глобального пространства имен, ради решения которой и был создан механизм именованных пространств. Поэтому лучше использовать либо квалифицированное имя:
#include |
std::string current_chapter = "Обзор С++";
либо селективную директиву using:
#include
using namespace std::string;
// Ok: string видим
string current_chapter = "Обзор С++";
Мы рекомендуем пользоваться последним способом.
В большинстве примеров этой книги директивы пространств имен были опущены. Это сделано ради сокращения размера кода, а также потому, что большинство примеров были скомпилированы компилятором, не поддерживающим пространства имен – достаточно недавнего нововведения С++. (Детали применения using-объявлений при работе с стандартной библиотекой С++ обсуждаются в разделе 8.6.)
В нижеследующих главах мы создадим еще четыре класса: String, Stack, List и модификацию Stack. Все они будут заключены в одно пространство имен – Cplusplus_Primer_3E. (Более подробно работа с пространствами имен рассматривается в главе 8.)
Упражнение 2.21
Дано пространство имен
namespace Exercize { |
class Array { ... };
template
void print (Array< EType > );
class String { ... }
template
class List { ... };
}
и текст программы:
int main() { |
Array
List
// ...
Array
List
print (*pas);
}
Программа не компилируется, поскольку объявления используемых классов заключены в пространство имен Exercise. Модифицируйте код программы, используя
(a) квалифицированные имена
(b) селективную директиву using
(c) механизм псевдонимов
(d) директиву using
Использование шаблонов
Наш класс IntArrayслужит хорошей альтернативой встроенному массиву целых чисел. Но в жизни могут потребоваться массивы для самых разных типов данных. Можно предположить, что единственным отличием массива элементов типа double от нашего является тип данных в объявлениях, весь остальной код совпадает буквально.
Для решения данной проблемы в С++ введен механизм шаблонов. В объявлениях классов и функций допускается использование параметризованных типов. Типы-параметры заменяются в процессе компиляции настоящими типами, встроенными или определенными пользователем. Мы можем создать шаблон класса Array, заменив в классе IntArray тип элементов int на обобщенный тип-параметр. Позже мы конкретизируем
типы-параметры, подставляя вместо них реальные типы int, double и string. В результате появится способ использовать эти конкретизации так, как будто мы на самом деле определили три разных класса для этих трех типов данных.
Вот как может выглядеть шаблон класса Array:
template class Array { public: explicit Array( int sz = DefaultArraySize ); Array( const elemType *ar, int sz ); Array( const Array &iA ); virtual ~Array() { delete[] _ia; } Array& operator=( const Array & ); int size() const { return _size; } virtual elemType& operator[]( int ix ) { return _ia[ix]; } virtual void sort( int,int ); virtual int find( const elemType& ); virtual elemType min(); virtual elemType max(); protected: void init( const elemType*, int ); void swap( int, int ); static const int DefaultArraySize = 12; int _size; elemType *_ia;
| |
};
Ключевое слово template
говорит о том, что задается шаблон,
параметры которого заключаются в угловые скобки (<>). В нашем случае имеется лишь один параметр elemType; ключевое слово class
перед его именем сообщает, что этот параметр представляет собой тип.
При конкретизации класса-шаблона Array
параметр elemType
заменяется на реальный тип при каждом использовании, как показано в примере:
#include #include "Array.h" int main() { const int array_size = 4; // elemType заменяется на int Array // elemType заменяется на double Array // elemType заменяется на char Array int ix; for ( ix = 0; ix < array_size; ++ix ) { ia[ix] = ix; da[ix] = ix * 1.75; ca[ix] = ix + 'a'; } for ( ix = 0; ix < array_size; ++ix ) cout << "[ " << ix << " ] ia: " << ia[ix] << "\tca: " << ca[ix] << "\tda: " << da[ix] << endl; return 0; |
Здесь определены три экземпляра класса Array:
Array |
Array
Что делает компилятор, встретив такое объявление? Подставляет текст шаблона Array, заменяя параметр elemType на тот тип, который указан в каждом конкретном случае. Следовательно, объявления членов приобретают в первом случае такой вид:
// Array
int _size;
int *_ia;
Заметим, что это в точности соответствует определению массива IntArray.
Для оставшихся двух случаев мы получим следующий код:
// Array |
double *_ia;
// Array
int _size;
char *_ia;
Что происходит с функциями-членами? В них тоже тип-параметр elemType заменяется на реальный тип, однако компилятор не конкретизирует те функции, которые не вызываются в каком-либо месте программы. (Подробнее об этом в разделе 16.8.)
При выполнении программа этого примера выдаст следующий результат:
[ 0 ] ia: 0 ca: a da: 0
[ 1 ] ia: 1 ca: b da: 1.75
[ 2 ] ia: 2 ca: c da: 3.5
[ 3 ] ia: 3 ca: d da: 5.25
Механизм шаблонов можно использовать и в наследуемых классах. Вот как выглядит определение шаблона класса ArrayRC:
#include |
template
class ArrayRC : public Array
public:
ArrayRC( int sz = DefaultArraySize )
: Array
ArrayRC( const ArrayRC& r )
: Array
ArrayRC( const elemType *ar, int sz )
: Array
elemType& ArrayRC
{
assert( ix >= 0 && ix < Array
return _ia[ ix ];
}
private:
// ...
};
Подстановка реальных параметров вместо типа-параметра elemType происходит как в базовом, так и в производном классах. Определение
ArrayRC
ведет себя точно так же, как определение IntArrayRC из предыдущего раздела. Изменим пример использования из предыдущего раздела. Прежде всего, чтобы оператор
// функцию swap() тоже следует сделать шаблоном |
был допустимым, нам потребуется представить функцию swap() в виде шаблона.
#include "Array.h" |
inline void
swap( Array
{
elemType tmp = array[ i ];
array[ i ] = array[ j ];
array[ j ] = tmp;
}
При каждом вызове swap()
генерируется подходящая конкретизация, которая зависит от типа массива. Вот как выглядит программа, использующая шаблоны Array и ArrayRC:
#include |
#include "ArrayRC.h"
template
inline void
swap( Array
{
elemType tmp = array[ i ];
array[ i ] = array[ j ];
array[ j ] = tmp;
}
int main()
{
Array
ArrayRC
cout << "swap() with Array
int size = ia1.size();
swap( ia1, 1, size );
cout << "swap() with ArrayRC
size = ia2.size();
swap( ia2, 1, size );
return 0;
}
Упражнение 2.13
Пусть мы имеем следующие объявления типов:
template |
typedef string *Pstring;
Есть ли ошибки в приведенных ниже описаниях объектов?
(a) Array< int*& > pri(1024); (b) Array< Array (c) Array< complex< double > > acd(1024); (d) Array< Status > as(1024); |
Упражнение 2.14
Перепишите следующее определение, сделав из него шаблон класса:
class example1 { public: example1 (double min, double max); example1 (const double *array, int size); double& operator[] (int index); bool operator== (const example1&) const; bool insert (const double*, int); bool insert (double); double min (double) const { return _min; }; double max (double) const { return _max; }; void min (double); void max (double); int count (double value) const; private: int size; double *parray; double _min; double _max; |
Упражнение 2.15
Имеется следующий шаблон класса:
template |
explicit Example2 (elemType val=0) : _val(val) {};
bool min(elemType value) { return _val < value; }
void value(elemType new_val) { _val = new_val; }
void print (ostream &os) { os << _val; }
private:
elemType _val;
}
template
ostream& operator<<(ostream &os,const Example2
{ ex.print(os); return os; }
Какие действия вызывают следующие инструкции?
(a) Example2 (b) ex1.min (&ex1); (c) Example2 (d) sa = sb; (e) Example2 |
Упражнение 2.16
Пример из предыдущего упражнения накладывает определенные ограничения на типы данных, которые могут быть подставлены вместо elemType. Так, параметр конструктора имеет по умолчанию значение 0:
explicit Example2 (elemType val=0) : _val(val) {};
Однако не все типы могут быть инициализированы нулем (например, тип string), поэтому определение объекта
Example2
является правильным, а
Example2
приведет к синтаксической ошибке[4]. Также ошибочным будет вызов функции min(), если для данного типа не определена операция меньше. С++ не позволяет задать ограничения для типов, подставляемых в шаблоны. Как вы думаете, было бы полезным иметь такую возможность? Если да, попробуйте придумать синтаксис задания ограничений и перепишите в нем определение класса Example2. Если нет, поясните почему.
Упражнение 2.17
Как было показано в предыдущем упражнении, попытка использовать шаблон Example2 с типом, для которого не определена операция меньше, приведет к синтаксической ошибке. Однако ошибка проявится только тогда, когда в тексте компилируемой программы действительно встретится вызов функции min(), в противном случае компиляция пройдет успешно. Как вы считаете, оправдано ли такое поведение? Не лучше ли предупредить об ошибке сразу, при обработке описания шаблона? Поясните свое мнение.
Итератор istream_iterator
В общем виде объявление потокового итератора чтения istream_iteratorимеет форму:
istream_iterator
где Type – это любой встроенный или пользовательский тип класса, для которого определен оператор ввода. Аргументом конструктора может быть объект либо класса istream, например cin, либо производного от него класса с открытым типом наследования – ifstream:
#include #include #include #include // прочитать последовательность объектов типа complex // из стандартного ввода istream_iterator< complex > is_complex( cin ); // прочитать последовательность строк из именованного файла ifstream infile( "C++Primer" );
| |
istream_iterator< string > is_string( infile );
При каждом применении оператора инкремента к объекту типа istream_iterator
читается следующий элемент из входного потока, для чего используется оператор operator>>(). Чтобы сделать то же самое в обобщенных алгоритмах, необходимо предоставить пару итераторов, обозначающих начальную и конечную позицию в файле. Начальную позицию дает istream_iterator, инициализированный объектом istream, – такой, скажем, как is_string. Для получения конечной позиции мы используем специальный конструктор по умолчанию класса istream_iterator:
// конструирует итератор end_of_stream, который будет служить маркером // конца потока в итераторной паре istream_iterator< string > end_of_stream vector // правильно: передаем пару итераторов copy( is_string, end_of_stream, inserter( text, text.begin() ));
| |
Итератор ostream_iterator
Объявление потокового итератора записи ostream_iteratorможет быть представлено в двух формах:
ostream_iterator
| |
ostream_iterator
где Type – это любой встроенный или пользовательский тип класса, для которого определен оператор вывода (operator<<). Во второй форме delimiter – это разделитель, то есть C-строка символов, которая выводится в файл после каждого элемента. Такая строка должна заканчиваться двоичным нулем, иначе поведение программы не определено (скорее всего, она аварийно завершит выполнение). В качестве аргумента ostream может выступать объект класса ostream, например cout, либо производного от него класса с открытым типом наследования, скажем ofstream:
#include #include #include #include // записать последовательность объектов типа complex // в стандартный вывод, разделяя элементы пробелами ostream_iterator< complex > os_complex( cin, " " ); // записать последовательность строк в именованный файл ofstream outfile( "dictionary" );
| |
ostream_iterator< string > os_string( outfile, "\n" );
Вот простой пример чтения из стандартного ввода и копирования на стандартный вывод с помощью безымянных потоковых итераторов и обобщенного алгоритма copy():
#include #include #include int main() { copy( istream_iterator< int >( cin ), istream_iterator< int >(), ostream_iterator< int >( cout, " " ));
| |
}
Ниже приведена небольшая программа, которая открывает указанный пользователем файл и копирует его на стандартный вывод, применяя для этого алгоритм copy() и потоковый итератор записи ostream_iterator:
#include #include #include #include main() { string file_name; cout << "please enter a file to open: "; cin >> file_name; if ( file_name.empty() || !cin ) { cerr << "unable to read file name\n"; return -1; } ifstream infile( file_name.c_str()); if ( !infile ) { cerr << "unable to open " << file_name << endl; return -2; } istream_iterator< string > ins( infile ), eos; ostream_iterator< string > outs( cout, " " ); copy( ins, eos, outs );
| |
}
Итераторы вставки
Вот еще один фрагмент программы, в котором есть тонкая, но серьезная ошибка. Видите ли вы, в чем она заключается?int ia[] = { 0, 1, 1, 2, 3, 5, 5, 8 }; vector< int > ivec( ia, ia+8 ), vres; // ... // поведение программы во время выполнения не определено |
unique_copy( ivec.begin(), ivec.end(), vres.begin() );
Проблема вызвана тем, что алгоритм unique_copy() использует присваивание для копирования значения каждого элемента из вектора ivec, но эта операция завершится неудачно, поскольку в vres не выделено место для хранения девяти целых чисел.
Можно было бы написать две версии алгоритма unique_copy(): одна присваивает элементы, а вторая вставляет их. Эта последняя версия должна, в таком случае, поддерживать вставку в начало, в конец или в произвольное место контейнера.
Альтернативный подход, принятый в стандартной библиотеке, заключается в определении трех адаптеров, которые возвращают специальные итераторы вставки:
· back_inserter()
вызывает определенную для контейнера операцию вставки push_back()
вместо оператора присваивания. Аргументом back_inserter() является сам контейнер. Например, вызов unique_copy()
можно исправить, написав:
// правильно: теперь unique_copy() вставляет элементы с помощью // vres.push_back()... unique_copy( ivec.begin(), ivec.end(), |
back_inserter( vres ) );
· front_inserter()
вызывает определенную для контейнера операцию вставки push_front()
вместо оператора присваивания. Аргументом front_inserter()
тоже является сам контейнер. Заметьте, однако, что класс vector не поддерживает push_front(), так что использовать такой адаптер для вектора нельзя:
// увы, ошибка: // класс vector не поддерживает операцию push_front() // следует использовать контейнеры deque или list unique_copy( ivec.begin(), ivec.end(), |
front_inserter( vres ) );
· inserter()
вызывает определенную для контейнера операцию вставки insert()
вместо оператора присваивания. inserter() принимает два аргумента: сам контейнер и итератор, указывающий позицию, с которой должна начаться вставка:
unique_copy( ivec.begin(), ivec.end(), |
· Итератор, указывающий на позицию начала вставки, сдвигается вперед после каждой вставки, так что элементы располагаются в нужном порядке, как если бы мы написали:
vector< int >::iterator iter = vres.begin(), iter2 = ivec.begin(); for ( ; iter2 != ivec.end() ++ iter, ++iter2 ) |
Итераторы
Итератор предоставляет обобщенный способ перебора элементов любого контейнера– как последовательного, так и ассоциативного. Пусть iterявляется итератором для какого-либо контейнера. Тогда
++iter;
перемещает итератор так, что он указывает на следующий элемент контейнера, а
*iter;
разыменовывает итератор, возвращая элемент, на который он указывает.
Все контейнеры имеют функции-члены begin() и end().
· begin()
возвращает итератор, указывающий на первый элемент контейнера.
· end()
возвращает итератор, указывающий на элемент, следующий за последним в контейнере.
Чтобы перебрать все элементы контейнера, нужно написать:
for ( iter = container. begin(); iter != container.end(); ++iter ) |
do_something_with_element( *iter );
Объявление итератора выглядит слишком сложным. Вот определение пары итераторов вектора типа string:
// vector vector
| |
vector
В классе vector для определения iterator
используется typedef. Синтаксис
vector
ссылается на iterator, определенный с помощью
typedef
внутри класса vector, содержащего элементы типа string.
Для того чтобы напечатать все элементы вектора, нужно написать:
for( ; iter != iter_end; ++iter ) |
cout << *iter << '\n';
Здесь значением *iter
выражения является, конечно, элемент вектора.
В дополнение к типу iterator в каждом контейнере определен тип const_iterator, который необходим для навигации по контейнеру, объявленному как const. const_iterator позволяет только читать элементы контейнера:
#include void even_odd( const vector vector vector { // const_iterator необходим для навигации по pvec vector vector for ( ; c_iter != c_iter_end; ++c_iter ) if ( *c_iter % 2 ) pvec_even->push_back( *c_iter ); else pvec_odd->push_back( *c_iter );
| |
}
Что делать, если мы хотим просмотреть некоторое подмножество элементов, например взять каждый второй или третий элемент, или хотим начать с середины? Итераторы поддерживают адресную арифметику, а значит, мы можем прибавить некоторое число к итератору:
vector
iter
получает значение адреса элемента из середины вектора, а выражение
iter += 2;
сдвигает iter на два элемента.
Арифметические действия с итераторами возможны только для контейнеров vector и deque. list не поддерживает адресную арифметику, поскольку его элементы не располагаются в непрерывной области памяти. Следующее выражение к списку неприменимо:
ilist.begin() + 2;
так как для перемещения на два элемента необходимо два раза перейти по адресу, содержащемуся в закрытом члене next. У классов vector и deque перемещение на два элемента означает прибавление 2 к указателю на текущий элемент. (Адресная арифметика рассматривается в разделе 3.3.)
Объект контейнерного типа может быть инициализирован парой итераторов, обозначающих начало и конец последовательности копируемых в новый объект элементов. (Второй итератор должен указывать на элемент, следующий за последним копируемым.) Допустим, есть вектор:
#include #include #include int main() { vector string intext; while ( cin >> intext ) svec.push_back( intext ); // обработать svec ... |
Вот как можно определить новые векторы, инициализируя их элементами первого вектора:
int main() { vector // ... // инициализация svec2 всеми элементами svec vector // инициализация svec3 первой половиной svec vector svec.begin() + svec.size()/2; vector // ... |
Использование специального типа istream_iterator (о нем рассказывается в разделе 12.4.3) упрощает чтение элементов из входного потока в svec:
#include #include #include int mainQ { // привязка istream_iterator к стандартному вводу istream_iterator // istream_iterator, отмечающий конец потока istream_iterator // инициализация svec элементами, считываемыми из cin; vector // ... |
Кроме итераторов, для задания диапазона значений, инициализирующих контейнер, можно использовать два указателя на массив встроенного типа. Пусть есть следующий массив строк:
#include string words[4] = { "stately", "plump", "buck", "mulligan" |
Мы можем инициализировать вектор с помощью указателей на первый элемент массива и на элемент, следующий за последним:
vector< string > vwords( words, words+4 );
Второй указатель служит “стражем”: элемент, на который он указывает, не копируется.
Аналогичным образом можно инициализировать список целых элементов:
int ia[6] = { 0, 1, 2, 3, 4, 5 }; |
В разделе 12.4 мы снова обратимся к итераторам и опишем их более детально. Сейчас информации достаточно для того, чтобы использовать итераторы в нашей системе текстового поиска. Но прежде чем вернуться к ней, рассмотрим некоторые дополнительные операции, поддерживаемые контейнерами.
Упражнение 6.9
Какие ошибки допущены при использовании итераторов:
const vector< int > ivec; vector< string > svec; list< int > ilist; (a) vector (b) list (c) vector (d) for ( vector it = svec.begin(); it != 0; ++it ) |
Упражнение 6.10
Найдите ошибки в использовании итераторов:
int ia[7] = { 0, 1, 1, 2, 3, 5, 8 }; string sa[6] = { "Fort Sumter", "Manassas", "Perryville", "Vicksburg", "Meridian", "Chancellorsvine" }; (a) vector (b) list (c) list (d) vector (e) list |
Явная специализация шаблона А
Не всегда удается написать шаблон функции, который годился бы для всех возможных типов, с которыми он может быть конкретизирован. В некоторых случаях имеется специальная информация о типе, позволяющая написать более эффективную функцию, чем конкретизированная по шаблону. А иногда общее определение, предоставляемое шаблоном, для некоторого типа просто не работает. Рассмотрим, например, следующее определение шаблона функции max():// обобщенное определение шаблона template T max( T t1, T t2 ) { return ( t1 > t2 ? t1 : t2 );
| |
}
Когда этот шаблон конкретизируется с аргументом типа const char*, то обобщенное определение оказывается семантически некорректным, если мы интерпретируем каждый аргумент как строку символов в смысле языка C, а не как указатель на символ. В этом случае необходимо предоставить специализированное определение для конкретизации шаблона.
Явное определение специализации – это такое определение, в котором за ключевым словом template следует пара угловых скобок <>, а за ними – определение специализированного шаблона. Здесь указывается имя шаблона, аргументы, для которых он специализируется, список параметров функции и ее тело. В следующем примере для max(const char*, const char*) определена явная специализация:
#include // явная специализация для const char*: // имеет приоритет над конкретизацией шаблона // по обобщенному определению typedef const char *PCC; template<> PCC max< PCC >( PCC s1, PCC s2 ) {
| |
return ( strcmp( s1, s2 ) > 0 ? s1 : s2 );
Поскольку имеется явная специализация, шаблон не будет конкретизирован с типом const char* при вызове в программе функции max(const char*, const char*). При любом обращении к max() с двумя аргументами типа const char*
работает специализированное определение. Для любых других обращений функция сначала конкретизируется по обобщенному определению шаблона, а затем вызывается. Вот как это выглядит:
#include // здесь должно быть определение шаблона функции max() // и его специализации для аргументов const char* int main() { // вызов конкретизированной функции: int max< int >( int, int ); int i = max( 10, 5 ); // вызов явной специализации: // const char* max< const char* >( const char*, const char* ); const char *p = max( "hello", "world" ); cout << "i: " << i << " p: " << p << endl; return 0; |
Можно объявлять явную специализацию шаблона функции, не определяя ее. Например, для функции max(const char*, const char*) она объявляется так:
// объявление явной специализации шаблона функции |
При объявлении или определении явной специализации шаблона функции нельзя опускать слово template и следующую за ним пару скобок <>. Кроме того, в объявлении специализации обязательно должен быть список параметров функции:
// ошибка: неправильные объявления специализации // отсутствует template<> PCC max< PCC >( PCC, PCC ); // отсутствует список параметров |
Однако здесь можно опускать задание аргументов шаблона, если они выводятся из формальных параметров функции:
// правильно: аргумент шаблона const char* выводится из типов параметров |
В следующем примере шаблон функции sum()
явно специализирован:
template T1 sum( T2 op1, T3 op2 ); // объявления явных специализаций // ошибка: аргумент шаблона для T1 не может быть выведен; // он должен быть задан явно template<> double sum( float, float ); // правильно: аргумент для T1 задан явно, // T2 и T3 выводятся и оказываются равными float template<> double sum // правильно: все аргументы заданы явно |
template<> int sum
Пропуск части template<> в объявлении явной специализации не всегда является ошибкой. Например:
// обобщенное определение шаблона template T max( T t1, T t2 ) { /* ... */ } // правильно: обычное объявление функции |
Однако эта инструкция не является специализацией шаблона функции. Здесь просто объявляется обычная функция с типом возвращаемого значения и списком параметров, которые соответствуют полученным при конкретизации шаблона. Объявление обычной функции, являющееся конкретизацией шаблона, не считается ошибкой.
Так почему бы просто не объявить обычную функцию? Как было показано в разделе 10.3, для преобразования фактического аргумента функции, конкретизированной по шаблону, в соответствующий формальный параметр в случае, когда этот аргумент принимает участие в выводе аргумента шаблона, может быть применено лишь ограниченное множество преобразований типов. Точно так же обстоит дело и в ситуации, когда шаблон функции специализируется явно: к фактическим аргументам функции при этом тоже применимо лишь ограниченное множество преобразований. Явные специализации не помогают обойти соответствующие ограничения. Если мы хотим выйти за их пределы, то должны определить обычную функцию вместо специализации шаблона. (В разделе 10.8 этот вопрос рассматривается более подробно; там же показано, как работает разрешение перегруженной функции для вызова, который соответствует как обычной функции, так и экземпляру, конкретизированному из шаблона.)
Явную специализацию можно объявлять даже тогда, когда специализируемый шаблон объявлен, но не определен. В предыдущем примере шаблон функции sum()
лишь объявлен к моменту специализации. Хотя определение шаблона не обязательно, объявление все же требуется. То, что sum() – шаблон, должно быть известно до того, как это имя может быть специализировано.
Такое объявление должно быть видимо до его использования в исходном файле. Например:
#include #include // обобщенное определение шаблона template T max( T t1, T t2 ) { /* ... */ } int main() { // конкретизация функции // const char* max< const char* >( const char*, const char* ); const char *p = max( "hello", "world" ); cout << "p: " << p << endl; return 0; } // некорректная программа: явная специализация const char *: // имеет приоритет над обобщенным определением шаблона typedef const char *PCC; |
В предыдущем примере конкретизация max(const char*, const char*) предшествует объявлению явной специализации. Поэтому компилятор имеет право предположить, что функция должна быть конкретизирована по обобщенному определению шаблона. Однако в программе не может одновременно существовать явная специализация и экземпляр, конкретизированный по тому же шаблону с тем же множеством аргументов. Когда в исходном файле после конкретизации встречается явная специализация
max(const char*, const char*), компилятор выдает сообщение об ошибке.
Если программа состоит из нескольких файлов, то объявление явной специализации шаблона должно быть видимо в каждом файле, в котором она используется. Не разрешается в одних файлах конкретизировать шаблон функции по обобщенному определению, а в других специализировать с тем же множеством аргументов. Рассмотрим следующий пример:
// --------- max.h ------- // обобщенное определение шаблона template Type max( Type t1, Type t2 ) { /* ... */ } // --------- File1.C ------- #include #include "max.h" void another(); int main() { // конкретизация функции // const char* max< const char* >( const char*, const char* ); const char *p = max( "hello", "world" ); cout << "p: " << p << endl; another(); return 0; } // --------- File2.C ------- #include #include #include "max.h" // явная специализация шаблона для const char* typedef const char *PCC; template<> PCC max< PCC >( PCC s1, PCC s2 ) { /* ... */ } void another() { // явная специализация // const char* max< const char* >( const char*, const char* ); const char *p = max( "hi", "again" ); cout << " p: " << p << endl; return 0; |
}
Эта программа состоит из двух файлов. В файле File1.C нет объявления явной специализации max(const char*, const char*). Вместо этого шаблон функции конкретизируется из обобщенного определения. В файле File2.C
объявлена явная специализация, и при обращении к max("hi", "again")
именно она и вызывается. Поскольку в одной и той же программе функция max(const char*, const char*) то конкретизируется по шаблону, то специализируется явно, компилятор считает программу некорректной. Для исправления этого объявление явной специализации шаблона должно предшествовать вызову функции max(const char*, const char*) в файле File1.C.
Чтобы избежать таких ошибок и гарантировать, что объявление явной специализации шаблона max(const char*, const char*) внесено в каждый файл, где используется шаблон функции max() с аргументами типа const char*, это объявление следует поместить в заголовочный файл "max.h" и включать его во все исходные файлы, в которых используется шаблон max():
// --------- max.h ------- // обобщенное определение шаблона template Type max( Type t1, Type t2 ) { /* ... */ } // объявление явной специализации шаблона для const char* typedef const char *PCC; template<> PCC max< PCC >( PCC s1, PCC s2 ); // --------- File1.C ------- #include #include "max.h" void another(); int main() { // специализация // const char* max< const char* >( const char*, const char* ); const char *p = max( "hello", "world" ); // .... |
Упражнение 10.10
Определите шаблон функции count() для подсчета числа появлений некоторого значения в массиве. Напишите вызывающую программу. Последовательно передайте в ней массив значений типа double, int и сhar. Напишите специализированный экземпляр шаблона count() для обработки строк.
Явное преобразование типов
Явное преобразование типов производится при помощи следующих операторов: static_cast, dynamic_cast, const_cast и reinterpret_cast. Заметим, что, хотя иногда явное преобразование необходимо, оно служит потенциальным источником ошибок, поскольку подавляет проверку типов, выполняемую компилятором. Давайте сначала посмотрим, зачем нужно такое преобразование.Указатель на объект любого неконстантного типа может быть присвоен указателю типа
void*, который используется в тех случаях, когда действительный тип объекта либо неизвестен, либо может меняться в ходе выполнения программы. Поэтому указатель void*
иногда называют универсальным
указателем. Например:
int iva1; int *pi = 0; char *pc = 0; void *pv; pv = pi; // правильно: неявное преобразование pv = pc; // правильно: неявное преобразование const int *pci = &iva1; pv = pci; // ошибка: pv имеет тип, отличный от const void*; |
const void *pcv = pci; // правильно
Однако указатель void* не может быть разыменован непосредственно. Компилятор не знает типа объекта, адресуемого этим указателем. Но это известно программисту, который хочет преобразовать указатель void* в указатель определенного типа. С++ не обеспечивает подобного автоматического преобразования:
#inc1ude int ival = 1024; void *pv; int *pi = &iva1; const char *pc = "a casting call"; void mumble() { pv = pi; // правильно: pv получает адрес ival pc = pv; // ошибка: нет стандартного преобразования char *pstr = new char[ str1en( pc )+1 ]; strcpy( pstr, pc );
| |
}
Компилятор выдает сообщение об ошибке, так как в данном случае указатель pv
содержит адрес целого числа ival, и именно этот адрес пытаются присвоить указателю на строку. Если бы такая программа была допущена до выполнения, то вызов функции strcpy(), которая ожидает на входе строку символов с нулем в конце, скорее всего привел бы к краху, потому что вместо этого strcpy() получает указатель на целое число. Подобные ошибки довольно просто не заметить, именно поэтому С++ запрещает неявное преобразование указателя на void в указатель на другой тип. Однако такой тип можно изменить явно:
void mumble 0 { // правильно: программа по-прежнему содержит ошибку, // но теперь она компилируется! // Прежде всего нужно проверить // явные преобразования типов... pc = static_cast< char* >( pv ); char *pstr = new char[ str1en( pc )+1 ]; // скорее всего приведет к краху strcpy( pstr, pc ); |
Другой причиной использования явного преобразования типов может служить необходимость избежать стандартного преобразования или выполнить вместо него собственное. Например, в следующем выражении ival
сначала преобразуется в double, потом к нему прибавляется dval, и затем результат снова трансформируется в int.
double dval; int iva1; |
Можно уйти от ненужного преобразования, явно заменив dval на int:
ival += static_cast< int >( dval );
Третьей причиной является желание избежать неоднозначных ситуаций, в которых возможно несколько вариантов применения правил преобразования по умолчанию. (Мы рассмотрим этот случай в главе 9, когда будем говорить о перегруженных функциях.)
Синтаксис операции явного преобразования типов таков:
cast-name< type >( expression );
Здесь cast-name – одно из ключевых слов static_cast, const_cast, dynamic_cast или reinterpret_cast, а type – тип, к которому приводится выражение expression.
Четыре вида явного преобразования введены для того, чтобы учесть все возможные формы приведения типов. Так const_cast
служит для трансформации константного типа в неконстантный и подвижного (volatile) – в неподвижный. Например:
extern char *string_copy( char* ); const char *pc_str; |
Любое иное использование const_cast вызывает ошибку компиляции, как и попытка подобного приведения с помощью любого из трех других операторов.
С применением static_cast
осуществляются те преобразования, которые могут быть сделаны неявно, на основе правил по умолчанию:
double d = 97.0; |
char ch = static_cast< char >( d );
Зачем использовать static_cast? Дело в том, что без него компилятор выдаст предупреждение о возможной потере точности. Применение оператора static_cast
говорит и компилятору, и человеку, читающему программу, что программист знает об этом.
Кроме того, с помощью static_cast
указатель void*
можно преобразовать в указатель определенного типа, арифметическое значение – в значение перечисления (enum), а базовый класс – в производный. (О преобразованиях типов базовых и производных классов говорится в главе 19.)
Эти изменения потенциально опасны, поскольку их правильность зависит от того, какое конкретное значение имеет преобразуемое выражение в данный момент выполнения программы:
enum mumble { first = 1, second, third }; extern int ival; |
Трансформация ival в mumble
будет правильной только в том случае, если ival равен 1, 2 или 3.
reinterpret_cast
работает с внутренними представлениями объектов (re-interpret – другая интерпретация того же внутреннего представления), причем правильность этой операции целиком зависит от программиста. Например:
complex |
Программист не должен забыть или упустить из виду, какой объект реально адресуется указателем char* pc. Формально это указатель на строку встроенного типа, и компилятор не будет препятствовать использованию pc для инициализации строки:
string str( pc );
хотя скорее всего такая команда вызовет крах программы.
Это хороший пример, показывающий, насколько опасны бывают явные преобразования типов. Мы можем присваивать указателям одного типа значения указателей совсем другого типа, и это будет работать до тех пор, пока мы держим ситуацию под контролем. Однако, забыв о подразумеваемых деталях, легко допустить ошибку, о которой компилятор не сможет нас предупредить.
Особенно трудно найти подобную ошибку, если явное преобразование типа делается в одном файле, а используется измененное значение в другом.
В некотором смысле это отражает фундаментальный парадокс языка С++: строгая проверка типов призвана не допустить подобных ошибок, в то же время наличие операторов явного преобразования позволяет “обмануть” компилятор и использовать объекты разных типов на свой страх и риск. В нашем примере мы “отключили” проверку типов при инициализации указателя pc и присвоили ему адрес комплексного числа. При инициализации строки str такая проверка производится снова, но компилятор считает, что pc указывает на строку, хотя, на самом-то деле, это не так!
Четыре оператора явного преобразования типов были введены в стандарт С++ как наименьшее зло при невозможности полностью запретить такое приведение. Устаревшая, но до сих пор поддерживаемая стандартом С++ форма явного преобразования выглядит так:
char *pc = (char*) pcom;
Эта запись эквивалентна применению оператора reinterpret_cast, однако выглядит не так заметно. Использование операторов xxx_cast
позволяет четко указать те места в программе, где содержатся потенциально опасные трансформации типов.
Если поведение программы становится ошибочным и непонятным, возможно, в этом виноваты явные видоизменения типов указателей. Использование операторов явного преобразования помогает легко обнаружить места в программе, где такие операции выполняются. (Другой причиной непредсказуемого поведения программы может стать нечаянное уничтожение объекта (delete), в то время как он еще должен использоваться в работе. Мы поговорим об этом в разделе 8.4, когда будем обсуждать динамическое выделение памяти.)
Оператор dynamic_cast
применяется при идентификации типа во время выполнения (run-time type identification). Мы вернемся к этой проблеме лишь в разделе 19.1.
Явное задание аргументов шаблона A
В некоторых ситуациях автоматически вывести типы аргументов шаблона невозможно. Как мы видели на примере шаблона функции min5(), если процесс вывода дает два различных типа для одного и того же параметра шаблона, то компилятор сообщает об ошибке – неудачном выводе аргументов.В таких ситуациях приходится подавлять механизм вывода и задавать аргументы явно, указывая их с помощью заключенного в угловые скобки списка разделенных запятыми значений, который следует после имени конкретизируемого шаблона функции. Например, если мы хотим задать тип unsigned int в качестве значения аргумента шаблона T в рассмотренном выше примере использования min5(), то нужно записать вызов конкретизируемого шаблона так:
// конкретизируется min5( unsigned int, unsigned int ) |
min5< unsigned int >( ui, 1024 );
В этом случае список аргументов шаблона
Обратите внимание, что при вызове функции min5() второй аргумент равен 1024, т.е. имеет тип int. Так как тип второго формального параметра функции при явном задании аргумента шаблона установлен в unsigned int, то второй фактический параметр функции приводится к типу unsigned int с помощью стандартного преобразования целых типов.
В предыдущем разделе мы говорили, что в процессе вывода аргументов шаблона к фактическим аргументам функции разрешается применять только ограниченное множество преобразований типов. Трансформация int в unsigned int в это множество не входит. Но если аргументы шаблона задаются явно, выполнять вывод типов не нужно, поскольку они уже зафиксированы. Следовательно, при явном задании аргументов шаблона для приведения типов фактических аргументов функции к типам формальных параметров можно применять любые стандартные преобразования.
Помимо разрешения любых преобразований фактических аргументов функции, явное задание аргументов шаблона помогает избежать и других проблем, встающих перед программистом. Рассмотрим следующую задачу. Мы хотим определить шаблон функции с именем sum()
так, чтобы его конкретизация возвращала значения типа, достаточно большого для представления суммы двух значений любых двух типов, переданных в любом порядке. Как это сделать? Какой тип возвращаемого значения следует задать?
// каким должен быть тип возвращаемого значения: T или U template |
В нашем случае нельзя использовать ни тот, ни другой параметрический тип, иначе мы неизбежно допустим ошибку:
char ch; unsigned int ui; // ни T, ни U нельзя использовать в качестве типа возвращаемого значения sum( ch, ui ); // правильно: U sum( T, U ); |
Решение заключается в том, чтобы ввести в шаблон третий параметр для обозначения типа возвращаемого значения:
// T1 не появляется в списке параметров шаблона функции template |
Поскольку тип возвращаемого значения может отличаться от типов аргументов функции, T1 не упоминается в списке формальных параметров. Это потенциальная проблема, так как тип T1 не может быть выведен из фактических аргументов функции. Однако, если при конкретизации sum() мы зададим аргументы шаблона явно, то избегнем сообщения компилятора о невозможности вывести T1.
Например:
typedef unsigned int ui_type; ui_type calc( char ch, ui_type ui ) { // ... // ошибка: невозможно вывести T1 ui_type loc1 = sum( ch, ui ); // правильно: аргументы шаблона заданы явно // T1 и T3 - это unsigned int, T2 - это char ui_type loc2 = sum< ui_type, ui_type >( ch, ui ); |
Не хватает возможности явно задать T1, но не T2 и T3, поскольку их можно вывести из аргументов функции при вызове.
При явном задании аргументов шаблона необходимо перечислять только те, которые не могут быть выведены автоматически. Но, как и в случае аргументов функции со значениями по умолчанию, опускать можно исключительно “хвостовые”:
// правильно: T3 - это unsigned int // T3 выведен из типа ui ui_type loc3 = sum< ui_type, char >( ch, ui ); // правильно: T2 - это char, T3 - unsigned int // T2 и T3 выведены из типа pf ui_type (*pf)( char, ui_type ) = &sum< ui_type >; // ошибка: опускать можно только “хвостовые” аргументы |
ui_type loc4 = sum< ui_type, , ui_type >( ch, ui );
Встречаются ситуации, когда невозможно вывести аргументы шаблона в контексте, где конкретизируется шаблон функции; следовательно, необходимо их явно задать. Именно выявление таких ситуаций и необходимость решить проблему послужила причиной поддержки явного задания аргументов шаблона в стандартном C++.
В следующем примере берется адрес конкретизированной функции sum() и передается в качестве аргумента перегруженной функции manipulate(). Как мы показали в разделе 10.2, невозможно понять, как именно нужно конкретизировать sum(), если есть только списки параметров функций manipulate(). Имеется две разных функции sum(), и обе удовлетворяют условиям вызова. Следовательно, вызов manipulate() неоднозначен. Одним из способов разрешения такой неоднозначности является явное приведение типов. Однако лучше использовать явное задание аргументов шаблона: оно позволяет указать, как именно конкретизировать sum(), и, следовательно, выбрать нужный вариант перегруженной функции manipulate(). Например:
template T1 sum( T2 op1, T3 op2 ) { /* ... */ } void manipulate( int (*pf)( int,char ) ); void manipulate( double (*pf)( float,float ) ); int main() { // ошибка: какой из возможных экземпляров sum: // int sum( int,char ) или double sum( float, float )? manipulate( &sum ); // берется адрес конкретизированного экземпляра // double sum( float, float ) // вызывается: void manipulate( double (*pf)( float, float ) ); manipulate( &sum< double, float, float > ); |
Отметим, что явное задание аргументов шаблона следует использовать только тогда, когда это абсолютно необходимо для разрешения неоднозначности или для конкретизации шаблона функции в контексте, где вывести аргументы невозможно. Во-первых, определение типов и значений аргументов шаблона проще оставить компилятору. А во-вторых, если мы модифицируем объявления в программе, так что типы аргументов функции при вызове конкретизированного шаблона изменятся, то компилятор автоматически скорректирует вызов без нашего вмешательства. С другой стороны, если аргументы шаблона заданы явно, необходимо проверить, что они по-прежнему отвечают новым типам аргументов функции. Поэтому мы рекомендуем избегать явного задания аргументов шаблона.
Упражнение 10.6
Назовите две ситуации, когда использование явного задания аргументов шаблона необходимо.
Упражнение 10.7
Пусть дано следующее определение шаблона функции sum():
template |
Какие из приведенных ниже вызовов ошибочны? Почему?
double dobj1, dobj2; float fobj1, fobj2; char cobj1, cobj2; (a) sum( dobj1, dobj2 ); (b) sum (c) sum |
Явные объявления конкретизации
При использовании модели с включением определение шаблона функций включается в каждый исходный файл, где встречается конкретизация этого шаблона. Мы отмечали, что, хотя неизвестно, где и когда понадобится шаблон функции, программа должна вести себя так, как будто экземпляр шаблона для данного множества аргументов конкретизирован ровно один раз. В действительности некоторые компиляторы (особенно старые) конкретизируют шаблон функции с данным множеством аргументов шаблона неоднократно. В рамках этой модели для использования на этапе сборки или на одной из предшествующих ей стадий выбирается один из конкретизированных экземпляров, а остальные игнорируются.Результат работы программы не зависит от того, сколько раз конкретизировался шаблон: в конечном итоге используется лишь один экземпляр. Но если приложение состоит из большого числа файлов, то время компиляции приложения заметно возрастает.
Подобные проблемы, характерные для старых компиляторов, затрудняли использование шаблонов. Поэтому в стандарте C++ введено понятие явного объявления конкретизации, помогающее программисту управлять моментом, когда конкретизация происходит.
В явном объявлении конкретизации за ключевым словом template идет объявление шаблона функции, в котором его аргументы указаны явно. Рассмотрим шаблон sum(int*, int):
template Type sum( Type op1, Type op2 ) { /* ... */ } // явное объявление конкретизации
| |
template int* sum< int* >( int*, int );
Здесь в качестве аргумента явно задается int*. Явное объявление конкретизации с одним и тем же множеством аргументов шаблона может встречаться в программе не более одного раза.
Определение шаблона функции должно находиться в том же файле, где и явное объявление конкретизации. Если же его не видно, то явное объявление приводит к ошибке:
#include template Type sum( Type op1, int op2 ); // только объявление // определяем typedef для vector< int > typedef vector< int > VI; // ошибка: sum() не определен
| |
Явный вызов деструктора
Иногда вызывать деструктор для некоторого объекта приходится явно. Особенно часто такая необходимость возникает в связи с оператором new (см. раздел 8.4). Рассмотрим пример. Когда мы пишем:char *arena = new char[ sizeof Image ];
то из хипа выделяется память, размер которой равен размеру объекта типа Image, она не инициализирована и заполнена случайными битами. Если же написать:
Image *ptr = new (arena) Image( "Quasimodo" );
то никакой новой памяти не выделяется. Вместо этого переменной ptr
присваивается адрес, ассоциированный с переменной arena. Теперь память, на которую указывает ptr, интерпретируется как занимаемая объектом класса Image, и конструктор применяется к уже существующей области. Таким образом, оператор размещения new()
позволяет сконструировать объект в ранее выделенной области памяти.
Закончив работать с изображением Quasimodo, мы можем произвести какие-то операции с изображением Esmerelda, размещенным по тому же адресу arena в памяти:
Image *ptr = new (arena) Image( "Esmerelda" );
Однако изображение Quasimodo при этом будет затерто, а мы его модифицировали и хотели бы записать на диск. Обычно сохранение выполняется в деструкторе класса Image, но если мы применим оператор delete:
// плохо: не только вызывает деструктор, но и освобождает память |
delete ptr;
то, помимо вызова деструктора, еще и возвратим в хип память, чего делать не следовало бы. Вместо этого можно явно вызвать деструктор класса Image:
ptr->~Image();
сохранив отведенную под изображение память для последующего вызова оператора размещения new.
Отметим, что, хотя ptr и arena
адресуют одну и ту же область памяти в хипе, применение оператора delete к arena
// деструктор не вызывается |
delete arena;
не приводит к вызову деструктора класса Image, так как arena
имеет тип char*, а компилятор вызывает деструктор только тогда, когда операндом в delete
является указатель на объект класса, имеющего деструктор.
Как определить последовательный контейнер?
Для того чтобы определить объект контейнерного типа, необходимо сначала включить соответствующий заголовочный файл:#include #inclnde #include #include |
#include
Определение контейнера начинается именем его типа, за которым в угловых скобках следует тип данных его элементов[12]. Например:
vector< string > svec; |
list< int > ilist;
Переменная svec
определяется как вектор, способный содержать элементы типа string, а ilist – как список с элементами типа int. Оба контейнера при таком определении пусты. Чтобы убедиться в этом, можно вызвать функцию-член empty():
if ( svec.empty() != true ) |
; // что-то не так
Простейший метод вставки элементов – использование функции-члена push_back(), которая добавляет элементы в конец контейнера. Например:
string text_word; while ( cin >> text_word ) |
svec.push_back( text_word );
Здесь строки из стандартного ввода считываются в переменную text_word, и затем копия каждой строки добавляется в контейнер svec с помощью push_back().
Список имеет функцию-член push_front(), которая добавляет элемент в его начало. Пусть есть следующий массив:
int ia[ 4 ] = { 0, 1, 2, 3 };
Использование push_back()
for ( int ix=0; ix<4; ++ix ) |
ilist.push_back( ia[ ix ] );
создаст последовательность 0, 1, 2, 3, а push_front()
for ( int ix=0; ix<4; ++ix ) |
ilist.push_front( ia[ ix ] );
создаст последовательность 3, 2, 1, 0. [13]
Мы можем при создании явно указать размер массива – как константным, так и неконстантным выражением:
#include #include #include extern int get_word_count( string file_name ); const int list_size = 64; list< int > ilist( list_size );
| |
vector< string > svec(get_word_count(string("Chimera")));
Каждый элемент контейнера инициализируется значением по умолчанию, соответствующим типу данных. Для int это 0. Для строкового типа вызывается конструктор по умолчанию класса string.
Мы можем указать начальное значение всех элементов:
list< int > ilist( list_size, -1 ); |
Разрешается не только задавать начальный размер контейнера, но и впоследствии изменять его с помощью функции-члена resize(). Например:
svec.resize( 2 * svec.size() );
Размер svec в этом примере удваивается. Каждый новый элемент получает значение по умолчанию. Если мы хотим инициализировать его каким-то другим значением, то оно указывается вторым параметром функции-члена resize():
// каждый новый элемент получает значение "piglet" |
Кстати, какова наиболее вероятная емкость svec при определении, если его начальный размер равен 24? Правильно, 24! В общем случае минимальная емкость вектора равна его текущему размеру. При удвоении размера емкость, как правило, тоже удваивается
Мы можем инициализировать новый контейнер с помощью существующего. Например:
vector< string > svec2( svec ); |
Каждый контейнер поддерживает полный набор операций сравнения: равенство, неравенство, меньше, больше, меньше или равно, больше или равно. Сопоставляются попарно все элементы контейнера. Если они равны и размеры контейнеров одинаковы, то эти контейнеры равны; в противном случае – не равны. Результат операций “больше” или “меньше” определяется сравнением первых двух неравных элементов. Вот что печатает программа, сравнивающая пять векторов:
ivecl: 1 3 5 7 9 12
ivec2: 0 1 1 2 3 5 8 13
ivec3: 1 3 9
ivec4: 1 3 5 7
ivec5: 2 4
// первый неравный элемент: 1, О
// ivecl больше чем ivec2
ivecl < ivec2 //false
ivec2 < ivecl //true
// первый неравный элемент: 5, 9
ivecl < ivec3 //true
// все элементы равны, но ivec4 содержит меньше элементов
// следовательно, ivec4 меньше, чем ivecl
ivecl < ivec4 //false
// первый неравный элемент: 1, 2
ivecl < ivec5 //true
ivecl == ivecl //true
ivecl == ivec4 //false
ivecl != ivec4 //true
ivecl > ivec2 //true
ivec3 > ivecl //true
ivec5 > ivec2 //true
Существуют три ограничения на тип элементов контейнера (практически это касается только пользовательских классов). Для должны быть определены:
· операция “равно”;
· операция “меньше” (все операции сравнения контейнеров, о которых говорилось выше, используют только эти две операции сравнения);
· значение по умолчанию (для класса это означает наличие конструктора по умолчанию).
Все предопределенные типы данных, включая указатели и классы из стандартной библиотеки С++ удовлетворяют этим требованиям.
Упражнение 6.5
Объясните, что делает данная программа:
#include #include #include #int main() { vector svec.reserve( 1024 ); string text_word; while ( cin >> text_word ) svec.push_back( text_word ); svec.resize( svec.size()+svec.size()/2 ); // ... |
Упражнение 6.6
Может ли емкость контейнера быть меньше его размера? Желательно ли, чтобы емкость была равна размеру: изначально или после вставки элемента? Почему?
Упражнение 6.7
Если программа из упражнения 6.5 прочитает 256 слов, то какова наиболее вероятная емкость контейнера после изменения размера? А если она считает 512 слов? 1000? 1048?
Упражнение 6.8
Какие из данных классов не могут храниться в векторе:
(a) class cl1 { public: c11( int=0 ); bool operator==(); bool operator!=(); bool operator<=(); bool operator<(); // ... }; (b) class c12 { public: c12( int=0 ); bool operator!=(); bool operator<=(); // ... }; (с) class c13 { public: int ival; }; (d) class c14 { public: c14( int, int=0 ); bool operator==(); bool operator!=(); // ... |
Как перегрузить имя функции
В C++ двум или более функциям может быть дано одно и то же имя при условии, что их списки параметров различаются либо числом параметров, либо их типами. В данном примере мы объявляем перегруженную функцию max():int max ( int, int ); int max( const vector
| |
int max( const matrix & );
Для каждого перегруженного объявления требуется отдельное определение функции max() с соответствующим списком параметров.
Если в некоторой области видимости имя функции объявлено более одного раза, то второе (и последующие) объявление интерпретируется компилятором так:
· если списки параметров двух функций отличаются числом или типами параметров, то функции считаются перегруженными:
// перегруженные функции void print( const string & ); |
void print( vector
· если тип возвращаемого значения и списки параметров в объявлениях двух функций одинаковы, то второе объявление считается повторным:
// объявления одной и той же функции void print( const string &str ); |
void print( const string & );
Имена параметров при сравнении объявлений во внимание не принимаются;
если списки параметров двух функций одинаковы, но типы возвращаемых значений различны, то второе объявление считается неправильным (несогласованным с первым) и помечается компилятором как ошибка:
unsigned int max( int i1, int i2 ); int max( int i1, int i2 ); // ошибка: отличаются только типы |
// возвращаемых значений
Перегруженные функции не могут различаться лишь типами возвращаемого значения;
· если списки параметров двух функций разнятся только подразумеваемыми по умолчанию значениями аргументов, то второе объявление считается повторным:
// объявления одной и той же функции int max ( int *ia, int sz ); |
Ключевое слово typedef
создает альтернативное имя для существующего типа данных, новый тип при этом не создается. Поэтому если списки параметров двух функций различаются только тем, что в одном используется typedef, а в другом тип, для которого typedef
служит псевдонимом, такие списки считаются одинаковыми, как, например, в следующих двух объявлениях функции calc(). В таком случае второе объявление даст ошибку компиляции, поскольку возвращаемое значение отличается от указанного раньше:
// typedef не вводит нового типа typedef double DOLLAR; // ошибка: одинаковые списки параметров, но разные типы // возвращаемых значений extern DOLLAR calc( DOLLAR ); |
Спецификаторы const или volatile при подобном сравнении не принимаются во внимание. Так, следующие два объявления считаются одинаковыми:
// объявляют одну и ту же функцию void f( int ); |
Спецификатор const
важен только внутри определения функции: он показывает, что в теле функции запрещено изменять значение параметра. Однако аргумент, передаваемый по значению, можно использовать в теле функции как обычную инициированную переменную: вне функции изменения не видны. (Способы передачи аргументов, в частности передача по значению, обсуждаются в разделе 7.3.) Добавление спецификатора const к параметру, передаваемому по значению, не влияет на его интерпретацию. Функции, объявленной как f(int), может быть передано любое значение типа int, равно как и функции f(const int). Поскольку они обе принимают одно и то же множество значений аргумента, то приведенные объявления не считаются перегруженными. f()
можно определить как
void f( int i ) { }
или как
void f( const int i ) { }
Наличие двух этих определений в одной программе – ошибка, так как одна и та же функция определяется дважды.
Однако, если спецификатор const или volatile
применяется к параметру указательного или ссылочного типа, то при сравнении объявлений он учитывается.
// объявляются разные функции void f( int* ); void f( const int* ); // и здесь объявляются разные функции void f( int& ); |
Как растет вектор?
Вектор может расти динамически. Как это происходит? Он должен выделить область памяти, достаточную для хранения всех элементов, скопировать в эту область все старые элементы и освободить ту память, в которой они содержались раньше. Если при этом элементы вектора являются объектами класса, то для каждого из них при таком копировании вызываются конструктор и деструктор. Поскольку у списка нет необходимости в таких дополнительных действиях при добавлении новых элементов, кажется очевидным, что ему проще поддерживать динамический рост контейнера. Однако на практике это не так. Давайте посмотрим почему.Вектор может запрашивать память не под каждый новый элемент. Вместо этого она запрашивается с некоторым запасом, так что после очередного выделения вектор может поместить в себя некоторое количество элементов, не обращаясь за ней снова. (Каков размер этого запаса, зависит от реализации.) На практике такое свойство вектора обеспечивает значительное увеличение его эффективности, особенно для небольших объектов. Давайте рассмотрим некоторые примеры из реализации стандартной библиотеки С++ от компании Rogue Wave. Однако сначала определим разницу между размером и емкостью контейнера.
Емкость– это максимальное количество элементов, которое может вместить контейнер без дополнительного выделения памяти. (Емкостью обладают только те контейнеры, в которых элементы хранятся в непрерывной области памяти, – vector, deque и string. Для контейнера list это понятие не определено.) Емкость может быть получена с помощью функции capacity(). Размер – это реальное количество элементов, хранящихся в данный момент в контейнере. Размер можно получить с помощью функции size(). Например:
#include #include int main() { vector< int > ivec; cout << "ivec: размер: " << ivec.size() << " емкость: " << ivec.capacity() << endl; for ( int ix = 0; -ix < 24; ++ix ) { ivec.push_back( ix ); cout << "ivec: размер: " << ivec.size() << " емкость: " << ivec.capacity() << endl; }
| |
}
В реализации Rogue Wave и размер, и емкость ivec сразу после определения равны 0. После вставки первого элемента размер становится равным 1, а емкость – 256. Это значит, что до первого дополнительного выделения памяти в ivec
можно вставить 256 элементов. При добавлении 256-го элемента вектор должен увеличиться: выделить память объемом в два раза больше текущей емкости, скопировать в нее старые элементы и освободить прежнюю память. Обратите внимание: чем больше и сложнее тип данных элементов, тем менее эффективен вектор в сравнении со списком. В таблице 6.1 показана зависимость начальной емкости вектора от используемого типа данных.
Таблица 6.1. Размер и емкость для различных типов данных
|
Тип данных |
Размер в байтах |
Емкость после первой вставки |
|
int |
4 |
256 |
|
double |
8 |
128 |
|
простой класс #1 |
12 |
85 |
|
string |
12 |
85 |
|
большой простой класс |
8000 |
1 |
|
большой сложный класс |
8000 |
1 |
Таблица 6.2. Время в секундах для вставки 10 000 000 элементов
|
Тип данных |
List |
Vector |
|
int |
10.38 |
3.76 |
|
double |
10.72 |
3.95 |
|
простой класс |
12.31 |
5.89 |
|
string |
14.42 |
11.80 |
|
Тип данных |
List |
Vector |
|
большой простой класс |
0.36 |
2.23 |
|
большой сложный класс |
2.37 |
6.70 |
Отсюда следует, что вектор лучше подходит для типов данных малого размера, нежели список, и наоборот. Эта разница объясняется необходимостью выделения памяти и копирования в нее старых элементов. Однако размер данных – не единственный фактор, влияющий на эффективность. Сложность типа данных также ухудшает результат. Почему?
Вставка элемента как в список, так и в вектор, требует вызова копирующего конструктора, если он определен. (Копирующий конструктор инициализирует один объект значением другого. В разделе 2.2 приводится начальная информация, а в разделе 14.5 о таких конструкторах рассказывается подробно). Это и объясняет различие в поведении простых и сложных объектов при вставке в контейнер. Объекты простого класса вставляются побитовым копированием (биты одного объекта пересылаются в биты другого), а для строк и сложных классов это производится вызовом копирующего конструктора.
Вектор должен вызывать их для каждого элемента при перераспределении памяти. Более того, освобождение памяти требует работы деструкторов для всех элементов (понятие деструктора вводится в разделе 2.2). Чем чаще происходит перераспределение памяти, тем больше времени тратится на эти дополнительные вызовы конструкторов и деструкторов.
Конечно, одним из решений может быть переход от вектора к списку, когда эффективность вектора становится слишком низкой. Другое, более предпочтительное решение состоит в том, чтобы хранить в векторе не объекты сложного класса, а указатели на них. Такая замена позволяет уменьшить затраты времени на 10 000 вставок с 6.70 секунд до 0.82 секунды. Почему? Емкость возросла с 1 до 256, что существенно снизило частоту перераспределения памяти. Кроме того, копирующий конструктор и деструктор не вызываются больше для каждого элемента при копировании прежнего содержимого вектора.
Функция reserve()
позволяет программисту явно задать емкость контейнера[11]. Например:
int main() { vector< string > svec; svec.reserve( 32 ); // задает емкость равной 32 // ... |
}
svec
получает емкость 32 при размере 0. Однако эксперименты показали, что любое изменение начальной емкости для вектора, у которого она по умолчанию отлична от 1, ведет к снижению производительности. Так, для векторов типа string и double
увеличение емкости с помощью reserve() дало худшие показатели. С другой стороны, увеличение емкости для больших сложных типов дает значительный рост производительности, как показано в таблице 6.4.
Таблица 6.4. Время в секундах для вставки 10 000 элементов при различной емкости*
|
Емкость |
Время в секундах |
|
1 по умолчанию |
670 |
|
4,096 |
555 |
|
8,192 |
444 |
|
10,000 |
222 |
|
*Сложный класс размером 8000 байт с конструктором копирования и деструктором |
Упражнение 6.2
Объясните разницу между размером и емкостью контейнера. Почему понятие емкости необходимо для контейнера, содержащего элементы в непрерывной области памяти, и не нужно для списка?
Упражнение 6.3
Почему большие сложные объекты удобнее хранить в контейнере в виде указателей на них, а для коллекции целых чисел применение указателей снижает эффективность?
Упражнение 6.4
Объясните, какой из типов контейнера – вектор или список – больше подходит для приведенных примеров (во всех случаях происходит вставка неизвестного заранее числа элементов):.
(a) Целые числа
(b) Указатели на большие сложные объекты
(c) Большие сложные объекты
Класс bitset
Таблица 4.4. Операции с классом bitset| Операция | Значение | Использование | |||
| test(pos) | Бит pos равен 1? | a.test(4) | |||
| any() | Хотя бы один бит равен 1? | a.any() | |||
| none() | Ни один бит не равен 1? | a.none() | |||
| count() | Количество битов, равных 1 | a.count() | |||
| size() | Общее количество битов | a.size() | |||
| [pos] | Доступ к биту pos | a[4] | |||
| flip() | Изменить значения всех | a.flip() | |||
| flip(pos) | Изменить значение бита pos | a.flip(4) | |||
| set() | Выставить все биты в 1 | a.set() | |||
| set(pos) | Выставить бит pos в 1 | a.set(4) | |||
| reset() | Выставить все биты в 0 | a.reset() | |||
| reset(pos) | Выставить бит pos в 0 | a.reset(4) |
Как мы уже говорили, необходимость создавать сложные выражения для манипуляции битовыми векторами затрудняет использование встроенных типов данных. Класс bitset
упрощает работу с битовым вектором. Вот какое выражение нам приходилось писать в предыдущем разделе для того, чтобы “взвести” 27-й бит:
quiz1 |= 1<<27;
При использовании bitset то же самое мы можем сделать двумя способами:
quiz1[27] = 1;
или
quiz1.set(27);
(В нашем примере мы не используем нулевой бит, чтобы сохранить “естественную” нумерацию. На самом деле, нумерация битов начинается с 0.)
Для использования класса bitset
необходимо включить заголовочный файл:
#include
Объект типа bitset
может быть объявлен тремя способами. В определении по умолчанию мы просто указываем размер битового вектора:
bitset<32> bitvec;
Это определение задает объект bitset, содержащий 32 бита с номерами от 0 до 31. Все биты инициализируются нулем. С помощью функции any()
можно проверить, есть ли в векторе единичные биты. Эта функция возвращает true, если хотя бы один бит отличен от нуля. Например:
bool is_set = bitvec.any();
Переменная is_set
получит значение false, так как объект bitset по умолчанию инициализируется нулями. Парная функция none()
возвращает true, если все биты равны нулю:
bool is_not_set = bitvec.none();
Изменить значение отдельного бита можно двумя способами: воспользовавшись функциями set() и reset() или индексом. Так, следующий цикл выставляет в 1 каждый четный бит:
for ( int index=0; index<32; ++index ) if ( index % 2 == 0 ) |
Аналогично существует два способа проверки значений каждого бита – с помощью функции test() и с помощью индекса. Функция () возвращает true, если соответствующий бит равен 1, и false в противном случае. Например:
if ( bitvec.test( 0 )) |
Значения битов с помощью индекса проверяются таким образом:
cout << "bitvec: включенные биты:\n\t"; for ( int index = 0; index < 32; ++-index ) if ( bitvec[ index ] ) cout << index << " "; |
Следующая пара операторов демонстрирует сброс первого бита двумя способами:
bitvec.reset(0); |
Функции set() и reset()
могут применяться ко всему битовому вектору в целом. В этом случае они должны быть вызваны без параметра. Например:
// сброс всех битов bitvec.reset(); if (bitvec.none() != true) // что-то не сработало // установить в 1 все биты вектора bitvec if ( bitvec.any() != true ) |
Функция flip()
меняет значение отдельного бита или всего битового вектора:
bitvec.f1ip( 0 ); // меняет значение первого бита bitvec[0].flip(); // тоже меняет значение первого бита |
Существуют еще два способа определить объект типа bitset. Оба они дают возможность проинициализировать объект определенным набором нулей и единиц. Первый способ – явно задать целое беззнаковое число как аргумент конструктору. Начальные N позиций битового вектора получат значения соответствующих двоичных разрядов аргумента. Например:
bitset< 32 > bitvec2( Oxffff );
инициализирует bitvec2
следующим набором значений:
00000000000000001111111111111111
В результате определения
bitset< 32 > bitvec3( 012 );
у bitvec3
окажутся ненулевыми биты на местах 1 и 3:
00000000000000000000000000001010
В качестве аргумента конструктору может быть передано и строковое значение, состоящее из нулей и единиц. Например, следующее определение инициализирует bitvec4 тем же набором значений, что и bitvec3:
// эквивалентно bitvec3 string bitva1( "1010" ); |
Можно также указать диапазон символов строки, выступающих как начальные значения для битового вектора. Например:
// подстрока с шестой позиции длиной 4: 1010 string bitval ( "1111110101100011010101" ); |
Мы получаем то же значение, что и для bitvec3 и bitvec4. Если опустить третий параметр, подстрока берется до конца исходной строки:
// подстрока с шестой позиции до конца строки: 1010101 string bitva1( "1111110101100011010101" ); |
Класс bitset
предоставляет две функции-члена для преобразования объекта bitset в другой тип. Для трансформации в строку, состоящую из символов нулей и единиц, служит функция to_string():
string bitva1( bitvec3.to_string() );
Вторая функция, to_long(), преобразует битовый вектор в его целочисленное представление в виде unsigned long, если, конечно, оно помещается в unsigned long. Это видоизменение особенно полезно, если мы хотим передать битовый вектор функции на С или С++, не пользующейся стандартной библиотекой.
К объектам типа bitset
можно применять побитовые операции. Например:
bitset<32> bitvec7 = bitvec2 & bitvec3;
Объект bitvec7
инициализируется результатом побитового И двух битовых векторов bitvec2 и bitvec3.
bitset<32> bitvec8 = bitvec2 | bitvec3;
Здесь bitvec8
инициализируется результатом побитового ИЛИ векторов bitvec2 и bitvec3. Точно так же поддерживаются и составные операции присваивания и сдвига.
Упражнение 4.15
Допущены ли ошибки в приведенных определениях битовых векторов?
(a) bitset<64> bitvec(32);
(b) bitset<32> bv( 1010101 );
(c) string bstr; cin >> bstr; bitset<8>bv( bstr );
(d) bitset<32> bv; bitset<16> bvl6( bv );
Упражнение 4.16
Допущены ли ошибки в следующих операциях с битовыми векторами?
extern void bitstring(const char*); bool bit_on (unsigned long, int); bitset<32> bitvec; (a) bitsting( bitvec.to_string().c_str() ); (b) if ( bit_on( bitvec.to_1ong(), 64 )) ... |
Упражнение 4.17
Дана последовательность: 1,2,3,5,8,13,21. Каким образом можно инициализировать объект bitset<32> для ее представления? Как присвоить значения для представления этой последовательности пустому битовому вектору? Напишите вариант инициализации и вариант с присваиванием значения каждому биту.
Класс complex
Класс комплексных чисел complex – еще один класс из стандартной библиотеки. Как обычно, для его использования нужно включить заголовочный файл:#include
Комплексное число состоит из двух частей – вещественной и мнимой. Мнимая часть представляет собой квадратный корень из отрицательного числа. Комплексное число принято записывать в виде
2 + 3i
где 2 – действительная часть, а 3i – мнимая. Вот примеры определений объектов типа complex:
// чисто мнимое число: 0 + 7-i comp1ex< double > purei( 0, 7 ); // мнимая часть равна 0: 3 + Oi comp1ex< float > rea1_num( 3 ); // и вещественная, и мнимая часть равны 0: 0 + 0-i comp1ex< long double > zero; // инициализация одного комплексного числа другим |
comp1ex< double > purei2( purei );
Поскольку complex, как и vector, является шаблоном, мы можем конкретизировать его типами float, double и long double, как в приведенных примерах. Можно также определить массив элементов типа complex:
complex< double > conjugate[ 2 ] = { complex< double >( 2, 3 ), complex< double >( 2, -3 ) |
};
Вот как определяются указатель и ссылка на комплексное число:
complex< double > *ptr = &conjugate[0]; |
complex< double > &ref = *ptr;
Комплексные числа можно складывать, вычитать, умножать, делить, сравнивать, получать значения вещественной и мнимой части. (Более подробно мы будем говорить о классе complex в разделе 4.6.)
Класс для обработки параметров командной строки
Чтобы не перегружать функцию main()деталями, касающимися обработки параметров командной строки, лучше отделить этот фрагмент. Можно написать для этого функцию. Например:
extern int parse_options( int arg_count, char *arg_vector ); int main( int argc, char *argv[] ) { // ... int option_status; option_status = parse_options( argc, argv ); // ... |
}
Как вернуть несколько значений? Обычно для этого используются глобальные объекты, которые не передаются ни в функцию для их обработки, ни обратно. Альтернативной стратегией является инкапсуляция обработки параметров командной строки в класс.
Данные-члены класса представляют собой параметры, заданные пользователем в командной строке. Набор открытых встроенных функций-членов позволяет получать их значения. Конструктор инициализирует параметры значениями по умолчанию. Функция-член получает argc и argv в качестве аргументов и обрабатывает их:
#include #include class CommandOpt { public: CommandOpt() : _limit( -1 ), _debug_on( false ) {} int parse_options( int argc, char *argv[] ); string out_file() { return _out_file; } bool debug_on() { return _debug_on; } int files() { return _file_names.size(); } string& operator[]( int ix ); private: inline void usage( int exit_value = 0 ); bool _debug_on; int _limit; string _out_file; vector static const char *const program_name; static const char *const program_version;
| |
};
Так выглядит модифицированная функция main():[18]
#include "CommandOpt.h" int main( int argc, char "argv[] ) { // ... CommandOpt com_opt; int option_status; opttion_status = com_opt. parse_options (argc, argv); // ... |
}
Упражнение 7.15
Добавьте обработку опций -t
(включение таймера) и -b
(задание размера буфера bufsize). Не забудьте обновить usage(). Например:
prog -t -b 512 dataO
Упражнение 7.16
Наша реализация не обрабатывает случая, когда между опцией и ассоциированным с ней значением нет пробела. Модифицируйте программу для поддержки такой обработки.
Упражнение 7.17
Наша реализация не может различить лишний пробел между дефисом и опцией:
prog - d dataO
Модифицируйте программу так, чтобы она распознавала подобную ошибку и сообщала о ней.
Упражнение 7.18
В нашей программе не предусмотрен случай, когда опции -l или -o
задаются несколько раз. Реализуйте такую возможность. Какова должна быть стратегия при разрешении конфликта?
Упражнение 7.19
В нашей реализации задание неизвестной опции приводит к фатальной ошибке. Как вы думаете, это оправдано? Предложите другое поведение.
Упражнение 7.20
Добавьте поддержку опций, начинающихся со знака плюс (+), обеспечив обработку +s и +pt, а также +sp и +ps. Предположим, что +s
включает строгую проверку синтаксиса, а +p допускает использование устаревших конструкций. Например:
prog +s +p -d -b 1024 dataO
Класс массива с множественным наследованием
Определим отсортированный массив с контролем выхода за границы. Для этого можно применить множественное наследование от Array_RC и Array_Sort. Вот как выглядит наша реализация (напомним еще раз, что мы ограничились тремя конструкторами и оператором взятия индекса). Определение находится в заголовочном файле Array_RC_S.h:#ifndef ARRAY_RC_S_H #define ARRAY_RC_S_H #include "Array_S.C" #include "Array_RC.C" template class Array_RC_S : public Array_RC public Array_Sort { public: Array_RC_S( int sz = Array : Array { clear_bit(); } Array_RC_S( const Array_RC_S &rca ) : Array { sort( 0,Array Array_RC_S( const Type* arr, int sz ) : Array { sort( 0,Array Type& operator[]( int index ) { set_bit(); return Array_RC } };
| |
#endif
Этот класс наследует две реализации каждой интерфейсной функции Array: из Array_Sort и из виртуального базового класса Array через Array_RC (за исключением оператора взятия индекса, для которого из обоих базовых классов наследуется замещенный экземпляр). При невиртуальном наследовании вызов find() был бы помечен компилятором как неоднозначный, поскольку он не знает, какой из унаследованных экземпляров мы имели в виду. В нашем случае замещенным в Array_Sort
экземплярам отдается предпочтение по сравнению с экземплярами, унаследованными из виртуального базового класса через Array_RC (см. раздел 18.5.4). Таким образом, при виртуальном наследовании неквалифицированный вызов find()
разрешается в пользу экземпляра, унаследованного из класса Array_Sort.
}
Вот что печатает программа для класса, конкретизированного типом string (теперь ошибка выхода за границы массива перехватывается):
конкретизация класса Array_Sort
try_array: начальные значения массива
( 7 )< Eeyore, Gopher, Heffalump, Owl, Piglet, Pooh
Tigger >
try_array: после присваиваний
( 7 )< Eeyore, Gopher, Owl, Piglet, Pooh, Pooh
Pooh >
try_array: почленная инициализация
( 7 )< Eeyore, Gopher, Owl, Piglet, Pooh, Pooh
Pooh >
try_array: после почленного копирования
( 7 )< Eeyore, Piglet, Owl, Piglet, Pooh, Pooh
Pooh >
try_array: после вызова grow
( 7 )<
Piglet, Piglet, Pooh, Pooh, Pooh >
искомое значение: Tigger возвращенный индекс: -1
Assertion failed: ix >= 0 && ix < size
Представленная в этой главе реализация иерархии класса Array иллюстрирует применение множественного и виртуального наследования. Детально проектирование класса массива описано в [NACKMAN94]. Однако, как правило, достаточно класса vector из стандартной библиотеки.
Упражнение 18.16
Добавьте в Array
функцию-член spy(). Она запоминает операции, примененные к объекту класса: число доступов по индексу; количество вызовов каждого члена; какой элемент искали с помощью find() и сколько было успешных поисков. Поясните свои проектные решения. Модифицируйте все подтипы Array
так, чтобы spy()
можно было использовать и для них тоже.
Упражнение 18.17
Стандартный библиотечный класс map
(отображение) называют еще ассоциативным массивом, поскольку он поддерживает индексирование значением ключа. Как вы думаете, является ли ассоциативный массив кандидатом на роль подтипа нашего класса Array? Почему?
Упражнение 18.18
Перепишите иерархию Array, пользуясь контейнерными классами из стандартной библиотеки и применяя обобщенные алгоритмы.
19
Класс pair
Класс pair(пара) стандартной библиотеки С++ позволяет нам определить одним объектом пару значений, если между ними есть какая-либо семантическая связь. Эти значения могут быть одинакового или разного типа. Для использования данного класса необходимо включить заголовочный файл:
#inc1ude
Например, инструкция
pair< string, string > author( "James", "Joyce" );
создает объект author
типа pair, состоящий из двух строковых значений.
Отдельные части пары могут быть получены с помощью членов first и second:
string firstBook; if ( Joyce.first == "James" && Joyce.second == "Joyce" ) |
firstBook = "Stephen Hero";
Если нужно определить несколько однотипных объектов этого класса, удобно использовать директиву typedef:
typedef pair< string, string > Authors; Authors proust( "marcel", "proust" ); Authors joyce( "James", "Joyce" ); |
Authors musil( "robert", "musi1" );
Вот другой пример употребления пары. Первое значение содержит имя некоторого объекта, второе – указатель на соответствующий этому объекту элемент таблицы.
class EntrySlot; extern EntrySlot* 1ook_up( string ); typedef pair< string, EntrySlot* > SymbolEntry; SymbolEntry current_entry( "author", 1ook_up( "author" )); // ... if ( EntrySlot *it = 1ook_up( "editor" )) { current_entry.first = "editor"; current_entry.second = it; |
}
(Мы вернемся к рассмотрению класса pair в разговоре о контейнерных типах в главе 6 и об обобщенных алгоритмах в главе 12.)
Класс string
Как мы только что видели, применение встроенного строкового типа чревато ошибками и не очень удобно из-за того, что он реализован на слишком низком уровне. Поэтому достаточно распространена разработка собственного класса или классов для представления строкового типа– чуть ли не каждая компания, отдел или индивидуальный проект имели свою собственную реализацию строки. Да что говорить, в предыдущих двух изданиях этой книги мы делали то же самое! Это порождало проблемы совместимости и переносимости программ. Реализация стандартного класса stringстандартной библиотекой С++ призвана была положить конец этому изобретению велосипедов.
Попробуем специфицировать минимальный набор операций, которыми должен обладать класс string:
· инициализация массивом символов (строкой встроенного типа) или другим объектом типа string. Встроенный тип не обладает второй возможностью;
· копирование одной строки в другую. Для встроенного типа приходится использовать функцию strcpy();
· доступ к отдельным символам строки для чтения и записи. Во встроенном массиве для этого применяется операция взятия индекса или косвенная адресация;
· сравнение двух строк на равенство. Для встроенного типа используется функция strcmp();
· конкатенация двух строк, получая результат либо как третью строку, либо вместо одной из исходных. Для встроенного типа применяется функция strcat(), однако чтобы получить результат в новой строке, необходимо последовательно задействовать функции strcpy() и strcat();
· вычисление длины строки. Узнать длину строки встроенного типа можно с помощью функции strlen();
· возможность узнать, пуста ли строка. У встроенных строк для этой цели приходится проверять два условия:
char str = 0; //... if ( ! str || ! *str ) |
Класс string
стандартной библиотеки С++ реализует все перечисленные операции (и гораздо больше, как мы увидим в главе 6). В данном разделе мы научимся пользоваться основными операциями этого класса.
Для того чтобы использовать объекты класса string, необходимо включить соответствующий заголовочный файл:
#include
Вот пример строки из предыдущего раздела, представленной объектом типа string и инициализированной строкой символов:
#include |
Длину строки возвращает функция-член size() (длина не включает завершающий нулевой символ).
cout << "Длина " << st << ": " << st.size() << " символов, включая символ новой строки\n"; |
string st2; // пустая строка
Как мы узнаем, пуста ли строка? Конечно, можно сравнить ее длину с 0:
if ( ! st.size() )
// правильно: пустая
Однако есть и специальный метод empty(), возвращающий true для пустой строки и false для непустой:
if ( st.empty() )
// правильно: пустая
Третья форма конструктора инициализирует объект типа string другим объектом того же типа:
string st3( st );
Строка st3
инициализируется строкой st. Как мы можем убедиться, что эти строки совпадают? Воспользуемся оператором сравнения (==):
if ( st == st3 )
// инициализация сработала
Как скопировать одну строку в другую? С помощью обычной операции присваивания:
st2 = st3; // копируем st3 в st2
Для конкатенации строк используется операция сложения (+) или операция сложения с присваиванием (+=). Пусть даны две строки:
string s1( "hello, " );
string s2( "world\n" );
Мы можем получить третью строку, состоящую из конкатенации первых двух, таким образом:
string s3 = s1 + s2;
Если же мы хотим добавить s2 в конец s1, мы должны написать:
s1 += s2;
Операция сложения может конкатенировать объекты класса string не только между собой, но и со строками встроенного типа. Можно переписать пример, приведенный выше, так, чтобы специальные символы и знаки препинания представлялись встроенным типом, а значимые слова – объектами класса string:
const char *pc = ", ";
string s1( "hello" ); |
string s3 = s1 + pc + s2 + "\n";
Подобные выражения работают потому, что компилятор знает, как автоматически преобразовывать объекты встроенного типа в объекты класса string. Возможно и простое присваивание встроенной строки объекту string:
string s1;
const char *pc = "a character array";
s1 = pc; // правильно
Обратное преобразование, однако, не работает. Попытка выполнить следующую инициализацию строки встроенного типа вызовет ошибку компиляции:
char *str = s1; // ошибка компиляции
Чтобы осуществить такое преобразование, необходимо явно вызвать функцию-член с несколько странным названием c_str():
char *str = s1.c_str(); // почти правильно
Функция c_str()
возвращает указатель на символьный массив, содержащий строку объекта string в том виде, в каком она находилась бы во встроенном строковом типе.
Приведенный выше пример инициализации указателя char *str все еще не совсем корректен. c_str()
возвращает указатель на константный массив, чтобы предотвратить возможность непосредственной модификации содержимого объекта через этот указатель, имеющий тип
const char *
(В следующем разделе мы расскажем о ключевом слове const). Правильный вариант инициализации выглядит так:
const char *str = s1.c_str(); // правильно
К отдельным символам объекта типа string, как и встроенного типа, можно обращаться с помощью операции взятия индекса. Вот, например, фрагмент кода, заменяющего все точки символами подчеркивания:
string str( "fa.disney.com" ); int size = str.size(); for ( int ix = 0; ix < size; ++ix ) if ( str[ ix ] == '.' ) |
Вот и все, что мы хотели сказать о классе string прямо сейчас. На самом деле, этот класс обладает еще многими интересными свойствами и возможностями. Скажем, предыдущий пример реализуется также вызовом одной-единственной функции replace():
replace( str.begin(), str.end(), '.', '_' );
replace() – один из обобщенных алгоритмов, с которыми мы познакомились в разделе 2.8 и которые будут детально разобраны в главе 12. Эта функция пробегает диапазон от begin() до end(), которые возвращают указатели на начало и конец строки, и заменяет элементы, равные третьему своему параметру, на четвертый.
Упражнение 3.12
Найдите ошибки в приведенных ниже операторах:
(a) char ch = "The long and winding road"; (b) int ival = &ch; (c) char *pc = &ival; (d) string st( &ch ); (e) pc = 0; (i) pc = '0'; (f) st = pc; (j) st = &ival; (g) ch = pc[0]; (k) ch = *pc; |
Упражнение 3.13
Объясните разницу в поведении следующих операторов цикла:
while ( st++ ) |
while ( *st++ )
++cnt;
Упражнение 3.14
Даны две семантически эквивалентные программы. Первая использует встроенный строковый тип, вторая – класс string:
// ***** Реализация с использованием C-строк ***** |
#include
int main()
{
int errors = 0;
const char *pc = "a very long literal string";
for ( int ix = 0; ix < 1000000; ++ix )
{
int len = strlen( pc );
char *pc2 = new char[ len + 1 ];
strcpy( pc2, pc );
if ( strcmp( pc2, pc ))
++errors;
delete [] pc2;
}
cout << "C-строки: "
<< errors << " ошибок.\n";
}
// ***** Реализация с использованием класса string ***** |
#include
int main()
{
int errors = 0;
string str( " a very long literal string" );
for ( int ix = 0; ix < 1000000; ++ix )
{
int len = str.size();
string str2 = str;
if ( str != str2 )
}
cout << "класс string: "
<< errors << " ошибок.\n;
}
Что эти программы делают?
Оказывается, вторая реализация выполняется в два раза быстрее первой. Ожидали ли вы такого результата? Как вы его объясните?
Упражнение 3.15
Могли бы вы что-нибудь улучшить или дополнить в наборе операций класса string, приведенных в последнем разделе? Поясните свои предложения.
Класс type_info
Точное определение класса type_infoзависит от реализации, но некоторые его характерные черты остаются неизменными в любой программе на C++:
class type_info { // ïðåäñòàâëåíèå çàâèñèò îò ðåàëèçàöèè private: type_info( const type_info& ); type_info& operator= ( const type_info& ); public: virtual ~type_info(); int operator==( const type_info& ); int operator!=( const type_info& ); const char * name() const; |
};
Поскольку копирующие конструктор и оператор присваивания – закрытые члены класса type_info, то пользователь не может создать его объекты в своей программе:
#include type_info t1; // îøèáêà: íåò êîíñòðóêòîðà ïî óìîë÷àíèþ // îøèáêà: êîïèðóþùèé êîíñòðóêòîð çàêðûò
| |
type_info t2 (typeid( unsigned int ) );
Единственный способ создать объект класса type_info – воспользоваться оператором typeid.
В классе определены также операторы сравнения. Они позволяют сравнивать два объекта type_info, а следовательно, и результаты, возвращенные двумя операторами typeid. (Мы говорили об этом в предыдущем подразделе.)
typeid( re ) == typeid( manager ) // èñòèííî |
typeid( *pe ) != typeid( employee ) // ложно
Функция name()
возвращает C-строку с именем типа, представленного объектом type_info. Этой функцией можно пользоваться в программах следующим образом:
#include int main() { employee *pe = new manager; // ïå÷àòàåò: "manager" cout << typeid( *pe ).name() << endl; |
Для работы с функцией-членом name()
нужно включить заголовочный файл
Имя типа – это единственная информация, которая гарантированно возвращается всеми реализациями C++, при этом используется функция-член name() класса type_info. В начале этого раздела упоминалось, что поддержка RTTI зависит от реализации и иногда в классе type_info
бывают дополнительные функции-члены. Чтобы узнать, каким образом обеспечивается поддержка RTTI в вашем компиляторе, обратитесь к справочному руководству по нему. Кроме того, можно получить любую информацию, которую компилятор знает о типе, например:
· список функций-членов класса;
· способ размещения объекта в памяти, т.е. взаимное расположение подобъектов базового и производных классов.
Одним из способов расширения поддержки RTTI является включение дополнительной информации в класс, производный от type_info. Поскольку в классе type_info
есть виртуальный деструктор, то оператор dynamic_cast позволяет выяснить, имеется ли некоторое конкретное расширение RTTI. Предположим, что некоторый компилятор предоставляет расширенную поддержку RTTI посредством класса extended_type_info, производного от type_info. С помощью оператора dynamic_cast
программа может узнать, принадлежит ли объект типа type_info, возвращенный оператором typeid, к типу extended_type_info. Если да, то пользоваться расширенной поддержкой RTTI разрешено.
#include // Файл typeinfo содержит определение типа extended_type_info void func( employee* p ) { // понижающее приведение типа type_info* к extended_type_info* if ( eti *eti_p = dynamic_cast { // если dynamic_cast завершается успешно, // можно пользоваться информацией из extended_type_info через eti_p } else { // если dynamic_cast завершается неудачно, // можно пользоваться только стандартным type_info } |
}
Если dynamic_cast
завершается успешно, то оператор typeid вернет объект класса extended_type_info, т.е. компилятор обеспечивает расширенную поддержку RTTI, чем программа может воспользоваться. В противном случае допустимы только базовые средства RTTI.
Упражнение 19.1
Дана иерархия классов, в которой у каждого класса есть конструктор по умолчанию и виртуальный деструктор:
class X { ... }; class A { ... }; class B : public A { ... }; class C : public B { ... }; |
Какие из данных операторов dynamic_cast
завершатся неудачно?
(a) D *pd = new D; |
(b) A *pa = new C; |
(c) B *pb = new B; |
(d) A *pa = new D; |
Упражнение 19.2
Объясните, когда нужно пользоваться оператором dynamic_cast вместо виртуальной функции?
Упражнение 19.3
Пользуясь иерархией классов из упражнения 19.1, перепишите следующий фрагмент так, чтобы в нем использовался ссылочный вариант dynamic_cast для преобразования *pa в тип D&:
if ( D *pd = dynamic_cast< D* >( pa ) ) { // использовать члены D } else { // использовать члены A |
Упражнение 19.4
Дана иерархия классов, в которой у каждого класса есть конструктор по умолчанию и виртуальный деструктор:
class X { ... }; class A { ... }; class B : public A { ... }; class C : public B { ... }; |
Какое имя типа будет напечатано в каждом из следующих случаев:
(a) A *pa = new D; cout << typeid( pa ).name() << endl; (b) X *px = new D; cout << typeid( *px ).name() << endl; (c) C obj; A& ra = cobj; cout << typeid( &ra ).name() << endl; (d) X *px = new D; A& ra = *px; |
Класс vector
Использование класса vector(см. раздел 2.8) является альтернативой применению встроенных массивов. Этот класс предоставляет гораздо больше возможностей, поэтому его использование предпочтительней. Однако встречаются ситуации, когда не обойтись без массивов встроенного типа. Одна из таких ситуаций– обработка передаваемых программе параметров командной строки, о чем мы будем говорить в разделе 7.8. Класс vector, как и класс string, является частью стандартной библиотеки С++.
Для использования вектора необходимо включить заголовочный файл:
#include
Существуют два абсолютно разных подхода к использованию вектора, назовем их идиомой массива и идиомой STL. В первом случае объект класса vector
используется точно так же, как массив встроенного типа. Определяется вектор заданной размерности:
vector< int > ivec( 10 );
что аналогично определению массива встроенного типа:
int ia[ 10 ];
Для доступа к отдельным элементам вектора применяется операция взятия индекса:
void simp1e_examp1e() { const int e1em_size = 10; vector< int > ivec( e1em_size ); int ia[ e1em_size ]; for ( int ix = 0; ix < e1em_size; ++ix ) ia[ ix ] = ivec[ ix ]; // ... |
}
Мы можем узнать размерность вектора, используя функцию size(), и проверить, пуст ли вектор, с помощью функции empty(). Например:
void print_vector( vector { if ( ivec.empty() ) return; for ( int ix=0; ix< ivec.size(); ++ix ) cout << ivec[ ix ] << ' ';
| |
}
Элементы вектора инициализируются значениями по умолчанию. Для числовых типов и указателей таким значением является 0. Если в качестве элементов выступают объекты класса, то инициатор для них задается конструктором по умолчанию (см. раздел 2.3). Однако инициатор можно задать и явно, используя форму:
vector< int > ivec( 10, -1 );
Все десять элементов вектора будут равны -1.
Массив встроенного типа можно явно инициализировать списком:
int ia[ 6 ] = { -2, -1, О, 1, 2, 1024 };
Для объекта класса vector
аналогичное действие невозможно. Однако такой объект может быть инициализирован с помощью массива встроенного типа:
// 6 элементов ia копируются в ivec |
Конструктору вектора ivec
передаются два указателя – указатель на начало массива ia и на элемент, следующий за последним. В качестве списка начальных значений допустимо указать не весь массив, а некоторый его диапазон:
// копируются 3 элемента: ia[2], ia[3], ia[4] |
Еще одним отличием вектора от массива встроенного типа является возможность инициализации одного объекта типа vector другим и использования операции присваивания для копирования объектов. Например:
vector< string > svec; void init_and_assign() { // один вектор инициализируется другим vector< string > user_names( svec ); // ... // один вектор копируется в другой svec = user_names; |
Говоря об идиоме STL[6], мы подразумеваем совсем другой подход к использованию вектора. Вместо того чтобы сразу задать нужный размер, мы определяем пустой вектор:
vector< string > text;
Затем добавляем к нему элементы при помощи различных функций. Например, функция push_back()вставляет элемент в конец вектора. Вот фрагмент кода, считывающего последовательность строк из стандартного ввода и добавляющего их в вектор:
string word; while ( cin >> word ) { text.push_back( word ); // ... |
Хотя мы можем использовать операцию взятия индекса для перебора элементов вектора:
cout << "считаны слова: \n"; for ( int ix =0; ix < text.size(); ++ix ) cout << text[ ix ] << ' '; |
более типичным в рамках данной идиомы будет использование итераторов:
cout << "считаны слова: \n"; for ( vector it != text.end(); ++it ) cout << *it << ' '; |
cout << endl;
Итератор – это класс стандартной библиотеки, фактически являющийся указателем на элемент массива.
Выражение
*it;
разыменовывает итератор и дает сам элемент вектора. Инструкция
++it;
сдвигает указатель на следующий элемент. Не нужно смешивать эти два подхода. Если следовать идиоме STL при определении пустого вектора:
vector
будет ошибкой написать:
ivec[0] = 1024;
У нас еще нет ни одного элемента вектора ivec; количество элементов выясняется с помощью функции size().
Можно допустить и противоположную ошибку. Если мы определили вектор некоторого размера, например:
vector
то вставка элементов увеличивает его размер, добавляя новые элементы к существующим. Хотя это и кажется очевидным, тем не менее,
начинающий программист вполне мог бы написать:
const int size = 7; int ia[ size ] = { 0, 1, 1, 2, 3, 5, 8 }; vector< int > ivec( size ); for ( int ix = 0; ix < size; ++ix ) |
Имелась в виду инициализация вектора ivec
значениями элементов ia, вместо чего получился вектор ivec размера 14.
Следуя идиоме STL, можно не только добавлять, но и удалять элементы вектора. (Все это мы рассмотрим подробно и с примерами в главе 6.)
Упражнение 3.24
Имеются ли ошибки в следующих определениях?
int ia[ 7 ] = { 0, 1, 1, 2, 3, 5, 8 }; (a) vector< vector< int > > ivec; (b) vector< int > ivec = { 0, 1, 1, 2, 3, 5, 8 }; (c) vector< int > ivec( ia, ia+7 ); (d) vector< string > svec = ivec; |
Упражнение 3.25
Реализуйте следующую функцию:
bool is_equa1( const int*ia, int ia_size, |
Функция is_equal()
сравнивает поэлементно два контейнера. В случае разного размера контейнеров “хвост” более длинного в расчет не принимается. Понятно, что, если все сравниваемые элементы равны, функция возвращает true, если отличается хотя бы один – false. Используйте итератор для перебора элементов. Напишите функцию main(), обращающуюся к is_equal().
Классы как члены пространства имен A
Представленные до сих пор классы определены в области видимости глобального пространства имен. Но их можно определять и в объявленных пользователем пространствах. Имя класса, определенного таким образом, доступно только в области видимости этого пространства, т.е. оно не конфликтует с именами, объявленными в других пространствах имен. Например:namespace cplusplus_primer { class Node { /* ... */ }; } namespace DisneyFeatureAnimation { class Node { /* ... */ }; } Node *pnode; // ошибка: Node не видно в глобальной области видимости // правильно: объявляет nodeObj как объект // квалифицированного типа DisneyFeatureAnimation::Node DisneyFeatureAnimation::Node nodeObj; // using-объявление делает Node видимым в глобальной области видимости using cplusplus_primer::Node; |
Node another; // cplusplus_primer::Node
Как было показано в двух предыдущих разделах, член класса (функция-член, статический член или вложенный класс) может быть определен вне его тела. Если мы реализуем библиотеку и помещаем определения наших классов в объявленное пользователем пространство имен, то где расположить определения членов, находящиеся вне тел своих классов? Их можно разместить либо в пространстве имен, которое содержит определение самого внешнего класса, либо в одном из объемлющих его пространств. Это дает возможность организовать код библиотеки следующим образом:
// --- primer.h --- namespace cplusplus_primer { class List { // ... private: class ListItem { public: void check_status(); int action(); // ... }; }; } // --- primer.C --- #include "primer.h" namespace cplusplus_primer { // правильно: check_status() определено в том же пространстве имен, // что и List void List::ListItem::check_status() { } } // правильно: action() определена в глобальной области видимости // в пространстве имен, объемлющем определение класса List // Имя члена квалифицировано именем пространства |
int cplusplus_primer::List::ListItem::action() { }
Члены вложенного класса ListItem
можно определить в пространстве имен cplusplus_primer, которое содержит определение List, или в глобальном пространстве, включающем определение cplusplus_primer. В любом случае имя члена в определении должно быть квалифицировано именами объемлющих классов и объявленных пользователем пространств, вне которых находится объявление члена.
Как происходит разрешение имени в определении члена, которое находится в объявленном пользователем пространстве? Например, как будет разрешено someVal:
int cplusplus_primer::List::ListItem::action() { int local = someVal; // ... |
Сначала просматриваются локальные области видимости в определении функции-члена, затем поиск продолжается в области видимости ListItem, затем – в области видимости List. До этого момента все происходит так же, как в процессе разрешения имен, описанном в разделе 13.10. Далее просматриваются объявления из пространства cplusplus_primer и наконец объявления в глобальной области видимости, причем во внимание принимаются только те, которые расположены до определения функции-члена action():
// --- primer.h --- namespace cplusplus_primer { class List { // ... private: class ListItem { public: int action(); // ... }; }; const int someVal = 365; } // --- primer.C --- #include "primer.h" namespace cplusplus_primer { int List::ListItem::action() { // правильно: cplusplus_primer::someVal int local = someVal; // ошибка: calc() еще не объявлена double result = calc( local ); // ... } double calc(int) { } // ... |
Определение пространства имен cplusplus_primer не является непрерывным. Определения класса List и объекта someVal
размещены в первом его разделе, который находится в заголовочном файле primer.h. Определение функции calc()
появляется в определении пространства имен, расположенном в файле реализации primer.C. Использование calc()
внутри action()
ошибочно, так как она объявлена после использования. Если calc() – часть интерфейса cplusplus_primer, ее следовало бы объявить в той части данного пространства, которая находится в заголовочном файле:
// --- primer.h --- namespace cplusplus_primer { class List { // ... } const int someVal = 365; double calc(int); |
Если же calc()
используется только в action() и не является частью интерфейса пространства имен, то ее нужно объявить перед action(), чтобы можно было ссылаться на нее внутри определения action().
Здесь прослеживается аналогия с процессом поиска объявлений в глобальной области видимости, о котором мы говорили в предыдущих разделах: объявления, предшествующие определению члена, принимаются во внимание, тогда как следующие за ним игнорируются.
Довольно просто запомнить, в каком порядке просматриваются области видимости при поиске имени из определения функции, расположенного вне определения класса. Имена, которыми квалифицировано имя члена, указывают порядок рассмотрения пространств. Например, имя action() в предыдущем примере квалифицируется так:
cplusplus_primer::List::ListItem::action()
Квалификаторы cplusplus_primer::List::ListItem::
записаны в порядке, обратном тому, в котором просматриваются имена областей видимости классов и пространств имен. Сначала поиск ведется в области ListItem, затем продолжается в объемлющем классе List и наконец в пространстве cplusplus_primer, предшествующем той области, в которой находится определение action(). Во время поиска в любой области видимости класса просматриваются все объявления членов, а в любом пространстве имен – только те объявления, которые встречались перед определением члена.
Класс, определенный в области видимости пространства имен, потенциально виден во всей программе. Если заголовочный файл primer.h включен в несколько исходных файлов, то имя cplusplus_primer::List
везде относится к одному и тому же классу. Класс – это сущность, для которой в программе может быть более одного определения. Определение класса должно присутствовать один раз в каждом исходном файле, где определяются или используются сам класс или его члены. Однако оно должно быть одинаковым во всех файлах, где встречается, поэтому его следует помещать в заголовочный файл, например primer.h.
Затем такой файл можно включать в любой исходный, где определяются или используются члены класса. Это предотвратит несоответствия в случае, когда определение класса записывается более одного раза.
Невстроенные функции-члены и статические данные-члены класса в пространстве имен – это также программные сущности. Однако они могут быть определены лишь один раз во всей программе. Поэтому их определения помещаются не в заголовочный, а в отдельный исходный файл типа primer.C.
Упражнение 13.22
Используя класс iStack, определенный в упражнении 13.21, объявите классы исключений pushOnFull и popOnEmpty как члены пространства имен LibException:
namespace LibException { class pushOnFull{ }; class popOnEmpty{ }; |
а сам iStack – членом пространства имен Container. Модифицируйте соответствующим образом определение данного класса и его функций-членов, а также определение main().
Классы
Механизм классов в C++ позволяет пользователям определять собственные типы данных. По этой причине их часто называют пользовательскими типами. Класс может наделять дополнительной функциональностью уже существующий тип. Так, например, IntArray, введенный в главе 2, предоставляет больше возможностей, чем тип “массив int”. С помощью классов можно создавать абсолютно новые типы, например Screen(экран) или Account
(расчетный счет). Как правило, классы используются для абстракций, не отражаемых встроенными типами адекватно.
В этой главе мы узнаем, как определять типы и использовать объекты классов; увидим, что определение класса вводит как данные-члены, описывающие его, так и функции-члены, составляющие набор операций, применимых к объектам класса. Мы покажем, как можно обеспечить сокрытие информации, объявив внутреннее представление и реализацию закрытыми, но открыв операции над объектами. Говорят, что закрытое внутреннее представление инкапсулировано, а открытую часть класса называют его интерфейсом.
Далее в этой главе мы познакомимся с особым видом членов класса– статическими членами. Мы расскажем также, как можно использовать указатели на члены и функции-члены класса, и рассмотрим объединения, представляющие собой специализированный вид класса для хранения объектов разных типов в одной области памяти. Завершается глава обсуждением области видимости класса и описанием правил разрешения имен в этой области; затрагиваются такие понятия, как вложенные классы, классы-члены пространства имен и локальные классы.
Когда использовать указатель this
Наша функция main()вызывает функции-члены класса Screen для объектов myScreen и bufScreen
таким образом, что каждое действие – это отдельная инструкция. У нас есть возможность определить функции-члены так, чтобы конкатенировать их вызовы при обращении к одному и тому же объекту. Например, все вызовы внутри main() будут выглядеть так:
int main() { // ... myScreen.clear().move( 2, 2 ), set( '*' ). display(); bufScreen.reSize( 5, 5 ).display(); |
}
Именно так интуитивно представляется последовательность операций с экраном: очистить экран myScreen, переместить курсор в позицию (2,2), записать в эту позицию символ '*' и вывести результат.
Операторы доступа “точка” и “стрелка” левоассоциативны, т.е. их последовательность выполняется слева направо. Например, сначала вызывается myScreen.clear(), затем myScreen.move() и т.д. Чтобы myScreen.move()
можно было вызвать после myScreen.clear(), функция clear()
должна возвращать объект myScreen, для которого она была вызвана. Мы уже видели, что доступ к объекту внутри функции-члена класса производится в помощью указателя this. Вот реализация clear():
// объявление clear() находится в теле класса // в нем задан аргумент по умолчанию bkground = '#' Screen& Screen::clear( char bkground ) { // установить курсор в левый верхний угол и очистить экран _cursor = 0; _screen.assign( // записать в строку _screen.size(), // size() символов bkground // со значением bkground ); // вернуть объект, для которого была вызвана функция return *this; |
}
Обратите внимание, что возвращаемый тип этой функции-члена – Screen& – ссылка на объект ее же класса. Чтобы конкатенировать вызовы, необходимо также пересмотреть реализацию move() и set(). Возвращаемый тип следует изменить с void на Screen&, а в определении возвращать *this.
Аналогично функцию-член display()
можно написать так:
Screen& Screen::display() { typedef string::size_type idx_type; for ( idx_type ix = 0; ix < _height; ++ix ) { // для каждой строки idx_type offset = _width * ix; // смещение строки for ( idx_type iy = 0; iy < _width; ++iy ) // для каждой колонки вывести элемент cout << _screen[ offset + iy ]; cout << endl; } return *this; |
}
А вот реализация reSize():
// объявление reSize() находится в теле класса // в нем задан аргумент по умолчанию bkground = '#' Screen& Screen::reSize( int h, int w, char bkground ) { // сделать высоту экрана равной h, а ширину - равной w // запомнить содержимое экрана string local(_screen); // заменить строку _screen _screen.assign( // записать в строку h * w, // h * w символов bkground // со значением bkground ); typedef string::size_type idx_type; idx_type local_pos = 0; // скопировать содержимое старого экрана в новый for ( idx_type ix = 0; ix < _height; ++ix ) { // для каждой строки idx_type offset = w * ix; // смещение строки for ( idx_type iy = 0; iy < _width; ++iy ) // для каждой колонки присвоить новое значение _screen[ offset + iy ] = local[ local_pos++ ]; } _height = h; _width = w; // _cursor не меняется return *this; |
Работа указателя this не исчерпывается возвратом объекта, к которому была применена функция-член. При рассмотрении copy() в разделе 13.3 мы видели и другой способ его использования:
void Screen::copy( const Screen& sobj ) { // если этот объект Screen и sobj - одно и то же, // копирование излишне if ( this != sobj ) { // скопировать значение sobj в this } |
Указатель this
хранит адрес объекта, для которого была вызвана функция-член. Если адрес, на который ссылается sobj, совпадает со значением this, то sobj и this
относятся к одному и тому же объекту, так что операция копирования не нужна. (Мы еще встретимся с этой конструкцией, когда будем рассматривать копирующий оператор присваивания в разделе 14.7.)
Упражнение 13.7
Указатель this
можно использовать для модификации адресуемого объекта, а также для его замены другим объектом того же типа. Например, функция-член assign()
класса classType
выглядит так. Можете ли вы объяснить, что она делает?
classType& classType::assign( const classType &source ) { if ( this != &source ) { this->~classType(); new (this) classType( source ); } return *this; |
Напомним, что ~classType – это имя деструктора. Оператор new выглядит несколько причудливо, но мы уже встречались с подобным в разделе 8.4.
Как вы относитесь к такому стилю программирования? Безопасна ли эта операция? Почему?
Когда использовать встроенные функции-члены
Обратите внимание, что определения функций home(), get(), height() и width() приведены прямо в теле класса. Такие функции называются встроенными. (Мы говорили об этом в разделе 7.6.)Функции-члены можно объявить в теле класса встроенными и явно, поместив перед типом возвращаемого значения ключевое слово inline:
class Screen { public: // использование ключевого слова inline // для объявления встроенных функций-членов inline void home() { _cursor = 0; } inline char get() { return _screen[_cursor]; } // ... |
};
Определения home() и get() в приведенных примерах эквивалентны. Поскольку ключевое слово inline
избыточно, мы в этой книге не пишем его явно для функций-членов, определенных в теле класса.
Функции-члены, состоящие из двух или более строк, лучше определять вне тела. Для идентификации функции как члена некоторого класса требуется специальный синтаксис объявления: имя функции должно быть квалифицировано
именем ее класса. Вот как выглядит определение функции checkRange(), квалифицированное именем Screen:
#include #include "screen.h" // имя функции-члена квалифицировано именем Screen:: bool Screen::checkRange( int row, int col ) { // проверить корректность координат if ( row < 1 || row > _height || col < 1 || col > _width ) { cerr << "Screen coordinates ( " << row << ", " << col << " ) out of bounds.\n"; return false; } return true;
| |
}
Прежде чем определять функцию-член вне тела класса, необходимо объявить ее внутри тела, обеспечив ее видимость. Например, если бы перед определением функции checkRange() не был включен заголовочный файл Screen.h, то компилятор выдал бы сообщение об ошибке. Тело класса определяет полный список его членов. Этот список не может быть расширен после закрытия тела.
Обычно функции-члены, определенные вне тела класса, не делают встроенными. Но объявить такую функцию встроенной можно, если явно добавить слово inline в объявление функции внутри тела класса или в ее определение вне тела, либо сделав то и другое одновременно. В следующем примере move()
определена как встроенная функция-член класса Screen:
inline void Screen::move( int r, int c ) { // переместить курсор в абсолютную позицию if ( checkRange( r, c ) ) // позиция на экране задана корректно? { int row = (r-1) * _width; // смещение начала строки _cursor = row + c - 1; } |
Функция get(int, int)
объявляется встроенной с помощью слова inline:
class Screen { public: inline char get( int, int ); // объявления других функций-членов не изменяются |
Определение функции следует после объявления класса. При этом слово inline
можно опустить:
char Screen::get( int r, int c ) { move( r, c ); // устанавливаем _cursor return get(); // вызываем другую функцию-член get() |
Так как встроенные функции-члены должны быть определены в каждом исходном файле, где они вызываются, то встроенную функцию, не определенную в теле класса, следует поместить в тот же заголовочный файл, в котором определен ее класс. Например, представленные ранее определения move() и get() должны находиться в заголовочном файле Screen.h
после определения класса Screen.
Когда не надо перегружать имя функции
В каких случаях перегрузка имени не дает преимуществ? Например, тогда, когда присвоение функциям разных имен облегчает чтение программы. Вот несколько примеров. Следующие функции оперируют одним и тем же абстрактным типом даты. На первый взгляд, они являются подходящими кандидатами для перегрузки:void setDate( Date&, int, int, int ); Date &convertDate( const string & ); |
void printDate( const Date& );
Эти функции работают с одним типом данных– классом Date, но выполняют семантически различные действия. В этом случае лексическая сложность, связанная с употреблением различных имен, проистекает из принятого программистом соглашения об обеспечении набора операций над типом данных и именования функций в соответствии с семантикой этих операций. Правда, механизм классов C++ делает такое соглашение излишним. Следовало бы сделать такие функции членами класса Date, но при этом оставить разные имена, отражающие смысл операции:
#include class Date { public: set( int, int, int ); Date& convert( const string & ); void print(); // ...
| |
};
Приведем еще один пример. Следующие пять функций-членов Screen выполняют различные операции над экранным курсором, являющимся принадлежностью того же класса. Может показаться, что разумно перегрузить эти функции под общим названием move():
Screen& moveHome(); Screen& moveAbs( int, int ); Screen& moveRel( int, int, char *direction ); Screen& moveX( int ); |
Screen& moveY( int );
Впрочем, последние две функции перегрузить нельзя, так как у них одинаковые списки параметров. Чтобы сделать сигнатуру уникальной, объединим их в одну функцию:
// функция, объединяющая moveX() и moveY() |
Screen& move( int, char xy );
Теперь у всех функций разные списки параметров, так что их можно перегрузить под именем move(). Однако этого делать не следует: разные имена несут информацию, без которой программу будет труднее понять. Так, выполняемые данными функциями операции перемещения курсора различны. Например, moveHome()
осуществляет специальный вид перемещения в левый верхний угол экрана. Какой из двух приведенных ниже вызовов более понятен пользователю и легче запоминается?
// какой вызов понятнее? myScreen.home(); // мы считаем, что этот! |
В некоторых случаях не нужно ни перегружать имя функции, ни назначать разные имена: применение подразумеваемых по умолчанию значений аргументов позволяет объединить несколько функций в одну. Например, функции управления курсором
moveAbs(int, int); |
различаются наличием третьего параметра типа char*. Если их реализации похожи и для третьего аргумента можно найти разумное значение по умолчанию, то обе функции можно заменить одной. В данном случае на роль значения по умолчанию подойдет указатель со значением 0:
move( int, int, char* = 0 );
Применять те или иные возможности следует тогда, когда этого требует логика приложения. Вовсе не обязательно включать перегруженные функции в программу только потому, что они существуют.
Когда нельзя использовать обобщенные алгоритмы
Ассоциативные контейнеры (отображения и множества) поддерживают определенный порядок элементов для быстрого поиска и извлечения. Поэтому к ним не разрешается применять обобщенные алгоритмы, меняющие порядок, такие, как sort() и partition(). Если в ассоциативном контейнере требуется переставить элементы, то необходимо сначала скопировать их в последовательный контейнер, например в вектор или список.Контейнер list
(список) реализован в виде двусвязного списка: в каждом элементе, помимо собственно данных, хранятся два члена-указателя– на следующий и на предыдущий элементы. Основное преимущество списка – это эффективная вставка и удаление одного элемента или целого диапазона в произвольное место списка, а недостаток – невозможность произвольного доступа. Например, можно написать:
vector
Такая форма вполне допустима и инициализирует vec_iter адресом восьмого элемента вектора, но запись
// ошибка: арифметические операции над итераторами // не поддерживаются списком |
list
некорректна, так как элементы списка не занимают непрерывную область памяти. Для того чтобы добраться до восьмого элемента, необходимо посетить все промежуточные.
Поскольку список не поддерживает произвольного доступа, то алгоритмы merge(), remove(), reverse(), sort() и unique()
лучше к таким контейнерам не применять, хотя ни один из них явно не требует наличия соответствующего итератора. Вместо этого для списка определены специализированные версии названных операций в виде функций-членов, а также операция splice():
· list::merge()
объединяет два отсортированных списка
· list::remove()
удаляет элементы с заданным значением
· list::remove_if()удаляет элементы, удовлетворяющие некоторому условию
· list::reverse()
переставляет элементы списка в обратном порядке
· list::sort()
сортирует элементы списка
· list::splice()
перемещает элементы из одного списка в другой
· list::unique()
оставляет один элемент из каждой цепочки одинаковых смежных элементов
Композиция объектов
Есть две формы композиции объектов:·
композиция по значению, когда членом одного класса объявляется сам объект другого класса. Мы показывали это в исправленной реализации PeekbackStack;
· композиция по ссылке, когда членом одного класса является указатель или ссылка на объект другого класса.
Композиция по значению обеспечивает автоматическое управление временем жизни объекта и семантику копирования. Кроме того, прямой доступ к объекту оказывается более эффективным. А в каких случаях следует предпочесть композицию по ссылке?
Предположим, что мы решили с помощью композиции представить класс Endangered. Надо ли определить его объект непосредственно внутри ZooAnimal или сослаться на него с помощью указателя или ссылки? Сначала выясним, все ли объекты ZooAnimal
обладают этой характеристикой, а если нет, то может ли она изменяться с течением времени (допустимо ли добавлять или удалять эту характеристику).
Если ответ на первый вопрос положительный, то, как правило, лучше применить композицию по значению. (Как правило, но не всегда, поскольку с точки зрения эффективности включение больших объектов не оптимально, особенно когда они часто копируются. В таких случаях композиция по ссылке позволит обойтись без ненужных копирований, если применять при этом подсчет ссылок и технику, называемую копированием при записи. Увеличение эффективности, правда, достигается за счет усложнения управления объектом. Обсуждение этой техники не вошло в наш вводный курс; тем, кому это интересно, рекомендуем прочитать книгу [KOENIG97], главы 6 и 7.)
Если же оказывается, что только некоторые объекты класса ZooAnimal обладают указанной характеристикой, то лучшим вариантом будет композиция по ссылке (скажем, в примере с зоопарком не имеет смысла включать в процветающие виды большой объект, описывающий виды вымирающие).
Поскольку объекта Endangered
может и не существовать, то представлять его надо указателем, а не ссылкой. (Предполагается, что нулевой указатель не адресует объект. Ссылка же всегда должна именовать определенный объект. В разделе 3.6 это различие объяснялось более подробно.)
Если ответ на второй вопрос положительный, то необходимо задать функции, позволяющие вставить и удалить объект Endangered во время выполнения.
В нашем примере лишь небольшая часть всего множества животных в зоопарке находится под угрозой вымирания. Кроме того, по крайней мере теоретически, данная характеристика не является постоянной, и, допустим, в один прекрасный день это может перестать грозить панде.
class ZooAnimal { public: // ... const Endangered* Endangered() const; void addEndangered( Endangered* ); void removeEndangered(); // ... protected: Endangered *_endangered; // ... |
Если предполагается, что наше приложение будет работать на разных платформах, то полезно инкапсулировать всю платформенно-зависимую информацию в иерархию абстрактных классов, чтобы запрограммировать платформенно-независимый интерфейс. Например, для вывода объекта ZooAnimal на дисплей UNIX-машины и ПК, можно определить иерархию классов DisplayManager:
class DisplayManager { ... }; class DisplayUNIX : public DisplayManager { ... }; |
Наш класс ZooAnimal не является разновидностью класса DisplayManager, но содержит экземпляр последнего посредством композиции, а не наследования. Возникает вопрос: использовать композицию по значению или по ссылке?
Композиция по значению не может представить объект DisplayManager, с помощью которого можно будет адресовать либо объект DisplayUNIX, либо объект DisplayPC. Только ссылка или указатель на объект DisplayManager позволят нам полиморфно манипулировать его подтипами. Иначе говоря, объектно-ориентированное программирование поддерживается только композицией по ссылке (подробнее см. [LIPPMAN96a].)
Теперь нужно решить, должен ли член класса ZooAnimal
быть ссылкой или указателем на DisplayManager:
· член может быть объявлен ссылкой лишь в том случае, если при создании объекта ZooAnimal имеется реальный объект DisplayManager, который не будет изменяться по ходу выполнения программы;
· если применяется стратегия отложенного выделения памяти, когда память для объекта DisplayManager
выделяется только при попытке вывести объект на дисплей, то объект следует представить указателем, инициализировав его значением 0;
· если мы хотим переключать режим вывода во время выполнения, то тоже должны представить объект указателем, который инициализирован нулем. Под переключением
мы понимаем предоставление пользователю возможности выбрать один из подтипов DisplayManager в начале или в середине работы программы.
Конечно, маловероятно, что для каждого подобъекта ZooAnimal в нашем приложении будет нужен собственный подтип DisplayManager для отображения. Скорее всего мы ограничимся статическим членом в классе ZooAnimal, указывающим на объект DisplayManager.
Упражнение 18.6
Объясните, в каких случаях имеет место наследование типа, а в каких – наследование реализации:
(a) Queue : List // очередь : список (b) EncryptedString : String // зашифрованная строка : строка (c) Gif : FileFormat (d) Circle : Point // окружность : точка (e) Dqueue : Queue, List |
Упражнение 18.7
Замените член IntArray в реализации PeekbackStack
(см. раздел 18.3.1) на класс deque из стандартной библиотеки. Напишите небольшую программу для тестирования.
Упражнение 18.8
Сравните композицию по ссылке с композицией по значению, приведите примеры их использования.
Конкретизация шаблона функции
Шаблон функции описывает, как следует строить конкретные функции, если задано множество фактических типов или значений. Процесс конструирования называется конкретизацией шаблона. Выполняется он неявно, как побочный эффект вызова или взятия адреса шаблона функции. Например, в следующей программе min()конкретизируется дважды: один раз для массива из пяти элементов типа int, а другой – для массива из шести элементов типа double:
// определение шаблона функции min() // с параметром-типом Type и параметром-константой size template Type min( Type (&r_array)[size] ) { Type min_val = r_array[0]; for ( int i = 1; i < size; ++i ) if ( r_array[i] < min_val ) min_val = r_array[i]; return min_val; } // size не задан -- ok // size = число элементов в списке инициализации int ia[] = { 10, 7, 14, 3, 25 }; double da[6] = { 10.2, 7.1, 14.5, 3.2, 25.0, 16.8 }; #include int main() { // конкретизация min() для массива из 5 элементов типа int // подставляется Type => int, size => 5 int i = min( ia ); if ( i != 3 ) cout << "??oops: integer min() failed\n"; else cout << "!!ok: integer min() worked\n"; // конкретизация min() для массива из 6 элементов типа double // подставляется Type => double, size => 6 double d = min( da ); if ( d != 3.2 ) cout << "??oops: double min() failed\n"; else cout << "!!ok: double min() worked\n"; return 0;
| |
}
Вызов
int i = min( ia );
приводит к конкретизации следующего экземпляра функции min(), в котором Type
заменено на int, а size на 5:
int min( int (&r_array)[5] ) { int min_val = r_array[0]; for ( int i = 1; i < 5; ++i ) if ( r_array[i] < min_val ) min_val = r_array[i]; return min_val; |
}
Аналогично вызов
double d = min( da );
конкретизирует экземпляр min(), в котором Type
заменено на double, а size на 6:
В качестве формальных параметров шаблона функции используются параметр-тип и параметр-константа. Для определения фактического типа и значения константы, которые надо подставить в шаблон, исследуются фактические аргументы, переданные при вызове функции. В нашем примере для идентификации аргументов шаблона при конкретизации используются тип ia (массив из пяти int) и da
(массив из шести double). Процесс определения типов и значений аргументов шаблона по известным фактическим аргументам функции называется выведением (deduction) аргументов шаблона. (В следующем разделе мы расскажем об этом подробнее. А в разделе 10.4 речь пойдет о возможности явного задания аргументов.)
Шаблон конкретизируется либо при вызове, либо при взятии адреса функции. В следующем примере указатель pf инициализируется адресом конкретизированного экземпляра шаблона. Его аргументы определяются путем исследования типа параметра функции, на которую указывает pf:
template Type min( Type (&p_array)[size] ) { /* ... */ } // pf указывает на int min( int (&)[10] ) |
Тип pf – это указатель на функцию с параметром типа int(&)[10], который определяет тип аргумента шаблона Type и значение аргумента шаблона size при конкретизации min(). Аргумент шаблона Type
будет иметь тип int, а значением аргумента шаблона size будет 10. Конкретизированная функция представляется как min(int(&)[10]), и указатель pf
адресует именно ее.
Когда берется адрес шаблона функции, контекст должен быть таким, чтобы можно было однозначно определить типы и значения аргументов шаблона. Если сделать это не удается, компилятор выдает сообщение об ошибке:
template Type min( Type (&r_array)[size] ) { /* ... */ } typedef int (&rai)[10]; typedef double (&rad)[20]; void func( int (*)(rai) ); void func( double (*)(rad) ); int main() { // ошибка: как конкретизировать min()? func( &min ); |
}
Функция func()
перегружена и тип ее параметра не позволяет однозначно определить ни аргумент шаблона Type, ни значение аргумента шаблона size. Результатом конкретизации вызова func()
может быть любая из следующих функций:
min( int (*)(int(&)[10]) ) |
Поскольку однозначно определить аргументы функции func() нельзя, взятие адреса конкретизированного шаблона в таком контексте приводит к ошибке компиляции.
Этого можно избежать, если использовать явное приведение типов для указания типа аргумента:
int main() { // правильно: с помощью явного приведения указывается тип аргумента func( static_cast< double(*)(rad) >(&min) ); |
Лучше, однако, применять явное задание аргументов шаблона, как будет показано в разделе 10.4.
Конкретизация шаблона класса
В определении шаблона указывается, как следует строить индивидуальные классы, если заданы один или более фактических типов или значений. По шаблону Queueавтоматически генерируются экземпляры классов Queue с разными типами элементов. Например, если написать:
Queue
то из обобщенного определения шаблона автоматически создается класс Queue для объектов типа int.
Генерация конкретного класса из обобщенного определения шаблона называется конкретизацией шаблона. При такой конкретизации Queue для объектов типа int
каждое вхождение параметра Type в определении шаблона заменяется на int, так что определение класса Queue принимает вид:
template class Queue { public: Queue() : front( 0 ), back ( 0 ) { } ~Queue(); int& remove(); void add( const int & ); bool is_empty() const { return front == 0; } private: QueueItem QueueItem
| |
};
Чтобы создать класс Queue для объектов типа string, надо написать:
Queue
При этом каждое вхождение Type в определении шаблона будет заменено на string. Объекты qi и qs
являются объектами автоматически созданных классов.
Каждый конкретизированный по одному и тому же шаблону экземпляр класса совершенно не зависит от всех остальных. Так, у Queue для типа int нет никаких прав доступа к неоткрытым членам того же класса для типа string.
Конкретизированный экземпляр шаблона будет иметь соответственно имя Queue
Queue qs; // ошибка: как конкретизируется шаблон?
Конкретизированный шаблон класса Queue
можно использовать в программе всюду, где допустимо употребление типа обычного класса:
// типы возвращаемого значения и обоих параметров конкретизированы из // шаблона класса Queue extern Queue< complex foo( Queue< complex // указатель на функцию- член класса, конкретизированного из шаблона Queue bool (Queue // явное приведение 0 к указателю на экземпляр Queue |
Объекты типа класса, конкретизированного по шаблону Queue, объявляются и используются так же, как объекты обычных классов:
extern Queue Queue Queue int main() { int ix; if ( ! pqi->is_empty() ) ix = pqi->remove(); // ... for ( ix = 0; ix < 1024; ++ix ) eqd[ ix ].add( ix ); // ... |
В объявлении и определении шаблона можно ссылаться как на сам шаблон, так и на конкретизированный по нему класс:
// объявление шаблона функции template void bar( Queue Queue |
Однако вне такого определения употребляются только конкретизированные экземпляры. Например, в теле обычной функции всегда надо задавать фактические аргументы шаблона Queue:
void foo( Queue { Queue // ... |
Шаблон класса конкретизируется только тогда, когда имя полученного экземпляра употребляется в контексте, где требуется определение шаблона. Не всегда определение класса должно быть известно. Например, перед объявлением указателей и ссылок на класс его знать необязательно:
class Matrix; Matrix *pm; // правильно: определение класса Matrix знать необязательно |
Поэтому объявление указателей и ссылок на конкретизированный шаблон класса не приводит к его конкретизации. (Отметим, что в некоторых компиляторах, написанных до принятия стандарта C++, шаблон конкретизируется при первом упоминании имени конкретизированного класса в тексте программы.) Так, в функции foo()
объявляются указатель и ссылка на Queue
// Queue void foo( Queue { Queue // ... |
Определение класса необходимо знать, когда определяется объект этого типа. В следующем примере определение obj1
ошибочно: чтобы выделить для него память, компилятору необходимо знать размер класса Matrix:
class Matrix; Matrix obj1; // ошибка: класс Matrix не определен class Matrix { ... }; |
Таким образом, конкретизация происходит тогда, когда определяется объект класса, конкретизированного по этому шаблону. В следующем примере определение объекта qi
приводит к конкретизации шаблона Queue
Queue
Определение Queue
становится известно компилятору именно в этой точке, которая называется точкой конкретизации данного класса.
Если имеется указатель или ссылка на конкретизированный шаблон, то конкретизация также производится в момент обращения к объекту, на который они ссылаются. В определенной выше функции foo() класс Queue
конкретизируется в следующих случаях: когда разыменовывается указатель pqi, когда ссылка qi
используется для получения значения именуемого объекта и когда pqi или qi
употребляются для доступа к членам или функциям-членам этого класса:
void foo( Queue { Queue // Queue pqi->add( 255 ); // ... |
Определение Queue
становится известным компилятору еще до вызова функции-члена add() из foo().
Напомним, что в определении шаблона класса Queue есть также ссылка на шаблон QueueItem:
template class Queue { public: // ... private: QueueItem QueueItem |
};
При конкретизации
Queue типом int
члены front и back
становятся указателями на QueueItem
ссылается на экземпляр QueueItem, конкретизированный типом int. Но поскольку соответствующие члены являются указателями, то QueueItem
конкретизируется лишь в момент их разыменования в функциях-членах класса Queue
Наш класс QueueItem
служит вспомогательным средством для реализации класса Queue и не будет непосредственно употребляться в вызывающей программе. Поэтому пользовательская программа способна манипулировать только объектами Queue. Конкретизация шаблона QueueItem
происходит лишь в момент конкретизации шаблона класса Queue или его членов. (В следующих разделах мы рассмотрим конкретизации членов шаблона класса.)
В зависимости от типов, которыми может конкретизироваться шаблон, при его определении надо учитывать некоторые нюансы. Почему, например, следующее определение конструктора класса QueueItem не подходит для конкретизации общего вида?
template class QueueItem { public: QueueItem( Type ); // неудачное проектное решение // ... |
В данном определении аргумент передается по значению. Это допустимо, если QueueItem
конкретизируется встроенным типом (например, QueueItem
QueueItem( const Type & );
Следующее определение приемлемо, если у типа, для которого конкретизируется QueueItem, нет ассоциированного конструктора:
template class QueueItem { // ... public: // потенциально неэффективно QueueItem( const Type &t ) { item = t; next = 0; } |
};
Если аргументом шаблона является тип класса с конструктором (например, string), то item
инициализируется дважды! Конструктор по умолчанию string
вызывается для инициализации item перед выполнением тела конструктора QueueItem. Затем для созданного объекта item производится почленное присваивание. Избежать такого можно с помощью явной инициализации item в списке инициализации членов внутри определения конструктора QueueItem:
template class QueueItem { // ... public: // item инициализируется в списке инициализации членов конструктора QueueItem( const Type &t ) : item(t) { next = 0; } |
(Списки инициализации членов и основания для их применения обсуждались в разделе 14.5.)
Конструирование базового и производного классов
Напомним, что объект производного класса состоит из одного или более подобъектов, соответствующих базовым классам, и части, относящейся к самому производному. Например, NameQueryсостоит из подобъекта Query и объекта-члена string. Для иллюстрации поведения конструктора производного класса введем еще один член встроенного типа:
class NameQuery : public Query { public: // ... protected: bool _present; string _name; |
};
Если _present
установлен в false, то слово _name в тексте отсутствует.
Рассмотрим случай, когда в NameQuery
конструктор не определен. Тогда при определении объекта этого класса
NameQuery nq;
по очереди вызывается конструктор по умолчанию Query, а затем конструктор по умолчанию класса string
(ассоциированный с объектом _name). Член _present
остается неинициализированным, что потенциально может служить источником ошибок. Чтобы инициализировать его, можно так определить конструктор по умолчанию для класса NameQuery:
inline NameQuery::NameQuery() { _present = false; }
Теперь при определении nq
вызываются три конструктора по умолчанию: для базового класса Query, для класса string при инициализации члена _name и для класса NameQuery.
А как передать аргумент конструктору базового класса Query? Ответить на этот вопрос можно, рассуждая по аналогии.
Для передачи одного или более аргументов конструктору объекта-члена мы используем список инициализации членов (здесь можно также задать начальные значения членам, не являющимся объектами классов; подробности см. в разделе 14.5):
inline NameQuery:: NameQuery( const string &name ) : _name( name ), _present( false ) |
{}
Для передачи одного или более аргументов конструктору базового класса также разрешается использовать список инициализации членов. В следующем примере мы передаем конструктору string
аргумент name, а конструктору базового класса Query – объект, адресованный указателем ploc:
inline NameQuery:: NameQuery( const string &name, vector : _name( name ), Query( *ploc ), _present( true )
| |
{}
Хотя Query
помещен в список инициализации вторым, его конструктор всегда вызывается раньше конструктора для _name. Порядок их вызова следующий:
Конструктор базового класса. Если базовых классов несколько, то конструкторы вызываются в порядке их следования в списке базовых классов, а не в порядке появления в списке инициализации. (О множественном наследовании в этой связи мы поговорим в главе 18.)
Конструктор объекта-члена. Если в классе есть несколько таких членов, то конструкторы вызываются в порядке их объявления в классе, а не в порядке появления в списке инициализации (подробнее см. раздел 14.5).
Конструктор производного класса.
Конструктор производного класса должен стремиться передать значение члена базового класса подходящему конструктору того же класса, а не присваивать его напрямую. В противном случае реализации двух классов становятся сильно связанными и тогда изменить или расширить реализацию базового будет затруднительно. (Ответственность разработчика базового класса ограничивается предоставлением подходящего множества конструкторов.)
В оставшейся части этого раздела мы последовательно изучим конструктор базового класса и конструкторы четырех производных от него, а после этого рассмотрим альтернативный дизайн иерархии классов Query, чтобы познакомиться с иерархиями глубиной больше двух. В конце раздела речь пойдет о деструкторах классов.
Конструктор базового класса
В нашем базовом классе объявлено два нестатических члена: _solution и _loc:class Query { public: // ... protected: set vector // ...
| |
};
Конструктор Query по умолчанию должен явно инициализировать только член _solution. Для инициализации _loc автоматически вызывается конструктор класса vector. Вот реализация нашего конструктора:
inline Query::Query(): _solution( 0 ) {}
В Query нам понадобится еще один конструктор, принимающий ссылку на вектор позиций:
inline Query:: Query( const vector< locaton > &loc ) : _solution( 0 ), _loc( loc ) |
{}
Он вызывается только из конструктора NameQuery, когда объект этого класса используется для представления указанного в запросе слова. В таком случае передается предварительно подготовленный для него вектор позиций. Остальные три производных класса вычисляют свои векторы позиций в соответствующей функции-члене eval(). (В следующем подразделе мы покажем, как это делается. Реализации функций-членов eval() приведены в разделе 17.5.)
Какой уровень доступа обеспечить для конструкторов? Мы не хотим объявлять их открытыми, так как предполагается, что Query будет существовать в программе только в виде подобъекта в составе объектов производных от него классов. Поэтому мы объявим конструктор не открытым, а защищенным:
class Query { public: // ... protected: Query(); // ... |
};
Ко второму конструктору класса Query
предъявляются еще более жесткие требования: он не только должен конструировать Query в виде подобъекта производного класса, но этот производный класс должен к тому же быть NameQuery. Можно объявить конструктор закрытым, а NameQuery сделать другом класса Query. (В предыдущем разделе мы говорили, что производный класс может получить доступ только к открытым и защищенным членам базового. Поэтому любая попытка вызвать второй конструктор из классов AndQuery, OrQuery или NotQuery приведет к ошибке компиляции.)
class Query { public: // ... protected: Query(); // ... private: explicit Query( const vector
| |
};
(Необходимость второго конструктора спорна; вероятно, правильнее заполнить _loc в функции eval()
класса NameQuery. Однако принятый подход в большей степени отвечает нашей цели проиллюстрировать использование конструктора базового класса.)
Конструктор как конвертер
Набор конструкторов класса, принимающих единственный параметр, например, SmallInt(int)класса SmallInt, определяет множество неявных преобразований в значения типа SmallInt. Так, конструктор SmallInt(int)
преобразует значения типа int в значения типа SmallInt.
extern void calc( SmallInt ); int i; // необходимо преобразовать i в значение типа SmallInt // это достигается применением SmallInt(int) |
calc( i );
При вызове calc(i)
число i
преобразуется в значение типа SmallInt с помощью конструктора SmallInt(int), вызванного компилятором для создания временного объекта нужного типа. Затем копия этого объекта передается в calc(), как если бы вызов функции был записан в форме:
// Псевдокод на C++ // создается временный объект типа SmallInt { SmallInt temp = SmallInt( i ); calc( temp ); |
}
Фигурные скобки в этом примере обозначают время жизни данного объекта: он уничтожается при выходе из функции.
Типом параметра конструктора может быть тип некоторого класса:
class Number { public: // создание значения типа Number из значения типа SmallInt Number( const SmallInt & ); // ... |
};
В таком случае значение типа SmallInt
можно использовать всюду, где допустимо значение типа Number:
extern void func( Number ); SmallInt si(87); int main() { // вызывается Number( const SmallInt & ) func( si ); // ... |
}
Если конструктор используется для выполнения неявного преобразования, то должен ли тип его параметра точно соответствовать типу подлежащего преобразованию значения? Например, будет ли в следующем коде вызван SmallInt(int), определенный в классе SmallInt, для приведения dobj к типу SmallInt?
extern void calc( SmallInt ); double dobj; // вызывается ли SmallInt(int)? Да // dobj преобразуется приводится от double к int // стандартным преобразованием |
calc( dobj );
Если необходимо, к фактическому аргументу применяется последовательность стандартных преобразований до того, как вызвать конструктор, выполняющий определенное пользователем преобразование. При обращении к функции calc()употребляется стандартное преобразование dobj из типа double в тип int. Затем уже для приведения результата к типу SmallInt вызывается SmallInt(int).
Компилятор неявно использует конструктор с единственным параметром для преобразования его типа в тип класса, к которому принадлежит конструктор. Однако иногда удобнее, чтобы конструктор Number(const SmallInt&) можно было вызывать только для инициализации объекта типа Number значением типа SmallInt, но ни в коем случае не для выполнения неявных преобразований. Чтобы избежать такого употребления конструктора, объявим его явным (explicit):
class Number { public: // никогда не использовать для неявных преобразований explicit Number( const SmallInt & ); // ... |
Компилятор никогда не применяет явные конструкторы для выполнения неявных преобразований типов:
extern void func( Number ); SmallInt si(87); int main() { // ошибка: не существует неявного преобразования из SmallInt в Number func( si ); // ... |
Однако такой конструктор все же можно использовать для преобразования типов, если оно запрошено явно в форме оператора приведения типа:
SmallInt si(87); int main() { // ошибка: не существует неявного преобразования из SmallInt в Number func( si ); func( Number( si ) ); // правильно: приведение типа func( static_cast< Number >( si ) ); // правильно: приведение типа |
Конструктор класса
Среди других функций-членов конструктор выделяется тем, что его имя совпадает с именем класса. Для объявления конструктора по умолчанию мы пишем2:class Account { public: // конструктор по умолчанию ... Account(); // ... private: char *_name; unsigned int _acct_nmbr; double _balance; |
};
Единственное синтаксическое ограничение, налагаемое на конструктор, состоит в том, что он не должен иметь тип возвращаемого значения, даже void. Поэтому следующие объявления ошибочны:
// ошибки: у конструктора не может быть типа возвращаемого значения void Account::Account() { ... } |
Account* Account::Account( const char *pc ) { ... }
Количество конструкторов у одного класса может быть любым, лишь бы все они имели разные списки формальных параметров.
Откуда мы знаем, сколько и каких конструкторов определить? Как минимум, необходимо присвоить начальное значение каждому члену, который в этом нуждается. Например, номер счета либо задается явно, либо генерируется автоматически таким образом, чтобы гарантировать его уникальность. Предположим, что он будет создаваться автоматически. Тогда мы должны разрешить инициализировать оставшиеся два члена _name и _balance:
Account( const char *name, double open_balance );
Объект класса Account, инициализируемый конструктором, можно объявить следующим образом:
Account newAcct( "Mikey Matz", 0 );
Если же есть много счетов, для которых начальный баланс равен 0, то полезно иметь конструктор, задающий только имя владельца и автоматически инициализирующий _balance
нулем. Один из способов сделать это– предоставить конструктор вида:
Account( const char *name );
Другой способ – включить в конструктор с двумя параметрами значение по умолчанию, равное нулю:
Account( const char *name, double open_balance = 0.0 );
Оба конструктора обладают необходимой пользователю функциональностью, поэтому оба решения приемлемы. Мы предпочитаем использовать аргумент по умолчанию, поскольку в такой ситуации общее число конструкторов класса сокращается.
Нужно ли поддерживать также задание одного лишь начального баланса без указания имени клиента? В данном случае спецификация класса явно запрещает это. Наш конструктор с двумя параметрами, из которых второй имеет значение по умолчанию, предоставляет полный интерфейс для указания начальных значений тех членов класса Account, которые могут быть инициализированы пользователем:
class Account { public: // конструктор по умолчанию ... Account(); // имена параметров в объявлении указывать необязательно Account( const char*, double=0.0 ); const char* name() { return name; } // ... private: // ... |
Ниже приведены два примера правильного определения объекта класса Account, где конструктору передается один или два аргумента:
int main() { // правильно: в обоих случаях вызывается конструктор // с двумя параметрами Account acct( "Ethan Stern" ); Account *pact = new Account( "Michael Lieberman", 5000 ); if ( strcmp( acct.name(), pact->name() )) // ... |
C++ требует, чтобы конструктор применялся к определенному объекту до его первого использования. Это означает, что как для acct, так и для объекта, на который указывает pact, конструктор будет вызван перед проверкой в инструкции if.
Компилятор перестраивает нашу программу, вставляя вызовы конструкторов. Вот как, по всей вероятности, будет модифицировано определение acct
внутри main():
// псевдокод на C++, // иллюстрирующий внутреннюю вставку конструктора int main() { Account acct; acct.Account::Account("Ethan Stern", 0.0); // ... |
Конечно, если конструктор определен как встроенный, то он подставляется в точке вызова.
Обработка оператора new
несколько сложнее. Конструктор вызывается только тогда, когда он успешно выделил память. Модификация определения pact в несколько упрощенном виде выглядит так:
// псевдокод на C++, // иллюстрирующий внутреннюю вставку конструктора при обработке new int main() { // ... Account *pact; try { pact = _new( sizeof( Account )); pact->Acct.Account::Account( "Michael Liebarman", 5000.0); } catch( std::bad_alloc ) { // оператор new закончился неудачей: // конструктор не вызывается } // ... |
}
Существует три в общем случае эквивалентных формы задания аргументов конструктора:
// в общем случае эти формы эквивалентны Account acct1( "Anna Press" ); Account acct2 = Account( "Anna Press" ); |
Форма acct3
может использоваться только при задании единственного аргумента. Если аргументов два или более, мы рекомендуем пользоваться формой acct1, хотя допустима и acct2.
// рекомендуемая форма вызова конструктора |
Новички часто допускают ошибку при объявлении объекта, инициализированного конструктором по умолчанию:
// увы! работает не так, как ожидалось |
Эта инструкция компилируется без ошибок. Однако при попытке использовать объект в таком контексте:
// ошибка компиляции ... |
компилятор не сможет применить к функции нотацию доступа к членам класса. Определение
// определяет функцию newAccount, // а не объект класса |
интерпретируется компилятором как определение функции без параметров, которая возвращает объект типа Account. Правильное объявление объекта класса, инициализируемого конструктором по умолчанию, не содержит пустых скобок:
// правильно: определяется объект класса ... |
Определять объект класса, не указывая списка фактических аргументов, можно в том случае, если в нем либо объявлен конструктор по умолчанию, либо вообще нет объявлений конструкторов. Если в классе объявлен хотя бы один конструктор, то не разрешается определять объект класса, не вызывая ни одного из них. В частности, если в классе определен конструктор, принимающий один или более параметров, но не определен конструктор по умолчанию, то в каждом определении объекта такого класса должны присутствовать необходимые аргументы. Можно возразить, что не имеет смысла определять конструктор по умолчанию для класса Account, поскольку не бывает счетов без имени владельца. В пересмотренной версии класса Account
такой конструктор исключен:
class Account { public: // имена параметров в объявлении указывать необязательно Account( const char*, double=0.0 ); const char* name() { return name; } // ... private: // ... |
Теперь при объявлении каждого объекта Account в конструкторе обязательно надо указать как минимум аргумент типа C-строки, но это скорее всего бессмысленно. Почему? Контейнерные классы (например, vector) требуют, чтобы для класса помещаемых в них элементов был либо задан конструктор по умолчанию, либо вообще никаких конструкторов. Аналогичная ситуация имеет место при выделении динамического массива объектов класса. Так, следующая инструкция вызвала бы ошибку компиляции для новой версии Account:
// ошибка: требуется конструктор по умолчанию для класса Account |
На практике часто требуется задавать конструктор по умолчанию, если имеются какие-либо другие конструкторы.
А если для класса нет разумных значений по умолчанию? Например, класс Account
требует задавать для любого объекта фамилию владельца счета. В таком случае лучше всего установить состояние объекта так, чтобы было видно, что он еще не инициализирован корректными значениями:
// конструктор по умолчанию для класса Account inline Account:: Account() { _name = 0; _balance = 0.0; _acct_nmbr = 0; |
Однако в функции-члены класса Account
придется включить проверку целостности объекта перед его использованием.
Существует и альтернативный синтаксис: список инициализации членов, в котором через запятую указываются имена и начальные значения. Например, конструктор по умолчанию можно переписать следующим образом:
// конструктор по умолчанию класса Account с использованием // списка инициализации членов inline Account:: Account() : _name(0), _balance( 0.0 ), _acct_nmbr( 0 ) |
Такой список допустим только в определении, но не в объявлении конструктора. Он помещается между списком параметров и телом конструктора и отделяется двоеточием. Вот как выглядит наш конструктор с двумя параметрами при частичном использовании списка инициализации членов:
inline Account:: Account( const char* name, double opening_bal ) : _balance( opening_bal ) { _name = new char[ strlen(name)+1 ]; strcpy( _name, name ); _acct_nmbr = get_unique_acct_nmbr(); |
get_unique_acct_nmbr() – это не являющаяся открытой функция-член, которая возвращает гарантированно не использованный ранее номер счета.
Конструктор нельзя объявлять с ключевыми словами const или volatile (см. раздел 13.3.5), поэтому приведенные записи неверны:
class Account { public: Account() const; // ошибка Account() volatile; // ошибка // ... |
Это не означает, что объекты класса с такими спецификаторами запрещено инициализировать конструктором. Просто к объекту применяется подходящий конструктор, причем без учета спецификаторов в объявлении объекта. Константность объекта класса устанавливается после того, как работа по его инициализации завершена, и пропадает в момент вызова деструктора. Таким образом, объект класса со спецификатором const
считается константным с момента завершения работы конструктора до момента запуска деструктора. То же самое относится и к спецификатору volatile.
Рассмотрим следующий фрагмент программы:
// в каком-то заголовочном файле extern void print( const Account &acct ); // ... int main() { // преобразует строку "oops" в объект класса Account // с помощью конструктора Account::Account( "oops", 0.0 ) print( "oops" ); // ... |
По умолчанию конструктор с одним параметром (или с несколькими – при условии, что все параметры, кроме первого, имеют значения по умолчанию) играет роль оператора преобразования. В этом фрагменте программы конструктор Account
неявно применяется компилятором для трансформации литеральной строки в объект класса Account при вызове print(), хотя в данной ситуации такое преобразование не нужно.
Непреднамеренные неявные преобразования классов, например трансформация "oops" в объект класса Account, оказались источником трудно обнаруживаемых ошибок. Поэтому в стандарт C++ было добавлено ключевое слово explicit, говорящее компилятору, что такие преобразования не нужны:
class Account { public: explicit Account( const char*, double=0.0 ); |
Данный модификатор применим только к конструктору. (Операторы преобразования и слово explicit
обсуждаются в разделе 15.9.2.)
Конструктор по умолчанию
Конструктором по умолчанию называется конструктор, который можно вызывать, не задавая аргументов. Это не значит, что такой конструктор не может принимать аргументов; просто с каждым его формальным параметром ассоциировано значение по умолчанию:// все это конструкторы по умолчанию Account::Account() { ... } iStack::iStack( int size = 0 ) { ... } |
Complex::Complex(double re=0.0, double im=0.0) { ... }
Когда мы пишем:
int main() { Account acct; // ... |
}
то компилятор сначала проверяет, определен ли для класса Account конструктор по умолчанию. Возникает одна из следующих ситуаций:
1. Такой конструктор определен. Тогда он применяется к acct.
2. Конструктор определен, но не является открытым. В данном случае определение acct помечается компилятором как ошибка: у функции main() нет прав доступа.
3. Конструктор по умолчанию не определен, но есть один или несколько конструкторов, требующих задания аргументов. Определение acct помечается как ошибка: слишком мало аргументов у конструктора.
4. Нет ни конструктора по умолчанию, ни какого-либо другого. Определение считается корректным, acct не инициализируется, конструктор не вызывается.
Пункты 1 и 3 должны быть уже достаточно понятны (если это не так, перечитайте данную главу) Посмотрим более внимательно на пункты 2 и 4.
Допустим, что все члены класса Account
объявлены открытыми и не объявлено никакого конструктора:
class Account { public: char *_name; unsigned int _acct_nmbr; double _balance; |
};
В таком случае при определении объекта класса Account специальной инициализации не производится. Начальные значения всех трех членов зависят только от контекста, в котором встретилось определение. Например, для статических объектов гарантируется, что все их члены будут обнулены (как и для объектов, не являющихся экземплярами классов):
// статический класс хранения // вся ассоциированная с объектом память обнуляется Account global_scope_acct; static Account file_scope_acct; Account foo() { static Account local_static_acct; // ... |
}
Однако объекты, определенные локально или распределенные динамически, в начальный момент будут содержать случайный набор битов, оставшихся в стеке программы:
// локальные и распределенные из хипа объекты не инициализированы // до момента явной инициализации или присваивания Account bar() { Account local_acct; Account *heap_acct = new Account; // ... |
Новички часто полагают, что компилятор автоматически генерирует конструктор, если он не задан, и применяет его для инициализации членов класса. Для Account в том виде, в каком мы его определили, это неверно. Никакой конструктор не генерируется и не вызывается. Для более сложных классов, имеющих члены, которые сами являются классами, или использующих наследование, это отчасти справедливо: конструктор по умолчанию может быть сгенерирован, но и он не присваивает начальных значений членам встроенных или составных типов, таким, как указатели или массивы.
Если мы хотим, чтобы подобные члены инициализировались, то должны сами позаботиться об этом, предоставив один или несколько конструкторов. В противном случае отличить корректное значение члена такого типа от неинициализированного, если объект создан локально или распределен из хипа,3 практически невозможно.
Конструктор производного класса
В классе NameQueryтакже определены два конструктора. Они объявлены открытыми, поскольку ожидается, что в приложении будут создаваться объекты этого класса:
class NameQuery : public Query { public: explicit NameQuery( const string& ); NameQuery( const string&, const vector // ... protected: // ...
| |
};
Конструктор с одним параметром принимает в качестве аргумента строку. Она передается конструктору объекта типа string, который вызывается для инициализации члена _name. Конструктор по умолчанию базового класса Query
вызывается неявно:
inline NameQuery:: NameQuery( const string &name ) // Query::Query() вызывается неявно : _name( name ) |
{}
Конструктор с двумя параметрами также принимает строку в качестве одного из них. Второй его параметр– это указатель на вектор позиций. Он передается закрытому конструктору базового класса Query. (Обратите внимание, что _present нам больше не нужен, и мы исключили его из числа членов NameQuery.)
inline NameQuery:: NameQuery( const string &name, vector : _name( name ), Query( *ploc )
| |
{}
Конструкторы можно использовать так:
string title( "Alice" ); NameQuery *pname; // проверим, встречается ли "Alice" в отображении слов // если да, получить ассоциированный с ним вектор позиций if ( vector pname = new NameQuery( title, ploc );
| |
else pname = new NameQuery( title );
В каждом из классов NotQuery, OrQuery и AndQuery определено по одному конструктору, каждый из которых вызывает конструктор базового класса неявно:
inline NotQuery:: NotQuery( Query *op = 0 ) : _op( op ) {} inline OrQuery:: OrQuery( Query *lop = 0, Query *rop = 0 ) : _lop( lop ), _rop( rop ) {} inline AndQuery:: AndQuery( Query *lop = 0, Query *rop = 0 ) : _lop( lop ), _rop( rop ) |
{}
(В разделе 17.7 мы построим объекты каждого из производных классов для представления различных запросов пользователя.)
Конструкторы и функциональные try-блоки
Можно объявить функцию так, что все ее тело будет заключено в try-блок. Такие try-блоки называются функциональными. (Мы упоминали их в разделе 11.2.) Например:int main() { try { // тело функции main() } catch ( pushOnFull ) { // ... } catch ( popOnEmpty ) { // ... |
}
Функциональный try-блок ассоциирует группу catch-обработчиков с телом функции. Если инструкция внутри тела возбуждает исключение, то поиск его обработчика ведется среди тех, что следуют за телом функции.
Функциональный try-блок необходим для конструкторов класса. Почему? Определение конструктора имеет следующий вид:
имя_класса( список_параметров ) // список инициализации членов: : член1(выражение1 ) , // инициализация член1 член2(выражение2 ) , // инициализация член2 // тело функции: |
{ /* ... */ }
выражение1 и выражение2
могут быть выражениями любого вида, в частности функциями, которые возбуждают исключения.
Рассмотрим еще раз класс Account, описанный в главе 14. Его конструктор можно переопределить так:
inline Account:: Account( const char* name, double opening_bal ) : _balance( opening_bal - ServiceCharge() ) { _name = new char[ strlen(name) + 1 ]; strcpy( _name, name ); _acct_nmbr = get_unique_acct_nmbr(); |
}
Функция ServiceCharge(), вызываемая для инициализации члена _balance, может возбуждать исключение. Как нужно реализовать конструктор, если мы хотим обрабатывать все исключения, возбуждаемые функциями, которые вызываются при конструировании объекта типа Account?
Помещать try-блок в тело функции нельзя:
inline Account:: Account( const char* name, double opening_bal ) : _balance( opening_bal - ServiceCharge() ) { try { _name = new char[ strlen(name) + 1 ]; strcpy( _name, name ); _acct_nmbr = get_unique_acct_nmbr(); } catch (...) { // специальная обработка // не перехватывает исключения, // возбужденные в списке инициализации членов } |
}
Поскольку try- блок не охватывает список инициализации членов, то catch-обработчик, находящийся в конце конструктора, не рассматривается при поиске кандидатов, которые способны перехватить исключение, возбужденное в функции ServiceCharge().
Использование функционального try-блока – это единственное решение, гарантирующее, что все исключения, возбужденные при создании объекта, будут перехвачены в конструкторе. Для конструктора класса Account такой try-блок можно определить следующим образом:
inline Account:: Account( const char* name, double opening_bal ) try : _balance( opening_bal - ServiceCharge() ) { _name = new char[ strlen(name) + 1 ]; strcpy( _name, name ); _acct_nmbr = get_unique_acct_nmbr(); |
catch (...) { // теперь специальная обработка // перехватывает исключения, // возбужденные в ServiceCharge() |
Обратите внимание, что ключевое слово try
находится перед списком инициализации членов, а составная инструкция, образующая try-блок, охватывает тело конструктора. Теперь предложение catch(...) принимается во внимание при поиске обработчика исключения, возбужденного как в списке инициализации членов, так и в теле конструктора.
Контейнеры multimap и multiset
Контейнеры map и set не допускают повторяющихся значений ключей, а multimap (мультиотображение) и multiset(мультимножество) позволяют сохранять ключи с дублирующимися значениями. Например, в телефонном справочнике может понадобиться отдельный список номеров для каждого абонента. В перечне книг одного автора может быть несколько названий, а в нашей программе с одним словом текста сопоставляется несколько позиций. Для использования multimap и multiset нужно включить соответствующий заголовочный файл – map или set:
#include |
multiset< type > multisetName;
Для прохода по мультиотображению или мультимножеству можно воспользоваться комбинацией итератора, который возвращает find() (он указывает на первый найденный элемент), и значения, которое возвращает count(). (Это работает, поскольку в данных контейнерах элементы с одинаковыми ключами обязательно являются соседними). Например:
#include |
}
Более элегантный способ перебрать все значения с одинаковыми ключами использует специальную функцию-член equal_range(), которая возвращает пару итераторов. Один из них указывает на первое найденное значение, а второй – на следующее за последним найденным. Если последний из найденных элементов является последним в контейнере, второй итератор содержит величину, равную end():
#include |
}
Вставка и удаление элементов в multimap и multiset ничем не отличаются от аналогичных операций с контейнерами map и set. Функция equal_range() доставляет итераторную пару, задающую диапазон удаляемых элементов:
#include #include typedef multimap< string, string >::iterator iterator; pair< iterator, iterator > pos; string search_item( "Kazuo Ishiguro" ); // authors - multimap // эквивалентно // authors.erase( search_item ); pos = authors.equa1_range( search_item ); |
При каждом вызове функции-члена insert()
добавляется новый элемент, даже если в контейнере уже был элемент с таким же ключом. Например:
typedef multimap multimap // первый элемент с ключом Barth authors.insert( valType ( string( "Barth, John" ), string( "Sot-Weed Factor" ))); // второй элемент с ключом Barth authors.insert( va1Type( string( "Barth, John" ), |
Контейнер multimap не поддерживает операцию взятия индекса. Поэтому следующее выражение ошибочно:
authors[ "Barth, John" ]; // ошибка: multimap
Упражнение 6.28
Перепишите программу текстового поиска из раздела 6.14 с использованием multimap для хранения позиций слов. Каковы производительность и дизайн в обоих случаях? Какое решение вам больше нравится? Почему?
Конвертеры
Конвертер– это особый случай функции-члена класса, реализующий определенное пользователем преобразование объекта в некоторый другой тип. Конвертер объявляется в теле класса путем указания ключевого слова operator, за которым следует целевой тип преобразования.Имя, находящееся за ключевым словом, не обязательно должно быть именем одного из встроенных типов. В показанном ниже классе Token определено несколько конвертеров. В одном из них для задания имени типа используется typedef tName, а в другом – тип класса SmallInt.
#include "SmallInt.h" typedef char *tName; class Token { public: Token( char *, int ); operator SmallInt() { return val; } operator tName() { return name; } operator int() { return val; } // другие открытые члены private: SmallInt val; char *name; |
};
Обратите внимание, что определения конвертеров в типы SmallInt и int одинаковы. Конвертер Token::operator int()
возвращает значение члена val. Поскольку val
имеет тип SmallInt, то неявно применяется SmallInt::operator int() для преобразования val в тип int. Сам Token::operator int() неявно употребляется компилятором для преобразования объекта типа Token в значение типа int. Например, этот конвертер используется для неявного приведения фактических аргументов t1 и t2
типа Token к типу int
формального параметра функции print():
#include "Token.h" void print( int i ) { cout << "print( int ) : " << i << endl; } Token t1( "integer constant", 127 ); Token t2( "friend", 255 ); int main() { print( t1 ); // t1.operator int() print( t2 ); // t2.operator int() return 0; |
}
После компиляции и запуска программа выведет такие строки:
print( int ) : 127 |
print( int ) : 255
Общий вид конвертера следующий:
operator type();
где type
может быть встроенным типом, типом класса или именем typedef. Конвертеры, в которых type – тип массива или функции, не допускаются. Конвертер должен быть функцией-членом. В его объявлении не должны задаваться ни тип возвращаемого значения, ни список параметров:
operator int( SmallInt & ); // ошибка: не член class SmallInt { public: int operator int(); // ошибка: задан тип возвращаемого значения operator int( int = 0 ); // ошибка: задан список параметров // ... |
Конвертер вызывается в результате явного преобразования типов. Если преобразуемое значение имеет тип класса, у которого есть конвертер, и в операции приведения указан тип этого конвертера, то он и вызывается:
#include "Token.h" Token tok( "function", 78 ); // функциональная нотация: вызывается Token::operator SmallInt() SmallInt tokVal = SmallInt( tok ); // static_cast: вызывается Token::operator tName() |
У конвертера Token::operator tName()
может быть нежелательный побочный эффект. Попытка прямого обращения к закрытому члену Token::name
помечается компилятором как ошибка:
char *tokName = tok.name; // ошибка: Token::name - закрытый член
Однако наш конвертер, разрешая пользователям непосредственно изменять Token::name, делает как раз то, от чего мы хотели защититься. Скорее всего, это не годится. Вот, например, как могла бы произойти такая модификация:
#include "Token.h" Token tok( "function", 78 ); char *tokName = tok; // правильно: неявное преобразование |
Мы намереваемся разрешить доступ к преобразованному объекту класса Token только для чтения. Следовательно, конвертер должен возвращать тип const char*:
typedef const char *cchar; class Token { public: operator cchar() { return name; } // ... }; // ошибка: преобразование char* в const char* не допускается char *pn = tok; |
Другое решение – заменить в определении Token тип char* на тип string из стандартной библиотеки C++:
class Token { public: Token( string, int ); operator SmallInt() { return val; } operator string() { return name; } operator int() { return val; } // другие открытые члены private: SmallInt val; string name; |
};
Семантика конвертера Token::operator string() состоит в возврате копии значения ( а не указателя на значение) строки, представляющей имя лексемы. Это предотвращает случайную модификацию закрытого члена name класса Token.
Должен ли целевой тип точно соответствовать типу конвертера? Например, будет ли в следующем коде вызван конвертер int(), определенный в классе Token?
extern void calc( double ); Token tok( "constant", 44 ); // Вызывается ли оператор int()? Да // применяется стандартное преобразование int --> double |
Если целевой тип (в данном случае double) не точно соответствует типу конвертера (в нашем случае int), то конвертер все равно будет вызван при условии, что существует последовательность стандартных преобразований, приводящая к целевому типу из типа конвертера. (Эти последовательности описаны в разделе 9.3.) При обращении к функции calc()
вызывается Token::operator int() для преобразования tok из типа Token в тип int. Затем для приведения результата от типа int к типу double
применяется стандартное преобразование.
Вслед за определенным пользователем преобразованием допускаются только стандартные. Если для достижения целевого типа необходимо еще одно пользовательское преобразование, то компилятор не применяет никаких преобразований. Предположим, что в классе Token не определен operator int(), тогда следующий вызов будет ошибочным:
extern void calc( int ); Token tok( "pointer", 37 ); // если Token::operator int() не определен, // то этот вызов приводит к ошибке компиляции |
Если конвертер Token::operator int() не определен, то приведение tok к типу int
потребовало бы вызова двух определенных пользователем конвертеров. Сначала фактический аргумент tok
надо было бы преобразовать из типа Token в тип SmallInt с помощью конвертера
Token::operator SmallInt()
а затем результат привести к типу int – тоже с помощью пользовательского конвертера
Token::operator int()
Вызов calc(tok)
помечается компилятором как ошибка, так как не существует неявного преобразования из типа Token в тип int.
Если логического соответствия между типом конвертера и типом класса нет, назначение конвертера может оказаться непонятным читателю программы:
class Date { public: // попробуйте догадаться, какой именно член возвращается! operator int(); private: int month, day, year; |
Какое значение должен вернуть конвертер int() класса Date? Сколь бы основательными ни были причины для того или иного решения, читатель останется в недоумении относительно того, как пользоваться объектами класса Date, поскольку между ними и целыми числами нет явного логического соответствия. В таких случаях лучше вообще не
определять конвертер.
Копирующий конструктор
Инициализация объекта другим объектом того же класса называется почленной инициализацией по умолчанию. Копирование одного объекта в другой выполняется путем последовательного копирования каждого нестатического члена. Проектировщик класса может изменить такое поведение, предоставив специальный копирующий конструктор. Если он определен, то вызывается всякий раз, когда один объект инициализируется другим объектом того же класса.Часто почленная инициализация не обеспечивает корректного поведения класса. Поэтому мы явно определяем копирующий конструктор. В нашем классе Account это необходимо, иначе два объекта будут иметь одинаковые номера счетов, что запрещено спецификацией класса.
Копирующий конструктор принимает в качестве формального параметра ссылку на объект класса (традиционно объявляемую со спецификатором const). Вот его реализация:
inline Account:: Account( const Account &rhs ) : _balance( rhs._balance ) { _name = new char[ strlen(rhs._name) + 1 ]; strcpy( _name, rhs._name ); // копировать rhs._acct_nmbr нельзя _acct_nmbr = get_unique_acct_nmbr(); |
}
Когда мы пишем:
Account acct2( acct1 );
компилятор определяет, объявлен ли явный копирующий конструктор для класса Account. Если он объявлен и доступен, то он и вызывается; а если недоступен, то определение acct2
считается ошибкой. В случае, когда копирующий конструктор не объявлен, выполняется почленная инициализация по умолчанию. Если впоследствии объявление копирующего конструктора будет добавлено или удалено, никаких изменений в программы пользователей вносить не придется. Однако перекомпилировать их все же необходимо. (Более подробно почленная инициализация рассматривается в разделе 14.6.)
Упражнение 14.1
Какие из следующих утверждений ложны? Почему?
1. У класса должен быть хотя бы один конструктор.
2. Конструктор по умолчанию – это конструктор с пустым списком параметров.
3. Если разумных начальных значений у членов класса нет, то не следует предоставлять конструктор по умолчанию.
4. Если в классе нет конструктора по умолчанию, то компилятор генерирует его автоматически и инициализирует каждый член значением по умолчанию для соответствующего типа.
Упражнение 14.2
Предложите один или несколько конструкторов для данного множества членов. Объясните свой выбор:
class NoName { public: // здесь должны быть конструкторы // ... protected: char *pstring; int ival; double dval; |
Упражнение 14.3
Выберите одну из следующих абстракций (или предложите свою собственную). Решите, какие данные (задаваемые пользователем) подходят для представляющего эту абстракцию класса. Напишите соответствующий набор конструкторов. Объясните свое решение.
· Книга
· Дата
· Служащий
· Транспортное средство
· Объект
· Дерево
Упражнение 14.4
Пользуясь приведенным определением класса:
class Account { public: Account(); explicit Account( const char*, double=0.0 ); // ... |
объясните, что происходит в результате следующих определений:
(a) Account acct; (b) Account acct2 = acct; (c) Account acct3 = "Rena Stern"; (d) Account acct4( "Anna Engel", 400.00 ); |
Упражнение 14.5
Параметр копирующего конструктора может и не быть константным, но обязан быть ссылкой. Почему ошибочна такая инструкция:
Account::Account( const Account rhs );
Краткий обзор С++
Эту главу мы начнем с рассмотрения встроенного в язык С++ типа данных “массив”. Массив – это набор данных одного типа, например массив целых чисел или массив строк. Мы рассмотрим недостатки, присущие встроенному массиву, и напишем для его представления свой класс Array, где попытаемся избавиться от этих недостатков. Затем мы построим целую иерархию подклассов, основываясь на нашем базовом классе Array. В конце концов мы сравним наш класс Array с классом vector из стандартной библиотеки С++, реализующим аналогичную функциональность. В процессе создания этих классов мы коснемся таких свойств С++, как шаблоны, пространства имен и обработка ошибок.Краткий обзор
Реализация обобщенного алгоритма не зависит от типа контейнера, поэтому одна основанная на шаблонах реализация может работать со всеми контейнерами, а равно и со встроенным типом массива. Рассмотрим алгоритм find(). Если коллекция не отсортирована, то, чтобы найти элемент, требуются лишь следующие общие шаги:1. По очереди исследовать каждый элемент.
2. Если элемент равен искомому значению, то вернуть его позицию в коллекции.
3. В противном случае анализировать следующий элемент Повторять шаг 2, пока значение не будет найдено либо пока не будет просмотрена вся коллекция.
4. Если мы достигли конца коллекции и не нашли искомого, то вернуть некоторое значение, показывающее, что нужного элемента нет.
Алгоритм, как мы и утверждали, не зависит ни от типа контейнера, к которому применяется, ни от типа искомого значения, однако для его использования необходимы:
· способ обхода коллекции: переход к следующему элементу и распознавание того, что достигнут конец коллекции. При работе с встроенным типом массива мы решаем эту проблему, передавая два аргумента: указатель на первый элемент и число элементов, подлежащих обходу (в случае строк символов в стиле C передавать второй аргумент необязательно, так как конец строки обозначается двоичным нулем);
· умение сравнивать каждый элемент контейнера с искомым значением. Обычно это делается с помощью оператора равенства, ассоциированного со значениями типа, или путем передачи указателя на функцию, осуществляющую сравнение;
· некоторый обобщенный тип для представления позиции элемента внутри контейнера и специального признака на случай, если элемент не найден. Обычно мы возвращаем индекс элемента либо указатель на него. В ситуации, когда поиск неудачен, возвращается –1 вместо индекса или 0 вместо указателя.
Обобщенные алгоритмы решают первую проблему, обход контейнера, с помощью абстракции итератора – обобщенного указателя, поддерживающего оператор инкремента для доступа к следующему элементу, оператор разыменования для получения его значения и операторы равенства и неравенства для определения того, совпадают ли два итератора. Диапазон, к которому применяется алгоритм, помечается парой итераторов: first
адресует первый элемент, а last – тот, который следует за последним. К самому элементу, адресованному итератором last, алгоритм не применяется; он служит стражем, прекращающим обход. Кроме того, last используется как возвращаемое значение с семантикой “отсутствует”. Если же значение получено, то возвращается итератор, помечающий позицию найденного элемента.
Имеется по две версии каждого обобщенного алгоритма: в одной для сравнения применяется оператор равенства, а в другой – объект-функция или указатель на функцию, реализующую сравнение. (Объекты-функции рассматриваются в разделе 12.3.) Вот, например, реализация обобщенного алгоритма find(), в котором используется оператор сравнения для типов хранимых в контейнере элементов:
template < class ForwardIterator, class Type > ForwardIterator find( ForwardIterator first, ForwardIterator last, Type value ) { for ( ; first != last; ++first ) if ( value == *first ) return first; return last; |
ForwardIterator
(однонаправленный итератор) – это один из пяти категорий итераторов, предопределенных в стандартной библиотеке. Он поддерживает чтение и запись адресуемого элемента. (Все пять категорий рассматриваются в разделе 12.4.)
Алгоритмы достигают независимости от типов за счет того, что никогда не обращаются к элементам контейнера непосредственно; доступ и обход элементов осуществляются только с помощью итераторов. Неизвестны ни фактический тип контейнера, ни даже то, является ли он контейнером или встроенным массивом. Для работы со встроенным типом массива обобщенному алгоритму можно передать не только обычные указатели, но и итераторы. Например, алгоритм find() для встроенного массива элементов типа int можно использовать так:
#include #include int main() { int search_value; int ia[ 6 ] = { 27, 210, 12, 47, 109, 83 }; cout << "enter search value: "; cin >> search_value; int *presult = find( &ia[0], &ia[6], search_value ); cout << "The value " << search_value << ( presult == &ia[6] ? " is not present" : " is present" ) << endl; |
Если возвращенный указатель равен адресу &ia[6] (который расположен за последним элементом массива), то поиск оказался безрезультатным, в противном случае значение найдено.
Вообще говоря, при передаче адресов элементов массива обобщенному алгоритму мы можем написать
int *presult = find( &ia[0], &ia[6], search_value );
или
int *presult = find( ia, ia+6, search_value );
Если бы мы хотели ограничиться лишь отрезком массива, то достаточно было бы модифицировать передаваемые алгоритму адреса. Так, при следующем обращении к find()
просматриваются только второй и третий элементы (напомним, что элементы массива нумеруются с нуля):
// искать только среди элементов ia[1] и ia[2] |
А вот пример использования контейнера типа vector с алгоритмом find():
#include #include #include int main() { int search_value; int ia[ 6 ] = { 27, 210, 12, 47, 109, 83 }; vector cout << "enter search value: "; cin >> search_value; vector presult = find( vec.begin(), vec.end(), search_value ); cout << "The value " << search_value << ( presult == vec.end() ? " is not present" : " is present" ) << endl; |
find() можно применить и к списку:
#include #include #include int main() { int search_value; int ia[ 6 ] = { 27, 210, 12, 47, 109, 83 }; list cout << "enter search value: "; cin >> search_value; list presult = find( ilist.begin(), ilist.end(), search_value ); cout << "The value " << search_value << ( presult == ilist.end() ? " is not present" : " is present" ) << endl; |
( В следующем разделе мы обсудим построение программы, в которой используются различные обобщенные алгоритмы, а затем рассмотрим объекты-функции. В разделе 12.4 мы подробнее расскажем об итераторах. Развернутое введение в обобщенные алгоритмы – предмет раздела 12.5, а их детальное обсуждение и иллюстрация применения вынесено в Приложение. В конце главы речь пойдет о случаях, когда применение обобщенных алгоритмов неуместно.)
Упражнение 12.1
Обобщенные алгоритмы критикуют за то, что при всей элегантности дизайна проверка корректности возлагается на программиста. Например, если передан неверный итератор или пара итераторов, помечающая неверный диапазон, то поведение программы не определено. Вы согласны с такой критикой? Следует ли оставить применение обобщенных алгоритмов только наиболее квалифицированным специалистам? Может быть, нужно запретить использование потенциально опасных конструкций, таких, как обобщенные алгоритмы, указатели и явные приведения типов?
Литералы
В С++ имеется набор встроенных типов данных для представления целых и вещественных чисел, символов, а также тип данных “символьный массив”, который служит для хранения символьных строк. Тип charслужит для хранения отдельных символов и небольших целых чисел. Он занимает один машинный байт. Типы short, int и long
предназначены для представления целых чисел. Эти типы различаются только диапазоном значений, которые могут принимать числа, а конкретные размеры перечисленных типов зависят от реализации. Обычно short
занимает половину машинного слова, int– одно слово, long – одно или два слова. В 32-битных системах int и long, как правило, одного размера.
Типы float, double и long double предназначены для чисел с плавающей точкой и различаются точностью представления (количеством значащих разрядов) и диапазоном. Обычно float
(одинарная точность) занимает одно машинное слово, double
(двойная точность) – два, а long double (расширенная точность) – три.
char, short, int и long вместе составляют целые типы, которые, в свою очередь, могут быть знаковыми (signed) и беззнаковыми (unsigned). В знаковых типах самый левый бит служит для хранения знака (0 – плюс, 1 – минус), а оставшиеся биты содержат значение. В беззнаковых типах все биты используются для значения. 8-битовый тип signed char
может представлять значения от -128 до 127, а unsigned char – от 0 до 255.
Когда в программе встречается некоторое число, например 1, то это число называется литералом, или литеральной константой. Константой, потому что мы не можем изменить его значение, и литералом, потому что его значение фигурирует в тексте программы. Литерал является неадресуемой величиной: хотя реально он, конечно, хранится в памяти машины, нет никакого способа узнать его адрес. Каждый литерал имеет определенный тип. Так, 0 имеет тип int, 3.14159 – тип double.
Литералы целых типов можно записать в десятичном, восьмеричном и шестнадцатеричном виде. Вот как выглядит число 20, представленное десятичным, восьмеричным и шестнадцатеричным литералами:
20 // десятичный
024 // восьмеричный
0х14 // шестнадцатеричный
Если литерал начинается с 0, он трактуется как восьмеричный, если с 0х или 0Х, то как шестнадцатеричный. Привычная запись рассматривается как десятичное число.
По умолчанию все целые литералы имеют тип signed int. Можно явно определить целый литерал как имеющий тип long, приписав в конце числа букву L (используется как прописная L, так и строчная l, однако для удобства чтения не следует употреблять строчную: ее легко перепутать с 1).
Буква U
(или u) в конце определяет литерал как unsigned int, а две буквы – UL или LU – как тип unsigned long. Например:
128u 1024UL 1L 8Lu
Литералы, представляющие действительные числа, могут быть записаны как с десятичной точкой, так и в научной (экспоненциальной) нотации. По умолчанию они имеют тип double. Для явного указания типа float
нужно использовать суффикс F или f, а для long double - L или l, но только в случае записи с десятичной точкой. Например:
3.14159F 0/1f 12.345L 0.0
3el 1.0E-3E 2. 1.0L
Слова true и false
являются литералами типа bool.
Представимые литеральные символьные константы записываются как символы в одинарных кавычках. Например:
'a' '2' ',' ' ' (пробел)
Специальные символы (табуляция, возврат каретки) записываются как escape-последовательности . Определены следующие такие последовательности (они начинаются с символа обратной косой черты):
новая строка \n
горизонтальная табуляция \t
забой \b
вертикальная табуляция \v
возврат каретки \r
прогон листа \f
звонок \a
обратная косая черта \\
вопрос \?
одиночная кавычка \'
двойная кавычка \"
escape-последовательность общего вида имеет форму \ooo, где ooo – от одной до трех восьмеричных цифр. Это число является кодом символа. Используя ASCII-код, мы можем написать следующие литералы:
\7 (звонок) \14 (новая строка)
\0 (null) \062 ('2')
Символьный литерал может иметь префикс L
(например, L'a'), что означает специальный тип wchar_t – двухбайтовый символьный тип, который применяется для хранения символов национальных алфавитов, если они не могут быть представлены обычным типом char, как, например, китайские или японские буквы.
Строковый литерал – строка символов, заключенная в двойные кавычки. Такой литерал может занимать и несколько строк, в этом случае в конце строки ставится обратная косая черта. Специальные символы могут быть представлены своими escape-последовательностями. Вот примеры строковых литералов:
"" (пустая строка)
"a"
"\nCC\toptions\tfile.[cC]\n"
"a multi-line \
string literal signals its \
continuation with a backslash"
Фактически строковый литерал представляет собой массив символьных констант, где по соглашению языков С и С++ последним элементом всегда является специальный символ с кодом 0 (\0).
Литерал 'A'
задает единственный символ А, а строковый литерал "А" – массив из двух элементов: 'А' и \0
(пустого символа).
Раз существует тип wchar_t, существуют и литералы этого типа, обозначаемые, как и в случае с отдельными символами, префиксом L:
L"a wide string literal"
Строковый литерал типа wchar_t – это массив символов того же типа, завершенный нулем.
Если в тесте программы идут подряд два или несколько строковых литералов (типа char или wchar_t), компилятор соединяет их в одну строку. Например, следующий текст
"two" "some"
породит массив из восьми символов – twosome и завершающий нулевой символ. Результат конкатенации строк разного типа не определен. Если написать:
// this is not a good idea |
то на каком-то компьютере результатом будет некоторая осмысленная строка, а на другом может оказаться нечто совсем иное. Программы, использующие особенности реализации того или иного компилятора или операционной системы, являются непереносимыми. Мы крайне не рекомендуем пользоваться такими конструкциями.
Упражнение 3.1
Объясните разницу в определениях следующих литералов:
(a) 'a', L'a', "a", L"a" (b) 10, 10u, 10L, 10uL, 012, 0*C |
Упражнение 3.2
Какие ошибки допущены в приведенных ниже примерах?
(a) "Who goes with F\144rgus?\014" (b) 3.14e1L (c) "two" L"some" (d) 1024f (e) 3.14UL (f) "multiple line |
Логические объекты-функции
Логические объекты-функции поддерживают операции “логическое И” (возвращает true, если оба операнда равны true, – применяет оператор &&, аcсоциированный с типом Type), “логическое ИЛИ” (возвращает true, если хотя бы один из операндов равен true, – применяет оператор ||, аcсоциированный с типом Type) и “логическое НЕ” (возвращает true, если операнд равен false, – применяет оператор !, аcсоциированный с типом Type)· Логическое И: logical_and
logical_and ires = intLess( ival1, ival2 );
| |
dres = BinaryFunc( logical_and
· Логическое ИЛИ: logical_or
logical_or ires = intSub( ival1, ival2 );
| |
dres = BinaryFunc( logical_or
· Логическое НЕ: logical_not
logical_not ires = IntNot( Ival1, Ival2 );
| |
dres = UnaryFunc( logical_or
Локальная область видимости
Локальная область видимости – это часть исходного текста программы, содержащаяся в определении функции (или блоке внутри тела функции). Все функции имеют свои локальные области видимости. Каждая составная инструкция (или блок) внутри функции также представляет собой отдельную локальную область. Такие области могут быть вложенными. Например, следующее определение функции содержит два их уровня (функция выполняет двоичный поиск в отсортированном векторе целых чисел):const int notFound = -1; // глобальная область видимости int binSearch( const vector { // локальная область видимости: уровень #1 int low = 0; int high = vec.size() - 1; while ( low <= high ) { // локальная область видимости: уровень #2 int mid = ( low + high ) / 2; if ( val < vec[ mid ] ) high = mid - 1; else low = mid + 1; } return notFound; // локальная область видимости: уровень #1
| |
}
Первая локальная область видимости – тело функции binSearch(). В ней объявлены параметры функции vec и val, а также переменные low и high. Цикл while
внутри функции задает вложенную локальную область, в которой определена одна переменная mid. Параметры vec и val и переменные low и high
видны во вложенной области. Глобальная область видимости включает в себя обе локальных. В ней определена одна целая константа notFound.
Имена параметров функции vec и val
принадлежат к первой локальной области видимости тела функции, и в ней использовать те же имена для других сущностей нельзя. Например:
int binSearch( const vector { // локальная область видимости: уровень #1 int val; // ошибка: неверное переопределение val
| |
// ...
Имена параметров употребляются как внутри тела функции binSearch(), так и внутри вложенной области видимости цикла while. Параметры vec и val
недоступны вне тела функции binSearch().
Разрешение имени в локальной области видимости происходит следующим образом: просматривается та область, где оно встретилось. Если объявление найдено, имя разрешено. Если нет, просматривается область видимости, включающая текущую. Этот процесс продолжается до тех пор, пока объявление не будет найдено либо не будет достигнута глобальная область видимости. Если и там имени нет, оно будет считаться ошибочным.
Из- за порядка просмотра областей видимости в процессе разрешения имен объявление из внешней области может быть скрыто
объявлением того же имени во вложенной области. Если бы в предыдущем примере переменная low
была объявлена в глобальной области видимости перед определением функции binSearch(), то использование low в локальной области видимости цикла while все равно относилось бы к локальному объявлению, скрывающему глобальное:
int low; int binSearch( const vector { // локальное объявление low // скрывает глобальное объявление int low = 0; // ... // low - локальная переменная while ( low <= high ) {//... } // ... |
Для некоторых инструкций языка C++ разрешено объявлять переменные внутри управляющей части. Например, в цикле for переменную можно определить внутри инструкции инициализации:
for ( int index = 0; index < vecSize; ++index ) { // переменная index видна только здесь if ( vec[ index ] == someValue ) break; } // ошибка: переменная index не видна |
Подобные переменные видны только в локальной области самого цикла for и вложенных в него (это верно для стандарта С++, в предыдущих версиях языка поведение было иным). Компилятор рассматривает это объявление так же, как если бы оно было записано в виде:
// представление компилятора { // невидимый блок int index = 0; for ( ; index < vecSize; ++index ) { // ... } |
Тем самым программисту запрещается применять управляющую переменную вне локальной области видимости цикла. Если нужно проверить index, чтобы определить, было ли найдено значение, то данный фрагмент кода следует переписать так:
int index = 0; for ( ; index < vecSize; ++index ) { // ... } // правильно: переменная index видна |
Поскольку переменная, объявленная в инструкции инициализации цикла for, является локальной для цикла, то же самое имя допустимо использовать аналогичным образом и в других циклах, расположенных в данной локальной области видимости:
void fooBar( int *ia, int sz ) { for (int i=0; i for (int i=0; i for (int i=0; i |
Аналогично переменная может быть объявлена внутри условия инструкций if и switch, а также внутри условия циклов while и for. Например:
if ( int *pi = getValue() ) { // pi != 0 -- *pi можно использовать здесь int result = calc(*pi); // ... } else { // здесь pi тоже видна // pi == 0 cout << "ошибка: getValue() завершилась неудачно" << endl; |
Переменные, определенные в условии инструкции if, как переменная pi, видны только внутри if и соответствующей части else, а также во вложенных областях. Значением условия является значение этой переменной, которое она получает в результате инициализации. Если pi
равна 0
(нулевой указатель), условие ложно и выполняется ветвь else. Если pi
инициализируется любым другим значением, условие истинно и выполняется ветвь if. (Инструкции if, switch, for и while
рассматривались в главе 5.)
Упражнение 8.1
Найдите различные области видимости в следующем примере. Какие объявления ошибочны и почему?
int ix = 1024; int ix() ; void func( int ix, int iy ) { int ix = 255; if (int ix=0) { int ix = 79; { int ix = 89; } } else { int ix = 99; } |
Упражнение 8.2
К каким объявлениям относятся различные использования переменных ix и iy в следующем примере:
int ix = 1024; void func( int ix, int iy ) { ix = 100; for( int iy = 0; iy < 400; iy += 100 ) { iy += 100; ix = 300; } iy = 400; |
Локальные классы A
Класс, определенный внутри тела функции, называется локальным. Он виден только в той локальной области, где определен. Не существует синтаксиса, позволяющего обратиться к члену такого класса, в отличие от вложенного, извне локальной области видимости, содержащей его определение. Поэтому функции-члены локального класса должны определяться внутри определения самого класса. На практике это ограничивает их сложность несколькими строками кода; помимо всего прочего, такой код становится трудно читать.Поскольку невозможно определить член локального класса в области видимости пространства имен, то в таком классе не бывает статических членов.
Класс, вложенный в локальный, может быть определен вне определения объемлющего класса, но только в локальной области видимости, содержащей это определение. Имя вложенного класса в таком определении должно быть квалифицировано именем объемлющего класса. Объявление вложенного класса в объемлющем нельзя опускать:
void foo( int val ) { class Bar { public: int barVal; class nested; // объявление вложенного класса обязательно }; // определение вложенного класса class Bar::nexted { // ... }; |
}
У объемлющей функции нет никаких специальных прав доступа к закрытым членам локального класса. Разумеется, это можно обойти, объявив ее другом данного класса. Однако необходимость делать его члены закрытыми вообще сомнительна, поскольку часть программы, из которой разрешается обратиться к нему, весьма ограничена. Локальный класс инкапсулирован в своей локальной области видимости. Дальнейшая инкапсуляция путем сокрытия информации не требуется: вряд ли на практике найдется причина, по которой не все члены локального класса должны быть открыты.
У локального класса, как и у вложенного, ограничен доступ к именам из объемлющей области видимости. Он может обратиться только к именам типов, статических переменных и элементов перечислений, определенных в объемлющих локальных областях. Например:
int a, val; void foo( int val ) { static int si; enum Loc { a = 1024, b }; class Bar { public: Loc locVal; // правильно int barVal; void fooBar ( Loc l = a ) { // правильно: Loc::a barVal = val; // ошибка: локальный объект barVal = ::val; // правильно: глобальный объект barVal = si; // правильно: статический локальный объект locVal = b; // правильно: элемент перечисления } }; // ... |
Имена в теле локального класса разрешаются лексически путем поиска в объемлющих областях видимости объявлений, предшествующих определению такого класса. При разрешении имен, встречающихся в телах его функций-членов, сначала просматривается область видимости класса, а только потом – объемлющие области,
Как всегда, если первое найденное объявление таково, что употребление имени оказывается некорректным, поиск других объявлений не производится. Несмотря на то что использование val в fooBar()
выше является ошибкой, глобальная переменная val не будет найдена, если только ее имени не предшествует оператор разрешения глобальной области видимости.
Локальные объекты
Объявление переменной в локальной области видимости вводит локальный объект. Существует три вида таких объектов: автоматические, регистровые и статические, различающиеся временем жизни и характеристиками занимаемой памяти. Автоматический объект существует с момента активизации функции, в которой он определен, до выхода из нее. Регистровый объект – это автоматический объект, для которого поддерживается быстрое считывание и запись его значения. Локальный статический объект располагается в области памяти, существующей на протяжении всего времени выполнения программы. В этом разделе мы рассмотрим свойства всех этих объектов.Массивы и векторы объектов
Массив объектов класса определяется точно так же, как массив элементов встроенного типа. Например:Account table[ 16 ];
определяет массив из 16 объектов Account. Каждый элемент по очереди инициализируется конструктором по умолчанию. Можно и явно передать конструкторам аргументы внутри заключенного в фигурные скобки списка инициализации массива. Строка:
Account pooh_pals[] = { "Piglet", "Eeyore", "Tigger" };
определяет массив из трех элементов, инициализируемых конструкторами:
Account( "Piglet", 0.0 ); // первый элемент (Пятачок) Account( "Eeyore", 0.0 ); // второй элемент (Иа-Иа) |
Account( "Tigger", 0.0 ); // третий элемент (Тигра)
Один аргумент можно задать явно, как в примере выше. Если же необходимо передать несколько аргументов, то придется воспользоваться явным вызовом конструктора:
Account pooh_pals[] = { Account( "Piglet", 1000.0 ), Account( "Eeyore", 1000.0 ), Account( "Tigger", 1000.0 ) |
};
Чтобы включить в список инициализации массива конструктор по умолчанию, мы употребляем явный вызов с пустым списком параметров:
Account pooh_pals[] = { Account( "Woozle", 10.0 ), // Бука Account( "Heffalump", 10.0 ), // Слонопотам Account(); |
};
Эквивалентный массив из трех элементов можно объявить и так:
Account pooh_pals[3] = { Account( "Woozle", 10.0 ), Account( "Heffalump", 10.0 ) |
};
Таким образом, члены списка инициализации последовательно используются для заполнения очередного элемента массива. Те элементы, для которых явные аргументы не заданы, инициализируются конструктором по умолчанию. Если его нет, то в списке должны быть заданы аргументы конструктора для каждого элемента массива.
Доступ к отдельным элементам массива объектов производится с помощью оператора взятия индекса, как и для массива элементов любого из встроенных типов. Например:
pooh_pals[0];
обращается к Piglet, а
pooh_pals[1];
к Eeyore и т.д. Для доступа к членам объекта, находящегося в некотором элементе массива, мы сочетаем операторы взятия индекса и доступа к членам:
pooh_pals[1]._name != pooh_pals[2]._name;
Не существует способа явно указать начальные значения элементов массива, память для которого выделена из хипа. Если класс поддерживает создание динамических массивов с помощью оператора new, он должен либо иметь конструктор по умолчанию, либо не иметь никаких конструкторов. На практике почти у всех классов есть такой конструктор.
Объявление
Account *pact = new Account[ 10 ];
создает в памяти, выделенной из хипа, массив из десяти объектов класса Account, причем каждый инициализируется конструктором по умолчанию.
Чтобы уничтожить массив, адресованный указателем pact, необходимо применить оператор delete. Однако написать
// увы! это не совсем правильно |
недостаточно, так как pact при этом не идентифицируется как массив объектов. В результате деструктор класса Account
применяется лишь к первому элементу массива. Чтобы применить его к каждому элементу, мы должны включить пустую пару скобок между оператором delete и адресом удаляемого объекта:
// правильно: // показывает, что pact адресует массив |
Пустая пара скобок говорит о том, что pact
адресует именно массив. Компилятор определяет, сколько в нем элементов, и применяет деструктор к каждому из них.
Массивы указателей на функции
Можно объявить массив указателей на функции. Например:int (*testCases[10])();
testCases – это массив из десяти элементов, каждый из которых является указателем на функцию, возвращающую значение типа int и не имеющую параметров.
Подобные объявления трудно читать, поскольку не сразу видно, с какой частью ассоциируется тип функции.
В этом случае помогает использование имен, определенных с помощью директивы typedef:
// typedef делает объявление более понятным typedef int (*PFV)(); // typedef для указателя на функцию |
PFV testCases[10];
Данное объявление эквивалентно предыдущему.
Вызов функций, адресуемых элементами массива testCases, выглядит следующим образом:
const int size = 10; PFV testCases[size]; int testResults[size]; void runtests() { for ( int i = 0; i < size; ++i ) // вызов через элемент массива testResults[ i ] = testCases[ i ](); |
}
Массив указателей на функции может быть инициализирован списком, каждый элемент которого является функцией. Например:
int lexicoCompare( const string &, const string & ); int sizeCompare( const string &, const string & ); typedef int ( *PFI2S )( const string &, const string & ); PFI2S compareFuncs[2] = { lexicoCompare, sizeCompare |
};
Можно объявить и указатель на compareFuncs, его типом будет “указатель на массив указателей на функции”:
PFI2S (*pfCompare)[2] = compareFuncs;
Это объявление раскладывается на составные части следующим образом:
(*pfCompare)
Оператор разыменования говорит, что pfCompare является указателем. [2]
сообщает о количестве элементов массива:
(*pfCompare) [2]
PFI2S – имя, определенное с помощью директивы typedef, называет тип элементов. Это “указатель на функцию, возвращающую int и имеющую два параметра типа const string &”. Тип элемента массива тот же, что и выражения &lexicoCompare.
Такой тип имеет и первый элемент массива compareFuncs, который может быть получен с помощью любого из выражений:
compareFunc[ 0 ]; |
(*pfCompare)[ 0 ];
Чтобы вызвать функцию lexicoCompare
через pfCompare, нужно написать одну из следующих инструкций:
// эквивалентные вызовы pfCompare [ 0 ]( string1, string2 ); // сокращенная форма |
((*pfCompare)[ 0 ])( string1, string2 ); // явная форма
Многомерные массивы
В С++ есть возможность использовать многомерные массивы, при объявлении которых необходимо указать правую границу каждого измерения в отдельных квадратных скобках. Вот определение двумерного массива:int ia[ 4 ][ 3 ];
Первая величина (4) задает количество строк, вторая (3)– количество столбцов. Объект ia
определен как массив из четырех строк по три элемента в каждой. Многомерные массивы тоже могут быть инициализированы:
int ia[ 4 ][ 3 ] = { { 0, 1, 2 }, { 3, 4, 5 }, { 6, 7, 8 }, { 9, 10, 11 } |
};
Внутренние фигурные скобки, разбивающие список значений на строки, необязательны и используются, как правило, для удобства чтения кода. Приведенная ниже инициализация в точности соответствует предыдущему примеру, хотя менее понятна:
int ia[4][3] = { 0,1,2,3,4,5,6,7,8,9,10,11 };
Следующее определение инициализирует только первые элементы каждой строки. Оставшиеся элементы будут равны нулю:
int ia[ 4 ][ 3 ] = { {0}, {3}, {6}, {9} };
Если же опустить внутренние фигурные скобки, результат окажется совершенно иным. Все три элемента первой строки и первый элемент второй получат указанное значение, а остальные будут неявно инициализированы 0.
int ia[ 4 ][ 3 ] = { 0, 3, 6, 9 };
При обращении к элементам многомерного массива необходимо использовать индексы для каждого измерения (они заключаются в квадратные скобки). Так выглядит инициализация двумерного массива с помощью вложенных циклов:
int main() { const int rowSize = 4; const int colSize = 3; int ia[ rowSize ][ colSize ]; for ( int = 0; i < rowSize; ++i ) for ( int j = 0; j < colSize; ++j ) ia[ i ][ j ] = i + j j; |
}
Конструкция
ia[ 1, 2 ]
является допустимой с точки зрения синтаксиса С++, однако означает совсем не то, чего ждет неопытный программист. Это отнюдь не объявление двумерного массива 1 на 2. Агрегат в квадратных скобках – это список выражений через запятую, результатом которого будет последнее значение 2 (см. оператор “запятая” в разделе 4.2). Поэтому объявление ia[1,2] эквивалентно ia[2]. Это еще одна возможность допустить ошибку.
Многоточие
Иногда нельзя перечислить типы и количество всех возможных аргументов функции. В этих случаях список параметров представляется многоточием (...), которое отключает механизм проверки типов. Наличие многоточия говорит компилятору, что у функции может быть произвольное количество аргументов неизвестных заранее типов. Многоточие употребляется в двух форматах:void foo( parm_list, ... ); |
void foo( ... );
Первый формат предоставляет объявления для части параметров. В этом случае проверка типов для объявленных параметров производится, а для оставшихся фактических аргументов – нет. Запятая после объявления известных параметров необязательна.
Примером вынужденного использования многоточия служит функция printf()
стандартной библиотеки С. Ее первый параметр является C-строкой:
int printf( const char* ... );
Это гарантирует, что при любом вызове printf() ей будет передан первый аргумент типа const char*. Содержание такой строки, называемой форматной, определяет, необходимы ли дополнительные аргументы при вызове. При наличии в строке формата метасимволов, начинающихся с символа %, функция ждет присутствия этих аргументов. Например, вызов
printf( "hello, world\n" );
имеет один строковый аргумент. Но
printf( "hello, %s\n", userName );
имеет два аргумента. Символ %
говорит о наличии второго аргумента, а буква s, следующая за ним, определяет его тип – в данном случае символьную строку.
Большинство функций с многоточием в объявлении получают информацию о типах и количестве фактических параметров по значению явно объявленного параметра. Следовательно, первый формат многоточия употребляется чаще.
Отметим, что следующие объявления неэквивалентны:
void f(); |
void f( ... );
В первом случае f()
объявлена как функция без параметров, во втором – как имеющая ноль или более параметров. Вызовы
f( someValue ); |
f( cnt, a, b, с );
корректны только для второго объявления. Вызов
f();
применим к любой из двух функций.
Упражнение 7.4
Какие из следующих объявлений содержат ошибки? Объясните.
(a) void print( int arr[][], int size ); (b) int ff( int a, int b = 0, int с = 0 ); |
(d) char *screenInit( int height = 24, int width, char background ); |
Упражнение 7.5
Повторные объявления всех приведенных ниже функций содержат ошибки. Найдите их.
(a) char *screenInit( int height, int width, char background = ' ' ); char *screenInit( int height = 24, int width, char background ); (b) void print( int (*arr)[6], int size ); void print( int (*arr)[5], int size ); (c) void manip( int *pi, int first, int end = 0 ); |
Упражнение 7.6
Даны объявления функций.
void print( int arr[][5], int size ); void operate(int *matrix[7]); char *screenInit( int height = 24, int width = 80, |
Вызовы этих функций содержат ошибки. Найдите их и объясните.
(a) screenInit(); (b) int *matrix[5]; operate( matrix ); (c) int arr[5][5]; |
Упражнение 7.7
Перепишите функцию putValues( vector
( 2 )
<
"first string"
"second string"
>
Напишите функцию main(), вызывающую новый вариант putValues() со следующим списком строк:
"put function declarations in header files" "use abstract container types instead of built-in arrays" "declare class parameters as references" "use reference to const types for invariant parameters" |
Упражнение 7.8
В каком случае вы применили бы параметр-указатель? А в каком – параметр-ссылку? Опишите достоинства и недостатки каждого способа.
Множественное и виртуальное наследование
В большинстве реальных приложений на C++ используется открытое наследование от одного базового класса. Можно предположить, что и в наших программах оно в основном будет применяться именно так. Но иногда одиночного наследования не хватает, потому что с его помощью либо нельзя адекватно смоделировать абстракцию предметной области, либо получающаяся модель чересчур сложна и неинтуитивна. В таких случаях следует предпочесть множественное наследование или его частный случай – виртуальное наследование. Их поддержка, имеющаяся вC++, – основная тема настоящей главы.
Множественное наследование
Для поддержки множественного наследования синтаксис списка базовых классовclass Bear : public ZooAnimal { ... };
расширяется: допускается наличие нескольких базовых классов, разделенных запятыми:
class Panda : public Bear, public Endangered { ... };
Для каждого из перечисленных базовых классов должен быть указан уровень доступа: public, protected или private. Как и при одиночном наследовании, множественно наследовать можно только классу, определение которого уже встречалось ранее.
Язык не накладывает никаких ограничений на число базовых классов, которым может наследовать производный. На практике чаще всего встречается два класса, один из которых представляет открытый абстрактный интерфейс, а второй – закрытую реализацию (хотя ни один из рассмотренных выше примеров этой модели не следует). Производные классы, наследующие от трех или более базовых, – это пример такого стиля проектирования, когда каждый базовый класс представляет одну грань полного интерфейса производного.
В случае множественного наследования объект производного класса содержит по одному подобъекту каждого из своих базовых (см. раздел 17.3). Например, когда мы пишем
Panda ying_yang;
то объект ying_yang
будет состоять из подобъекта класса Bear (который в свою очередь содержит подобъект ZooAnimal), подобъекта Endangered и нестатических членов, объявленных в самом классе Panda, если таковые есть (см. рис. 18.3).
![]() |
![]() |
ZooAnimal Endangered
![]() |

Panda
Рис. 18.3. Иерархия множественного наследования класса Panda
Конструкторы базовых классов вызываются в порядке объявления в списке базовых классов. Например, для ying_yang эта последовательность такова: конструктор Bear (но поскольку класс Bear – производный от ZooAnimal, то сначала вызывается конструктор ZooAnimal), затем конструктор Endangered и в самом конце конструктор Panda.
Как отмечалось в разделе 17.4, на порядок вызова не
влияет ни наличие базовых классов в списке инициализации членов, ни порядок их перечисления. Иными словами, если бы конструктор Bear
вызывался неявно и потому не был бы упомянут в списке инициализации членов, как в следующем примере:
// конструктор по умолчанию класса Bear вызывается до // êîíñòðóêòîðà êëàññà Endangered ñ äâóìÿ àðãóìåíòàìè ... Panda::Panda() : Endangered( Endangered::environment, Endangered::critical ) |
то все равно конструктор по умолчанию Bear был бы вызван раньше, чем явно заданный в списке конструктор класса Endangered с двумя аргументами.
Порядок вызова деструкторов всегда противоположен порядку вызова конструкторов. В нашем примере деструкторы вызываются в такой последовательности: ~Panda(), ~Endangered(), ~Bear(), ~ZooAnimal().
В разделе 17.3 уже говорилось, что в случае одиночного наследования к открытым и защищенным членам базового класса можно обращаться напрямую (не квалифицируя имя члена именем его класса), как если бы они были членами производного класса. То же самое справедливо и для множественного наследования. Однако при этом можно унаследовать одноименные члены из двух или более базовых классов. В таком случае прямое обращение оказывается неоднозначным и приводит к ошибке компиляции.
Однако такую ошибку вызывает не потенциальная
неоднозначность неквалифицированного доступа к одному из двух одноименных членов, а лишь попытка фактического обращения к нему (см. раздел 17.4). Например, если в обоих классах Bear и Endangered определена функция-член print(), то инструкция
ying_yang.print( cout );
приводит к ошибке компиляции, даже если у двух унаследованных функций-членов разные списки параметров.
Error: ying_yang.print( cout ) -- ambiguous, one of Bear::print( ostream& ) |
Endangered::print( ostream&, int )
Ошибка: ying_yang.print( cout ) -- неоднозначно, одна из Bear::print( ostream& ) |
Причина в том, что унаследованные функции- члены не образуют множество перегруженных функций внутри производного класса (см. раздел 17.3). Поэтому print()
разрешается только по имени, а не по типам фактических аргументов. (О том, как производится разрешение, мы поговорим в разделе 18.4.)
В случае одиночного наследования указатель, ссылка или объект производного класса при необходимости автоматически преобразуются в указатель, ссылку или объект базового класса, которому открыто наследует производный. Это остается верным и для множественного наследования. Так, указатель, ссылку или сам объект класса Panda
можно преобразовать в указатель, ссылку или объект ZooAnimal, Bear или Endangered:
extern void display( const Bear& ); extern void highlight( const Endangered& ); Panda ying_yang; display( ying_yang ); // ïðàâèëüíî highlight( ying_yang ); // ïðàâèëüíî extern ostream& operator<<( ostream&, const ZooAnimal& ); |
Однако вероятность неоднозначных преобразований при множественном наследовании намного выше. Рассмотрим, к примеру, две функции:
extern void display( const Bear& ); |
Неквалифицированный вызов display() для объекта класса Panda
Panda ying_yang; |
приводит к ошибке компиляции:
Error: display( ying_yang ) -- ambiguous, one of
display( const Bear& );
display( const Endangered& );
Ошибка: display( ying_yang ) -- неоднозначно, одна из
display( const Bear& );
display( const Endangered& );
Компилятор не может различить два непосредственных базовых класса с точки зрения преобразования производного. Равным образом применимы обе трансформации. (Мы покажем способ разрешения этого конфликта в разделе 18.4.)
Чтобы понять, какое влияние оказывает множественное наследование на механизм виртуальных функций, определим их набор в каждом из непосредственных базовых классов Panda. (Виртуальные функции введены в разделе 17.2 и подробно обсуждались в разделе 17.5.)
|
class Bear : public ZooAnimal { public: virtual ~Bear(); virtual ostream& print( ostream& ) const; virtual string isA() const; // ... }; class Endangered { public: virtual ~Endangered(); virtual ostream& print( ostream& ) const; virtual void highlight() const; // ... |
Теперь определим в классе Panda
собственный экземпляр print(), собственный деструктор и еще одну виртуальную функцию cuddle():
|
class Panda : public Bear, public Endangered { public: virtual ~Panda(); virtual ostream& print( ostream& ) const; virtual void cuddle(); // ... |
Множество виртуальных функций, которые можно напрямую вызывать для объекта Panda, представлено в табл. 18.1.
Таблица 18.1. Виртуальные функции для класса Panda
|
Имя виртуальной функции |
Активный экземпляр |
| деструктор | Panda::~Panda() |
| print(ostream&) const | Panda::print(ostream&) |
| isA() const | Bear::isA() |
| highlight() const | Endangered::highlight() |
| cuddle() | Panda::cuddle() |
инициализируется адресом объекта Panda или ему присваивается такой адрес, то части интерфейса, связанные с классами Panda и Endangered, становятся недоступны:
|
Bear *pb = new Panda; pb->print( cout ); // ïðàâèëüíî: Panda::print(ostream&) pb->isA(); // ïðàâèëüíî: Bear::isA() pb->cuddle(); // îøèáêà: ýòî íå ÷àñòü èíòåðôåéñà Bear pb->highlight(); // îøèáêà: ýòî íå ÷àñòü èíòåðôåéñà Bear |
delete pb; // правильно: Panda::~Panda()
( Обратите внимание, что если бы объекту класса Panda был присвоен указатель на ZooAnimal, то все показанные выше вызовы разрешались бы так же.)
Аналогично, если ссылка или указатель на объект Endangered инициализируется адресом объекта Panda или ему присваивается такой адрес, то части интерфейса, связанные с классами Panda и Bear, становятся недоступными:
Endangered *pe = new Panda; pe->print( cout ); // правильно: Panda::print(ostream&) // îøèáêà: ýòî íå ÷àñòü èíòåðôåéñà Endangered pe->cuddle(); pe->highlight(); // правильно: Endangered::highlight() |
Обработка виртуального деструктора выполняется правильно независимо от типа указателя, через который мы уничтожаем объект. Например, во всех четырех инструкциях порядок вызова деструкторов один и тот же – обратный порядку вызова конструкторов:
// ZooAnimal *pz = new Panda; delete pz; // Bear *pb = new Panda; delete pb; // Panda *pp = new Panda; delete pp; // Endangered *pe = new Panda; |
Деструктор класса Panda
вызывается с помощью механизма виртуализации. После его выполнения по очереди статически вызываются деструкторы Endangered и Bear, а в самом конце – ZooAnimal.
Почленная инициализация и присваивание объекту производного класса, наследующего нескольким базовым, ведут себя точно так же, как и при одиночном наследовании (см. раздел 17.6). Например, для нашего объявления класса Panda
class Panda : public Bear, public Endangered |
в результате почленной инициализации объекта ling_ling
Panda yin_yang; |
вызывается копирующий конструктор класса Bear (но, так как Bear
производный от ZooAnimal, сначала выполняется копирующий конструктор класса ZooAnimal), затем – класса Endangered и только потом – класса Panda. Почленное присваивание ведет себя аналогично.
Упражнение 18.1
Какие из следующих объявлений ошибочны? Почему?
(a) class CADVehicle : public CAD, Vehicle { ... };
(b) class DoublyLinkedList: |
(c) class iostream: |
Упражнение 18.2
Дана иерархия, в каждом классе которой определен конструктор по умолчанию:
class A { ... }; class B : public A { ... }; class C : public B { ... }; class X { ... }; class Y { ... }; class Z : public X, public Y { ... }; |
Каков порядок вызова конструкторов в таком определении:
MI mi;
Упражнение 18.3
Дана иерархия, в каждом классе которой определен конструктор по умолчанию:
class X { ... }; class A { ... }; class B : public A { ... }; class C : private B { ... }; |
Какие из следующих преобразований недопустимы:
D *pd = new D;
(a) X *px = pd; (c) B *pb = pd;
(b) A *pa = pd; (d) C *pc = pd;
Упражнение 18.4
Дана иерархия классов, обладающая приведенным ниже набором виртуальных функций:
class Base { public: virtual ~Base(); virtual ostream& print(); virtual void debug(); virtual void readOn(); virtual void writeOn(); // ... }; class Derived1 : virtual public Base { public: virtual ~Derived1(); virtual void writeOn(); // ... }; class Derived2 : virtual public Base { public: virtual ~Derived2(); virtual void readOn(); // ... }; class MI : public Derived1, public Derived2 { public: virtual ~MI(); virtual ostream& print(); virtual void debug(); // ... |
Какой экземпляр виртуальной функции вызывается в каждом из следующих случаев:
Base *pb = new MI; (a) pb->print(); (c) pb->readOn(); (e) pb->log(); |
Упражнение 18.5
На примере иерархии классов из упражнения 18.4 определите, какие виртуальные функции активны при вызове через pd1 и pd2:
(a) Derived1 *pd1 new MI;
(b) MI obj; |
Модель компиляции с включением
Согласно этой модели мы включаем определение шаблона в каждый файл, где этот шаблон конкретизируется. Обычно оно помещается в заголовочный файл, как и для встроенных функций. Именно такой моделью мы пользуемся в нашей книге. Например:// model1.h // модель с включением: // определения шаблонов помещаются в заголовочный файл template Type min( Type t1, Type t2 ) { return t1 < t2 ? t1 : t2;
| |
}
Этот заголовочный файл включается в каждый файл, где конкретизируется функция min():
// определения шаблонов включены раньше // используется конкретизация шаблона #include "model1.h" int i, j; |
double dobj = min( i, j );
Заголовочный файл можно включить в несколько файлов с исходными текстами программы. Означает ли это, что компилятор конкретизирует экземпляр функции min() с целыми параметрами в каждом файле, где имеется обращение к ней? Нет. Программа должна вести себя так, словно min() с целыми параметрами определена только один раз. Где и когда в действительности конкретизируется шаблон функции, оставляется на усмотрение разработчика компилятора. Нам достаточно знать, что где-то в программе нужная функция min() была конкретизирована. (Как мы покажем далее, с помощью явного объявления конкретизации можно указать, где и когда оно должно быть выполнено. Такие объявления желательно использовать на поздних стадиях разработки продукта для улучшения производительности.)
Решение включать определения шаблонов функций в заголовочные файлы не всегда удачно. Тело шаблона описывает детали реализации, которые пользователям не интересны или которые мы хотели бы от них скрыть. В действительности, если определение шаблона велико, то количество кода в заголовочном файле может превысить разумные пределы. Кроме того, многократная компиляция одного и того же определения при обработке разных файлов увеличивает общее время компиляции программы. Отделить объявления шаблонов функций от их определений позволяет модель компиляции с разделением. Посмотрим, как ее можно использовать.
В этой модели мы включаем определения функций-членов и статических членов шаблонов классов в каждый файл, где они конкретизируются. Для встроенных функций-членов, определенных в теле шаблона, это происходит автоматически. В противном случае такое определение следует поместить в один заголовочный файл с определением шаблона класса. Именно этой моделью мы и пользуемся в настоящей книге. Например, определения шаблонов Queue и QueueItem, как и их функций-членов и статических членов, находятся в заголовочном файле Queue.h.
Подобное размещение не лишено недостатков: определения функций-членов могут быть довольно большими и содержать детали реализации, которые неинтересны пользователям или должны быть скрыты от них. Кроме того, многократная компиляция одного определения шаблона при обработке разных файлов увеличивает общее время компиляции программы. Описанная модель (если она доступна) позволяет отделить интерфейс шаблона от реализации (т.е. от определений функций-членов и статических данных-членов).
Модели компиляции шаблонов А
Шаблон функции задает алгоритм для построения определений множества экземпляров функций. Сам шаблон не определяет никакой функции. Например, когда компилятор видит шаблон:template Type min( Type t1, Type t2 ) { return t1 < t2 ? t1 : t2;
| |
}
он сохраняет внутреннее представление min(), но и только. Позже, когда встретится ее реальное использование, скажем:
int i, j; |
double dobj = min( i, j );
компилятор строит определение min() по сохраненному внутреннему представлению.
Здесь возникает несколько вопросов. Чтобы компилятор мог конкретизировать шаблон функции, должно ли его определение быть видимо при вызове экземпляра этой функции? Например, нужно ли определению шаблона min() появиться до ее конкретизации c целыми параметрами при инициализации dobj? Следует ли помещать шаблоны в заголовочные файлы, как мы поступаем с определениями встроенных (inline) функций? Или в заголовочные файлы можно помещать только объявления шаблонов, оставляя определения в файлах исходных текстов?
Чтобы ответить на эти вопросы, нам придется объяснить принятую в C++ модель компиляции шаблонов, сформулировать требования к организации определений и объявлений шаблонов в программах. В C++ поддерживаются две таких модели: модель с включением и модель с разделением. В данном разделе описываются обе модели и объясняется их использование.
Начинаем
В этой главе представлены основные элементы языка: встроенные типы данных, определения именованных объектов, выражений и операторов, определение и использование именованных функций. Мы посмотрим на минимальную законченную С++ программу, вкратце коснемся процесса компиляции этой программы, узнаем, что такое препроцессор, и бросим самый первый взгляд на поддержку ввода и вывода. Мы увидим также ряд простых, но законченных С++ программ.Наилучшая из устоявших функций
Наследование влияет и на третий шаг разрешения перегрузки – выбор наилучшей из устоявших функций. На этом шаге ранжируются преобразования типов, с помощью которых можно привести фактические аргументы функции к типам соответственных формальных параметров. Следующие неявные преобразования имеют тот же ранг, что и стандартные (стандартные преобразования рассматривались в разделе 9.3):· преобразование аргумента типа производного класса в параметр типа любого из его базовых;
· преобразование указателя на тип производного класса в указатель на тип любого из его базовых;
· инициализация ссылки на тип базового класса с помощью l-значения типа производного.
Они не являются пользовательскими, так как не зависят от конвертеров и конструкторов, имеющихся в классе:
extern void release( const ZooAnimal& ); Panda yinYang; // стандартное преобразование: Panda -> ZooAnimal |
release( yinYang );
Поскольку аргумент yinYang
типа Panda
инициализирует ссылку на тип базового класса, то преобразование имеет ранг стандартного.
В разделе 15.10 мы говорили, что стандартные преобразования имеют более высокий ранг, чем пользовательские:
class Panda : public Bear, public Endangered { // наследует ZooAnimal::operator const char *() }; Panda yinYang; extern void release( const ZooAnimal& ); extern void release( const char * ); // стандартное преобразование: Panda -> ZooAnimal // выбирается: release( const ZooAnimal& ) |
release( yinYang );
Как release(const char*), так и release(ZooAnimal&)
являются устоявшими функциями: первая потому, что инициализация параметра-ссылки значением аргумента – стандартное преобразование, а вторая потому, что аргумент можно привести к типу const char* с помощью конвертера ZooAnimal::operator const char*(), который представляет собой пользовательское преобразование. Так как стандартное преобразование лучше пользовательского, то в качестве наилучшей из устоявших выбирается функция release(const ZooAnimal&).
При ранжировании различных стандартных преобразований из производного класса в базовые лучшим считается приведение к тому базовому классу, который ближе к производному. Так, показанный ниже вызов не будет неоднозначным, хотя в обоих случаях требуется стандартное преобразование. Приведение к базовому классу Bear
лучше, чем к ZooAnimal, поскольку Bear
ближе к классу Panda. Поэтому лучшей из устоявших будет функция release(const Bear&):
extern void release( const ZooAnimal& ); extern void release( const Bear& ); // правильно: release( const Bear& ) |
Аналогичное правило применимо и к указателям. При ранжировании стандартных преобразований из указателя на тип производного класса в указатели на типы различных базовых лучшим считается то, для которого базовый класс наименее удален от производного. Это правило распространяется и на тип void*.
Стандартное преобразование в указатель на тип любого базового класса всегда лучше, чем преобразование в void*. Например, если дана пара перегруженных функций:
void receive( void* ); |
то наилучшей из устоявших для вызова с аргументом типа Panda* будет receive(ZooAnimal*).
В случае множественного наследования два стандартных преобразования из типа производного класса в разные типы базовых могут иметь одинаковый ранг, если оба базовых класса равноудалены от производного. Например, Panda
наследует классам Bear и Endangered. Поскольку они равноудалены от производного Panda, то преобразования объекта Panda в любой из этих классов одинаково хороши. Но тогда единственной наилучшей из устоявших функции для следующего вызова не существует, и он считается ошибочным:
extern void mumble( const Bear& ); extern void mumble( const Endangered& ); /* ошибка: неоднозначный вызов: * может быть выбрана любая из двух функций * void mumble( const Bear& ); * void mumble( const Endangered& ); */ |
Для разрешения неоднозначности программист может применить явное приведение типа:
mumble( static_cast< Bear >( yinYang ) ); // правильно
Инициализация объекта производного класса или ссылки на него объектом типа базового, а также преобразование указателя на тип базового класса в указатель на тип производного никогда не выполняются компилятором неявно. (Однако их можно выполнить с помощью явного применения dynamic_cast, как мы видели в разделе 19.1.) Для данного вызова не существует наилучшей из устоявших функции, так как нет неявного преобразования аргумента типа ZooAnimal в тип производного класса:
extern void release( const Bear& ); extern void release( const Panda& ); ZooAnimal za; // ошибка: нет соответствия |
В следующем примере наилучшей из устоявших будет release(const char*). Это может показаться удивительным, так как к аргументу применена последовательность пользовательских преобразований, в которой участвует конвертер const char*(). Но поскольку неявного приведения от типа базового класса к типу производного не существует, то release(const Bear&) не является устоявшей функцией, так что остается только release(const char*):
Class ZooAnimal { public: // преобразование: ZooAnimal ==> const char* operator const char*(); // ... }; extern void release( const char* ); extern void release( const Bear& ); ZooAnimal za; // za ==> const char* // правильно: release( const char* ) |
Упражнение 19.9
Дана такая иерархия классов:
class Base1 { public: ostream& print(); void debug(); void writeOn(); void log( string ); void reset( void *); // ... }; class Base2 { public: void debug(); void readOn(); void log( double ); // ... }; class MI : public Base1, public Base2 { public: ostream& print(); using Base1::reset; void reset( char * ); using Base2::log; using Base2::log; // ... |
};
Какие функции входят в множество кандидатов для каждого из следующих вызовов:
MI *pi = new MI; (a) pi->print(); (c) pi->readOn(); (e) pi->log( num ); |
Упражнение 19.10
Дана такая иерархия классов:
class Base { public: operator int(); operator const char *(); // ... }; class Derived : public Base { public: operator double(); // ... |
Удастся ли выбрать наилучшую из устоявших функций для каждого из следующих вызовов? Назовите кандидаты, устоявшие функции и преобразования типов аргументов для каждой из них, наилучшую из устоявших (если она есть):
(a) void operate( double ); void operate( string ); void operate( const Base & ); Derived *pd = new Derived; |
(b) void calc( int ); void calc( double ); void calc( const Derived & ); Base *pb = new Derived; |
20
Наилучшая из устоявших функция
Наилучшей считается та из устоявших функций, формальные параметры которой наиболее точно соответствуют типам фактических аргументов. Для любой такой функции преобразования типов, применяемые к каждому аргументу, ранжируются для определения степени его соответствия параметру. (В разделе 6.2 описаны поддерживаемые преобразования типов.) Наилучшей из устоявших называют функцию, для которой одновременно выполняются два условия:·
преобразования, примененные к аргументам, не хуже
преобразований, необходимых для вызова любой другой устоявшей функции;
· хотя бы для одного аргумента примененное преобразование лучше, чем для того же аргумента в любой другой устоявшей функции.
Может оказаться так, что для приведения фактического аргумента к типу соответствующего формального параметра нужно выполнить несколько преобразований. Так, в следующем примере
int arr[3]; void putValues(const int *); int main() { putValues(arr); // необходимо 2 преобразования // массив в указатель + преобразование спецификатора return 0; |
}
для приведения аргумента arr от типа “массив из трех int” к типу “указатель на const int” применяется последовательность преобразований:
1. Преобразование массива в указатель, которое трансформирует массив из трех int в указатель на int.
2. Преобразование спецификатора, которое трансформирует указатель на int в указатель на const int.
Поэтому было бы более правильно говорить, что для приведения фактического аргумента к типу формального параметра устоявшей функции требуется последовательность преобразований. Поскольку применяется не одна, а несколько трансформаций, то на третьем шаге процесса разрешения перегрузки функции на самом деле ранжируются последовательности преобразований.
Рангом такой последовательности считается ранг самой плохой из входящих в нее трансформаций. Как объяснялось в разделе 9.2, преобразования типов ранжируются следующим образом: точное соответствие лучше расширения типа, а расширение типа лучше стандартного преобразования. В предыдущем примере оба изменения имеют ранг точного соответствия. Поэтому и у всей последовательности такой же ранг.
Такая совокупность состоит из нескольких преобразований, применяемых в указанном порядке:
преобразование l-значения ->
расширение типа или стандартное преобразование ->
преобразование спецификаторов
Термин преобразование l-значения
относится к первым трем трансформациям из категории точных соответствий, рассмотренных в разделе 9.2: преобразование l-значения в r-значение, преобразование массива в указатель и преобразование функции в указатель. Последовательность трансформаций состоит из нуля или одного преобразования l-значения, за которым следует нуль или одно расширение типа или стандартное преобразование, и наконец нуль или одно преобразование спецификаторов. Для приведения фактического аргумента к типу формального параметра может быть применено только одна трансформация каждого вида.
Описанная последовательность называется последовательностью стандартных преобразований. Существует также последовательность определенных пользователем преобразований, которая связана с функцией-конвертером, являющейся членом класса. (Конвертеры и последовательности определенных пользователем преобразований рассматриваются в главе 15.)
Каковы последовательности изменений фактических аргументов в следующем примере?
namespace libs_R_us { int max( int, int ); double max( double, double ); } // using-объявление using libs_R_us::max; void func() { char c1, c2; max( c1, c2 ); // вызывается libs_R_us::max( int, int ) |
Аргументы в вызове функции max()
имеют тип char. Последовательность преобразований аргументов при вызове функции libs_R_us::max(int,int)
следующая:
1a. Так как аргументы передаются по значению, то с помощью преобразования l-значения в r-значение извлекаются значения аргументов c1 и c2.
2a. С помощью расширения типа аргументы трансформируются из char в int.
Последовательность преобразований аргументов при вызове функции libs_R_us::max(double,double)
следующая:
1b. С помощью преобразования l-значения в r-значение извлекаются значения аргументов c1 и c2.
2b. Стандартное преобразование между целым и плавающим типом приводит аргументы от типа char к типу double.
Ранг первой последовательности – расширение типа (самое худшее из примененных изменений), тогда как ранг второй – стандартное преобразование. Так как расширение типа лучше, чем преобразование, то в качестве наилучшей из устоявших для данного вызова выбирается функция libs_R_us::max(int,int).
Если ранжирование последовательностей преобразований аргументов не может выявить единственной устоявшей функции, то вызов считается неоднозначным. В данном примере для обоих вызовов calc()
требуется такая последовательность:
1. Преобразование l-значения в r-значение для извлечения значений аргументов i и j.
2. Стандартное преобразование для приведения типов фактических аргументов к типам соответствующих формальных параметров.
Поскольку нельзя сказать, какая из этих последовательностей лучше другой, вызов неоднозначен:
int i, j; extern long calc( long, long ); extern double calc( double, double ); void jj() { // ошибка: неоднозначность, нет наилучшего соответствия calc( i, j ); |
Преобразование спецификаторов (добавление спецификатора const или volatile к типу, который адресует указатель) имеет ранг точного соответствия. Однако, если две последовательности трансформаций отличаются только тем, что в конце одной из них есть дополнительное преобразование спецификаторов, то последовательность без него считается лучше. Например:
void reset( int * ); void reset( const int * ); int* pi; int main() { reset( pi ); // без преобразования спецификаторов лучше: // выбирается reset( int * ) return 0; |
Последовательность стандартных преобразований, примененная к фактическому аргументу для первой функции-кандидата reset(int*), – это точное соответствие, требуется лишь переход от l-значения к r-значению, чтобы извлечь значение аргумента. Для второй функции-кандидата reset(const int *)
также применяется трансформация l-значения в r-значение, но за ней следует еще и преобразование спецификаторов для приведения результирующего значения от типа “указатель на int” к типу “указатель на const int”. Обе последовательности представляют собой точное соответствие, но неоднозначности при этом не возникает. Так как вторая последовательность отличается от первой наличием трансформации спецификаторов в конце, то последовательность без такого преобразования считается лучшей. Поэтому наилучшей из устоявших функций будет reset(int*).
Вот еще пример, в котором приведение спецификаторов влияет на то, какая последовательность будет выбрана:
int extract( void * ); int extract( const void * ); int* pi; int main() { extract( pi ); // выбирается extract( void * ) return 0; |
Здесь для вызова есть две устоявших функции: extract(void*) и extract(const void*). Последовательность преобразований для функции extract(void*)
состоит из трансформации l-значения в r-значение для извлечения значения аргумента, сопровождаемого стандартным преобразованием указателя: из указателя на int в указатель на void. Для функции extract(const void*)
такая последовательность отличается от первой дополнительным преобразованием спецификаторов для приведения типа результата от указателя на void к указателю на const void. Поскольку последовательности различаются лишь этой трансформацией, то первая выбирается как более подходящая и, следовательно, наилучшей из устоявших будет функция extract(const void*).
Спецификаторы const и volatile
влияют также на ранжирование инициализации параметров-ссылок. Если две такие инициализации отличаются только добавлением спецификатора const и volatile, то инициализация без дополнительной спецификации считается лучшей при разрешении перегрузки:
#include void manip( vector void manip( const vector vector extern vector int main() { manip( vec ); // выбирается manip( vector manip( f() ); // выбирается manip( const vector return 0; |
}
В первом вызове инициализация ссылок для вызова любой функции является точным соответствием. Но этот вызов все же не будет неоднозначным. Так как обе инициализации одинаковы во всем, кроме наличия дополнительной спецификации const во втором случае, то инициализация без такой спецификации считается лучше, поэтому перегрузка будет разрешена в пользу устоявшей функции manip(vector
Для второго вызова существует только одна устоявшая функция manip(const vector
Разумеется, у функций может быть несколько фактических аргументов. Выбор наилучшей из устоявших должен производиться с учетом ранжирования последовательностей преобразований всех аргументов. Рассмотрим пример:
extern int ff( char*, int ); extern int ff( int, int ); int main() { ff( 0, 'a' ); // ff( int, int ) return 0; |
Функция ff(), принимающая два аргумента типа int, выбирается в качестве наилучшей из устоявших по следующим причинам:
1. ее первый аргумент лучше. 0
дает точное соответствие с формальным параметром типа int, тогда как для установления соответствия с параметром типа char *
требуется стандартное преобразование указателя;
2. ее второй аргумент имеет тот же ранг. К аргументу 'a'
типа char для установления соответствия со вторым формальным параметром любой из двух функций должна быть применена последовательность преобразований, имеющая ранг расширения типа.
Вот еще один пример:
int compute( const int&, short ); int compute( int&, double ); extern int iobj; int main() { compute( iobj, 'c' ); // compute( int&, double ) return 0; |
Обе функции compute( const int&, short ) и compute( int&, double )
устояли. Вторая выбирается в качестве наилучшей по следующим причинам:
1. ее первый аргумент лучше. Инициализация ссылки для первой устоявшей функции хуже потому, что она требует добавления спецификатора const, не нужного для второй функции;
2. ее второй аргумент имеет тот же ранг. К аргументу 'c'
типа char для установления соответствия со вторым формальным параметром любой из двух функций должна быть применена последовательность трансформаций, имеющая ранг стандартного преобразования.
Наследование и композиция
Реализация класса PeekbackStack с помощью закрытого наследования от IntArray работает, но необходимо ли это? Помогло ли нам наследование в данном случае? Нет.Открытое наследование – это мощный механизм для поддержки отношения “ЯВЛЯЕТСЯ”. Однако реализация PeekbackStack по отношению к IntArray – пример отношения “СОДЕРЖИТ”. Класс PeekbackStack содержит класс IntArray как часть своей реализации. Отношение “СОДЕРЖИТ”, как правило, лучше поддерживается с помощью композиции, а не наследования. Для ее реализации надо один класс сделать членом другого. В нашем случае объект IntArray
делается членом PeekbackStack. Вот реализация PeekbackStack на основе композиции:
class PeekbackStack { private: const int static bos = -1; public: explicit PeekbackStack( int size ) : stack( size ), _top( bos ) {} bool empty() const { return _top == bos; } bool full() const { return _top == size()-1; } int top() const { return _top; } int pop() { if ( empty() ) /* обработать ошибку */ ; return stack[ _top-- ]; } void push( int value ) { if ( full() ) /* обработать ошибку */ ; stack[ ++_top ] = value; } bool peekback( int index, int &value ) const; private: int _top; IntArray stack; }; inline bool PeekbackStack:: peekback( int index, int &value ) const { if ( empty() ) /* обработать ошибку */ ; if ( index < 0 || index > _top ) { value = stack[ _top ]; return false; } value = stack[ index ]; return true; |
}
Решая, следует ли использовать при проектировании класса с отношением “СОДЕРЖИТ” композицию или закрытое наследование, можно руководствоваться такими соображениями:
· если мы хотим заместить какие-либо виртуальные функции базового класса, то должны закрыто наследовать ему;
· если мы хотим разрешить нашему классу ссылаться на класс из иерархии типов, то должны использовать композицию по ссылке (мы подробно расскажем о ней в разделе 18.3.4);
· если, как в случае с классом PeekbackStack, мы хотим воспользоваться готовой реализацией, то композиция по значению предпочтительнее наследования. Если требуется отложенное выделение памяти для объекта, то следует выбрать композицию по ссылке (с помощью указателя).
Наследование и подтипизация классов
В главе 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 { typedef map 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 /* комментарии /* */ не могут быть вложенными. * Строку "не вкладываются" компилятор рассматривает, * как часть программы. Это же относится к данной и следующей строкам */ 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; // ----- исходный файл ----- |
Хотя bufp
объявлена как const, ее значение не может быть вычислено во время компиляции (она инициализируется с помощью оператора new, который требует вызова библиотечной функции). Такая конструкция в заголовочном файле означала бы, что константа определяется каждый раз, когда этот заголовочный файл включается. Символическая константа – это любой объект, объявленный со спецификатором const. Можете ли вы сказать, почему следующее объявление, помещенное в заголовочный файл, вызывает ошибку связывания, если такой файл включается в два различных исходных?
// ошибка: не должно быть в заголовочном файле |
Проблема вызвана тем, что 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; |
Упражнение 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; |
Объединение– класс, экономящий память
Объединение – это специальный вид класса. Данные-члены хранятся в нем таким образом, что перекрывают друг друга. Все члены размещаются, начиная с одного и того же адреса. Для объединения отводится столько памяти, сколько необходимо для хранения самого большого его члена. В любой момент времени можно присвоить значение лишь одному такому члену.Рассмотрим пример, иллюстрирующий использование объединения. Лексический анализатор, входящий в состав компилятора, разбивает программу на последовательность лексем. Так, инструкция
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 |
Обращение к членам объединения, как и к членам класса, производится с помощью операторов доступа:
last_token._ival = 97; |
Члены объединения можно объявлять открытыми, закрытыми или защищенными:
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 ) |
При работе с объединением, являющимся членом класса, полезно иметь набор функций для каждого хранящегося в объединении типа данных:
#include // функции доступа к члену объединения 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( ¬q ); 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 класса |
В C++ полиморфизм существует только в пределах отдельных иерархий классов. Указатели типа void*
можно назвать полиморфными, но в языке их поддержка не предусмотрена. Такими указателями программист должен управлять самостоятельно, с помощью явных приведений типов и той или иной формы дискриминанта, показывающего, объект какого типа в данный момент адресуется. (Можно сказать, что это “второсортные” полиморфные объекты.)
Язык C++ обеспечивает поддержку полиморфизма следующими способами:
· путем неявного преобразования указателя или ссылки на производный класс к указателю или ссылке на открытый базовый:
Query *pquery = new NameQuery( "Class" );
· через механизм виртуальных функций:
pquery->eval();
· с помощью операторов dynamic_cast и typeid (они подробно обсуждаются в разделе 19.1):
if ( NameQuery *pnq = |
Проблему представления запроса мы решим, определив каждый операнд в классах AndQuery, NotQuery и OrQuery как указатель на тип Query*. Например:
class AndQuery { public: // ... private: Query *_lop; Query *_rop; |
Теперь оба операнда могут адресовать объект любого класса, производного от абстрактного базового класса Query, без учета того, определен он уже сейчас или появится в будущем. Благодаря механизму виртуальных функций, вычисление операнда, происходящее во время выполнения программы, не зависит от фактического типа:
_rop->eval();

![]() |
![]() |
![]() |
![]() |
Рис. 17.1. Иерархия классов Query
В разделе 2.4 мы рассматривали реализацию иерархии классов IntArray. Синтаксическая структура определения иерархии, изображенной на рис. 17.1, аналогична:
|
class Query { ... }; class AndQuery : public Query { ... }; class OrQuery : public Query { ... }; class NotQuery : public Query { ... }; |
Наследование задается с помощью списка базовых классов. В случае одиночного наследования этот список имеет вид:
: уровень-доступа базовый-класс
где уровень-доступа – это одно из ключевых слов public, protected, private (смысл защищенного и закрытого наследования мы обсудим в разделе 18.3), а базовый-класс – имя ранее определенного класса. Например, Query является открытым базовым классом для любого из четырех классов запросов.
Класс, встречающийся в списке базовых, должен быть предварительно определен. Следующего опережающего объявления Query
недостаточно для того, чтобы он мог выступать в роли базового:
|
// ошибка: Query должен быть определен class Query; |
Опережающее объявление производного класса должно включать только его имя, но не список базовых классов. Поэтому следующее опережающее объявление класса NameQuery
приводит к ошибке компиляции:
|
// ошибка: опережающее объявление не должно // включать списка базовых классов |
Правильный вариант в данном случае выглядит так:
|
// опережающее объявление как производного, // так и обычного класса содержит только имя класса class Query; |
class NameQuery;
Главное различие между базовыми классами Query и IntArray (см. раздел 2.4) состоит в том, что Query не представляет никакого реального объекта в нашем приложении. Пользователи класса IntArray
вполне могут определять и использовать объекты этого типа непосредственно. Что же касается Query, то разрешается определять лишь указатели и ссылки на него, используя их для косвенного манипулирования объектами производных классов. О Query
говорят, что это абстрактный базовый класс. В противоположность этому IntArray является конкретным базовым классом. Преобладающей формой в объектно-ориентированном проектировании является определение абстрактного базового класса типа Query и одиночное открытое наследование ему.
Упражнение 17.1
Библиотека может выдавать на руки предметы, для каждого из которых определены специальные правила выдачи и возврата. Организуйте их в иерархию наследования:
книга аудио-книга аудиокассета детская кукла видеокассета видеоигра для приставки SEGA книга с подневной оплатой видеоигра для приставки SONY |
Упражнение 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&); |
хотя реализация этих функций может быть совершенно идентичной. Было бы лучше написать единственную функцию, которая могла бы работать не только со всеми нашими массивами, но и с теми их вариациями, какие мы, возможно, реализуем впоследствии.
Парадигма объектно-ориентированного программирования позволяет осуществить все эти пожелания. Механизм наследования
обеспечивает пожелания из первого пункта. Если один класс является потомком другого (например, IntArrayRC
потомок класса IntArray), то наследник имеет возможность пользоваться всеми данными и функциями-членами, определенными в классе-предке. То есть класс IntArrayRC может просто использовать всю основную функциональность, предоставляемую классом IntArray, и добавить только то, что нужно ему для обеспечения проверки границ индекса.
В С++ класс, свойства которого наследуются, называют также базовым классом, а класс-наследник – производным классом, или подклассом
базового. Класс и подкласс имеют общий интерфейс, предоставляемый базовым классом (т.к. подкласс имеет все функции-члены базового класса). Значит, программу, использующую только функции из этого общего интерфейса, не должен интересовать фактический тип объекта, с которым она работает, – базового ли типа этот объект или производного. В этом смысле общий интерфейс скрывает специфичные для подкласса детали. Отношения между классами и подклассами называются иерархией наследования классов. Вот как может выглядеть реализация функции swap(), которая меняет местами два указанных элемента массива. Первым параметром функции является ссылка на базовый класс IntArray:
#include void swap (IntArray &ia, int i, int j) { int temp ia[i]; ia[i] = ia[j]; ia[j] = temp; } // ниже идут обращения к функции swap: IntArray ia; IntArrayRC iarc; IntSortedArray ias; // правильно - ia имеет тип IntArray swap (ia,0,10); // правильно - iarc является подклассом IntArray swap (iarc,0,10); // правильно - ias является подклассом IntArray swap (ias,0,10); |
// ошибка - string не является подклассом IntArray
string str("Это не IntArray!");
swap (str,0,10);
Каждый из трех классов реализует операцию взятия индекса по-своему. Поэтому важно, чтобы внутри функции swap()
вызывалась нужная операция взятия индекса. Так, если swap()
вызвана для IntArrayRC:
swap (iarc,0,10);
то должна вызываться функция взятия индекса для объекта класса IntArrayRC, а для
swap (ias,0,10);
функция взятия индекса IntSortedArray. Именно это и обеспечивает механизм
виртуальных функций С++.
Давайте попробуем сделать наш класс IntArray
базовым для иерархии подклассов. Что нужно изменить в его описании? Синтаксически – совсем немного. Возможно, придется открыть для производных классов доступ к скрытым членам класса. Кроме того, те функции, которые мы собираемся сделать виртуальными, необходимо явно пометить специальным ключевым словом virtual. Основная же трудность состоит в таком изменении реализации базового класса, которая позволит ей лучше отвечать своей новой цели – служить базой для целого семейства подклассов.
При простом объектном подходе можно выделить двух разработчиков конечной программы – разработчик класса и пользователь класса (тот, кто использует данный класс в конечной программе), причем последний обращается только к открытому интерфейсу. Для такого случая достаточно двух уровней доступа к членам класса – открытого (public) и закрытого (private).
Если используется наследование, то к этим двум группам разработчиков добавляется третья, промежуточная. Производный класс может проектировать совсем не тот человек, который проектировал базовый, и для того чтобы реализовать класс-наследник, совсем не обязательно иметь доступ к реализации базового. И хотя такой доступ может потребоваться при проектировании подкласса, от конечного пользователя обоих классов эта часть по-прежнему должна быть закрыта. К двум уровням доступа добавляется третий, в некотором смысле промежуточный, – защищенный
(protected). Члены класса, объявленные как защищенные, могут использоваться классами-потомками, но никем больше. (Закрытые члены класса недоступны даже для его потомков.)
Вот как выглядит модифицированное описание класса IntArray:
class IntArray { public: // конструкторы explicit IntArray (int sz = DefaultArraySize); IntArray (int *array, int array_size); IntArray (const IntArray &rhs); // виртуальный деструктор virtual ~IntArray() { delete[] ia; } // операции сравнения: bool operator== (const IntArray&) const; bool operator!= (const IntArray&) const; // операция присваивания: IntArray& operator= (const IntArray&); int size() const { return _size; }; // мы убрали проверку индекса... |
{ return ia[index]; }
virtual void sort();
virtual int min() const;
virtual int max() const;
virtual int find (int value) const;
protected:
static const int DefaultArraySize = 12;
void init (int sz; int *array);
int _size;
int *ia;
}
Открытые функции-члены по-прежнему определяют интерфейс класса, как и в реализации из предыдущего раздела. Но теперь это интерфейс не только базового, но и всех производных от него подклассов.
Нужно решить, какие из членов, ранее объявленных как закрытые, сделать защищенными. Для нашего класса IntArray
сделаем защищенными все оставшиеся члены.
Теперь нам необходимо определить, реализация каких функций-членов базового класса может меняться в подклассах. Такие функции мы объявим виртуальными. Как уже отмечалось выше, реализация операции взятия индекса будет отличаться по крайней мере для подкласса IntArrayRC. Реализация операторов сравнения и функции size() одинакова для всех подклассов, следовательно, они не будут виртуальными.
При вызове невиртуальной функции компилятор определяет все необходимое еще на этапе компиляции. Если же он встречает вызов виртуальной функции, то не пытается сделать этого. Выбор нужной из набора виртуальных функций (разрешение вызова) происходит во время выполнения программы и основывается на типе объекта, из которого она вызвана. Рассмотрим пример:
void init (IntArray &ia) |
for (int ix=0; ix
ia[ix] = ix;
}
Формальный параметр функции ia
может быть ссылкой на IntArray, IntArrayRC или на IntSortedArray. Функция-член size() не является виртуальной и разрешается на этапе компиляции. А вот виртуальный оператор взятия индекса не может быть разрешен на данном этапе, поскольку реальный тип объекта, на который ссылается ia, в этот момент неизвестен.
(В главе 17 мы будем говорить о виртуальных функциях более подробно. Там мы рассмотрим также и накладные расходы, которые влечет за собой их использование.)
Вот как выглядит определение производного класса IntArrayRC:
#ifndef IntArrayRC_H #define IntArrayRC_H #include "IntArray.h" class IntArrayRC : public IntArray { public: IntArrayRC( int sz = DefaultArraySize ); IntArrayRC( const int *array, int array_size ); IntArrayRC( const IntArrayRC &rhs ); virtual int& operator[]( int ) const; private: void check_range( int ix ); }; |
Этот текст мы поместим в заголовочный файл IntArrayRC.h. Обратите внимание на то, что в наш файл включен заголовочный файл IntArray.h.
В классе IntArrayRC мы должны реализовать только те особенности, которые отличают его от IntArray: класс IntArrayRC
должен иметь свою собственную реализацию операции взятия индекса; функцию для проверки индекса и собственный набор конструкторов.
Все данные и функции-члены класса IntArray
можно использовать в классе IntArrayRC так, как будто это его собственные члены. В этом и заключается смысл наследования. Синтаксически наследование выражается строкой
class IntArrayRC : public IntArray
Эта строка показывает, что класс IntArrayRC
произведен от класса IntArray, другими словами, наследует ему. Ключевое слово public в данном контексте говорит о том, что производный класс сохраняет открытый интерфейс базового класса, то есть что все открытые функции базового класса остаются открытыми и в производном. Объект типа IntArrayRC
может использоваться вместо объекта типа IntArray, как, например, в приведенном выше примере с функцией swap(). Таким образом, подкласс IntArrayRC – это расширенная версия класса IntArray.
Вот как выглядит реализация операции взятия индекса:
IntArrayRC::operator[]( int index ) { check_range( index ); return _ia[ index ]; |
А вот реализация встроенной функции check_range():
#include inline void IntArrayRC::check_range(int index) { assert (index>=0 && index < _size); |
(Мы говорили о макросе assert() в разделе 1.3.)
Почему проверка индекса вынесена в отдельную функцию, а не выполняется прямо в теле оператора взятия индекса? Потому что, если мы когда-нибудь потом захотим изменить что-то в реализации проверки, например написать свою обработку ошибок, а не использовать assert(), это будет сделать проще.
В каком порядке активизируются конструкторы при создании производного класса? Первым вызывается конструктор базового класса, инициализирующий те члены, которые входят в базовый класс. Затем начинает работать конструктор производного класса, где мы должны проинициализировать только те члены, которые являются специфичными для подкласса, то есть отсутствуют в базовом классе.
Однако заметим, что в нашем производном классе IntArrayRC нет новых членов, представляющих данные. Значит ли это, что нам не нужно реализовывать конструкторы для него? Ведь вся работа по инициализации членов данных уже проделана конструкторами базового класса.
На самом деле конструкторы, как и деструкторы или операторы присваивания, не наследуются – это правило языка С++. Кроме того, конструктор производного класса обеспечивает механизм передачи параметров конструктору базового класса. Рассмотрим пример. Пусть мы хотим создать объект класса IntArrayRC
следующим образом:
int ia[] = {0,1,1,2,3,5,8,13}; |
Нам нужно передать параметры ia и 8
конструктору базового класса IntArray. Для этого служит специальная синтаксическая конструкция. Вот как выглядят реализации двух конструкторов IntArrayRC:
inline IntArrayRC::IntArrayRC( int sz ) : IntArray( sz ) {} inline IntArrayRC::IntArrayRC( const int *iar, int sz ) |
(Мы будем подробно говорить о конструкторах в главах 14 и 17. Там же мы покажем, почему не нужно реализовывать конструктор копирования для IntArrayRC.)
Часть определения, следующая за двоеточием, называется списком инициализации членов. Именно здесь, указав конструктор базового класса, мы можем передать ему параметры. Тела обоих конструкторов пусты, поскольку их работа состоит исключительно в передаче параметров конструктору базового класса. Нам не нужно реализовывать деструктор для IntArrayRC, так как ему просто нечего делать. Точно так же, как при создании объекта производного типа вызывается сначала конструктор базового типа, а затем производного, при уничтожении автоматически вызываются деструкторы – естественно, в обратном порядке: сначала деструктор производного, затем базового. Таким образом, деструктор базового класса будет вызван для объекта типа IntArrayRC, хотя тот и не имеет собственной аналогичной функции.
Мы поместим все встроенные функции класса IntArrayRC в тот же заголовочный файл IntArrayRC.h. Поскольку у нас нет невстроенных функций, то создавать файл IntArrayRC.C не нужно.
Вот пример простой программы, использующей классы IntArray и IntArrayRC:
#include #include "IntArray.h" #include "IntArrayRC.h" void swap( IntArray &ia, int ix, int jx ) { int tmp = ia[ ix ]; ia[ ix ] = ia[ jx ]; ia[ jx ] = tmp; } int main() { int array[ 4 ] = { 0, 1, 2, 3 }; IntArray ia1( array, 4 ); IntArrayRC ia2( array, 4 ); // ошибка: должно быть size-1 // не может быть выявлена объектом IntArray cout << "swap() with IntArray ia1" << endl; swap( ia1, 1, ia1.size() ); // правильно: объект IntArrayRC "поймает" ошибку cout << "swap() with IntArrayRC ia2" << endl; swap( ia2, 1, ia2.size() ); return 0; |
}
При выполнении программа выдаст следующий результат:
swap() with IntArray ia1
swap() with IntArrayRC ia2
Assertion failed: ix >= 0 && ix < _size,
file IntArrayRC.h, line 19
Упражнение 2.8
Отношение наследования между типом и подтипом служит примером отношения является. Так, массив IntArrayRC
является подвидом массива IntArray, книга является подвидом выдаваемых библиотекой предметов, аудиокнига является подвидом книги и т.д. Какие из следующих утверждений верны?
(a) функция-член является подвидом функции (b) функция-член является подвидом класса (c) конструктор является подвидом функции-члена (d) самолет является подвидом транспортного средства (e) машина является подвидом грузовика (f) круг является подвидом геометрической фигуры (g) квадрат является подвидом треугольника (h) автомобиль является подвидом самолета |
Упражнение 2.9
Определите, какие из следующих функций могут различаться в реализации для производных классов и, таким образом, выступают кандидатами в виртуальные функции:
(a) rotate(); (b) print(); (c) size(); (d) DateBorrowed(); // дата выдачи книги (e) rewind(); (f) borrower(); // читатель (g) is_late(); // книга просрочена |
Упражнение 2.10
Ходят споры о том, не нарушает ли принципа инкапсуляции введение защищенного уровня доступа. Есть мнение, что для соблюдения этого принципа следует отказаться от использования такого уровня и работать только с закрытыми членами. Противоположная точка зрения гласит, что без защищенных членов производные классы невозможно реализовывать достаточно эффективно и в конце концов пришлось бы везде задействовать открытый уровень доступа. А каково ваше мнение по этому поводу?
Упражнение 2.11
Еще одним спорным аспектом является необходимость явно указывать виртуальность функций в базовом классе. Есть мнение, что все функции должны быть виртуальными по умолчанию, тогда ошибка в разработке базового класса не повлечет таких серьезных последствий в разработке производного, когда из-за невозможности изменить реализацию функции, ошибочно не определенной в базовом классе как виртуальная, приходится сильно усложнять реализацию. С другой стороны, виртуальные функции невозможно объявить как встроенные, и использование только таких функций сильно снизит эффективность. Каково ваше мнение?
Упражнение 2.12
Каждая из приведенных ниже абстракций определяет целое семейство подвидов, как, например, абстракция “транспортное средство” может определять “самолет”, “автомобиль”, “велосипед”. Выберите одно из семейств и составьте для него иерархию подвидов. Приведите пример открытого интерфейса для этой иерархии, включая конструкторы. Определите виртуальные функции. Напишите псевдокод маленькой программы, использующей данный интерфейс.
(a) Точка (b) Служащий (c) Фигура (d) Телефонный_номер (e) Счет_в_банке |
Объектный подход
В этом разделе мы спроектируем и реализуем абстракцию массива, используя механизм классов С++. Первоначальный вариант будет поддерживать только массив элементов типа int. Впоследствии при помощи шаблонов мы расширим наш массив для поддержки любых типов данных.Первый шаг состоит в том, чтобы определить, какие операции будет поддерживать наш массив. Конечно, было бы заманчиво реализовать все мыслимые и немыслимые операции, но невозможно сделать сразу все на свете. Поэтому для начала определим то, что должен уметь наш массив:
1.
обладать некоторыми знаниями о самом себе. Пусть для начала это будет знание собственного размера;
2. поддерживать операцию присваивания и операцию сравнения на равенство;
3. отвечать на некоторые вопросы, например: какова величина минимального и максимального элемента; содержит ли массив элемент с определенным значением; если да, то каков индекс первого встречающегося элемента, имеющего это значение;
4. сортировать сам себя. Пусть такая операция покажется излишней, все-таки реализуем ее в качестве дополнительного упражнения: ведь кому-то это может пригодиться.
5. Конечно, мы должны реализовать и базовые операции работы с массивом, а именно:Возможность задать размер массива при его создании. (Речь не идет о том, чтобы знать эту величину на этапе компиляции.)
6. Возможность проинициализировать массив некоторым набором значений.
7. Возможность обращаться к элементу массива по индексу. Пусть эта возможность реализуется с помощью стандартной операции взятия индекса.
8. Возможность обнаруживать обращения к несуществующим элементам массива и сигнализировать об ошибке. Не будем обращать внимание на тех потенциальных пользователей нашего класса, которые привыкли работать со встроенными массивами С и не считают данную возможность полезной – мы хотим создать такой массив, который был бы удобен в использовании даже самым неискушенным программистам на С++.
Кажется, мы перечислили достаточно потенциальных достоинств нашего будущего массива, чтобы загореться желанием немедленно приступить к его реализации. Как же это будет выглядеть на С++? В самом общем случае объявление класса выглядит следующим образом:
class classname { public: // набор открытых операций private: // закрытые функции, обеспечивающие реализацию |
class, public и private – это ключевые слова С++, а classname – имя, которое программист дал своему классу. Назовем наш проектируемый класс IntArray: на первом этапе этот массив будет содержать только целые числа. Когда мы научим его обращаться с данными любого типа, можно будет переименовать его в Array.
Определяя класс, мы создаем новый тип данных. На имя класса можно ссылаться точно так же, как на любой встроенный описатель типа. Можно создавать объекты этого нового типа аналогично тому, как мы создаем объекты встроенных типов:
// статический объект типа IntArray |
// указатель на динамический объект типа IntArray
IntArray *pArray = new IntArray;
Определение класса состоит из двух частей: заголовка
(имя, предваренное ключевым словом class) и тела, заключенного в фигурные скобки. Заголовок без тела может служить объявлением класса.
// объявление класса IntArray |
class IntArray;
Тело класса состоит из определений членов и спецификаторов доступа – ключевых слов public, private и protected. (Пока мы ничего не будем говорить об уровне доступа protected.) Членами класса могут являться функции, которые определяют набор действий, выполняемых классом, и переменные, содержащие некие внутренние данные, необходимые для реализации класса. Функции, принадлежащие классу, называют функциями-членами или, по-другому, методами класса. Вот набор методов класса IntArray:
class IntArray { public: // операции сравнения: #2b bool operator== (const IntArray&) const; bool operator!= (const IntArray&) const; // операция присваивания: #2a IntArray& operator= (const IntArray&); int size() const; // #1 void sort(); // #4 int min() const; // #3a int max() const; // #3b // функция find возвращает индекс первого // найденного элемента массива // или -1, если элементов не найдено |
int find (int value) const; // #3c
private:
// дальше идут закрытые члены,
// обеспечивающие реализацию класса
...
}
Номера, указанные в комментариях при объявлениях методов, ссылаются на спецификацию класса, которую мы составили в начале данного раздела. Сейчас мы не будем объяснять смысл ключевого слова const, он не так уж важен для понимания того, что мы хотим продемонстрировать на данном примере. Будем считать, что это ключевое слово необходимо для правильной компиляции программы.
Именованная функция-член (например, min()) может быть вызвана с использованием одной из двух операций доступа к члену класса.
Первая операция доступа, обозначаемая точкой (.), применяется к объектам класса, вторая – стрелка (->) – к указателям на объекты. Так, чтобы найти минимальный элемент в объекте, имеющем тип IntArray, мы должны написать:
// инициализация переменной min_val |
int min_val = myArray.min(); |
int min_val = pArray->min();
(Да, мы еще ничего не сказали о том, как же проинициализировать наш объект – задать его размер и наполнить элементами. Для этого служит специальная функция-член, называемая конструктором. Мы поговорим об этом чуть ниже.)
Операции применяются к объектам класса точно так же, как и к встроенным типам данных. Пусть мы имеем два объекта типа IntArray:
IntArray myАrray0, myArray1;
Инструкции присваивания и сравнения с этими объектами выглядят совершенно обычным образом:
// инструкция присваивания - // вызывает функцию-член myArray0.operator=(myArray1) myArray0 = myArray1; // инструкция сравнения - // вызывает функцию-член myArray0.operator==(myArray1) if (myArray0 == myArray1) |
Спецификаторы доступа public и private
определяют уровень доступа к членам класса. К тем членам, которые перечислены после public, можно обращаться из любого места программы, а к тем, которые объявлены после private, могут обращаться только функции-члены данного класса. (Помимо функций-членов, существуют еще функции-друзья
класса, но мы не будем говорить о них вплоть до раздела 15.2.)
В общем случае открытые члены класса составляют его открытый интерфейс, то есть набор операций, которые определяют поведение класса. Закрытые члены класса обеспечивают его скрытую реализацию.
Такое деление на открытый интерфейс и скрытую реализацию называют сокрытием информации, или инкапсуляцией. Это очень важная концепция программирования, мы еще поговорим о ней в следующих главах. В двух словах, эта концепция помогает решить следующие проблемы:
· если мы меняем или расширяем реализацию класса, то изменения можно выполнить так, что большинство пользовательских программ, использующих наш класс, их “не заметят”: модификации коснутся лишь скрытых членов (мы поговорим об этом в разделе 6.18);
· если в реализации класса обнаруживается ошибка, то обычно для ее исправления достаточно проверить код, составляющий именно скрытую реализацию, а не весь код программы, где данный класс используется.
Какие же внутренние данные потребуются для реализации класса IntArray? Необходимо где-то сохранить размер массива и сами его элементы. Мы будем хранить их в массиве встроенного типа, память для которого выделяется динамически. Так что нам потребуется указатель на этот массив. Вот как будут выглядеть определения этих данных-членов:
class IntArray { public: // ... int size() const { return _size; } private: // внутренние данные-члены |
int *ia;
};
Поскольку мы поместили член _size в закрытую секцию, пользователь класса не имеет возможности обратиться к нему напрямую. Чтобы позволить внешней программе узнать размер массива, мы написали функцию-член size(), которая возвращает значение члена _size. Нам пришлось добавить символ подчеркивания к имени нашего скрытого члена _size, поскольку функция-член с именем size() уже определена. Члены класса – функции и данные – не могут иметь одинаковые имена.
Может показаться, что реализуя подобным образом доступ к скрытым данным класса, мы очень сильно проигрываем в эффективности. Сравним два выражения (предположим, что мы изменили спецификатор доступа члена _size на public):
IntArray array; |
array_size = array._size;
Действительно, вызов функции гораздо менее эффективен, чем прямой доступ к памяти, как во втором операторе. Так что же, принцип сокрытия информации заставляет нас жертвовать эффективностью?
На самом деле, нет. С++ имеет механизм встроенных
(inline) функций. Текст встроенной функции подставляется компилятором в то место, где записано обращение к ней. (Это напоминает механизм макросов, реализованный во многих языках, в том числе и в С++. Однако есть определенные отличия, о которых мы сейчас говорить не будем.) Вот пример. Если у нас есть следующий фрагмент кода:
for (int index=0; index |
то функция size() не будет вызываться _size раз во время исполнения. Вместо вызова компилятор подставит ее текст, и результат компиляции предыдущего кода будет в точности таким же, как если бы мы написали:
for (int index=0; index
// ...
Если функция определена внутри тела класса (как в нашем случае), она автоматически считается встроенной. Существует также ключевое слово inline, позволяющее объявить встроенной любую функцию[3].
Мы до сих пор ничего не сказали о том, как будем инициализировать наш массив.
Одна из самых распространенных ошибок при программировании (на любом языке) состоит в том, что объект используется без предварительной инициализации. Чтобы помочь избежать этой ошибки, С++ обеспечивает механизм автоматической инициализации для определяемых пользователем классов – конструктор
класса.
Конструктор – это специальная функция-член, которая вызывается автоматически при создании объекта типа класса. Конструктор пишется разработчиком класса, причем у одного класса может быть несколько конструкторов.
Функция- член класса, носящее то же имя, что и сам класс, считается конструктором. (Нет никаких специальных ключевых слов, позволяющих определить конструктор как-то по-другому.) Мы уже сказали, что конструкторов может быть несколько. Как же так: разные функции с одинаковыми именами?
В С++ это возможно. Разные функции могут иметь одно и то же имя, если у этих функций различны количество и/или типы параметров. Это называется перегрузкой функции. Обрабатывая вызов перегруженной функции, компилятор смотрит не только на ее имя, но и на список параметров. По количеству и типам передаваемых параметров компилятор может определить, какую же из одноименных функций нужно вызывать в данном случае. Рассмотрим пример. Мы можем определить следующий набор перегруженных функций min(). (Перегружаться могут как обычные функции, так и функции-члены.)
// список перегруженных функций min() // каждая функция отличается от других списком параметров #include int min (const int *pia,int size); int min (int, int); int min (const char *str); char min (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::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 { |
// конструкторы
explicit IntArray (int sz = DefaultArraySize);
IntArray (int *array, int array_size);
IntArray (const IntArray &rhs);
// деструктор
~IntArray() { delete[] ia; }
// ...
private:
// ...
};
Теперь нам нужно определить операции доступа к элементам массива IntArray. Мы хотим, чтобы обращение к элементам IntArray выглядело точно так же, как к элементам массива встроенного типа, с использованием оператора взятия индекса:
IntArray array; |
int temp = array[0];
array[0] = array[last_pos];
array[last_pos] = temp;
Для реализации доступа мы используем возможность перегрузки операций. Вот как выглядит функция, реализующая операцию взятия индекса:
#include int& IntArray::operator[] (int index) { assert (index >= 0 && index < _size); return ia[index]; |
Обычно для проектируемого класса перегружают операции присваивания, операцию сравнения на равенство, возможно, операции сравнения по величине и операции ввода/вывода. Как и перегруженных функций, перегруженных операторов, отличающихся типами операндов, может быть несколько. К примеру, можно создать несколько операций присваивания объекту значения другого объекта того же самого или иного типа. Конечно, эти объекты должны быть более или менее “похожи”. (Подробно о перегрузке операций мы расскажем в главе 15, а в разделе 3.15 приведем еще несколько примеров.)
Определения класса, различных относящихся к нему констант и, быть может, каких-то еще переменных и макросов по принятым соглашениям помещаются в заголовочный файл, имя которого совпадает с именем класса. Для класса IntArray мы должны создать заголовочный файл IntArray.h. Любая программа, в которой будет использоваться класс IntArray, должна включать этот заголовочный файл директивой препроцессора #include.
По тому же самому соглашению функции-члены класса, определенные вне его описания, помещаются в файл с именем класса и расширением, обозначающим исходный текст С++ программы. Мы будем использовать расширение .С
(напомним, что в разных системах вы можете встретиться с разными расширениями исходных текстов С++ программ) и назовем наш файл IntArray.C.
Упражнение 2.5
Ключевой особенностью класса С++ является разделение интерфейса и реализации. Интерфейс представляет собой набор операций (функций), выполняемых объектом; он определяет имя функции, возвращаемое значение и список параметров. Обычно пользователь не должен знать об объекте ничего, кроме его интерфейса. Реализация скрывает алгоритмы и данные, нужные объекту, и может меняться при развитии объекта, никак не затрагивая интерфейс. Попробуйте определить интерфейсы для одного из следующих классов (выберите любой):
(a) матрица
(b) булевское значение
(c) паспортные данные человека
(d) дата
(e) указатель
(f) точка
Упражнение 2.6
Попробуйте определить набор конструкторов, необходимых для класса, выбранного вами в предыдущем упражнении. Нужен ли деструктор для вашего класса? Помните, что на самом деле конструктор не создает объект: память под объект отводится до начала работы данной функции, и конструктор только производит определенные действия по инициализации объекта. Аналогично деструктор уничтожает не сам объект, а только те дополнительные ресурсы, которые могли быть выделены в результате работы конструктора или других функций-членов класса.
Упражнение 2.7
В предыдущих упражнениях вы практически полностью определили интерфейс выбранного вами класса. Попробуйте теперь написать программу, использующую ваш класс. Удобно ли пользоваться вашим интерфейсом? Не хочется ли Вам пересмотреть спецификацию? Сможете ли вы сделать это и одновременно сохранить совместимость со старой версией?
Объекты-функции
Наша функция min()дает хороший пример как возможностей, так и ограничений механизма шаблонов:
template 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&); |
У объекта-функции есть два преимущества по сравнению с указателем на функцию. Во-первых, если перегруженный оператор вызова – это встроенная функция, то компилятор может выполнить ее подстановку, обеспечивая значительный выигрыш в производительности. Во-вторых, объект-функция способен содержать произвольное количество дополнительных данных, например кэш или информацию, полезную для выполнения текущей операции.
Ниже приведена измененная реализация шаблона 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-обработчиком для базового, то этот обработчик не может использовать особенности производного класса. Например, к функции-члену 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 в полный стек
Таким образом, ссылка в объявлении исключения позволяет вызывать виртуальные функции, ассоциированные с классом объекта-исключения.
Объекты-исключения
Объявлением исключения в 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.
Объекты классов
Определение класса, например 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; |
Упражнение 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 #include 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 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 foobar { ... }; template void foo( QueueItem template class Queue { void bar(); // ... }; template class QueueItem { friend class foobar friend void foo friend void Queue // ...
| |
};
Прежде чем шаблон класса можно будет использовать в объявлениях друзей, он сам должен быть объявлен или определен. В нашем примере шаблоны классов foobar и Queue, а также шаблон функции foo()
следует объявить до того, как они объявлены друзьями в QueueItem.
Синтаксис, использованный для объявления foo() другом, может показаться странным:
friend void foo
За именем функции следует список явных аргументов шаблона: foo
friend void foo( QueueItem
то компилятор интерпретировал бы объявление как относящееся к обычной функции (а не к шаблону), у которой тип параметра – это экземпляр шаблона QueueItem. Как отмечалось в разделе 10.6, шаблон функции и одноименная обычная функция могут сосуществовать, и присутствие объявления такого шаблона перед определением класса QueueItem не вынуждает компилятор соотнести объявление друга именно с ним. Для того, чтобы соотнесение было верным, в конкретизированном шаблоне функции необходимо указать список явных аргументов;
· несвязанный
дружественный шаблон класса или функции. В следующем примере имеется отображение один-ко-многим между конкретизациями шаблона класса QueueItem и его друзьями. Для каждой конкретизации типа QueueItem все конкретизации foobar, foo() и Queue
являются друзьями:
template class QueueItem { template friend class foobar; template friend void foo( QueueItem template friend class Queue // ... |
Следует отметить, что этот вид объявлений друзей в шаблоне класса не поддерживается компиляторами, написанными до принятия стандарта C++.
Объявления друзей в шаблонах Queue и QueueItem
Поскольку QueueItem не предназначен для непосредственного использования в вызывающей программе, то объявление конструктора этого класса помещено в закрытую секцию шаблона. Теперь класс Queueнеобходимо объявить другом QueueItem, чтобы можно было создавать и манипулировать объектами последнего.
Существует два способа объявить шаблон класса другом. Первый заключается в том, чтобы объявить любой экземпляр Queue
другом любого экземпляра QueueItem:
template class QueueItem { // любой экземпляр Queue является другом // любого экземпляра QueueItem template
| |
};
Однако нет смысла объявлять, например, класс Queue, конкретизированный типом string, другом QueueItem, конкретизированного типом complex
должен быть другом только для класса QueueItem
template class QueueItem { // для любого экземпляра QueueItem другом является // только конкретизированный тем же типом экземпляр Queue friend class Queue // ...
| |
};
Данное объявление говорит о том, что для любой конкретизации QueueItem некоторым типом экземпляр Queue, конкретизированный тем же типом, является другом. Так, экземпляр Queue, конкретизированный типом int, будет другом экземпляра QueueItem, тоже конкретизированного типом int. Но для экземпляров QueueItem, конкретизированных типами complex
другом не будет.
В любой точке программы у пользователю может понадобиться распечатать содержимое объекта Queue. Такая возможность предоставляется с помощью перегруженного оператора вывода. Этот оператор должен быть объявлен другом шаблона Queue, так как ему необходим доступ к закрытым членам класса. Какой же будет его сигнатура?
// как задать аргумент типа Queue? |
Поскольку Queue – это шаблон класса, то в имени конкретизированного экземпляра должен быть задан полный список аргументов:
ostream& operator<<( ostream &, const Queue
Так мы определили оператор вывода для класса, конкретизированного из шаблона Queue
типом int. Но что, если Queue – это очередь элементов типа string?
ostream& operator<<( ostream &, const Queue
Вместо того чтобы явно определять нужный оператор вывода по мере необходимости, желательно сразу определить общий оператор, который будет работать для любой конкретизации Queue. Например:
ostream& operator<<( ostream &, const Queue
Однако из этого перегруженного оператора вывода придется сделать шаблон функции:
template |
Теперь всякий раз, когда оператору ostream
передается конкретизированный экземпляр Queue, конкретизируется и вызывается шаблон функции. Вот одна из возможных реализаций оператора вывода в виде такого шаблона:
template ostream& operator<<( ostream &os, const Queue { os << "< "; QueueItem 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 Queue { friend ostream& operator<<( ostream &, const Queue // ... |
Здесь, как и при объявлении друга в шаблоне класса Queue, создается взаимно однозначное соответствие между конкретизациями Queue и оператора operator<<().
Распечатка элементов Queue производится оператором вывода operator<<()
класса QueueItem:
os << *p;
Этот оператор также должен быть реализован в виде шаблона функции; тогда можно быть уверенным, что в нужный момент будет конкретизирован подходящий экземпляр:
template ostream& operator<<( ostream &os, const QueueItem { os << qi.item; return os; |
Поскольку здесь имеется обращение к закрытому члену item класса QueueItem, оператор следует объявить другом шаблона QueueItem. Это делается следующим образом:
template class QueueItem { friend class Queue friend ostream& operator<<( ostream &, const QueueItem // ... |
Оператор вывода класса QueueItem
полагается на то, что item
умеет распечатывать себя:
os << qi.item;
Это порождает тонкую зависимость типов при конкретизации Queue. Любой определенный пользователем и связанный с Queue
класс, содержимое которого нужно распечатывать, должен предоставлять оператор вывода. В языке нет механизма, с помощью которого можно было бы задать такую зависимость в определении самого шаблона Queue. Но если оператор вывода не определен для типа, с которым конкретизируется данный шаблон, и делается попытка вывести содержимое конкретизированного экземпляра, то в том месте, где используется отсутствующий оператор вывода, компилятор выдает сообщение об ошибке. Шаблон Queue
можно конкретизировать типом, не имеющим оператора вывода, – при условии, что не будет попытки распечатать содержимое очереди.
Следующая программа демонстрирует конкретизацию и использование функций-друзей шаблонов классов Queue и QueueItem:
#include #include "Queue.h" int main() { Queue // конкретизируются оба экземпляра // ostream& operator<<(ostream &os, const Queue // ostream& operator<<(ostream &os, const QueueItem 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 );
Объявления перегруженных функций-членов
Функции-члены класса можно перегружать: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 ) // ... |
};
Какая из функций-членов будет вызвана– статическая или нестатическая – зависит от результатов разрешения перегрузки. Процесс разрешения в ситуации, когда устояли как статические, так и нестатические члены, мы подробно рассмотрим в следующем разделе.
Объявления перегруженных функций
Теперь, научившись объявлять, определять и использовать функции в программах, познакомимся с перегрузкой – еще одним аспектом в C++. Перегрузка позволяет иметь несколько одноименных функций, выполняющих схожие операции над аргументами разных типов.Вы уже воспользовались предопределенной перегруженной функцией. Например, для вычисления выражения
1 + 3
вызывается операция целочисленного сложения, тогда как вычисление выражения
1.0 + 3.0
осуществляет сложение с плавающей точкой. Выбор той или иной операции производится незаметно для пользователя. Операция сложения перегружена, чтобы обеспечить работу с операндами разных типов. Ответственность за распознавание контекста и применение операции, соответствующей типам операндов, возлагается на компилятор, а не на программиста.
В этой главе мы покажем, как определять собственные перегруженные функции.
Область видимости и время жизни
В этой главе обсуждаются два важных вопроса, касающиеся объявлений в С++. Где употребляется объявленное имя? Когда можно безопасно использовать объект или вызывать функцию, т.е. каково время жизни сущности в программе? Для ответа на первый вопрос мы введем понятие областей видимости и покажем, как они ограничивают применение имен в исходном файле программы. Мы рассмотрим разные типы таких областей: глобальную и локальную, а также более сложное понятие областей видимости пространств имен, которое появится в конце главы. Отвечая на второй вопрос, мы опишем, как объявления вводят глобальные объекты и функции (сущности, “живущие” в течение всего времени работы программы), локальные (“живущие” на определенном отрезке выполнения) и динамически размещаемые объекты (временем жизни которых управляет программист). Мы также исследуем свойства времени выполнения, характерные для этих объектов и функций.Область видимости класса 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() |
Инициализатор _interestRate
вызывает статическую функцию-член Account::initInterest()
несмотря на то, что ее имя не квалифицировано именем класса.
Не только инициализатор, но и все, что следует за именем статического члена _interestRate до завершающей точки с запятой, находится в области видимости класса Account. Поэтому в определении статического члена name
может быть обращение к члену класса nameSize:
class Account: // ... private: static const int nameSize = 16; static const char name[nameSize]; // nameSize не квалифицировано именем класса Account |
Хотя член nameSize не квалифицирован именем класса Account, определение name не является ошибкой, так как оно находится в области видимости своего класса и может ссылаться на его члены после того, как компилятор прочитал Account::name.
В определении члена, которое появляется вне тела, часть программы перед определяемым именем не находится в области видимости класса. При обращении к члену в этой части следует пользоваться оператором разрешения области видимости. Например, если типом статического члена является typedef Money, определенный в классе Account, то имя Money
должно быть квалифицировано, когда статический член данных определяется вне тела класса:
class Account { typedef double Money; //... private: static Money _interestRate; static Money initInterest(); }; // Money должно быть квалифицировано именем класса Account:: |
С каждым классом ассоциируется отдельная область видимости, причем у разных классов эти области различны. К членам одного класса нельзя напрямую обращаться в определениях членов другого класса, если только один из них не является для второго базовым. (Наследование и базовые классы рассматриваются в главах 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() |
Если же объявление найдено в двух или более поддеревьях, то обращение считается неоднозначным и компилятор выдает сообщение об ошибке. Так будет при неквалифицированном обращении к 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 // ... |
(a) Почему приведенный фрагмент дает ошибку компиляции?
MI mi; string dancer( "Nejinsky" ); |
(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 // ... |
и структура функции-члена 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; |
Упражнение 18.11
Даны иерархия классов из упражнения 18.9 и скелет функции-члена MI::foobar():
int id; void MI:: foobar( float cval ) { int dval; // вопросы упражнения относятся к коду, начинающемуся с этого места ... |
(a) Присвойте локальной переменной dval
Область видимости
Каждое имя в С++ программе должно относиться к уникальной сущности (объекту, функции, типу или шаблону). Это не значит, что оно встречается только один раз во всей программе: его можно повторно использовать для обозначения другой сущности, если только есть некоторый контекст, помогающий различить разные значения одного и того же имени. Контекстом, служащим для такого различения, служит область видимости. В С++ поддерживается три их типа: локальная область видимости, область видимости пространства имени область видимости класса.
Локальная область– это часть исходного текста программы, содержащаяся в определении функции (или в блоке). Любая функция имеет собственную такую часть, и каждая составная инструкция (или блок) внутри функции также представляет собой отдельную локальную область.
Область видимости пространства имен – часть исходного текста программы, не содержащаяся внутри объявления или определения функции или определения класса. Самая внешняя часть называется глобальной областью видимости или глобальной областью видимости пространства имен.
Объекты, функции, типы и шаблоны могут быть определены в глобальной области видимости. Программисту разрешено задать пользовательские
пространства имен, заключенные внутри глобальной области с помощью определения пространства имен. Каждое такое пространство является отдельной областью видимости. Пользовательское пространство, как и глобальное, может содержать объявления и определения объектов, функций, типов и шаблонов, а также вложенные пользовательские пространства имен. (Они рассматриваются в разделах 8.5 и 8.6.)
Каждое определение класса представляет собой отдельную область видимости класса. (О таких областях мы расскажем в главе 13.)
Имя может обозначать различные сущности в зависимости от области видимости. В следующем фрагменте программы имя s1
относится к четырем разным сущностям:
#include #include // сравниваем 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 – в шаблонах классов.)
Области видимости и разрешение имен – понятия времени компиляции. Они применимы к отдельным частям текста программы. Компилятор интерпретирует текст программы согласно правилам областей видимости и правилам разрешения имен.
Обобщенные алгоритмы в алфавитном порядке
В этом приложении мы рассмотрим все алгоритмы. Мы решили расположить их в алфавитном порядке (за небольшими исключениями), чтобы проще было найти нужный. Каждый алгоритм представлен в следующем виде: сначала описывается прототип функции, затем сам алгоритм, причем особое внимание уделяется интуитивно неочевидным особенностям, и, наконец, приводится пример программы, показывающий, как можно данный алгоритм использовать.Первыми двумя аргументами всех обобщенных алгоритмов (естественно, не без исключений) является пара итераторов, обычно first и last, обозначающих диапазон элементов внутри контейнера или встроенного массива, над которым работает алгоритм. Этот диапазон (часто называемый интервалом с включенной левой границей), как правило, записывается в виде:
// следует читать: включая first и все последующие // элементы до last, но не включая сам last |
[ first, last )
Это означает, что диапазон начинается с first и заканчивается last, однако сам элемент last
не включается. Если
first == last
то говорят, что диапазон пуст.
К паре итераторов предъявляется такое требование: last должен быть достижим, если начать с first и последовательно применять оператор инкремента. Однако компилятор не может проверить выполнение данного ограничения. Если требование не будет выполнено, поведение программы не определено; обычно это заканчивается ее крахом и дампом памяти.
В объявлении каждого алгоритма подразумевается минимальная поддержка, которую должны обеспечить итераторы (краткое обсуждение пяти категорий итераторов см. в разделе 12.4). Например, алгоритм find(), реализующий однопроходный обход контейнера и выполняющий только чтение, требует итератора чтения InputIterator. Ему также можно передать одно- или двунаправленный итератор или итератор с произвольным доступом. Однако передача итератора записи приведет к ошибке. Не гарантируется, что подобные ошибки (при передаче итератора неподходящей категории) будут обнаружены компилятором, поскольку категории итераторов– это не сами типы, а лишь параметры, которыми конкретизируется шаблон функции.
Обобщенные алгоритмы
Операции, описанные в предыдущих разделах, составляют набор, поддерживаемый непосредственно контейнерами vector и deque. Согласитесь, что это весьма небогатый интерфейс и ему явно не хватает базовых операций find(), sort(), merge() и т.д. Планировалось вынести общие для всех контейнеров операции в набор обобщенных алгоритмов, которые могут применяться ко всем контейнерным типам, а также к массивам встроенных типов. (Обобщенные алгоритмы описываются в главе 12 и в Приложении.) Эти алгоритмы связываются с определенным типом контейнера с помощью передачи им в качестве параметров пары соответствующих итераторов. Вот как выглядят вызовы алгоритма find() для списка, вектора и массива разных типов:#include #include int ia[ 6 ] = { 0, 1, 2, 3, 4, 5 }; vector list // соответствующий заголовочный файл #include vector list #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
Используя различные операции вставки и подходящие значения 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
Используя функцию-член 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(), которому передается объект-предикат или указатель на функцию.
Обобщенный список
Наш класс ilistимеет серьезный недостаток: он может хранить элементы только целого типа. Если бы он мог содержать элементы любого типа– как встроенного, так и определенного пользователем, – то его область применения была бы гораздо шире. Модифицировать ilist для поддержки произвольных типов данных позволяет механизм шаблонов (см. главу 16).
При использовании шаблона вместо параметра подставляется реальный тип данных. Например:
list< string > slist;
создает экземпляр списка, способного содержать объекты типа string, а
list< int > ilist;
создает список, в точности повторяющий наш ilist. С помощью шаблона класса можно обеспечить поддержку произвольных типов данных одним экземпляром кода. Рассмотрим последовательность действий, уделив особое внимание классу list_item.
Определение шаблона класса начинается ключевым словом template, затем следует список параметров в угловых скобках. Параметр представляет собой идентификатор, перед которым стоит ключевое слово class или typename. Например:
template
| |
class list_item;
Эта инструкция объявляет list_item
шаблоном класса с единственным параметром-типом. Следующее объявление эквивалентно предыдущему:
template
| |
class list_item;
Ключевые слова class и typename
имеют одинаковое значение, можно использовать любое из них. Более удобное для запоминания typename появилось в стандарте С++ сравнительно недавно и поддерживается еще не всеми компиляторами. Поскольку наши тексты были написаны до появления этого ключевого слова, в них употребляется class. Шаблон класса list_item
выглядит так:
template 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
компилятор подставляет double
вместо elemType и создает экземпляр list_item, поддерживающий данный тип.
Аналогичным образом модифицируем класс ilist в шаблон класса list:
template 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 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 list_item list_item 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 1ist_item list_item int _size; |
Объекты шаблона класса list
используются точно так же, как и объекты класса ilist. Основное преимущество шаблона в том, что он обеспечивает поддержку произвольных типов данных с помощью единственного определения.
(Шаблоны являются важной составной частью концепции программирования на С++. В главе 6 мы рассмотрим набор классов контейнерных типов, предоставляемых стандартной библиотекой С++. Неудивительно, что она содержит шаблон класса, реализующего операции со списками, равно как и шаблон класса, поддерживающего векторы; мы рассматривали их в главах 2 и 3.)
Наличие класса списка в стандартной библиотеке представляет некоторую проблему. Мы выбрали для нашей реализации название list, но, к сожалению, стандартный класс также носит это название. Теперь мы не можем использовать в программе одновременно оба класса. Конечно, проблему решит переименование нашего шаблона, однако во многих случаях эта возможность отсутствует.
Более общее решение состоит в использовании механизма пространства имен, который позволяет разработчику библиотеки заключить все свои имена в некоторое поименованное пространство и таким образом избежать конфликта с именами из глобального пространства. Применяя нотацию квалифицированного доступа, мы можем употреблять эти имена в программах. Стандартная библиотека С++ помещает свои имена в пространство std. Мы тоже поместим наш код в собственное пространство:
namespace Primer_Third_Edition { template class list_item{ ... }; template 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(); |
Как вы думаете, почему мы их не включили? Реализуйте их.
Упражнение 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, |
где 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 ); |
изменит список таким образом:
(9) ( 0 1 1 2 3 5 8 13 21 )
Упражнение 5.22
Функции concat() и reverse()
модифицируют оригинальный список. Это не всегда желательно. Напишите аналогичную пару функций, которые создают новый объект ilist:
ilist ilist::reverse_copy(); |
Обрабатываем знаки препинания
После того как мы разбили каждую строку на слова, необходимо избавиться от знаков препинания. Пока из строки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 { vector vector // Если 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():
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" ) |
составит предложение
"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 и 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 #include #include #include #include |
}
Упражнение 6.25
Объясните, почему нам потребовался специальный класс inserter для заполнения набора стоп-слов (это упоминается в разделе 6.13.1, а детально рассматривается в 12.4.1).
set ifstream infile( "exclusion_set" ); copy( default_excluded_words, default_excluded_words+25, |
Упражнение 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 #include int main() { int ia[10] = {0,1,2,3,4,5,6,7,8,9}; vector int ix_vec = 0, ix_ia = 9; while ( ix_vec < 10 ) ivec[ ix_vec++ ] = ia[ ix_ia-- ]; int *pia = &ia[9]; vector 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 ) |
Операции с комплексными числами
Класс комплексных чисел стандартной библиотеки С++ представляет собой хороший пример использования объектной модели. Благодаря перегруженным арифметическим операциям объекты этого класса используются так, как будто они принадлежат одному из встроенных типов данных. Более того, в подобных операциях могут одновременно принимать участие и переменные встроенного арифметического типа, и комплексные числа. (Отметим, что здесь мы не рассматриваем общие вопросы математики комплексных чисел. См. [PERSON68] или любую книгу по математике.) Например, можно написать:#inc1ude 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() ); |
выглядит так:
( 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, с - комплексные числа |
Кроме этих операций, класс комплексных чисел имеет следующие функции-члены: sqrt(), abs(), polar(), sin(), cos(), tan(), exp(), log(), log10() и pow().
Упражнение 4.9
Реализация стандартной библиотеки С++, доступная нам в момент написания книги, не поддерживает составных операций присваивания, если правый операнд не является комплексным числом. Например, подобная запись недопустима:
complex_obj += 1;
(Хотя согласно стандарту С++ такое выражение должно быть корректно, производители часто не успевают за стандартом.) Мы можем определить свой собственный оператор для реализации такой операции. Вот вариант функции, реализующий оператор сложения для complex
#include inline complex operator+=( complex { return cval += complex |
(Это пример перегрузки оператора для определенного типа данных, детально рассмотренной в главе 15.)
Используя этот пример, реализуйте три других составных оператора присваивания для типа complex
#include #include // определения операций... 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
#include #include // определения операций... 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 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 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, |
или любого контейнера, содержащего строки:[14]
list< string > slist; // ... // вставляем элементы svec // перед элементом, содержащим stringVal list< string >::iterator iter = find( slist.begin(), slist.end(), stringVal ); |
Операции сравнения и логические операции
Таблица 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 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 ] ) // поменять местами элементы |
Еще один пример возможной ошибки. Мы хотели убедиться, что все три величины ival, jval и kval различаются. Где мы промахнулись?
// Внимание! это не сравнение 3 переменных друг с другом if ( ival != jva1 != kva1 ) |
Значения 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 ]; |
Упражнение 4.5
Язык С++ не диктует порядок вычисления операций сравнения для того, чтобы позволить компилятору делать это оптимальным образом. Как вы думаете, стоило бы в данном случае пожертвовать эффективностью, чтобы избежать ошибок, связанных с предположением о вычислении выражения слева направо?
Операция list_merge()
void list::merge( list rhs ); template
| |
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_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::remove()
void list::remove( const elemType &value );Операция remove()
удаляет все элементы с заданным значением:
ilist1.remove( 1 );
Операция list::reverse()
void list::reverse();Операция reverse()
изменяет порядок следования элементов списка на противоположный:
ilist1.reverse();
Операция list::sort()
void list::sort(); template
| |
void list::sort( Compare comp );
По умолчанию sort()
упорядочивает элементы списка по возрастанию с помощью оператора “меньше”, определенного в классе элементов контейнера. Вместо этого можно явно передать в качестве аргумента оператор сравнения. Так,
list1.sort();
упорядочивает list1 по возрастанию, а
list1.sort( greater
упорядочивает 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.
Операция list::unique()
void list::unique(); template
| |
void list::unique( BinaryPredicate pred );
Операция unique()
удаляет соседние дубликаты. По умолчанию при сравнении используется оператор равенства, определенный для типа элементов контейнера. Например, если даны значения {0,2,4,6,4,2,0}, то после применения unique()
список останется таким же, поскольку в соседних позициях дубликатов нет. Но если мы сначала отсортируем список, что даст {0,0,2,2,4,4,6}, а потом применим unique(), то получим четыре различных значения {0,2,4,6}.
ilist.unique();
Вторая форма unique()
принимает альтернативный оператор сравнения. Например,
class EvenPair { public: bool operator()( int val1, val2 ) { return ! (val2 % val1 ); } }; |
ilist.unique( EvenPair() );
удаляет соседние элементы, если второй элемент без остатка делится на первый.
Эти операции, являющиеся членами класса, следует предпочесть соответствующим обобщенным алгоритмам при работе со списками. Остальные обобщенные алгоритмы, такие, как find(), transform(), for_each() и т.д., работают со списками так же эффективно, как и с другими контейнерами (еще раз напомним, что подробно все алгоритмы рассматриваются в Приложении).
Упражнение 12.8
Измените программу из раздела 12.2, используя список вместо вектора.
Часть IV
Объектное программирование
В части 4 мы сосредоточимся на объектном программировании, т.е. на применении классов C++ для определения новых типов, манипулировать которыми так же просто, как и встроенными. Создавая новые типы для описания предметной области, C++ помогает программисту писать более легкие для понимания приложения. Классы позволяют отделить детали, касающиеся реализации нового типа, от определения интерфейса и операций, предоставляемых пользователю. При этом уделяется меньше внимания мелочам, из-за чего программирование становится таким утомительным занятием. Значимые для приложения типы можно реализовать всего один раз, после чего использовать повторно. Средства, обеспечивающие инкапсуляцию данных и функций, необходимых для реализации типа, помогают значительно упростить последующее сопровождение и развитие приложения.
В главе 13 мы рассмотрим общий механизм классов: порядок их определения, концепцию сокрытия информации (т.е. отделение открытого интерфейса от закрытой реализации), способы определения и манипулирования объектами класса, область видимости, вложенные классы и классы как члены пространства имен.
В главе 14 изучаются предоставляемые C++ средства инициализации и уничтожения объектов класса, а также присваивания им значений путем применения таких специальных функций-членов класса, как конструкторы, деструкторы и копирующие конструкторы. Мы рассмотрим вопрос о почленной инициализации и копировании, когда объект класса инициализируется или ему присваивается значение другого объекта того же класса.
В главе 15 мы расскажем о перегрузке операторов, которая позволяет использовать операнды типа класса со встроенными операторами, описанными в главе 4. Таким образом, работа с объектами типа класса может быть сделана столь же понятной, как и работа со встроенными типами. В начале главы 15 представлены общие концепции и соображения, касающиеся проектирования перегрузки операторов, а затем рассмотрены конкретные операторы, такие, как присваивание, взятие индекса, вызов, а также специфичные для классов операторы new и delete. Иногда необходимо объявить перегруженный оператор, как друга класса, наделив его специальными правами доступа, в данной главе объясняется, зачем это нужно. Здесь же представлен еще один специальный вид функций-членов – конвертеры, которые позволяют программисту определить стандартные преобразования. Конвертеры неявно применяются компилятором, когда объекты класса используются в качестве фактических аргументов функции или операндов встроенного либо перегруженного оператора. Завершается глава изложением правил разрешения перегрузки функций с учетом аргументов типа класса, функций-членов и перегруженных операторов.
Тема главы 16 – шаблоны классов. Шаблон – это предписание для создания класса, в котором один или несколько типов параметризованы. Например, vector
может быть параметризован типом элементов, хранящихся в нем, а buffer – типом элементов в буфере или его размером. В этой главе объясняется, как определить и конкретизировать шаблон. Поддержка классов в C++ теперь рассматривается иначе – в свете наличия шаблонов, и снова обсуждаются функции-члены, объявления друзей и вложенные типы. Здесь мы еще раз вернемся к модели компиляции шаблонов, описанной в главе 10, чтобы показать, какое влияние оказывают на нее шаблоны классов.
13
Оператор dynamic_cast
Оператор dynamic_castможно применять для преобразования указателя, ссылающегося на объект типа класса в указатель на тип класса из той же иерархии. Его также используют для трансформации l-значения объекта типа класса в ссылку на тип класса из той же иерархии. Приведение типов с помощью оператора dynamic_cast, в отличие от других имеющихся в C++ способов, осуществляется во время выполнения программы. Если указатель или l-значение не могут быть преобразованы в целевой тип, то dynamic_cast
завершается неудачно. В случае приведения типа указателя признаком неудачи служит возврат нулевого значения. Если же l-значение нельзя трансформировать в ссылочный тип, возбуждается исключение. Ниже мы приведем примеры неудачного выполнения
этого оператора.
Прежде чем перейти к более детальному рассмотрению dynamic_cast, посмотрим, зачем его нужно применять. Предположим, что в программе используется библиотека классов для представления различных категорий служащих компании. Входящие в иерархию классы поддерживают функции-члены для вычисления зарплаты:
class employee { public: virtual int salary(); }; class manager : public employee { public: int salary(); }; class programmer : public employee { public: int salary(); }; void company::payroll( employee *pe ) { // используется pe->salary() |
}
В компании есть разные категории служащих. Параметром функции-члена payroll()
класса company
является указатель на объект employee, который может адресовать один из типов manager или programmer. Поскольку payroll()
обращается к виртуальной функции-члену salary(), то вызывается подходящая замещающая функция, определенная в классе manager или programmer, в зависимости от того, какой объект адресован указателем.
Допустим, класс employee
перестал удовлетворять нашим потребностям, и мы хотим его модифицировать, добавив еще одну функцию-член bonus(), используемую совместно с salary() при расчете платежной ведомости. Для этого нужно включить новую функцию-член в классы, составляющие иерархию employee:
class employee { public: virtual int salary(); // çàðïëàòà virtual int bonus(); // ïðåìèÿ }; class manager : public employee { public: int salary(); }; class programmer : public employee { public: int salary(); int bonus(); }; void company::payroll( employee *pe ) { // èñïîëüçóåòñÿ pe->salary() è pe->bonus() |
Если параметр pe
функции payroll()
указывает на объект типа manager, то вызывается виртуальная функция-член bonus() из базового класса employee, поскольку в классе manager она не замещена. Если же pe
указывает на объект типа programmer, то вызывается виртуальная функция-член bonus() из класса programmer.
После добавления новых виртуальных функций в иерархию классов придется перекомпилировать все функции-члены. Добавить bonus() можно, если у нас есть доступ к исходным текстам функций-членов в классах employee, manager и programmer. Однако если иерархия была получена от независимого поставщика, то не исключено, что в нашем распоряжении имеются только заголовочные файлы, описывающие интерфейс библиотечных классов и объектные файлы с их реализацией, а исходные тексты функций-членов недоступны. В таком случае перекомпиляция всей иерархии невозможна.
Если мы хотим расширить функциональность библиотеки классов, не добавляя новые виртуальные функции-члены, можно воспользоваться оператором dynamic_cast.
Этот оператор применяется для получения указателя на производный класс, чтобы иметь возможность работать с теми его элементами, которые по-другому не доступны. Предположим, что мы расширяем библиотеку за счет добавления новой функции-члена bonus() в класс programmer. Ее объявление можно включить в определение programmer, находящееся в заголовочном файле, а саму функцию определить в одном из своих исходных файлов:
class employee { public: virtual int salary(); }; class manager : public employee { public: int salary(); }; class programmer : public employee { public: int salary(); int bonus(); |
};
Напомним, что payroll()
принимает в качестве параметра указатель на базовый класс employee. Мы можем применить оператор dynamic_cast для получения указателя на производный programmer и воспользоваться им для вызова функции-члена bonus():
void company::payroll( employee *pe ) { programmer *pm = dynamic_cast< programmer* >( pe ); // åñëè pe óêàçûâàåò íà îáúåêò òèïà programmer, // òî dynamic_cast âûïîëíèòñÿ óñïåøíî è pm áóäåò // óêàçûâàòü íà íà÷àëî îáúåêòà programmer if ( pm ) { // èñïîëüçîâàòü pm äëÿ âûçîâà programmer::bonus() } // åñëè pe íå óêàçûâàåò íà îáúåêò òèïà programmer, // òî dynamic_cast âûïîëíèòñÿ íåóäà÷íî // è pm áóäåò ñîäåðæàòü 0 else { // èñïîëüçîâàòü ôóíêöèè-÷ëåíû êëàññà employee } |
Оператор
dynamic_cast< programmer* >( pe )
приводит свой операнд pe к типу programmer*. Преобразование будет успешным, если pe ссылается на объект типа programmer, и неудачным в противном случае: тогда результатом dynamic_cast
будет 0.
Таким образом, оператор dynamic_cast
осуществляет сразу две операции. Он проверяет, выполнимо ли запрошенное приведение, и если это так, выполняет его. Проверка производится во время работы программы. dynamic_cast
безопаснее, чем другие операции приведения типов в C++, поскольку проверяет возможность корректного преобразования.
Если в предыдущем примере pe
действительно указывает на объект типа programmer, то операция dynamic_cast
завершится успешно и pm
будет инициализирован указателем на объект типа programmer. В противном случае pm
получит значение 0. Проверив значение pm, функция company::payroll()
может узнать, указывает ли pm на объект programmer. Если это так, то она вызывает функцию-член programmer::bonus() для вычисления премии программисту. Если же dynamic_cast завершается неудачно, то pe
указывает на объект типа manager, а значит, необходимо применить более общий алгоритм расчета, не использующий новую функцию-член programmer::bonus().
Оператор dynamic_cast
употребляется для безопасного приведения указателя на базовый класс к указателю на производный. Такую операцию часто называют понижающим приведением (downcasting). Она применяется, когда необходимо воспользоваться особенностями производного класса, отсутствующими в базовом. Манипулирование объектами производного класса с помощью указателей на базовый обычно происходит автоматически, с помощью виртуальных функций. Однако иногда использовать виртуальные функции невозможно. В таких ситуациях dynamic_cast
предлагает альтернативное решение, хотя этот механизм в большей степени подвержен ошибкам, чем виртуализация, и должен применяться с осторожностью.
Одна из возможных ошибок – это работа с результатом dynamic_cast без предварительной проверки на 0: нулевой указатель нельзя использовать для адресации объекта класса. Например:
void company::payroll( employee *pe ) { programmer *pm = dynamic_cast< programmer* >( pe ); // ïîòåíöèàëüíàÿ îøèáêà: pm èñïîëüçóåòñÿ áåç ïðîâåðêè çíà÷åíèÿ static int variablePay = 0; variablePay += pm->bonus(); // ... |
Результат, возвращенный dynamic_cast, всегда следует проверять, прежде чем использовать в качестве указателя. Более правильное определение функции company::payroll()
могло бы выглядеть так:
void company::payroll( employee *pe ) { // âûïîëíèòü dynamic_cast è ïðîâåðèòü ðåçóëüòàò if ( programmer *pm = dynamic_cast< programmer* >( pe ) ) { // èñïîëüçîâàòü pm äëÿ âûçîâà programmer::bonus() } else { // èñïîëüçîâàòü ôóíêöèè-÷ëåíû êëàññà employee } |
Результат операции dynamic_cast
используется для инициализации переменной pm внутри условного выражения в инструкции if. Это возможно, так как объявления в условиях возвращают значения. Ветвь, соответствующая истинности условия, выполняется, если pm не равно нулю: мы знаем, что операция dynamic_cast завершилась успешно и pe
указывает на объект programmer. В противном случае результатом объявления будет 0 и выполняется ветвь else. Поскольку теперь оператор и проверка его результата находятся в одной инструкции программы, то невозможно случайно вставить какой-либо код между выполнением dynamic_cast и проверкой, так что pm
будет использоваться только тогда, когда содержит правильный указатель.
В предыдущем примере операция dynamic_cast
преобразует указатель на базовый класс в указатель на производный. Ее также можно применять для трансформации l-значения типа базового класса в ссылку на тип производного. Синтаксис такого использования dynamic_cast
следующий:
dynamic_cast< Type & >( lval )
где Type& – это целевой тип преобразования, а lval – l-значение типа базового класса. Операнд lval
успешно приводится к типу Type& только в том случае, когда lval
действительно относится к объекту класса, для которого один из производных имеет тип Type.
Поскольку нулевых ссылок не бывает (см. раздел 3.6), то проверить успешность выполнения операции путем сравнения результата (т.е. возвращенной оператором dynamic_cast
ссылки) с нулем невозможно. Если вместо указателей используются ссылки, условие
if ( programmer *pm = dynamic_cast< programmer* >( pe ) )
нельзя переписать в виде
if ( programmer &pm = dynamic_cast< programmer& >( pe ) )
Для извещения об ошибке в случае приведения к ссылочному типу оператор dynamic_cast
возбуждает исключение. Следовательно, предыдущий пример можно записать так:
#include void company::payroll( employee &re ) { try { programmer &rm = dynamic_cast< programmer & >( re ); // èñïîëüçîâàòü rm äëÿ âûçîâà programmer::bonus() } catch ( std::bad_cast ) { // èñïîëüçîâàòü ôóíêöèè-÷ëåíû êëàññà employee } |
В случае неудачного завершения ссылочного варианта dynamic_cast возбуждается исключение типа bad_cast. Класс bad_cast
определен в стандартной библиотеке; для ссылки на него необходимо включить в программу заголовочный файл
Когда следует употреблять ссылочный вариант dynamic_cast вместо указательного? Это зависит только от желания программиста. При его использовании игнорировать ошибку приведения типа и работать с результатом без проверки (как в указательном варианте) невозможно; с другой стороны, применение исключений увеличивает накладные расходы во время выполнения программы (см. главу 11).
Оператор размещения new А
Существует третья форма оператора new, которая создает объект без отведения для него памяти, то есть в памяти, которая уже была выделена. Эту форму называют оператором размещения new. Программист указывает адрес области памяти, в которой размещается объект:new (place_address) type-specifier
place_address
должен быть указателем. Такая форма (она включается заголовочным файлом
#include #include const int chunk = 16; class Foo { public: int val() { return _val; } FooQ(){ _val = 0; } private: int _val; }; // выделяем память, но не создаем объектов Foo char *buf = new char[ sizeof(Foo) * chunk ]; int main() { // создаем объект Foo в buf Foo *pb = new (buf) Foo; // проверим, что объект помещен в buf if ( pb.val() == 0 ) cout << "Оператор new сработал!" << endl; // здесь нельзя использовать pb delete[] buf; return 0;
| |
}
Результат работы программы:
Оператор new сработал!
Для оператора размещения new нет парного оператора delete: он не нужен, поскольку эта форма не выделяет память. В предыдущем примере необходимо освободить память, адресуемую указателем buf, а не pb. Это происходит в конце программы, когда буфер больше не нужен. Поскольку buf
ссылается на символьный массив, оператор delete имеет форму
delete[] buf;
При уничтожении buf
прекращают существование все объекты, созданные в нем. В нашем примере pb
больше не ссылается на существующий объект класса Foo.
Упражнение 8.5
Объясните, почему приведенные операторы new
ошибочны:
(a) const float *pf = new const float[100]; (b) double *pd = new doub1e[10] [getDim()]; (c) int (*pia2)[ 1024 ] = new int[ ][ 1024 ]; |
(d) const int *pci = new const int;
Упражнение 8.6
Как бы вы уничтожили pa?
typedef int arr[10]; |
int *pa = new arr;
Упражнение 8.7
Какие из следующих операторов delete
содержат потенциальные ошибки времени выполнения и почему:
int globalObj; char buf[1000]; void f() { int *pi = &global0bj; double *pd = 0; float *pf = new float(O); int *pa = new(buf)int[20]; delete pi; // (a) delete pd; // (b) delete pf; // (c) de1ete[] pa; // (d) |
Упражнение 8.8
Какие из данных объявлений auto_ptr
неверны или грозят ошибками времени выполнения? Объясните каждый случай.
int ix = 1024; int *pi = & ix; int *pi2 = new int ( 2048 ); (a) auto_ptr (b) auto_ptr (c) auto_ptr (d) auto_ptr (e) auto_ptr (f) auto_ptr (9) auto_ptr |
Упражнение 8.9
Объясните разницу между следующими инструкциями:
int *pi0 = p2.get(); |
Для каких случаев более приемлем тот или иной вызов?
Упражнение 8.10
Пусть мы имеем:
auto_ptr< string > ps( new string( "Daniel" ) );
В чем разница между этими двумя вызовами assign()?Какой их них предпочтительнее и почему?
ps.get()->assign( "Danny" ); |
Оператор размещения new() и оператор delete()
Оператор-член new()может быть перегружен при условии, что все объявления имеют разные списки параметров. Первый параметр должен иметь тип size_t:
class Screen { public: void *operator new( size_t ); void *operator new( size_t, Screen * ); // ... |
};
Остальные параметры инициализируются аргументами размещения, заданными при вызове new:
void func( Screen *start ) { Screen *ps = new (start) Screen; // ... |
}
Та часть выражения, которая находится после ключевого слова new и заключена в круглые скобки, представляет аргументы размещения. В примере выше вызывается оператор new(), принимающий два параметра. Первый автоматически инициализируется значением, равным размеру класса Screen в байтах, а второй– значением аргумента размещения start.
Можно также перегружать и оператор-член delete(). Однако такой оператор никогда не вызывается из выражения delete. Перегруженный delete()
неявно вызывается компилятором, если конструктор, вызванный при выполнении оператора new
(это не опечатка, мы действительно имеем в виду new), возбуждает исключение. Рассмотрим использование delete()
более внимательно.
Последовательность действий при вычислении выражения
Screen *ps = new ( start ) Screen;
такова:
1. Вызывается определенный в классе оператор new(size_t, Screen*).
2. Вызывается конструктор по умолчанию класса Screen для инициализации созданного объекта.
Переменная ps
инициализируется адресом нового объекта Screen.
Предположим, что оператор класса new(size_t, Screen*) выделяет память с помощью глобального new(). Как разработчик может гарантировать, что память будет освобождена, если вызванный на шаге 2 конструктор возбуждает исключение? Чтобы защитить пользовательский код от утечки памяти, следует предоставить перегруженный оператор delete(), который вызывается только в подобной ситуации.
Если в классе имеется перегруженный оператор с параметрами, типы которых соответствуют типам параметров new(), то компилятор автоматически вызывает его для освобождения памяти. Предположим, есть следующее выражение с оператором размещения new:
Оператор разрешения области видимости
Имя члена пользовательского пространства дополняется поставленным спереди именем этого пространства и оператором разрешения области видимости (::). Использование неквалифицированного члена, например matrix, является ошибкой. Компилятор не знает, к какому объявлению относится это имя:// определение интерфейса библиотеки #include "primer.h" // ошибка: нет объявления для matrix |
void func( matrix &m );
Объявление члена пространства имен скрыто в своем пространстве. Если мы не укажем компилятору, где именно искать объявление, он произведет поиск только в текущей области видимости и в областях, включающих текущую. Допустим, если переписать предыдущую программу так:
// определение интерфейса библиотеки #include "primer.h" class matrix { /* пользовательское определение */ }; // правильно: глобальный тип matrix найден |
void func( matrix &m );
то определение класса matrix
компилятор находит в глобальной области видимости и программа компилируется без ошибок. Поскольку объявление matrix как члена пространства имен cplusplus_primer
скрыто в этом пространстве, оно не конфликтует с классом, объявленным в глобальной области видимости.
Именно поэтому мы говорим, что пространства имен решают проблему засорения глобального пространства: имена их членов невидимы, если имя пространства не указано явно, с помощью оператора разрешения области видимости. Существуют и другие механизмы, позволяющие сделать объявление члена пространства имен видимым вне его. Это using-объявления и using-директивы. Мы рассмотрим их в следующем разделе.
Отметим, что оператор области видимости может быть использован и для того, чтобы сослаться на элемент глобального пространства имен. Поскольку это пространство не имеет имени, запись
::member_name
относится к его элементу. Такой способ полезен для указания членов глобального пространства, если их имена оказываются скрыты именами, объявленными во вложенных локальных областях видимости.
Следующий пример демонстрирует использование оператора области видимости для обращения к скрытому члену глобального пространства имен. Функция вычисляет последовательность чисел Фибоначчи. В программе два определения переменной max. Глобальная переменная указывает максимальное значение элемента последовательности, при превышении которого вычисление прекращается, а локальная – желаемую длину последовательности при данном вызове функции. (Напоминаем, что параметры функции относятся к ее локальной области видимости.) Внутри функции должны быть доступны обе переменных. Однако неквалифицированное имя max
ссылается на локальное объявление этой переменной. Чтобы получить глобальную переменную, нужно использовать оператор разрешения области видимости ::max. Вот текст программы:
#include const int max = 65000; const int lineLength = 12; void fibonacci( int max ) { if ( max < 2 ) return; cout << "0 1 "; int v1 = 0, v2 = 1, cur; for ( int ix = 3; ix <= max; ++ix ) { cur = v1 + v2; if ( cur > ::max ) break; cout << cur << " "; vl = v2; v2 = cur; if (ix % "lineLength == 0) cout << end"!; } |
Так выглядит функция main(), вызывающая fibonacci():
#include void fibonacci( int ); int main() { cout << "Числа Фибоначчи: 16\n"; fibonacci( 16 ); return 0; |
Результат работы программы:
Числа Фибоначчи: 16
0 1 1 2 3 5 8 13 21 34 55 89
144 233 377 610
Оператор sizeof
Оператор sizeofвозвращает размер в байтах объекта или типа данных. Синтаксис его таков:
sizeof ( type name ); sizeof ( object ); |
sizeof object;
Результат имеет специальный тип size_t, который определен как typedef в заголовочном файле cstddef. Вот пример использования обеих форм оператора sizeof:
#include int ia[] = { 0, 1, 2 }; // sizeof возвращает размер всего массива size_t array_size = sizeof ia; // sizeof возвращает размер типа int
| |
size_t element_size = array_size / sizeof( int );
Применение sizeof к массиву дает количество байтов, занимаемых массивом, а не количество его элементов и не размер в байтах каждого из них. Так, например, в системах, где int
хранится в 4 байтах, значением array_size будет 12. Применение sizeof к указателю дает размер самого указателя, а не объекта, на который он указывает:
int *pi = new int[ 3 ]; |
size_t pointer_size = sizeof ( pi );
Здесь значением pointer_size
будет память под указатель в байтах (4 в 32-битных системах), а не массива ia.
Вот пример программы, использующей оператор sizeof:
#include #include #include int main() { size_t ia; ia = sizeof( ia ); // правильно ia = sizeof ia; // правильно // ia = sizeof int; // ошибка ia = sizeof( int ); // правильно int *pi = new int[ 12 ]; cout << "pi: " << sizeof( pi ) << " *pi: " << sizeof( pi ) << endl; // sizeof строки не зависит от // ее реальной длины string stl( "foobar" ); string st2( "a mighty oak" ); string *ps = &stl; cout << " st1: " << sizeof( st1 ) << " st2: " << sizeof( st2 ) << " ps: sizeof( ps ) << " *ps: " << sizeof( *ps ) << endl; cout << "short :\t" << sizeof(short) << endl; cout << "shorf" :\t" << sizeof(short*) << endl; cout << "short& :\t" << sizeof(short&) << endl; cout << "short[3] :\t" << sizeof(short[3]) << endl;
| |
}
Результатом работы программы будет:
pi: 4 *pi: 4
st1: 12 st2: 12 ps: 4 *ps:12
short : 2
short* : 4
short& : 2
short[3] : 6
Из данного примера видно, что применение sizeof к указателю позволяет узнать размер памяти, необходимой для хранения адреса. Если же аргументом sizeof
является ссылка, мы получим размер связанного с ней объекта.
Гарантируется, что в любой реализации С++ размер типа char равен 1.
// char_size == 1 |
Значение оператора sizeof
вычисляется во время компиляции и считается константой. Оно может быть использовано везде, где требуется константное значение, в том числе в качестве размера встроенного массива. Например:
// правильно: константное выражение |
Оператор “стрелка”
Оператор “стрелка”, разрешающий доступ к членам, может перегружаться для объектов класса. Он должен быть определен как функция-член и обеспечивать семантику указателя. Чаще всего этот оператор используется в классах, которые предоставляют “интеллектуальный указатель” (smart pointer), ведущий себя аналогично встроенным, но поддерживают и некоторую дополнительную функциональность.Допустим, мы хотим определить тип класса для представления указателя на объект Screen (см. главу 13):
class ScreenPtr { // ... private: Screen *ptr; |
};
Определение ScreenPtr
должно быть таким, чтобы объект этого класса гарантировано указывал на объект Screen: в отличие от встроенного указателя, он не может быть нулевым. Тогда приложение сможет пользоваться объектами типа ScreenPtr, не проверяя, указывают ли они на какой-нибудь объект Screen. Для этого нужно определить класс ScreenPtr с конструктором, но без конструктора по умолчанию (детально конструкторы рассматривались в разделе 14.2):
class ScreenPtr { public: ScreenPtr( const Screen &s ) : ptr( &s ) { } // ... |
};
В любом определении объекта класса ScreenPtr
должен присутствовать инициализатор– объект класса Screen, на который будет ссылаться объект ScreenPtr:
ScreenPtr p1; // ошибка: у класса ScreenPtr нет конструктора по умолчанию Screen myScreen( 4, 4 ); |
ScreenPtr ps( myScreen ); // правильно
Чтобы класс ScreenPtr вел себя как встроенный указатель, необходимо определить некоторые перегруженные операторы – разыменования (*) и “стрелку” для доступа к членам:
// перегруженные операторы для поддержки поведения указателя class ScreenPtr { public: Screen& operator*() { return *ptr; } Screen* operator->() { return ptr; } // ... |
};
Оператор доступа к членам унарный, поэтому параметры ему не передаются. При использовании в составе выражения его результат зависит только от типа левого операнда. Например, в инструкции
point->action();
исследуется тип point. Если это указатель на некоторый тип класса, то применяется семантика встроенного оператора доступа к члену. Если же это объект или ссылка на объект, то проверяется, есть ли в этом классе перегруженный оператор доступа. Когда перегруженный оператор “стрелка” определен, он вызывается для объекта point, иначе инструкция неверна, поскольку для обращения к членам самого объекта (в том числе по ссылке) следует использовать оператор “точка”.
Перегруженный оператор “стрелка” должен возвращать либо указатель на тип класса, либо объект класса, в котором он определен. Если возвращается указатель, то к нему применяется семантика встроенного оператора “стрелка”. В противном случае процесс продолжается рекурсивно, пока не будет получен указатель или определена ошибка. Например, так можно воспользоваться объектом ps
класса ScreenPtr для доступа к членам Screen:
ps->move( 2, 3 );
Поскольку слева от оператора “стрелка” находится объект типа ScreenPtr, то употребляется перегруженный оператор этого класса, который возвращает указатель на объект Screen. Затем к полученному значению применяется встроенный оператор “стрелка” для вызова функции-члена move().
Ниже приводится небольшая программа для тестирования класса ScreenPtr. Объект типа ScreenPtr
используется точно так же, как любой объект типа Screen*:
#include #include #include "Screen.h" void printScreen( const ScreenPtr &ps ) { cout << "Screen Object ( " << ps->height() << ", " << ps->width() << " )\n\n"; for ( int ix = 1; ix <= ps->height(); ++ix ) { for ( int iy = 1; iy <= ps->width(); ++iy ) cout << ps->get( ix, iy ); cout << "\n"; } } int main() { Screen sobj( 2, 5 ); string init( "HelloWorld" ); ScreenPtr ps( sobj ); // Установить содержимое экрана string::size_type initpos = 0; for ( int ix = 1; ix <= ps->height(); ++ix ) for ( int iy = 1; iy <= ps->width(); ++iy ) { ps->move( ix, iy ); ps->set( init[ initpos++ ] ); } // Вывести содержимое экрана printScreen( ps ); return 0; |
Разумеется, подобные манипуляции с указателями на объекты классов не так эффективны, как работа со встроенными указателями. Поэтому интеллектуальный указатель должен предоставлять дополнительную функциональность, важную для приложения, чтобы оправдать сложность своего использования.
Оператор typeid
Второй оператор, входящий в состав RTTI, – это typeid, который позволяет выяснить фактический тип выражения. Если оно принадлежит типу класса и этот класс содержит хотя бы одну виртуальную функцию-член, то ответ может и не совпадать с типом самого выражения. Так, если выражение является ссылкой на базовый класс, то typeidсообщает тип производного класса объекта:
#include programmer pobj; employee &re = pobj; // ñ ôóíêöèåé name() ìû ïîçíàêîìèìñÿ â ïîäðàçäåëå, ïîñâÿùåííîì type_info // îíà âîçâðàùàåò C-ñòðîêó "programmer"
| |
coiut << typeid( re ).name() << endl;
Операнд re
оператора typeid
имеет тип employee. Но так как re – это ссылка на тип класса с виртуальными функциями, то typeid
говорит, что тип адресуемого объекта – programmer (а не employee, на который ссылается re). Программа, использующая такой оператор, должна включать заголовочный файл
Где применяется typeid? В сложных системах разработки, например при построении отладчиков, а также при использовании устойчивых объектов, извлеченных из базы данных. В таких системах необходимо знать фактический тип объекта, которым программа манипулирует с помощью указателя или ссылки на базовый класс, например для получения списка его свойств во время сеанса работы с отладчиком или для правильного сохранения или извлечения объекта из базы данных. Оператор typeid
допустимо использовать с выражениями и именами любых типов. Например, его операндами могут быть выражения встроенных типов и константы. Если операнд не принадлежит к типу класса, то typeid просто возвращает его тип:
int iobj; cout << typeid( iobj ).name() << endl; // ïå÷àòàåòñÿ: int |
Если операнд имеет тип класса, в котором нет виртуальных функций, то typeid
возвращает тип операнда, а не связанного с ним объекта:
class Base { /* нет виртуальных функций */ }; class Derived : public Base { /* íåò âèðòóàëüíûõ ôóíêöèé */ }; Derived dobj; Base *pb = &dobj; |
Операнд typeid
имеет тип Base, т.е. тип выражения *pb. Поскольку в классе Base нет виртуальных функций, результатом typeid будет Base, хотя объект, на который указывает pb, имеет тип Derived.
Результаты, возвращенные оператором typeid, можно сравнивать. Например:
#include employee *pe = new manager; employee& re = *pe; if ( typeid( pe ) == typeid( employee* ) ) // èñòèííî // ÷òî-òî ñäåëàòü /* if ( typeid( pe ) == typeid( manager* ) ) // ëîæíî if ( typeid( pe ) == typeid( employee ) ) // ëîæíî if ( typeid( pe ) == typeid( manager ) ) // ëîæíî |
Условие в инструкции if
сравнивает результаты применения typeid к операнду, являющемуся выражением, и к операнду, являющемуся именем типа. Обратите внимание, что сравнение
typeid( pe ) == typeid( employee* )
возвращает истину. Это удивит пользователей, привыкших писать:
// вызов виртуальной функции |
что приводит к вызову виртуальной функции salary() из производного класса manager. Поведение typeid(pe) не подчиняется данному механизму. Это связано с тем, что pe – указатель, а для получения типа производного класса операндом typeid
должен быть тип класса с виртуальными функциями. Выражение typeid(pe)
возвращает тип pe, т.е. указатель на employee. Это значение совпадает со значением typeid(employee*), тогда как все остальные сравнения дают ложь.
Только при употреблении выражения *pe в качестве операнда typeid
результат будет содержать тип объекта, на который указывает pe:
typeid( *pe ) == typeid( manager ) // истинно |
В этих сравнениях *pe – выражение типа класса, который имеет виртуальные функции, поэтому результатом применения typeid
будет тип адресуемого операндом объекта manager.
Такой оператор можно использовать и со ссылками:
typeid( re ) == typeid( manager ) // истинно typeid( re ) == typeid( employee ) // ложно typeid( &re ) == typeid( employee* ) // истинно |
В первых двух сравнениях операнд re
имеет тип класса с виртуальными функциями, поэтому результат применения typeid
содержит тип объекта, на который ссылается re. В последних двух сравнениях операнд &re
имеет тип указателя, следовательно, результатом будет тип самого операнда, т.е. employee*.
На самом деле оператор typeid
возвращает объект класса типа type_info, который определен в заголовочном файле
Оператор вывода <<
Оператор вывода обычно применяется для записи на стандартный вывод cout. Например, программа#include int main() { cout << "сплетница Анна Ливия\n";
| |
}
печатает на терминале строку:
сплетница Анна Ливия
Имеются операторы, принимающие аргументы любого встроенного типа данных, включая const char*, а также типов string и complex из стандартной библиотеки. Любое выражение, включая вызов функции, может быть аргументом оператора вывода при условии, что результатом его вычисления будет тип, принимаемый каким-либо вариантом этого оператора. Например, программа
#include #include int main() { cout << "Длина 'Улисс' равна:\t"; cout << strlen( "Улисс" ); cout << '\n'; cout << "Размер 'Улисс' равен:\t"; cout << sizeof( "Улисс" ); cout << endl;
| |
}
выводит на терминал следующее:
Длина 'Улисс' равна:7
Размер 'Улисс' равен:8
endl – это манипулятор вывода, который вставляет в выходной поток символ перехода на новую строку, а затем сбрасывает буфер объекта ostream. (С буферизацией мы познакомимся в разделе 20.9.)
Операторы вывода, как правило, удобнее сцеплять в одну инструкцию. Например, предыдущую программу можно записать таким образом:
#include #include int main() { // операторы вывода можно сцеплять cout << "Длина 'Улисс' равна:\t"; << strlen( "Улисс" ) << '\n'; cout << "Размер 'Улисс' равен:\t" << sizeof( "Улисс" ) << endl;
| |
}
Сцепление операторов вывода (и ввода тоже) возможно потому, что результатом выражения
cout << "некоторая строка";
служит левый операнд оператора вывода, т.е. сам объект cout. Затем этот же объект передается следующему оператору и далее по цепочке (мы говорим, что оператор << левоассоциативен).
Имеется также предопределенный оператор вывода для указательных типов, который печатает адрес объекта. По умолчанию адреса отображаются в шестнадцатеричном виде. Например, программа
#include int main() { int i = 1024; int *pi = &i; cout << "i: " << i << "\t&i:\t" << &i << '\n'; cout << "*pi: " << *pi << "\tpi:\t" << pi << endl << "\t\t&pi:\t" << &pi << endl; |
выводит на терминал следующее:
i: 1024 &i: 0x7fff0b4
*pi: 1024 pi: 0x7fff0b4
&pi: 0x7fff0b0
Позже мы покажем, как напечатать адреса в десятичном виде.
Следующая программа ведет себя странно. Мы хотим напечатать адрес, хранящийся в переменной pstr:
#include const char *str = "vermeer"; int main() { const char *pstr = str; cout << "Адрес pstr равен: " << pstr << endl; |
Но после компиляции и запуска программа неожиданно выдает такую строку:
Адрес pstr равен: vermeer
Проблема в том, что тип const char*
интерпретируется как C-строка. Чтобы все же напечатать адрес, хранящийся в pstr, необходимо подавить обработку типа const char* по умолчанию. Для этого мы сначала убираем спецификатор const, а затем приводим pstr к типу void*:
<< static_cast
Теперь программа
выводит ожидаемый результат:
Адрес pstr равен: 0x116e8
А вот еще одна загадка. Нужно напечатать большее из двух чисел:
#include inline void max_out( int val1, int val2 ) { cout << ( val1 > val2 ) ? val1 : val2; } int main() { int ix = 10, jx = 20; cout << "Большее из " << ix << ", " << jx << " равно "; max_out( ix, jx ); cout << endl; |
}
Однако программа выдает неправильный результат:
Большее из 10, 20 равно 0
Проблема в том, что оператор вывода имеет более высокий приоритет, чем оператор условного выражения, поэтому печатается результат сравнения val1 и val2. Иными словами, выражение
cout << ( val1 > val2 ) ? val1 : val2;
вычисляется как
(cout << ( val1 > val2 )) ? val1 : val2;
Поскольку val1 не больше val2, то результатом сравнения будет false, обозначаемый нулем. Чтобы изменить приоритет операций, весь оператор условного выражения следует заключить в скобки:
cout << ( val1 > val2 ? val1 : val2 );
Теперь результат получается правильный:
Большее из 10, 20 равно 20
Такого рода ошибку было бы проще найти, если бы значения литералов true и false
типа bool
печатались как строки, а не как 1 и 0. Тогда мы увидели бы строку:
Большее из 10, 20 равно false
и все стало бы ясно. По умолчанию литерал false
печатается как 0, а true – как 1. Это можно изменить, воспользовавшись манипулятором boolalpha(), что и сделано в следующей программе:
int main() { cout << "печать значений типа bool по умолчанию: " << true << " " << false << "\nи в виде строк: " << boolalpha() << true << " " << false << endl; |
Вот результат:
печать значений типа bool по умолчанию: 1 0
и в виде строк: true false
Для вывода массива, а также вектора или отображения, необходимо обойти все элементы и напечатать каждый из них:
#include #include #include string pooh_pals[] = { "Тигра", "Пятачок", "Иа-Иа", "Кролик" }; int main() { vector vector vector cout << "Это друзья Пуха: "; for ( ; iter != iter_end; iter++ ) cout << *iter << " "; cout << endl; |
}
Вместо того чтобы явно обходить все элементы контейнера, выводя каждый по очереди, можно воспользоваться потоковым итератором ostream_iterator. Так выглядит эквивалентная программа, где используется эта техника (подробное обсуждение итератора ostream_iterator см. в разделе 12.4):
#include #include #include #include string pooh_pals[] = { "Тигра", "Пятачок", "Иа-Иа", "Кролик" }; int main() { vector vector vector cout << "Это друзья Пуха: "; // копируем каждый элемент в cout ... ostream_iterator< string > output( cout, " " ); copy( iter, iter_end, output ); cout << endl; |
Программа печатает такую строку:
Ýòî äðóçüÿ Ïóõà: Òèãðà Ïÿòà÷îê Èà-Èà Êðîëèê
Упражнение 20.1
Даны следующие определения объектов:
string sa[4] = { "пух", "тигра", "пятачок", "иа-иа" }; vector< string > svec( sa, sa+4 ); string robin( "кристофер робин" ); const char *pc = robin.c_str(); int ival = 1024; char blank = ' '; double dval = 3.14159; |
(a) Направьте значение каждого объекта в стандартный вывод.
(b) Напечатайте значение адреса pc.
(c) Напечатайте наименьшее из двух значений ival и dval, пользуясь оператором условного выражения:
ival < dval ? ival : dval
Оператор вызова функции
Оператор вызова функции может быть перегружен для объектов типа класса. (Мы уже видели, как он используется, при рассмотрении объектов-функций в разделе 12.3.) Если определен класс, представляющий некоторую операцию, то для ее вызова перегружается соответствующий оператор. Например, для взятия абсолютного значения числа типа intможно определить класс absInt:
class absInt { public: int operator()( int val ) { int result = val < 0 ? -val : val; return result; } |
};
Перегруженный оператор operator()
должен быть объявлен как функция-член с произвольным числом параметров. Параметры и возвращаемое значение могут иметь любые типы, допустимые для функций (см. разделы 7.2, 7.3 и 7.4). operator() вызывается путем применения списка аргументов к объекту того класса, в котором он определен. Мы рассмотрим, как он используется в одном из обобщенных алгоритмов, описанных в главе 12. В следующем примере обобщенный алгоритм transform() вызывается для применения определенной в absInt
операции к каждому элементу вектора ivec, т.е. для замены элемента его абсолютным значением.
#include #include int main() { int ia[] = { -0, 1, -1, -2, 3, 5, -5, 8 }; vector< int > ivec( ia, ia+8 ); // заменить каждый элемент его абсолютным значением transform( ivec.begin(), ivec.end(), ivec.begin(), absInt() ); // ...
| |
}
Первый и второй аргументы transform()
ограничивают диапазон элементов, к которым применяется операция absInt. Третий указывает на начало вектора, где будет сохранен результат применения операции.
Четвертый аргумент – это временный объект класса absInt, создаваемый с помощью конструктора по умолчанию. Конкретизация обобщенного алгоритма transform(), вызываемого из main(), могла бы выглядеть так:
typedef vector< int >::iterator iter_type; // конкретизация transform() // операция absInt применяется к элементу вектора int iter_type transform( iter_type iter, iter_type last, iter_type result, absInt func ) { while ( iter != last ) *result++ = func( *iter++ ); // вызывается absInt::operator() return iter; |
}
func – это объект класса, который предоставляет операцию absInt, заменяющую число типа int его абсолютным значением. Он используется для вызова перегруженного оператора operator()
класса absInt. Этому оператору передается аргумент *iter, указывающий на тот элемент вектора, для которого мы хотим получить абсолютное значение.
Оператор взятия индекса
Оператор взятия индекса operator[]()можно определять для классов, представляющих абстракцию контейнера, из которого извлекаются отдельные элементы. Примерами таких контейнеров могут служить наш класс String, класс IntArray, представленный в главе 2, или шаблон класса vector, определенный в стандартной библиотеке C++. Оператор взятия индекса обязан быть функцией-членом класса.
У пользователей String
должна иметься возможность чтения и записи отдельных символов члена _string. Мы хотим поддержать следующий способ применения объектов данного класса:
String entry( "extravagant" ); String mycopy; for ( int ix = 0; ix < entry.size(); ++ix ) |
mycopy[ ix ] = entry[ ix ];
Оператор взятия индекса может появляться как слева, так и справа от оператора присваивания. Чтобы быть в левой части, он должен возвращать l-значение индексируемого элемента. Для этого мы возвращаем ссылку:
#include inine char& String::operator[]( int elem ) const { assert( elem >= 0 && elem < _size ); return _string[ elem ];
| |
}
В следующем фрагменте нулевому элементу массива color присваивается символ 'V':
String color( "violet" ); |
color[ 0 ] = 'V';
Обратите внимание, что в определении оператора проверяется выход индекса за границы массива. Для этого используется библиотечная C-функция assert(). Можно также возбудить исключение, показывающее, что значение elem меньше 0 или больше длины C-строки, на которую ссылается _string. (Возбуждение и обработка исключений обсуждались в главе 11.)
Оператор “запятая”
Одно выражение может состоять из набора подвыражений, разделенных запятыми; такие подвыражения вычисляются слева направо. Конечным результатом будет результат самого правого из них. В следующем примере каждое из подвыражений условного оператора представляет собой список. Результатом первого подвыражения условного оператора является ix, второго– 0.int main() { // примеры оператора "запятая" // переменные ia, sz и index определены в другом месте ... int ival = (ia != 0) ? ix=get_va1ue(), ia[index]=ix : ia=new int[sz], ia[index]=0; // ... |
}
Оператор =
Присваивание одного объекта другому объекту того же класса выполняется с помощью копирующего оператора присваивания. (Этот специальный случай был рассмотрен в разделе 14.7.)Для класса могут быть определены и другие операторы присваивания. Если объектам класса надо присваивать значения типа, отличного от этого класса, то разрешается определить такие операторы, принимающие подобные параметры. Например, чтобы поддержать присваивание C-строки объекту String:
String car ("Volks"); |
car = "Studebaker";
мы предоставляем оператор, принимающий параметр типа const char*. Эта операция уже была объявлена в нашем классе:
class String { public: // оператор присваивания для char* String& operator=( const char * ); // ... private: int _size; char *string; |
};
Такой оператор реализуется следующим образом. Если объекту String присваивается нулевой указатель, он становится “пустым”. В противном случае ему присваивается копия C-строки:
String& String::operator=( const char *sobj ) { // sobj - нулевой указатель if (! sobj ) { _size = 0; delete[] _string; _string = 0; } else { _size = strlen( sobj ); delete[] _string; _string = new char[ _size + 1 ]; strcpy( _string, sobj ); } return *this; |
}
_string
ссылается на копию той C-строки, на которую указывает sobj. Почему на копию? Потому что непосредственно присвоить sobj
члену _string
нельзя:
_string = sobj; // ошибка: несоответствие типов
sobj – это указатель на const и, следовательно, не может быть присвоен указателю на “не-const” (см. раздел 3.5). Изменим определение оператора присваивания:
String& String::operator=( const *sobj ) { // ... }
Теперь _string
прямо ссылается на C-строку, адресованную sobj. Однако при этом возникают другие проблемы. Напомним, что C-строка имеет тип const char*. Определение параметра как указателя на не-const делает присваивание невозможным:
car = "Studebaker"; // недопустимо с помощью operator=( char
*) !
Итак, выбора нет. Чтобы присвоить C- строку объекту типа String, параметр должен иметь тип const char*.
Хранение в _string
прямой ссылки на C-строку, адресуемую sobj, порождает и иные сложности. Мы не знаем, на что именно указывает sobj. Это может быть массив символов, который модифицируется способом, неизвестным объекту String. Например:
char ia[] = { 'd', 'a', 'n', 'c', 'e', 'r' }; String trap = ia; // trap._string ссылается на ia |
// модифицируется и ia, и trap._string
Если trap._string
напрямую ссылался на ia, то объект trap
демонстрировал бы своеобразное поведение: его значение может изменяться без вызова функций-членов класса String. Поэтому мы полагаем, что выделение области памяти для хранения копии значения C-строки менее опасно.
Обратите внимание, что в операторе присваивания используется delete. Член _string
содержит ссылку на массив символов, расположенный в хипе. Чтобы предотвратить утечку, память, выделенная под старую строку, освобождается с помощью delete до выделения памяти под новую. Поскольку _string адресует массив символов, следует использовать версию delete для массивов (см. раздел 8.4).
И последнее замечание об операторе присваивания. Тип возвращаемого им значения – это ссылка на класс String. Почему именно ссылка? Дело в том, что для встроенных типов операторы присваивания можно сцеплять:
// сцепление операторов присваивания int iobj, jobj; |
Они ассоциируются справа налево, т.е. в предыдущем примере присваивания выполняются так:
iobj = (jobj = 63);
Это удобно и при работе с объектами класса String: поддерживается, к примеру, следующая конструкция:
String ver, noun; |
При первом присваивании из этой цепочки вызывается определенный ранее оператор для const char*. Тип полученного результата должен быть таким, чтобы его можно было использовать как аргумент для копирующего оператора присваивания класса String. Поэтому, хотя параметр данного оператора имеет тип const char *, возвращается все же ссылка на String.
Операторы присваивания бывают перегруженными. Например, в нашем классе String
есть такой набор:
// набор перегруженных операторов присваивания String& operator=( const String & ); |
Отдельный оператор присваивания может существовать для каждого типа, который разрешено присваивать объекту String. Однако все такие операторы должны быть определены как функции-члены класса.
Операторные функции-кандидаты
Операторная функция является кандидатом, если она имеет то же имя, что и вызванная. При использовании следующего оператора сложенияSmallInt si(98); int iobj = 65; |
int res = si + iobj;
операторной функцией-кандидатом является operator+. Какие объявления operator+
принимаются во внимание?
Потенциально в случае применения операторного синтаксиса с операндами, имеющими тип класса, строится пять множеств кандидатов. Первые три– те же, что и при вызове обычных функций с аргументами типа класса:
· множество операторов, видимых в точке вызова. Объявления функции operator+(), видимые в точке использования оператора, являются кандидатами. Например, operator+(), объявленный в глобальной области
видимости, – кандидат в случае применения operator+() внутри main():
SmallInt operator+ ( const SmallInt &, const SmallInt & ); int main() { SmallInt si(98); int iobj = 65; int res = si + iobj; // ::operator+() - функция-кандидат |
}
· множество операторов, объявленных в пространстве имен, в котором определен тип операнда. Если операнд имеет тип класса и этот тип объявлен в пользовательском пространстве имен, то операторные функции, объявленные в том же пространстве и имеющие то же имя, что и использованный оператор, считаются кандидатами:
namespace NS { class SmallInt { /* ... */ }; SmallInt operator+ ( const SmallInt&, double ); } int main() { // si имеет тип SmallInt: // этот класс объявлен в пространстве имен NS NS::SmallInt si(15); // NS::operator+() - функция-кандидат int res = si + 566; return 0; |
}
Операнд si
имеет тип класса SmallInt, объявленного в пространстве имен NS. Поэтому перегруженный operator+(const SmallInt, double), объявленный в том же пространстве, добавляется к множеству кандидатов;
· множество операторов, объявленных друзьями классов, к которым принадлежат операнды. Если операнд принадлежит к типу класса и в определении этого класса есть одноименные применяемому оператору функции-друзья, то они добавляются к множеству кандидатов:
namespace NS { class SmallInt { friend SmallInt operator+( const SmallInt&, int ) { /* ... */ } }; } int main() { NS::SmallInt si(15); // функция-друг operator+() - кандидат int res = si + 566; return 0; |
Операнд si
имеет тип SmallInt. Операторная функция operator+(const SmallInt&, int), являющаяся другом этого класса, – член пространства имен NS, хотя непосредственно в этом пространстве она не объявлена. При обычном поиске в NS эта операторная функция не будет найдена. Однако при использовании operator+() с аргументом типа SmallInt
функции-друзья, объявленные в области видимости этого класса, включаются в рассмотрение и добавляются к множеству кандидатов.
Эти три множества операторных функций-кандидатов формируются точно так же, как и для вызовов обычных функций с аргументами типа класса. Однако при использовании операторного синтаксиса строятся еще два множества:
· множество операторов-членов, объявленных в классе левого операнда. Если такой операнд оператора operator+()
имеет тип класса, то в множество функций-кандидатов включаются объявления operator+(), являющиеся членами этого класса:
class myFloat { myFloat( double ); }; class SmallInt { public: SmallInt( int ); SmallInt operator+ ( const myFloat & ); }; int main() { SmallInt si(15); int res = si + 5.66; // оператор-член operator+() - кандидат |
Оператор-член SmallInt::operator+(const myFloat &), определенный в SmallInt, включается в множество функций-кандидатов для разрешения вызова operator+() в main();
· множество встроенных операторов. Учитывая типы, которые можно использовать со встроенным operator+(), кандидатами являются также:
int operator+( int, int ); double operator+( double, double ); T* operator+( T*, I ); |
Первое объявление относится к встроенному оператору для сложения двух значений целых типов, второе – к оператору для сложения значений типов с плавающей точкой. Третье и четвертое соответствуют встроенному оператору сложения указательных типов, который используется для прибавления целого числа к указателю. Два последних объявления представлены в символическом виде и описывают целое семейство встроенных операторов, которые могут быть выбраны компилятором на роль кандидатов при обработке операций сложения.
Любое из первых четырех множеств может оказаться пустым. Например, если среди членов класса SmallInt нет функции с именем operator+(), то четвертое множество будет пусто.
Все множество операторных функций-кандидатов является объединением пяти подмножеств, описанных выше:
namespace NS { class myFloat { myFloat( double ); }; class SmallInt { friend SmallInt operator+( const SmallInt &, int ) { /* ... */ } public: SmallInt( int ); operator int(); SmallInt operator+ ( const myFloat & ); // ... }; SmallInt operator+ ( const SmallInt &, double ); } int main() { // тип si - class SmallInt: // Этот класс объявлен в пространстве имен NS NS::SmallInt si(15); int res = si + 5.66; // какой operator+()? return 0; |
В эти пять множеств входят семь операторных функций-кандидатов на роль operator+() в main():
· первое множество пусто. В глобальной области видимости, а именно в ней употреблен operator+() в функции main(), нет объявлений перегруженного оператора operator+();
· второе множество содержит операторы, объявленные в пространстве имен NS, где определен класс SmallInt. В этом пространстве имеется один оператор:
NS::SmallInt NS::operator+( const SmallInt &, double );
· третье множество содержит операторы, объявленные друзьями класса SmallInt. Сюда входит
NS::SmallInt NS::operator+( const SmallInt &, int );
· четвертое множество содержит операторы, объявленные членами SmallInt. Такой тоже есть:
NS::SmallInt NS::SmallInt::operator+( const myFloat & );
· пятое множество содержит встроенные бинарные операторы:
int operator+( int, int ); double operator+( double, double ); T* operator+( T*, I ); |
Да, формирование множества кандидатов для разрешения оператора, использованного с применением операторного синтаксиса, утомительно. Но после того как оно построено, устоявшие функции и наилучшая из них находятся, как и прежде, путем анализа преобразований, применимых к операндам отобранных кандидатов.
Операторы инкремента и декремента
Продолжая развивать реализацию класса ScreenPtr, введенного в предыдущем разделе, рассмотрим еще два оператора, которые поддерживаются для встроенных указателей и которые желательно иметь и для нашего интеллектуального указателя: инкремент (++) и декремент (--).Чтобы использовать класс ScreenPtr для ссылки на элементы массива объектов Screen, туда придется добавить несколько дополнительных членов.
Сначала мы определим новый член size, который содержит либо нуль (это говорит о том, что объект ScreenPtr
указывает на единственный объект), либо размер массива, адресуемого объектом ScreenPtr. Нам также понадобится член offset, запоминающий смещение от начала данного массива:
class ScreenPtr { public: // ... private: int size; // размер массива: 0, если единственный объект int offset; // смещение ptr от начала массива Screen *ptr; |
};
Модифицируем конструктор класса ScreenPtr с учетом его новой функциональности и дополнительных членов,. Пользователь нашего класса должен передать конструктору дополнительный аргумент, если создаваемый объект указывает на массив:
class ScreenPtr { public: ScreenPtr( Screen &s , int arraySize = 0 ) : ptr( &s ), size ( arraySize ), offset( 0 ) { } private: int size; int offset; Screen *ptr; |
};
С помощью этого аргумента задается размер массива. Чтобы сохранить прежнюю функциональность, предусмотрим для него значение по умолчанию, равное нулю. Таким образом, если второй аргумент конструктора опущен, то член size окажется равен 0 и, следовательно, такой объект будет указывать на единственный объект Screen. Объекты нового класса ScreenPtr
можно определять следующим образом:
Screen myScreen( 4, 4 ); ScreenPtr pobj( myScreen ); // правильно: указывает на один объект const int arrSize = 10; Screen *parray = new Screen[ arrSize ]; |
ScreenPtr parr( *parray, arrSize ); // правильно: указывает на массив
Теперь мы готовы определить в ScreenPtr
перегруженные операторы инкремента и декремента. Однако они бывают двух видов: префиксные и постфиксные. К счастью, можно определить оба варианта. Для префиксного оператора объявление не содержит ничего неожиданного:
class ScreenPtr { public: Screen& operator++(); Screen& operator--(); // ... |
Такие операторы определяются как унарные операторные функции. Использовать префиксный оператор инкремента можно, к примеру, следующим образом:
const int arrSize = 10; Screen *parray = new Screen[ arrSize ]; ScreenPtr parr( *parray, arrSize ); for ( int ix = 0; ix < arrSize; ++ix, ++parr ) // эквивалентно parr.operator++() } |
Определения этих перегруженных операторов приведены ниже:
Screen& ScreenPtr::operator++() { if ( size == 0 ) { cerr << "не могу инкрементировать указатель для одного объекта\n"; return *ptr; } if ( offset >= size - 1 ) { cerr << "уже в конце массива\n"; return *ptr; } ++offset; return *++ptr; } Screen& ScreenPtr::operator--() { if ( size == 0 ) { cerr << "не могу декрементировать указатель для одного объекта\n"; return *ptr; } if ( offset <= 0 ) { cerr << "уже в начале массива\n"; return *ptr; } --offset; return *--ptr; |
Чтобы отличить префиксные операторы от постфиксных, в объявлениях последних имеется дополнительный параметр типа int. В следующем фрагменте объявлены префиксные и постфиксные варианты операторов инкремента и декремента для класса ScreenPtr:
class ScreenPtr { public: Screen& operator++(); // префиксные операторы Screen& operator--(); Screen& operator++(int); // постфиксные операторы Screen& operator--(int); // ... |
Ниже приведена возможная реализация постфиксных операторов:
Screen& ScreenPtr::operator++(int) { if ( size == 0 ) { cerr << "не могу инкрементировать указатель для одного объекта\n"; return *ptr; } if ( offset == size ) { cerr << "уже на один элемент дальше конца массива\n"; return *ptr; } ++offset; return *ptr++; } Screen& ScreenPtr::operator--(int) { if ( size == 0 ) { cerr << "не могу декрементировать указатель для одного объекта\n"; return *ptr; } if ( offset == -1 ) { cerr << "уже на один элемент раньше начала массива\n"; return *ptr; } --offset; return *ptr--; |
}
Обратите внимание, что давать название второму параметру нет необходимости, поскольку внутри определения оператора он не употребляется. Компилятор сам подставляет для него значение по умолчанию, которое можно игнорировать. Вот пример использования постфиксного оператора:
const int arrSize = 10; Screen *parray = new Screen[ arrSize ]; ScreenPtr parr( *parray, arrSize ); for ( int ix = 0; ix < arrSize; ++ix) |
При его явном вызове необходимо все же передать значение второго целого аргумента. В случае нашего класса ScreenPtr это значение игнорируется, поэтому может быть любым:
parr.operator++(1024); // вызов постфиксного operator++
Перегруженные операторы инкремента и декремента разрешается объявлять как дружественные функции. Изменим соответствующим образом определение класса ScreenPtr:
class ScreenPtr { // объявления не членов friend Screen& operator++( Screen & ); // префиксные операторы friend Screen& operator--( Screen & ); friend Screen& operator++( Screen &, int); // постфиксные операторы friend Screen& operator--( Screen &, int); public: // определения членов |
Упражнение 15.7
Напишите определения перегруженных операторов инкремента и декремента для класса ScreenPtr, предположив, что они объявлены как друзья класса.
Упражнение 15.8
С помощью ScreenPtr
можно представить указатель на массив объектов класса Screen. Модифицируйте перегруженные operator*() и operator?>()
(см. раздел 15.6) так, чтобы указатель ни при каком условии не адресовал элемент перед началом или за концом массива. Совет: в этих операторах следует воспользоваться новыми членами size и offset.
Операторы new и delete
Каждая программа во время работы получает определенное количество памяти, которую можно использовать. Такое выделение памяти под объекты во время выполнения называется динамическим, а сама память выделяется из хипа (heap). (Мы уже касались вопроса о динамическом выделении памяти в главе 1.) Напомним, что выделение памяти объекту производится с помощью оператора new, возвращающего указатель на вновь созданный объект того типа, который был ему задан. Например:int *pi =
new int;
размещает объект типа int в памяти и инициализирует указатель pi адресом этого объекта. Сам объект в таком случае не инициализируется, но это легко изменить:
int *pi = new int( 1024 );
Можно динамически выделить память под массив:
int *pia = new int[ 10 ];
Такая инструкция размещает в памяти массив встроенного типа из десяти элементов типа int. Для подобного массива нельзя задать список начальных значений его элементов при динамическом размещении. (Однако если размещается массив объектов типа класса, то для каждого из элементов вызывается конструктор по умолчанию.) Например:
string *ps = new string;
размещает в памяти один объект типа string, инициализирует ps его адресом и вызывает конструктор по умолчанию для вновь созданного объекта типа string. Аналогично
string *psa = new string[10];
размещает в памяти массив из десяти элементов типа string, инициализирует psa его адресом и вызывает конструктор по умолчанию для каждого элемента массива.
Объекты, размещаемые в памяти с помощью оператора new, не имеют собственного имени. Вместо этого возвращается указатель на безымянный объект, и все действия с этим объектом производятся посредством косвенной адресации.
После использования объекта, созданного таким образом, мы должны явно освободить память, применив оператор delete к указателю на этот объект. (Попытка применить оператор delete к указателю, не содержащему адрес объекта, полученного описанным способом, вызовет ошибку времени выполнения.) Например:
delete pi;
освобождает память, на которую указывает объект типа int, на который указывает pi. Аналогично
delete ps;
освобождает память, на которую указывает объект класса string, адрес которого содержится в ps. Перед уничтожением этого объекта вызывается деструктор. Выражение
delete [] pia;
освобождает память, отведенную под массив pia. При выполнении такой операции необходимо придерживаться указанного синтаксиса.
(Об операциях new и delete мы еще поговорим в главе 8.)
Упражнение 4.11
Какие из следующих выражений ошибочны?
(a) vector
(b) vector
(c) vector
(d) vector
(e) vector
(f) delete svec;
(g) delete pvecl;
(h) delete [] pvec2;
(i) delete pvl;
(j) delete pv2;
class Screen { public: void operator delete( void * ); |
Когда операндом delete
служит указатель на объект типа класса, компилятор проверяет, определен ли в этом классе оператор delete(). Если да, то для освобождения памяти вызывается именно он, в противном случае – глобальная версия оператора. Следующая инструкция
delete ps;
освобождает память, занятую объектом класса Screen, на который указывает ps. Поскольку в Screen
есть оператор-член delete(), то применяется именно он. Параметр оператора типа void*
автоматически инициализируется значением ps.
Добавление delete() в класс или его удаление оттуда никак не сказываются на пользовательском коде. Вызов delete
выглядит одинаково как для глобального оператора, так и для оператора-члена. Если бы в классе Screen не было собственного оператора delete(), то обращение осталось бы правильным, только вместо оператора-члена вызывался бы глобальный оператор.
С помощью оператора разрешения глобальной области видимости можно вызвать глобальный delete(), даже если в Screen
определена собственная версия:
::delete ps;
В общем случае используемый оператор delete()
должен соответствовать тому оператору new(), с помощью которого была выделена память. Например, если ps указывает на область памяти, выделенную глобальным new(), то для ее освобождения следует использовать глобальный же delete().
Оператор delete(), определенный для типа класса, может содержать два параметра вместо одного. Первый параметр по-прежнему должен иметь тип void*, а второй – предопределенный тип size_t (не забудьте включить заголовочный файл
class Screen { public: // заменяет // void operator delete( void * ); void operator delete( void *, size_t ); |
Если второй параметр есть, компилятор автоматически инициализирует его значением, равным размеру адресованного первым параметром объекта в байтах. (Этот параметр важен в иерархии классов, когда оператор delete() может наследоваться производным классом. Подробнее наследование обсуждается в главе 17.)
Рассмотрим реализацию операторов new()
и delete() в классе Screen
более детально. В основе нашей стратегии распределения памяти будет лежать связанный список объектов Screen, на начало которого указывает член freeStore. При каждом обращении к оператору-члену new() возвращается следующий объект из списка. При вызове delete()
объект возвращается в список. Если при создании нового объекта список, адресованный freeStore, пуст, то вызывается глобальный оператор new(), чтобы получить блок памяти, достаточный для хранения screenChunk объектов класса Screen.
Как screenChunk, так и freeStore
представляют интерес только для Screen, поэтому мы сделаем их закрытыми членами. Кроме того, для всех создаваемых объектов нашего класса значения этих членов должны быть одинаковыми, а следовательно, нужно объявить их статическими. Чтобы поддержать структуру связанного списка объектов Screen, нам понадобится третий член next:
class Screen { public: void *operator new( size_t ); void operator delete( void *, size_t ); // ... private: Screen *next; static Screen *freeStore; static const int screenChunk; |
Вот одна из возможных реализаций оператора new() для класса Screen:
#include "Screen.h" #include // статические члены инициализируются // в исходных файлах программы, а не в заголовочных файлах Screen *Screen::freeStore = 0; const int Screen::screenChunk = 24; void *Screen::operator new( size_t size ) { Screen *p; if ( !freeStore ) { // связанный список пуст: получить новый блок // вызывается глобальный оператор new size_t chunk = screenChunk * size; freeStore = p = reinterpret_cast< Screen* >( new char[ chunk ] ); // включить полученный блок в список for ( ; p != &freeStore[ screenChunk - 1 ]; ++p ) p->next = p+1; p->next = 0; } p = freeStore; freeStore = freeStore->next; return p; |
}
А вот реализация оператора delete():
void Screen::operator delete( void *p, size_t ) { // вставить "удаленный" объект назад, // в список свободных ( static_cast< Screen* >( p ) )->next = freeStore; freeStore = static_cast< Screen* >( p ); |
Оператор new()
можно объявить в классе и без соответствующего delete(). В таком случае объекты освобождаются с помощью одноименного глобального оператора. Разрешается также объявить и оператор delete() без new(): объекты будут создаваться с помощью одноименного глобального оператора. Однако обычно эти операторы реализуются одновременно, как в примере выше, поскольку разработчику класса, как правило, нужны оба.
Они являются статическими членами класса, даже если программист явно не объявит их таковыми, и подчиняются обычным ограничениями для подобных функций-членов: им не передается указатель this, а следовательно, напрямую они могут получить доступ только к статическим членам. (См. обсуждение статических функций-членов в разделе 13.5.) Причина, по которой эти операторы делаются статическими, заключается в том, что они вызываются либо перед конструированием объекта класса (new()), либо после его уничтожения (delete()).
Выделение памяти с помощью оператора
new(), например:
Screen *ptr = new Screen( 10, 20 );
эквивалентно последовательному выполнению таких инструкций:
// Псевдокод на C++ ptr = Screen::operator new( sizeof( Screen ) ); |
Иными словами, сначала вызывается определенный в классе оператор new(), чтобы выделить память для объекта, а затем этот объект инициализируется конструктором. Если new()
неудачно завершает работу, то возбуждается исключение типа bad_alloc и конструктор не вызывается.
Освобождение памяти с помощью оператора delete(), например:
delete ptr;
эквивалентно последовательному выполнению таких инструкций:
// Псевдокод на C++ Screen::~Screen( ptr ); |
Таким образом, при уничтожении объекта сначала вызывается деструктор класса, а затем определенный в классе оператор delete() для освобождения памяти. Если значение ptr равно 0, то ни деструктор, ни delete() не вызываются.
Операторы new[ ] и delete [ ]
Оператор new(), определенный в предыдущем подразделе, вызывается только при выделении памяти для единичного объекта. Так, в данной инструкции вызывается new()класса Screen:
// вызывается Screen::operator new() |
Screen *ps = new Screen( 24, 80 );
тогда как ниже вызывается глобальный оператор new[]() для выделения из хипа памяти под массив объектов типа Screen:
// вызывается Screen::operator new[]() |
Screen *psa = new Screen[10];
В классе можно объявить также операторы new[]() и delete[]() для работы с массивами.
Оператор-член new[]()
должен возвращать значение типа void* и принимать в качестве первого параметра значение типа size_t. Вот его объявление для Screen:
class Screen { public: void *operator new[]( size_t ); // ... |
};
Когда с помощью new
создается массив объектов типа класса, компилятор проверяет, определен ли в классе оператор new[](). Если да, то для выделения памяти под массив вызывается именно он, в противном случае – глобальный new[](). В следующей инструкции в хипе создается массив из десяти объектов Screen:
Screen *ps = new Screen[10];
В этом классе есть оператор new[](), поэтому он и вызывается для выделения памяти. Его параметр size_t
автоматически инициализируется значением, равным объему памяти в байтах, необходимому для размещения десяти объектов Screen.
Даже если в классе имеется оператор-член new[](), программист может вызвать для создания массива глобальный new[](), воспользовавшись оператором разрешения глобальной области видимости:
Screen *ps = ::new Screen[10];
Оператор delete(), являющийся членом класса, должен иметь тип void, а в качестве первого параметра принимать void*. Вот как выглядит его объявление для Screen:
class Screen { public: void operator delete[]( void * ); |
};
Чтобы удалить массив объектов класса, delete должен вызываться следующим образом:
delete[] ps;
Когда операндом delete
является указатель на объект типа класса, компилятор проверяет, определен ли в этом классе оператор delete[](). Если да, то для освобождения памяти вызывается именно он, в противном случае – его глобальная версия. Параметр типа void* автоматически инициализируется значением адреса начала области памяти, в которой размещен массив.
Даже если в классе имеется оператор-член delete[](), программист может вызвать глобальный delete[](), воспользовавшись оператором разрешения глобальной области видимости:
::delete[] ps;
Добавление операторов new[]() или delete[]() в класс или удаление их оттуда не отражаются на пользовательском коде: вызовы как глобальных операторов, так и операторов-членов выглядят одинаково.
При создании массива сначала вызывается new[]()
для выделения необходимой памяти, а затем каждый элемент инициализируется с помощью конструктора по умолчанию. Если у класса есть хотя бы один конструктор, но нет конструктора по умолчанию, то вызов оператора new[]()
считается ошибкой. Не существует синтаксической конструкции для задания инициализаторов элементов массива или аргументов конструктора класса при создании массива подобным образом.
При уничтожении массива сначала вызывается деструктор класса для уничтожения элементов, а затем оператор delete[]() – для освобождения всей памяти. При этом важно использовать правильный синтаксис. Если в инструкции
delete ps;
ps указывает на массив объектов класса, то отсутствие квадратных скобок приведет к вызову деструктора лишь для первого элемента, хотя память будет освобождена полностью.
У оператора-члена delete[]()
может быть не один, а два параметра, при этом второй должен иметь тип size_t:
class Screen { public: // заменяет // void operator delete[]( void* ); void operator delete[]( void*, size_t ); |
Если второй параметр присутствует, то компилятор автоматически инициализирует его значением, равным объему отведенной под массив памяти в байтах.
Определение базового класса
Члены Queryпредставляют:
· множество операций, поддерживаемых всеми производными от него классами запросов. Сюда входят как виртуальные операции, переопределяемые в производных классах, так и невиртуальные, разделяемые всеми производными классами (мы приведем примеры тех и других);
· множество данных-членов, общих для всех производных классов. Если вынести такие члены в абстрактный базовый класс Query, мы сможем обращаться к ним вне зависимости от того, с объектом какого производного класса мы работаем.
Если имеется запрос вида:
fiery || untamed
то двумя основными операциями для него будут: нахождение строк текста, удовлетворяющих условиям запроса, и представление найденных строк пользователю. Назовем эти операции соответственно eval() и display().
Алгоритм работы eval()
свой для каждого производного класса, поэтому эту функцию следует объявить виртуальной в определении Query. Всякий производный класс должен предоставить собственную реализацию для нее. Сам же Query
лишь включает ее в свой открытый интерфейс.
Алгоритм работы функции display(), выводящей найденные строки текста, не зависит от типа производного класса. Нам необходимо лишь иметь доступ к представлению самого текста и списку строк, удовлетворяющих запросу. Вместо того чтобы дублировать реализацию алгоритма и необходимые для него данные в каждом производном классе, определим единственный наследуемый экземпляр в Query.
Такое проектное решение позволит нам вызывать любую операцию, не зная фактического типа объекта, которым мы манипулируем:
void doit( Query *pq ) { // виртуальный вызов pq->eval(); // статический вызов Query::display() pq->display(); |
}
Как следует представить найденные строки текста? Каждому упомянутому в запросе слову будет соответствовать вектор позиций, построенный во время поиска. Позиция – это пара (строка, колонка), в которой каждый член – это значение типа short int. Отображение слов на векторы позиций, построенное функцией build_text_map(), содержит такие векторы для каждого встречающегося в тексте слова, распознанного нашей системой. Ключами для этого отображения служат значения типа string, представляющие слова. Например, для текста
Alice Emma has long flowing red hair. Her Daddy says
when the wind blows through her hair, it looks almost alive,
like a fiery bird in flight. A beautiful fiery bird, he tells her,
magical but untamed. "Daddy, shush, there is no such thing,"
she tells him, at the same time wanting him to tell her more.
Shyly, she asks, "I mean, Daddy, is there?"
приведена часть отображения для некоторых слов, встречающихся неоднократно (слово – это ключ отображения; пары значений в скобках – элементы вектора позиций; отметим, что нумерация строк и колонок начинается с нуля):
bird ((2,3),(2,9))
daddy ((0,8),(3,3),(5,5))
fiery ((2,2),(2,8))
hair ((0,6),(1,6))
her ((0,7),(1,5),(2,12),(4,11))
him ((4,2),(4,8))
she ((4,0),(5,1))
tell ((2,11),(4,1),(4,10))
Однако такой вектор – это еще ответ на запрос. К примеру, слово fiery
представлено двумя позициями, причем обе находятся в одной и той же строке.
Нам нужно вычислить множество неповторяющихся строк, соответствующих вектору позиций. Для этого можно, например, создать вектор, в который помещаются все номера строк, представленные в векторе позиций, а затем передать его обобщенному алгоритму unique(), который удалит все дубликаты (см. алгоритм unique() в Приложении). Оставшиеся строки должны быть расположены в порядке возрастания номеров. Чтобы не оставалось никаких сомнений, к вектору строк можно применить обобщенный алгоритм sort().
Мы выбрали другой подход – построить множество (объект set) из номеров строк в векторе позиций. Такое множество содержит по одному экземпляру каждого элемента, причем хранит их в отсортированном виде. Нам потребуется функция для преобразования вектора позиций в множество неповторяющихся номеров строк:
set
Объявим _vec2set()
защищенной функцией-членом Query. Она не является открытой, поскольку не принадлежит к числу операций, которые могут вызывать пользователи данной иерархии. Но она и не закрыта, поскольку это вспомогательная функция, которая должна быть доступна производным классам. (Подчерк в имени функции призван обратить внимание на то, что это не часть открытого интерфейса иерархии Query.)
Например, вектор позиций для слова bird
содержит два вхождения в одной и той же строке, поэтому его разрешающее множество будет состоять из одного элемента: (2). Вектор позиций для слова tell
содержит три вхождения, из них два относятся к одной и той же строке; следовательно, в его разрешающем множестве будет два элемента: (2,4). Вот как выглядят результаты для всех представленных выше векторов позиций:
bird (2)
daddy (0,3,5)
fiery (2)
hair (0,1)
her (0,1,2,4)
him (4)
she (4,5)
tell (2,4)
Чтобы вычислить результат запроса NameQuery, достаточно получить вектор позиций для указанного слова, преобразовать его в множество неповторяющихся номеров строк и вывести соответствующие строки текста.
Ответом на NotQuery
служит множество строк, в которых не встречается указанное слово. Так, результатом запроса
! daddy
служит множество (1,2,4). Для вычисления результата надо знать, сколько всего строк содержится в тексте. (Мы не сохраняли эту информацию, поскольку не были уверены, что она потребуется; к сожалению, недостаточно и этого.) Чтобы упростить обработку NotQuery, полезно сгенерировать множество всех номеров строк текста (0,1,2,3,4,5): теперь для получения результата достаточно с помощью алгоритма set_difference()
вычислить разность двух множеств. (Ответом на показанный выше запрос будет множество (0,3,5).)
Результатом OrQuery
является объединение номеров строк, где встречается левый или правый операнд. Например, если дан запрос:
fiery || her
то результирующим множеством будет (0,1,2,4), которое получается объединением множества (2) для слова fiery и множества (0,1,2,4) для слова her. Такое множество должно быть упорядочено по возрастанию номеров строк и не содержать дубликатов.
До сих пор нам удавалось вычислять результат запроса, работая только с множествами неповторяющихся номеров строк. Однако для обработки AndQuery
надо принимать во внимание как номер строки, так и номер колонки в каждой паре. Так, указанные в запросе
her && hair
слова встречаются в четырех разных строках. Определенная нами семантика AndQuery
говорит, что строка является подходящей, если содержит точную последовательность her hair. Вхождения слов в первую строку не удовлетворяют этому условию, хотя они стоят рядом:
Alice Emma has long flowing red hair. Her Daddy says
а вот во второй строке слова расположены так, как нужно:
when the wind blows through her hair, it looks almost alive,
Для оставшихся двух вхождений слова her
слово hair не является соседним. Таким образом, ответом на запрос является вторая строка текста: (1).
Если бы не операция AndQuery, нам не пришлось бы вычислять вектор позиций для каждой операции. Но, поскольку операндом AndQuery
может быть результат любого запроса, то для каждого приходится вычислять и сохранять не только множество неповторяющихся строк, но и пары (строка, колонка). Рассмотрим следующие запросы:
fiery && ( hair || bird || potato )
fiery && ( ! burr )
NotQuery
может быть операндом AndQuery, следовательно, мы должны создать не просто вектор, содержащий по одному элементу для каждой подходящей строки, но и вектор, в котором хранятся позиции. (Мы еще вернемся к этому при рассмотрении функции eval() для класса NotQuery в разделе 17.5.)
Таким образом, идентифицирован еще один необходимый член – вектор позиций, ассоциированный с вычислением каждой операции. У нас есть выбор: объявить его членом каждого производного класса или членом абстрактного базового класса Query, наследуемым всеми производными. Объем памяти для хранения этого члена в обоих случаях одинаков. Мы поместим его в базовый класс, локализовав поддержку инициализации и доступа к члену.
Решение о том, представлять ли множество неповторяющихся номеров строк (мы называем его разрешающим множеством) в виде члена класса или каждый раз вычислять его, принимает разработчик. Мы предпочли вычислять его по мере необходимости, а затем сохранять адрес для последующего доступа, объявляя этот адрес членом абстрактного базового класса Query.
Для вывода найденных строк нам необходимо как разрешающее множество, так и фактический текст, из которого взяты строки. Причем вектор позиций у каждой операции должен быть свой, а экземпляр текста нужен только один. Поэтому мы определим его статическим членом класса Query. (Реализация функции display()
опирается только на эти два члена.)
Вот результат первой попытки создать абстрактный базовый класс Query (конструкторы, деструктор и копирующий оператор присваивания еще не объявлены: этим мы займемся в разделах 17.4 и 17.6):
#include #include #include #include typedef pair< short, short > location; class Query { public: // конструкторы и деструктор обсуждаются в разделе 17.4 // копирующий конструктор и копирующий оператор присваивания // обсуждаются в разделе 17.6 // операции для поддержки открытого интерфейса virtual void eval() = 0; virtual void display () const; // функции доступа для чтения const set const vector static const vector protected: set static vector set vector }; inline const set Query:: solution() { return _solution ? _solution : _solution = _vec2set( &_loc ); |
Странный синтаксис
virtual void eval() = 0;
говорит о том, что для виртуальной функции eval() в абстрактном базовом классе Query нет определения: это чисто виртуальная функция, “удерживающая место” в открытом интерфейсе иерархии классов и не предназначенная для непосредственного вызова из программы. Вместо нее каждый производный класс должен предоставить настоящую реализацию. (Подробно виртуальные функции будут рассматриваться в разделе 17.5.)
Определение члена пространства имен
Мы видели, что определение члена пространства имен может появиться внутри определения самого пространства. Например, класс matrix и константа piпоявляются внутри вложенного пространства имен MatrixLib, а определения функций operator+() и inverse()
приводятся где-то в другом месте текста программы:
// ---- primer.h ---- namespace cplusplus_primer { // первое вложенное пространство имен: // матричная часть библиотеки namespace MatrixLib { class matrix { /* ... */ }; const double pi = 3.1416; matrix operators+ ( const matrix &ml, const matrix &m2 ); void inverse( matrix & ); // ... } |
}
Член пространства имен можно определить и вне соответствующего пространства. В таком случае имя члена должно быть квалифицировано именами пространств, к которым он принадлежит. Например, если определение функции operator+()
помещено в глобальную область видимости, то оно должно выглядеть следующим образом:
// ---- primer.C ---- #include "primer.h" // определение в глобальной области видимости cplusplus_primer::MatrixLib::matrix cplusplus_primer::MatrixLib::operator+ ( const matrix& ml, const matrix &m2 ) |
{ /* ... */ }
Имя operator+()
квалифицировано в данном случае именами пространств cplusplus_primer и MatrixLib.
Однако обратите внимание на тип matrix в списке параметров operator+(): употреблено неквалифицированное имя. Как такое может быть?
В определении функции operator+()
можно использовать неквалифицированные имена для членов своего пространства, поскольку определение принадлежит к его области видимости. При разрешении имен внутри функции operator+()
используется MatrixLib. Заметим, однако, что в типе возвращаемого значения все же нужно указывать квалифицированное имя, поскольку он расположен вне области видимости, заданной определением функции:
cplusplus_primer::MatrixLib::operator+
В определении operator+()
неквалифицированные имена могут встречаться в любом объявлении или выражении внутри списка параметров или тела функции. Например, локальное объявление внутри operator+()
способно создать объект класса matrix:
// ---- primer.C ---- #include "primer.h" cplusplus_primer::MatrixLib::matrix cplusplus_primer::MatrixLib::operator+ ( const matrix &ml, const matrix &m2 ) { // объявление локальной переменной типа // cplusplus_primer::MatrixLib::matrix matrix res; // вычислим сумму двух объектов matrix return res; |
Хотя члены могут быть определены вне своего пространства имен, такие определения допустимы не в любом месте. Их разрешается помещать только в пространства, объемлющие данное. Например, определение operator+() может появиться в глобальной области видимости, в пространстве имен cplusplus_primer и в пространстве MatrixLib. В последнем случае это выглядит так:
// ---- primer.C -- #include "primer.h" namespace cplusplus_primer { MatrixLib::matrix MatrixLib::operator+ ( const matrix &ml, const matrix &m2 ) { /* ... */ } |
Член может определяться вне своего пространства только при условии, что ранее он был объявлен внутри. Последнее приведенное определение operator+()
было бы ошибочным, если бы ему не предшествовало объявление в файле primer.h:
namespace cplusplus_primer { namespace MatrixLib { class matrix { /*...*/ }; // следующее объявление не может быть пропущено matrix operator+ ( const matrix &ml, const matrix &m2 ); // ... } |
Определение иерархии классов
В этой главе мы построим иерархию классов для представления запроса пользователя. Сначала реализуем каждую операцию в виде отдельного класса:NameQuery // Shakespeare NotQuery // ! Shakespeare OrQuery // Shakespeare || Marlowe |
AndQuery // William && Shakespeare
В каждом классе определим функцию-член eval(), которая выполняет соответствующую операцию. К примеру, для NameQuery она возвращает вектор позиций, содержащий координаты (номера строки и колонки) начала каждого вхождения слова (см. раздел 6.8); для OrQuery
строит объединение векторов позиций обоих своих операндов и т.д.
Таким образом, запрос
untamed || fiery
состоит из объекта класса OrQuery, который содержит два объекта NameQuery в качестве операндов. Для простых запросов этого достаточно, но при обработке составных запросов типа
Alice || Emma && Weeks
возникает проблема. Данный запрос состоит из двух подзапросов: объекта OrQuery, содержащего объекты NameQuery для представления слов Alice и Emma, и объекта AndQuery. Правым операндом AndQuery
является объект NameQuery для слова Weeks.
AndQuery OrQuery NameQuery ("Alice") NameQuery ("Emma") |
NameQuery ("Weeks")
Но левый операнд – это объект OrQuery, предшествующий оператору &&. На его месте мог бы быть объект NotQuery или другой объект AndQuery. Как же следует представить операнд, если он может принадлежать к типу любого из четырех классов? Эта проблема имеет две стороны:
· необходимо уметь объявлять тип операнда в классах OrQuery, AndQuery и NotQuery так, чтобы с его помощью можно было представить тип любого из четырех классов запросов;
· какое бы решение мы ни выбрали в предыдущем случае, мы должны иметь возможность вызывать соответствующий классу каждого операнда вариант функции-члена eval().
Решение, не согласующееся с объектной ориентированностью, состоит в том, чтобы определить тип операнда как объединение и включить дискриминант, показывающий текущий тип операнда:
// не объектно-ориентированное решение union op_type { // объединение не может содержать объекты классов с // ассоциированными конструкторами NotQuery *nq; OrQuery *oq; AndQuery *aq; string *word; }; enum opTypes { Not_query=1, O_query, And_query, Name_query }; class AndQuery { public: // ... private: /* * opTypes хранит информацию о фактических типах операндов запроса * op_type - это сами операнды */ op_type _lop, _rop; opTypes _lop_type, _rop_type; |
Хранить указатели на объекты можно и с помощью типа void*:
class AndQuery { public: // ... private: void * _lop, _rop; opTypes _lop_type, _rop_type; |
Нам все равно нужен дискриминант, поскольку напрямую использовать объект, адресуемый указателем типа void*, нельзя, равно как невозможно определить тип такого объекта по указателю. (Мы не рекомендуем применять описанное решение в C++, хотя в языке C это весьма распространенный подход.)
Основной недостаток рассмотренных решений состоит в том, что ответственность за определение типа возлагается на программиста. Например, в случае решения, основанного на void*-указателях, операцию eval() для объекта AndQuery
можно реализовать так:
void AndQuery:: eval() { // не объектно-ориентированный подход // ответственность за разрешение типа ложится на программиста // определить фактический тип левого операнда switch( _lop_type ) { case And_query: AndQuery *paq = static_cast paq->eval(); break; case Or_query: OrQuery *pqq = static_cast poq->eval(); break; case Not_query: NotQuery *pnotq = static_cast pnotq->eval(); break; case Name_query: AndQuery *pnmq = static_cast pnmq->eval(); break; } // то же для правого операнда |
}
В результате явного управления разрешением типов увеличивается размер и сложность кода и добавление нового типа или исключение существующего при сохранении работоспособности программы затрудняется.
Объектно-ориентированное программирование предлагает альтернативное решение, в котором работа по разрешению типов перекладывается с программиста на компилятор. Например, так выглядит код операции eval() для класса AndQuery в случае применения объектно-ориентированного подхода (eval()
объявлена виртуальной):
// объектно-ориентированное решение // ответственность за разрешение типов перекладывается на компилятор // примечание: теперь _lop и _rop - объекты типа класса // их определения будут приведены ниже void AndQuery:: eval() { _lop->eval(); _rop->eval(); |
Если потребуется добавить или исключить какие-либо типы, эту часть программы не придется ни переписывать, ни перекомпилировать.
Определение класса UserQuery
Объект класса UserQueryможно инициализировать указателем на вектор строк, представляющий запрос пользователя, или передать ему адрес этого вектора позже, с помощью функции-члена query(). Это позволяет использовать один объект для нескольких запросов. Фактическое построение иерархии классов Query выполняется функцией eval_query():
// определить объект, не имея запроса пользователя UserQuery user_query; string text; vector // обработать запросы пользователя do { while( cin >> text ) query_text.push_back( text ); // передать запрос объекту UserQuery user_query.query( &query_text ); // вычислить результат запроса и вернуть // корень иерархии Query* Query *query = user_query.eval_query(); }
| |
while ( /* пользователь продолжает формулировать запросы */ );
Вот определение нашего класса UserQuery:
#ifndef USER_QUERY_H #define USER_QUERY_H #include #include #include |
#endif
Обратите внимание, что два объявленных нами стека содержат указатели на объекты типа Query, а не сами объекты. Хотя правильное поведение обеспечивается обеими реализациями, хранение объектов значительно менее эффективно, поскольку каждый объект (и его операнды) должен быть почленно скопирован в стек (напомним, что операнды копируются виртуальной функцией clone()) только для того, чтобы вскоре быть уничтоженным. Если мы не собираемся модифицировать объекты, помещаемые в контейнер, то хранение указателей на них намного эффективнее.
Ниже показаны реализации различных встроенных операций eval. Операции evalAnd() и evalOr()
выполняют следующие шаги. Сначала объект извлекается из стека _query_stack
(напомним, что для класса stack, определенного в стандартной библиотеке, это требует двух операций: top() для получения элемента и pop() для удаления его из стека). Затем из хипа выделяется память для объекта класса AndQuery или OrQuery, и указатель на него передается объекту, извлеченному из стека. Каждая операция передает объекту AndQuery или OrQuery
счетчики левых или правых скобок, необходимые ему для вывода своего содержимого. И наконец неполный оператор помещается в стек _current_op:
inline void UserQuery:: evalAnd() { Query *pop = _query_stack.top(); _query_stack.pop(); AndQuery *pq = new AndQuery( pop ); if ( _lparenOn ) { pq->lparen( _lparenOn ); _lparenOn = 0; } if ( _rparenOn ) { pq->rparen( _rparenOn ); _rparenOn = 0; } _current_op.push( pq ); } inline void UserQuery:: evalOr() { Query *pop = _query_stack.top(); _query_stack.pop(); OrQuery *pq = new OrQuery( pop ); if ( _lparenOn ) { pq->lparen( _lparenOn ); _lparenOn = 0; } if ( _rparenOn ) { pq->rparen( _rparenOn ); _rparenOn = 0; } _current_op.push( pq ); |
Операция evalNot()
работает следующим образом. В хипе создается новый объект класса NotQuery, которому передаются счетчики левых и правых скобок для правильного отображения содержимого. Затем неполный оператор помещается в стек _current_op:
inline void UserQuery:: evalNot() { NotQuery *pq = new NotQuery; if ( _lparenOn ) { pq->lparen( _lparenOn ); _lparenOn = 0; } if ( _rparenOn ) { pq->rparen( _rparenOn ); _rparenOn = 0; } _current_op.push( pq ); |
При обнаружении закрывающей скобки вызывается операция evalRParen(). Если число активных левых скобок больше числа элементов в стеке _current_op, то ничего не происходит. В противном случае выполняются следующие действия. Из стека _query_stack
извлекается текущий еще не присоединенный к оператору операнд, а из стека _current_op – текущий неполный оператор. Вызывается виртуальная функция add_op()
класса Query, которая их объединяет. И наконец полный оператор помещается в стек _query_stack:
inline void UserQuery:: evalRParen() { if ( _paren < _current_op.size() ) { Query *poperand = _query_stack.top(); _query_stack.pop(); Query *pop = _current_op.top(); _current_op.pop(); pop->add_op( poperand ); _query_stack.push( pop ); } |
Операция evalWord()
выполняет следующие действия. Она ищет указанное слово в отображении _word_map
взятых из файла слов на векторы позиций. Если слово найдено, берется его вектор позиций и в хипе посредством конструктора с двумя параметрами создается новый объект NameQuery. В противном случае объект порождается с помощью конструктора с одним параметром. Если число элементов в стеке _current_op меньше либо равно числу встреченных ранее скобок, то нет неполного оператора, ожидающего операнда типа NameQuery, поэтому новый объект помещается в стек _query_stack. Иначе из стека _current_op
извлекается неполный оператор, к которому с помощью виртуальной функции add_op()
присоединяется операнд NameQuery, после чего ставший полным оператор помещается в стек _query_stack:
inline void UserQuery:: evalWord( const string &query ) { NameQuery *pq; loc *ploc; if ( ! _word_map->count( query )) pq = new NameQuery( query ); else { ploc = ( *_word_map )[ query ]; pq = new NameQuery( query, *ploc ); } if ( _current_op.size() <= _paren ) _query_stack.push( pq ); else { Query *pop = _current_op.top(); _current_op.pop(); pop->add_op( pq ); _query_stack.push( pop ); } |
Упражнение 17.21
Напишите деструктор, копирующий конструктор и копирующий оператор присваивания для класса UserQuery.
Упражнение 17.22
Напишите функции print() для класса UserQuery. Обоснуйте свой выбор того, что она выводит.
Определение класса
Определение класса состоит из двух частей: заголовка, включающего ключевое слово class, за которым следует имя класса, и тела, заключенного в фигурные скобки. После такого определения должны стоять точка с запятой или список объявлений:class Screen { /* ... */ }; |
class Screen { /* ... */ } myScreen, yourScreen;
Внутри тела объявляются данные-члены и функции-члены и указываются уровни доступа к ним. Таким образом, тело класса определяет список его членов.
Каждое определение вводит новый тип данных. Даже если два класса имеют одинаковые списки членов, они все равно считаются разными типами:
class First { int memi; double memd; }; class Second { int memi; double memd; }; class First obj1; |
Second obj2 = obj1; // ошибка: obj1 и obj2 имеют разные типы
Тело класса определяет отдельную область видимости. Объявление членов внутри тела помещает их имена в область видимости класса. Наличие в двух разных классах членов с одинаковыми именами – не ошибка, эти имена относятся к разным объектам. (Подробнее об областях видимости классов мы поговорим в разделе 13.9.)
После того как тип класса определен, на него можно ссылаться двумя способами:
· написать ключевое слово class, а после него – имя класса. В предыдущем примере объект obj1
класса First
объявлен именно таким образом;
· указать только имя класса. Так объявлен объект obj2
класса Second из приведенного примера.
Оба способа сослаться на тип класса эквивалентны. Первый заимствован из языка C и остается корректным методом задания типа класса; второй способ введен в C++ для упрощения объявлений.
Определение объекта map и заполнение его элементами
Чтобы определить объект класса map, мы должны указать, как минимум, типы ключа и значения. Например:map
Здесь задается объект word_count
типа map, для которого ключом служит объект типа string, а ассоциированным с ним значением – объект типа int. Аналогично
class employee; |
map
personnel;
определяет personnel как отображение ключа типа int (уникальный номер служащего) на указатель, адресующий объект класса employee.
Для нашей поисковой системы полезно такое отображение:
typedef pair typedef vector
| |
map
Поскольку имевшийся в нашем распоряжении компилятор не поддерживал аргументы по умолчанию для параметров шаблона, нам пришлось написать более развернутое определение:
map allocator> // распределитель памяти по умолчанию
| |
text_map;
По умолчанию сортировка ассоциативных контейнеров производится с помощью операции “меньше”. Однако можно указать и другой оператор сравнения (см. раздел 12.3 об объектах-функциях).
После того как отображение определено, необходимо заполнить его парами ключ/значение. Интуитивно хочется написать примерно так:
#include |
// и так далее ...
Когда мы пишем:
word_count[ string("Anna") ] = 1;
на самом деле происходит следующее:
1. Безымянный временный объект типа string
инициализируется значением "Anna" и передается оператору взятия индекса, определенному в классе map.
2. Производится поиск элемента с ключом "Anna" в массиве word_count. Такого элемента нет.
3. В word_count вставляется новая пара ключ/значение. Ключом является, естественно, строка "Anna". Значением – 0, а не 1.
4. После этого значению присваивается величина 1.
Если элемент отображения вставляется в отображение с помощью операции взятия индекса, то значением этого элемента становится значение по умолчанию для его типа данных. Для встроенных арифметических типов – 0.
Следовательно, если инициализация отображения производится оператором взятия индекса, то каждый элемент сначала получает значение по умолчанию, а затем ему явно присваивается нужное значение. Если элементы являются объектами класса, у которого инициализация по умолчанию и присваивание значения требуют больших затрат времени, программа будет работать правильно, но недостаточно эффективно.
Для вставки одного элемента предпочтительнее использовать следующий метод:
// предпочтительный метод вставки одного элемента word_count.insert( map value_type( string("Anna"), 1 ) |
В контейнере map
определен тип value_type для представления хранимых в нем пар ключ/значение. Строки
map< string,int >:: |
создают объект pair, который затем непосредственно вставляется в map. Для удобства чтения можно использовать typedef:
typedef map
Теперь операция вставки выглядит проще:
word_count.insert( valType( string("Anna"), 1 ));
Чтобы вставить элементы из некоторого диапазона, можно использовать метод insert(), принимающий в качестве параметров два итератора. Например:
map< string, int > word_count; // ... заполнить map< string,int > word_count_two; // скопируем все пары ключ/значение |
Мы могли бы сделать то же самое, просто проинициализировав одно отображение другим:
// инициализируем копией всех пар ключ/значение |
Посмотрим, как можно построить отображение для хранения нашего текста. Функция separate_words(), описанная в разделе 6.8, создает два объекта: вектор строк, хранящий все слова текста, и вектор позиций, хранящий пары (номер строки, номер колонки) для каждого слова. Таким образом, первый объект дает нам множество значений ключей нашего отображения, а второй – множество ассоциированных с ними значений.
}
Синтаксически сложное выражение
(*word_map)[(*text_words)[ix]]-> |
будет проще понять, если мы разложим его на составляющие:
// возьмем слово, которое надо обновить string word = (*text_words) [ix]; // возьмем значение из вектора позиций vector // возьмем позицию - пару координат loc = (*text_locs)[ix]; // вставим новую позицию |
Выражение все еще остается сложным, так как наши векторы представлены указателями. Поэтому вместо употребления оператора взятия индекса:
string word = text_words[ix]; // ошибка
мы вынуждены сначала разыменовать указатель на вектор:
string word = (*text_words) [ix]; // правильно
В конце концов build_word_map() возвращает построенное отображение:
return word_map;
Вот как выглядит вызов этой функции из main():
int main() { // считываем файл и выделяем слова vector text_loc *text_locations = separate_words( text_file ); // обработаем слова // ... // построим отображение слов на векторы позиций map *text_map = build_word_map( text_locatons ); // ... |
separate_words()
возвращает эти два вектора как объект типа pair, содержащий указатели на них. Сделаем эту пару аргументом функции build_word_map(), в результате которой будет получено соответствие между словами и позициями:
// typedef для удобства чтения typedef pair< short,short > location; typedef vector< location > loc; typedef vector< string > text; typedef pair< text*,loc* > text_loc; extern map< string, loc* >* |
Сначала выделим память для пустого объекта map и получим из аргумента-пары указатели на векторы:
map vector |
Теперь нам надо синхронно обойти оба вектора, учитывая два случая:
· слово встретилось впервые. Нужно поместить в map
новую пару ключ/значение;
· слово встречается повторно. Нам нужно обновить вектор позиций, добавив дополнительную пару (номер строки, номер колонки).
Вот текст функции:
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; // определяем, занесено ли слово в отображение // если count() возвращает 0 - нет: добавим его if ( ! word_map->count((*text_words)[-ix] )) { loc *ploc = new vector 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]); |
Определение объекта set и заполнение его элементами
Перед использованием класса setнеобходимо включить соответствующий заголовочный файл:
#include
Вот определение нашего множества стоп-слов:
set
Отдельные элементы могут добавляться туда с помощью операции insert(). Например:
exclusion_set.insert( "the" ); |
exclusion_set.insert( "and" );
Передавая insert() пару итераторов, можно добавить целый диапазон элементов. Скажем, наша поисковая система позволяет указать файл со стоп-словами. Если такой файл не задан, берется некоторый набор слов по умолчанию:
typedef set< string >::difference_type diff_type; set< string > 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 << "предупреждение! невозможно открыть файл стоп-слов! -- " << "используется стандартный набор слов \n"; copy( default_excluded_words, default_excluded_words+25, inserter( exclusion_set, exclusion_set.begin() )); } else { istream_iterator copy( input_set, eos, inserter( exclusion_set, exclusion_set.begin() ));
| |
}
В этом фрагменте кода встречаются два элемента, которые мы до сих пор не рассматривали: тип difference_type и класс inserter. difference_type – это тип результата вычитания двух итераторов для нашего множества строк. Он передается в качестве одного из параметров шаблона istream_iterator.
copy() –один из обобщенных алгоритмов. (Мы рассмотрим их в главе 12 и в Приложении.) Первые два параметра – пара итераторов или указателей – задают диапазон. Третий параметр является либо итератором, либо указателем на начало контейнера, в который элементы копируются.
Проблема с этой функцией вызвана ограничением, вытекающим из ее реализации: количество копируемых элементов не может превосходить числа элементов в контейнере-адресате. Дело в том, что copy() не вставляет элементы, она только присваивает каждому элементу новое значение. Однако ассоциативные контейнеры не позволяют явно задать размер. Чтобы скопировать элементы в наше множество, мы должны заставить copy()
вставлять элементы. Именно для этого служит класс inserter
(детально он рассматривается в разделе 12.4).
Определение объекта
В самом простом случае оператор определения объекта состоит из спецификатора типа и имени объекта и заканчивается точкой с запятой. Например:double salary; double wage; int month; int day; int year; |
unsigned long distance;
В одном операторе можно определить несколько объектов одного типа. В этом случае их имена перечисляются через запятую:
double salary, wage;
int month,
day, year;
unsigned long distance;
Простое определение переменной не задает ее начального значения. Если объект определен как глобальный, спецификация С++ гарантирует, что он будет инициализирован нулевым значением. Если же переменная локальная либо динамически размещаемая (с помощью оператора new), ее начальное значение не определено, то есть она может содержать некоторое случайное значение.
Использование подобных переменных– очень распространенная ошибка, которую к тому же трудно обнаружить. Рекомендуется явно указывать начальное значение объекта, по крайней мере в тех случаях, когда неизвестно, может ли объект инициализировать сам себя. Механизм классов вводит понятие конструктора по умолчанию, который служит для присвоения значений по умолчанию. (Мы уже сказали об этом в разделе 2.3. Разговор о конструкторах по умолчанию будет продолжен немного позже, в разделах 3.11 и 3.15, где мы будем разбирать классы string и complex из стандартной библиотеки.)
int main() { |
// неинициализированный локальный объект
int ival;
// объект типа string инициализирован
// конструктором по умолчанию
string project;
// ...
}
Начальное значение может быть задано прямо в операторе определения переменной. В С++ допустимы две формы инициализации переменной – явная, с использованием оператора присваивания:
int ival = 1024; |
string project = "Fantasia 2000";
и неявная, с заданием начального значения в скобках:
int ival( 1024 ); |
string project( "Fantasia 2000" );
Оба варианта эквивалентны и задают начальные значения для целой переменной ival как 1024 и для строки project как "Fantasia 2000".
Явную инициализацию можно применять и при определении переменных списком:
double salary = 9999.99, wage = salary + 0.01; int month = 08; |
Переменная становится видимой (и допустимой в программе) сразу после ее определения, поэтому мы могли проинициализировать переменную wage
суммой только что определенной переменной salary с некоторой константой. Таким образом, определение:
// корректно, но бессмысленно
int bizarre = bizarre;
является синтаксически допустимым, хотя и бессмысленным.
Встроенные типы данных имеют специальный синтаксис для задания нулевого значения:
// ival получает значение 0, а dval - 0.0 |
double dval = double();
В следующем определении:
// int() применяется к каждому из 10 элементов |
к каждому из десяти элементов вектора применяется инициализация с помощью int(). (Мы уже говорили о классе vector в разделе 2.8. Более подробно об этом см. в разделе 3.10 и главе 6.)
Переменная может быть инициализирована выражением любой сложности, включая вызовы функций. Например:
#include |
double price = 109.99, discount = 0.16;
double sale_price( price * discount );
string pet( "wrinkles" );
extern int get_value();
int val = get_value();
unsigned abs_val = abs( val );
abs() – стандартная функция, возвращающая абсолютное значение параметра. get_value() – некоторая пользовательская функция, возвращающая целое значение.
Упражнение 3.3
Какие из приведенных ниже определений переменных содержат синтаксические ошибки?
(a) int car = 1024, auto = 2048; (b) int ival = ival; (c) int ival( int() ); (d) double salary = wage = 9999.99; |
Упражнение 3.4
Объясните разницу между l-значением и r-значением. Приведите примеры.
Упражнение 3.5
Найдите отличия в использовании переменных name и student в первой и второй строчках каждого примера:
(a) extern string name; string name( "exercise 3.5a" ); (b) extern vector |
Упражнение 3.6
Какие имена объектов недопустимы в С++? Измените их так, чтобы они стали синтаксически правильными:
(a) int double = 3.14159; (b) vector< int > _; (c) string namespase; (d) string catch-22; |
Упражнение 3.7
В чем разница между следующими глобальными и локальными определениями переменных?
string global_class; int global_int; |
int local_int;
string local_class;
// ...
}
Определение производных классов
Каждый производный класс наследует данные и функции-члены своего базового класса, и программировать приходится лишь те аспекты, которые изменяют или расширяют его поведение. К примеру, в классе NameQuery необходимо определить реализацию eval(). Кроме того, нужна поддержка для хранения слова-операнда, представленного объектом класса типа string.Наконец, для получения ассоциированного вектора позиций должно быть доступно отображение слов на векторы. Поскольку один такой объект разделяется всеми объектами класса NameQuery, мы объявляем его статическим членом. Первая попытка определения NameQuery
(рассмотрение конструкторов, деструктора и копирующего оператора присваивания мы снова отложим) выглядит так:
typedef vector class NameQuery : public Query { public: // ... // переопределяет виртуальную функцию Query::eval()2 virtual void eval(); // функция чтения string name() const { return _name; } static const map protected: string _name; static map
| |
};
Класс NotQuery в дополнение к предоставлению реализации виртуальной функции eval() должен обеспечить поддержку своего единственного операнда. Поскольку им может быть объект любого из производных классов, определим его как указатель на тип Query. Результат запроса NotQuery, напомним, обязан содержать не только строки текста, где нет указанного слова, но также и номера колонок внутри каждой строки. Например, если есть запрос:
! daddy
то операнд запроса NotQuery
включает следующий вектор позиций:
daddy ((0,8),(3,3),(5,5))
Вектор позиций, возвращаемый в ответ на исходный запрос, должен включать все номера колонок в строках (1,2,4). Кроме того, он должен включать все номера колонок в строке (0), кроме колонки (8), все номера колонок в строке (3), кроме колонки (3), и все номера колонок в строке (5), кроме колонки (5).
Простейший способ вычислить все это – создать единственный разделяемый всеми объектами вектор позиций, который содержит пары (строка, колонка) для каждого слова в тексте (полную реализацию мы рассмотрим в разделе 17.5, когда будем обсуждать функцию eval()
класса NotQuery). Так или иначе, этот член мы объявим статическим для NotQuery.
Вот определение класса NotQuery (и снова рассмотрение конструкторов, деструктора и копирующего оператора присваивания отложено):
class NotQuery : public Query { public: // ... // альтернативный синтаксис: явно употреблено ключевое слово virtual // переопределение Query::eval() virtual void eval(); // функция доступа для чтения const Query *op() const { return _op; } static const vector< location > * all_locs() { return _all_locs; } protected: Query *_op; static const vector< location > *_all_locs; |
Классы AndQuery и OrQuery
представляют бинарные операции, у которых есть левый и правый операнды. Оба операнда могут быть объектами любого из производных классов, поэтому мы определим соответствующие члены как указатели на тип Query. Кроме того, в каждом классе нужно переопределить виртуальную функцию eval(). Вот начальное определение OrQuery:
class OrQuery : public Query { public: // ... virtual void eval(); const Query *rop() const { return _rop; } const Query *lop() const { return _lop; } protected: Query *_lop; Query *_rop; |
Любой объект AndQuery
должен иметь доступ к числу слов в каждой строке. В противном случае при обработке запроса AndQuery мы не сможем найти соседние слова, расположенные в двух смежных строках. Например, если есть запрос:
tell && her && magical
то нужная последовательность находится в третьей и четвертой строках:
like a fiery bird in flight. A beautiful fiery bird, he tells her,
magical but untamed. "Daddy, shush, there is no such thing,"
Векторы позиций, ассоциированные с каждым из трех слов, следующие:
her ((0,7),(1,5),(2,12),(4,11))
magical ((3,0))
tell ((2,11),(4,1),(4,10))
Если функция eval() класса AndQuery “не знает”, сколько слов содержится в строке (2), то она не сможет определить, что слова magical и her
соседствуют. Мы создадим единственный экземпляр вектора, разделяемый всеми объектами класса, и объявим его статическим членом. (Реализацию eval() мы детально рассмотрим в разделе 17.5.) Итак, определим AndQuery:
class AndQuery : public Query { public: // конструкторы обсуждаются в разделе 17.4 virtual void eval(); const Query *rop() const { return _rop; } const Query *lop() const { return _lop; } static void max_col( const vector< int > *pcol ) { if ( !_max_col ) _max_col = pcol; } protected: Query *_lop; Query *_rop; static const vector< int > *_max_col; |
Определение шаблона функции
Иногда может показаться, что сильно типизированный язык создает препятствия для реализации совсем простых функций. Например, хотя следующий алгоритм функции min()тривиален, сильная типизация требует, чтобы его разновидности были реализованы для всех типов, которые мы собираемся сравнивать:
int min( int a, int b ) { return a < b ? a : b; } double min( double a, double b ) { return a < b ? a : b; |
}
Заманчивую альтернативу явному определению каждого экземпляра функции min()
представляет использование макросов, расширяемых препроцессором:
#define min(a, b) ((a) < (b) ? (a) : (b))
Но этот подход таит в себе потенциальную опасность. Определенный выше макрос правильно работает при простых обращениях к min(), например:
min( 10, 20 ); |
min( 10.0, 20.0 );
но может преподнести сюрпризы в более сложных случаях: такой механизм ведет себя не как вызов функции, он лишь выполняет текстовую подстановку аргументов. В результате значения обоих аргументов оцениваются дважды: один раз при сравнении a и b, а второй – при вычислении возвращаемого макросом результата:
#include #define min(a,b) ((a) < (b) ? (a) : (b)) const int size = 10; int ia[size]; int main() { int elem_cnt = 0; int *p = &ia[0]; // подсчитать число элементов массива while ( min(p++,&ia[size]) != &ia[size] ) ++elem_cnt; cout << "elem_cnt : " << elem_cnt << "\texpecting: " << size << endl; return 0;
| |
}
На первый взгляд, эта программа подсчитывает количество элементов в массиве ia
целых чисел. Но в этом случае макрос min() расширяется неверно, поскольку операция постинкремента применяется к аргументу-указателю дважды при каждой подстановке. В результате программа печатает строку, свидетельствующую о неправильных вычислениях:
elem_cnt : 5 expecting: 10
Шаблоны функций предоставляют в наше распоряжение механизм, с помощью которого можно сохранить семантику определений и вызовов функций (инкапсуляция фрагмента кода в одном месте программы и гарантированно однократное вычисление аргументов), не принося в жертву сильную типизацию языка C++, как в случае применения макросов.
Шаблон дает алгоритм, используемый для автоматической генерации экземпляров функций с различными типами. Программист параметризует
все или только некоторые типы в интерфейсе функции (т.е. типы формальных параметров и возвращаемого значения), оставляя ее тело неизменным. Функция хорошо подходит на роль шаблона, если ее реализация остается инвариантной на некотором множестве экземпляров, различающихся типами данных, как, скажем, в случае min().
Так определяется шаблон функции min():
template Type min2( Type a, Type b ) { return a < b ? a : b; } int main() { // правильно: min( int, int ); min( 10, 20 ); // правильно: min( double, double ); min( 10.0, 20.0 ); return 0; |
Если вместо макроса препроцессора min()
подставить в текст предыдущей программы этот шаблон, то результат будет правильным:
elem_cnt : 10 expecting: 10
(В стандартной библиотеке C++ есть шаблоны функций для многих часто используемых алгоритмов, например для min(). Эти алгоритмы описываются в главе 12. А в данной вводной главе мы приводим собственные упрощенные версии некоторых алгоритмов из стандартной библиотеки.)
Как объявление, так и определение шаблона функции всегда должны начинаться с ключевого слова template, за которым следует список разделенных запятыми идентификаторов, заключенный в угловые скобки '<' и '>', – список параметров шаблона, обязательно непустой. У шаблона могут быть параметры-типы, представляющие некоторый тип, и параметры-константы,
представляющие фиксированное константное выражение.
Параметр-тип состоит из ключевого слова class или ключевого слова typename, за которым следует идентификатор. Эти слова всегда обозначают, что последующее имя относится к встроенному или определенному пользователем типу. Имя параметра шаблона выбирает программист. В приведенном примере мы использовали имя Type, но могли выбрать и любое другое:
template Glorp min2( Glorp a, Glorp b ) { return a < b ? a : b; |
}
При конкретизации ( порождении конкретного экземпляра) шаблона вместо параметра-типа подставляется фактический встроенный или определенный пользователем тип. Любой из типов int, double, char*, vector
является допустимым аргументом шаблона.
Параметр-константа выглядит как обычное объявление. Он говорит о том, что вместо имени параметра должно быть подставлено значение константы из определения шаблона. Например, size – это параметр-константа, который представляет размер массива arr:
template |
Вслед за списком параметров шаблона идет объявление или определение функции. Если не обращать внимания на присутствие параметров в виде спецификаторов типа или констант, то определение шаблона функции выглядит точно так же, как и для обычных функций:
template Type min( const Type (&r_array)[size] ) { /* параметризованная функция для отыскания * минимального значения в массиве */ Type min_val = r_array[0]; for ( int i = 1; i < size; ++i ) if ( r_array[i] < min_val ) min_val = r_array[i]; return min_val; |
В этом примере Type
определяет тип значения, возвращаемого функцией min(), тип параметра r_array и тип локальной переменной min_val; size задает размер массива r_array. В ходе работы программы при использовании функции min()
вместо Type
могут быть подставлены любые встроенные и определенные пользователем типы, а вместо size – те или иные константные выражения. (Напомним, что работать с функцией можно двояко: вызвать ее или взять ее адрес).
Процесс подстановки типов и значений вместо параметров называется конкретизацией шаблона. (Подробнее мы остановимся на этом в следующем разделе.)
Список параметров нашей функции min()
может показаться чересчур коротким. Как было сказано в разделе 7.3, когда параметром является массив, передается указатель на его первый элемент, первая же размерность фактического аргумента-массива внутри определения функции неизвестна. Чтобы обойти эту трудность, мы объявили первый параметр min() как ссылку на массив, а второй – как его размер. Недостаток подобного подхода в том, что при использовании шаблона с массивами одного и того же типа int, но разных размеров генерируются (или конкретизируются) различные экземпляры функции min().
Имя параметра разрешено употреблять внутри объявления или определения шаблона. Параметр-тип служит спецификатором типа; его можно использовать точно так же, как спецификатор любого встроенного или пользовательского типа, например в объявлении переменных или в операциях приведения типов. Параметр-константа применяется как константное значение – там, где требуются константные выражения, например для задания размера в объявлении массива или в качестве начального значения элемента перечисления.
// size определяет размер параметра-массива и инициализирует // переменную типа const int template Type min( const Type (&r_array)[size] ) { const int loc_size = size; Type loc_array[loc_size]; // ... |
Если в глобальной области видимости объявлен объект, функция или тип с тем же именем, что у параметра шаблона, то глобальное имя оказывается скрытым. В следующем примере тип переменной tmp не double, а тот, что у параметра шаблона Type:
typedef double Type; template Type min( Type a, Type b ) { // tmp имеет тот же тип, что параметр шаблона Type, а не заданный // глобальным typedef Type tm = a < b ? a : b; return tmp; |
Объект или тип, объявленные внутри определения шаблона функции, не могут иметь то же имя, что и какой-то из параметров:
template Type min( Type a, Type b ) { // ошибка: повторное объявление имени Type, совпадающего с именем // параметра шаблона typedef double Type; Type tmp = a < b ? a : b; return tmp; |
Имя параметра-типа шаблона можно использовать для задания типа возвращаемого значения:
// правильно: T1 представляет тип значения, возвращаемого min(), // а T2 и T3 – параметры-типы этой функции template |
В одном списке параметров некоторое имя разрешается употреблять только один раз. Например, следующее определение будет помечено как ошибка компиляции:
// ошибка: неправильное повторное использование имени параметра Type template |
Однако одно и то же имя можно многократно применять внутри объявления или определения шаблона:
// правильно: повторное использование имени Type внутри шаблона template |
template |
Имена параметров в объявлении и определении не обязаны совпадать. Так, все три объявления min()
относятся к одному и тому же шаблону функции:
// все три объявления min() относятся к одному и тому же шаблону функции // опережающие объявления шаблона template template // фактическое определение шаблона template |
Количество появлений одного и того же параметра шаблона в списке параметров функции не ограничено. В следующем примере Type
используется для представления двух разных параметров:
#include // правильно: Type используется неоднократно в списке параметров шаблона template |
Если шаблон функции имеет несколько параметров-типов, то каждому из них должно предшествовать ключевое слово class или typename:
// правильно: ключевые слова typename и class могут перемежаться template T minus( T*, U ); // ошибка: должно быть // template |
В списке параметров шаблона функции ключевые слова typename и class
имеют одинаковый смысл и, следовательно, взаимозаменяемы. Любое из них может использоваться для объявления разных параметров-типов шаблона в одном и том же списке (как было продемонстрировано на примере шаблона функции minus()). Для обозначения параметра-типа более естественно, на первый взгляд, употреблять ключевое слово typename, а не class, ведь оно ясно указывает, что за ним следует имя типа. Однако это слово было добавлено в язык лишь недавно, как часть стандарта C++, поэтому в старых программах вы скорее всего встретите слово class. (Не говоря уже о том, что class
короче, чем typename, а человек по природе своей ленив.)
Ключевое слово typename
упрощает разбор определений шаблонов. (Мы лишь кратко остановимся на том, зачем оно понадобилось. Желающим узнать об этом подробнее рекомендуем обратиться к книге Страуструпа “Design and Evolution of C++”.)
При таком разборе компилятор должен отличать выражения-типы от тех, которые таковыми не являются; выявить это не всегда возможно. Например, если компилятор встречает в определении шаблона выражение Parm::name и если Parm – это параметр-тип, представляющий класс, то следует ли считать, что name
представляет член-тип класса Parm?
template Parm minus( Parm* array, U value ) { Parm::name * p; // это объявление указателя или умножение? // На самом деле умножение |
Компилятор не знает, является ли name
типом, поскольку определение класса, представленного параметром Parm, недоступно до момента конкретизации шаблона. Чтобы такое определение шаблона можно было разобрать, пользователь должен подсказать компилятору, какие выражения включают типы. Для этого служит ключевое слово typename. Например, если мы хотим, чтобы выражение Parm::name в шаблоне функции minus()
было именем типа и, следовательно, вся строка трактовалась как объявление указателя, то нужно модифицировать текст следующим образом:
template Parm minus( Parm* array, U value ) { typename Parm::name * p; // теперь это объявление указателя |
Ключевое слово typename
используется также в списке параметров шаблона для указания того, что параметр является типом.
Шаблон функции можно объявлять как inline или extern – как и обычную функцию. Спецификатор помещается после списка параметров, а не перед словом template.
// правильно: спецификатор после списка параметров template inline Type min( Type, Type ); // ошибка: спецификатор inline не на месте inline template |
Type min( Array
Упражнение 10.1
Определите, какие из данных определений шаблонов функций неправильны. Исправьте ошибки.
(a) template void foo( T, U, V ); (b) template T foo( int *T ); (c) template T1 foo( T2, T3 ); (d) inline template T foo( T, unsigned int* ); (e) template void foo( myT, myT ); (f) template foo( T, T ); (g) typedef char Ctype; template |
Упражнение 10.2
Какие из повторных объявлений шаблонов ошибочны? Почему?
(a) template Type bar( Type, Type ); template Type bar( Type, Type ); (b) template void bar( T1, T2 ); template |
Упражнение 10.3
Перепишите функцию putValues() из раздела 7.3.3 в виде шаблона. Параметризуйте его так, чтобы было два параметра шаблона (для типа элементов массива и для размера массива) и один параметр функции, являющийся ссылкой на массив. Напишите определение шаблона функции.
Определение шаблона класса
Предположим, что нам нужно определить класс, поддерживающий механизм очереди. Очередь– это структура данных для хранения коллекции объектов; они помещаются в конец очереди, а извлекаются из ее начала. Поведение очереди описывают аббревиатурой FIFO – “первым пришел, первым ушел”. (Определенный в стандартной библиотеке C++ тип, реализующий очередь,упоминался в разделе 6.17. В этой главе мы создадим упрощенный тип для знакомства с шаблонами классов.)
Необходимо, чтобы наш класс Queue
поддерживал следующие операции:
· добавить элемент в конец очереди:
void add( item );
· удалить элемент из начала очереди:
item remove();
· определить, пуста ли очередь:
bool is_empty();
· определить, заполнена ли очередь:
bool is_full();
Определение Queue
могло бы выглядеть так:
template class Queue { public: Queue(); ~Queue(); Type& remove(); void add( const Type & ); bool is_empty(); bool is_full(); private: // ... |
Чтобы создать классы Queue, способные хранить целые числа, комплексные числа и строки, программисту достаточно написать:
Queue Queue< complex |
Реализация Queue
представлена в следующих разделах с целью иллюстрации определения и применения шаблонов классов. В реализации используются две абстракции шаблона:
· сам шаблон класса Queue
предоставляет описанный выше открытый интерфейс и пару членов: front и back. Очередь реализуется с помощью связанного списка;
· шаблон класса QueueItem
представляет один узел связанного списка Queue. Каждый помещаемый в очередь элемент сохраняется в объекте QueueItem, который содержит два члена: value и next. Тип value
будет различным в каждом экземпляре класса Queue, а next – это всегда указатель на следующий объект QueueItem в очереди.
Прежде чем приступать к детальному изучению реализации этих шаблонов, рассмотрим, как они объявляются и определяются. Вот объявление шаблона класса QueueItem:
template |
Как объявление, так и определение шаблона всегда начинаются с ключевого слова template. За ним следует заключенный в угловые скобки список параметров шаблона, разделенных запятыми. Список не бывает пустым. В нем могут быть параметры-типы, представляющие некоторый тип, и параметры-константы, представляющие некоторое константное выражение.
Параметр-тип шаблона состоит из ключевого слова class или typename (в списке параметров они эквивалентны), за которым следует идентификатор. (Ключевое слово typename не поддерживается компиляторами, написанными до принятия стандарта C++. В разделе 10.1 подробно объяснялось, зачем это слово было добавлено в язык.) Оба ключевых слова обозначают, что последующее имя параметра относится к встроенному или определенному пользователем типу. Например, в приведенном выше определении шаблона QueueItem
имеется один параметр-тип T. Допустимым фактическим аргументом для T
является любой встроенный или определенный пользователем тип, такой, как int, double, char*, complex или string.
У шаблона класса может быть несколько параметров-типов:
template |
Однако ключевое слово class или typename
должно предшествовать каждому. Следующее объявление ошибочно:
// ошибка: должно быть // template |
class collection;
Объявленный параметр-тип служит спецификатором типа в оставшейся части определения шаблона и употребляется точно так же, как любой встроенный или определенный пользователем тип в обычном определении класса. Например, параметр-тип можно использовать для объявления данных и функций-членов, членов вложенных классов и т.д.
Не являющийся типом параметр шаблона представляет собой обычное объявление. Он показывает, что следующее за ним имя – это потенциальное значение, употребляемое в определении шаблона в качестве константы. Так, шаблон класса Buffer
может иметь параметр-тип, представляющий типы элементов, хранящихся в буфере, и параметр-константу, содержащий его размер:
template |
За списком параметров шаблона следует определение или объявление класса. Шаблон определяется так же, как обычный класс, но с указанием параметров:
template class QueueItem { public: // ... private: // Type представляет тип члена Type item; QueueItem *next; |
В этом примере Type
используется для обозначения типа члена item. По ходу выполнения программы вместо Type
могут быть подставлены различные встроенные или определенные пользователем типы. Такой процесс подстановки называется конкретизацией
шаблона.
Имя параметра шаблона можно употреблять после его объявления и до конца объявления или определения шаблона. Если в глобальной области видимости объявлена переменная с таким же именем, как у параметра шаблона, то это имя будет скрыто. В следующем примере тип item
равен не double, а типу параметра:
typedef double Type; template class QueueItem { public: // ... private: // тип Item - не double Type item; QueueItem *next; |
Член класса внутри определения шаблона не может быть одноименным его параметру:
template class QueueItem { public: // ... private: // ошибка: член не может иметь то же имя, что и // параметр шаблона Type typedef double Type; Type item; QueueItem *next; |
};
Имя параметра шаблона может встречаться в списке только один раз. Поэтому следующее объявление компилятор помечает как ошибку:
// ошибка: неправильное использование имени параметра шаблона Type template |
Такое имя разрешается повторно использовать в объявлениях или определениях других шаблонов:
// правильно: повторное использование имени Type в разных шаблонах template class QueueItem; template |
Имена параметров в опережающем объявлении и последующем определении одного и того же шаблона не обязаны совпадать. Например, все эти объявления QueueItem
относятся к одному шаблону класса:
// все три объявления QueueItem // относятся к одному и тому же шаблону класса // объявления шаблона template template // фактическое определение шаблона template |
У параметров могут быть аргументы по умолчанию (это справедливо как для параметров-типов, так и для параметров-констант) – тип или значение, которые используются в том случае, когда при конкретизации шаблона фактический аргумент не указан. В качестве такого аргумента следует выбирать тип или значение, подходящее для большинства конкретизаций. Например, если при конкретизации шаблона класса Buffer не указан размер буфера, то по умолчанию принимается 1024:
template |
В последующих объявлениях шаблона могут быть заданы дополнительные аргументы по умолчанию. Как и в объявлениях функций, если для некоторого параметра задан такой аргумент, то он должен быть задан и для всех параметров, расположенных в списке правее (даже в другом объявлении того же шаблона):
template |
// правильно: рассматриваются аргументы по умолчанию из обоих объявлений template |
class Buffer;
(Отметим, что аргументы по умолчанию для параметров шаблонов не поддерживаются в компиляторах, реализованных до принятия стандарта C++. Чтобы примеры из этой книги, в частности из главы 12, компилировались большинством современных компиляторов, мы не использовали такие аргументы.)
Внутри определения шаблона его имя можно применять как спецификатор типа всюду, где допустимо употребление имени обычного класса. Вот более полная версия определения шаблона QueueItem:
template class QueueItem { public: QueueItem( const Type & ); private: Type item; QueueItem *next; |
Обратите внимание, что каждое появление имени QueueItem в определении шаблона – это сокращенная запись для
QueueItem
Такую сокращенную нотацию можно употреблять только внутри определения QueueItem (и, как мы покажем в следующих разделах, в определениях его членов, которые находятся вне определения шаблона класса). Если QueueItem
применяется как спецификатор типа в определении какого-либо другого шаблона, то необходимо задавать полный список параметров. В следующем примере шаблон класса используется в определении шаблона функции display. Здесь за именем шаблона класса QueueItem
должны идти параметры, т.е. QueueItem
template void display( QueueItem { QueueItem // ... |
class Queue {
public:
Queue();
~Queue();
Type& remove();
void add( const Type & );
bool is_empty();
bool is_full();
private:
// ...
};
Вопрос в том, какой тип использовать вместо Type? Предположим, что мы решили реализовать класс Queue, заменив Type на int. Тогда Queue
может управлять коллекциями объектов типа int. Если бы понадобилось поместить в очередь объект другого типа, то его пришлось бы преобразовать в тип int, если же это невозможно, компилятор выдаст сообщение об ошибке:
Queue qObj; string str( "vivisection" ); qObj.add( 3.14159 ); // правильно: в очередь помещен объект 3 |
qObj.add( str ); // ошибка: нет преобразования из string в int
Поскольку любой объект в коллекции имеет тип int, то язык C++ гарантирует, что в очередь можно поместить либо значение типа int, либо значение, преобразуемое в такой тип. Это подходит, если предстоит работа с очередями объектов только типа int. Если же класс Queue
Определения пространства имен А
По умолчанию любой объект, функция, тип или шаблон, объявленный в глобальной области видимости, также называемой областью видимости глобального пространства имен, вводит глобальную сущность. Каждая такая сущность обязана иметь уникальное имя. Например, функция и объект не могут быть одноименными, даже если они объявлены в разных исходных файлах.Таким образом, используя в своей программе некоторую библиотеку, мы должны быть уверены, что имена глобальных сущностей нашей программы не совпадают с именами из библиотеки. Это нелегко, если мы работаем с библиотеками разных производителей, где определено много глобальных имен. Собирая программу с такими библиотеками, нельзя гарантировать, что имена глобальных сущностей не будут вступать в конфликт.
Обойти эту проблему, названную проблемой засорения области видимости глобального пространства имен, можно посредством очень длинных имен. Часто в качестве их префикса употребляется определенная последовательность символов. Например:
class cplusplus_primer_matrix { ... }; |
void inverse( cplusplus_primer_matrix & );
Однако у этого решения есть недостаток. Программа, написанная на С++, может содержать множество глобальных классов, функций и шаблонов, видимых в любой точке кода. Работать со слишком длинными идентификаторами для программистов утомительно.
Пространства имен помогают справиться с проблемой засорения более удобным способом. Автор библиотеки может задать собственное пространство и таким образом вынести используемые в библиотеке имена из глобальной области видимости:
namespace cplusplus_primer { class matrix { /*...*/ }; void inverse ( matrix & ); |
}
cplusplus_primer
является пользовательским пространством имен
(в отличие от глобального пространства, которое неявно подразумевается и существует в любой программе).
Каждое такое пространство представляет собой отдельную область видимости. Оно может содержать вложенные определения пространств имен, а также объявления или определения функций, объектов, шаблонов и типов. Все сущности, объявленные внутри некоторого пространства имен, называются его членами. Каждое имя в пользовательском пространстве, как и в глобальном, должно быть уникальным в пределах этого пространства.
Однако в разных пользовательских пространствах могут встречаться члены с одинаковыми именами.
Имя члена пространства имен автоматически дополняется, или квалифицируется, именем этого пространства. Например, имя класса matrix, объявленное в пространстве cplusplus_primer, становится cplusplus_primer::matrix, а имя функции inverse()
превращается в cplusplus_primer::inverse().
Члены cplusplus_primer
могут использоваться в программе с помощью спецификации имени:
void func( cplusplus_primer::matrix &m ) { // ... cplusplus_primer::inverse(m); return m; |
Если в другом пользовательском пространстве имен (скажем, DisneyFeatureAnimation) также существует класс matrix и функция inverse() и мы хотим использовать этот класс вместо объявленного в пространстве cplusplus_primer, то функцию func()
нужно модифицировать следующим образом:
void func( DisneyFeatureAnimation::matrix &m ) { // ... DisneyFeatureAnimation::inverse(m); return m; |
Конечно, каждый раз указывать специфицированные имена типа
namespace_name::member_name
неудобно. Поэтому существуют механизмы, позволяющие облегчить использование пространств имен в программах. Это псевдонимы пространств имен, using-объявления и using-директивы. (Мы рассмотрим их в разделе 8.6.)
Определения пространства имен
Определение пользовательского пространства имен начинается с ключевого слова namespace, за которым следует идентификатор. Он должен быть уникальным в той области видимости, в которой определяется данное пространство; наличие другой сущности с тем же именем является ошибкой. Конечно, это не означает, что проблема засорения глобального пространства решена полностью, но существенно помогает в ее решении.За идентификатором пространства имен следует блок в фигурных скобках, содержащий различные объявления. Любое объявление, допустимое в области видимости глобального пространства, может встречаться и в пользовательском: классы, переменные (вместе с инициализацией), функции (вместе со своими определениями), шаблоны.
Помещая объявление в пользовательское пространство, мы не меняем его семантики. Единственное отличие состоит в том, что имена, вводимые такими объявлениями, включают в себя имя пространства, внутри которого они объявлены. Например:
namespace cplusplus_primer { class matrix { /* ... */ }; void inverse ( matrix & ); matrix operator+ ( const matrix &ml, const matrix &m2 ) {/* ... */ } const double pi = 3.1416; |
}
Именем класса, объявленного в пространстве cplusplus_primer, будет
cplusplus_primer::matrix
Именем функции
cplusplus_primer::inverse()
Именем константы
cplusplus_primer::pi
Имя класса, функции или константы расширяется именем пространства, в котором они объявлены. Такие имена называют квалифицированными.
Определение пространства имен не обязательно должно быть непрерывным. Например, предыдущее пространство могло быть определено таким образом:
namespace cplusplus_primer { class matrix { /* ... */ }; const double pi = 3.1416; } namespace cplusplus_primer { void inverse ( matrix & ); matrix operator+ ( const matrix &ml, const matrix &m2 ) {/* ... */ } |
}
Два приведенных примера эквивалентны: оба задают пространство имен cplusplus_primer, содержащее класс matrix, функцию inverse(), константу pi и operator+(). Определение пространства имен может состоять из нескольких соединенных частей.
Последовательность
namespace namespace_name {
задает новое пространство, если имя namespace_name не совпадает с одним из ранее объявленных. В противном случае новые объявления добавляются в старое пространство.
Возможность разбить пространство имен на несколько частей помогает при организации библиотеки. Ее исходный код легко разделить на интерфейсную часть и реализацию. Например:
// Эта часть пространства имен // определяет интерфейс библиотеки namespace cplusplus_primer { class matrix { /* ... */ }; const double pi = 3.1416; matrix operator+ ( const matrix &ml, const matrix &m2 ); void inverse ( matrix & ); } // Эта часть пространства имен // определяет реализацию библиотеки namespace cplusplus_primer { void inverse ( matrix &m ) { /* ... */ } matrix operator+ ( const matrix &ml, const matrix &m2 ) { /* ... */ } |
Первая часть пространства имен содержит объявления и определения, служащие интерфейсом библиотеки: определения типов, констант, объявления функций. Во второй части находятся детали реализации, то есть определения функций.
Еще более полезной для организации исходного кода библиотеки является возможность разделить определение одного пространства имен на несколько файлов: эти определения также объединяются. Наша библиотека может быть устроена следующим образом:
// ---- primer.h ---- namespace cplusplus_primer { class matrix { /*... */ }; const double pi = 3.1416; matrix operator+ ( const matrix &m1, const matrix &m2 ); void inverse( matrix & ); } // ---- primer.C ---- #include "primer.h" namespace cplusplus_primer { void inverse( matrix &m ) { /* ... */ } matrix operator+ ( const matrix &m1, const matrix &m2 ) { /* ... */ } |
Программа, использующая эту библиотеку, выглядит так:
// ---- user.C ---- // определение интерфейса библиотеки #include "primer.h" void func( cplusplus_primer::matrix &m ) { //... cplusplus_primer: :inverse( m ); return m; |
Подобная организация программы обеспечивает модульность библиотеки, необходимую для сокрытия реализации от пользователей, в то же время позволяя без ошибок скомпилировать и связать файлы primer.C и user.C в одну программу.
Определения шаблонов классов Queue и QueueItem
Ниже представлено определение шаблона класса Queue. Оно помещено в заголовочный файл Queue.hвместе с определением шаблона QueueItem:
#ifndef QUEUE_H #define QUEUE_H // объявление QueueItem template template class Queue { public: Queue() : front( 0 ), back ( 0 ) { } ~Queue(); Type& remove(); void add( const Type & ); bool is_empty() const { return front == 0; } private: QueueItem QueueItem };
| |
#endif
При использовании имени Queue
внутри определения шаблона класса Queue список параметров
можно опускать. Однако пропуск списка параметров шаблона QueueItem в определении шаблона Queue
недопустим. Так, объявление члена front является ошибкой:
template class Queue { public: // ... private: // ошибка: список параметров для QueueItem неизвестен QueueItem
| |
}
Упражнение 16.1
Найдите ошибочные объявления (или пары объявлений) шаблонов классов:
(a) template class Container1; template
| |
class Container1;
(b) template
| |
class Container2;
(c) template
| |
class Container3 {};
(d) template
| |
class Container4 {};
(e) template
| |
class Container5;
(f) template class Container6; template
| |
class Container6;
Упражнение 16.2
Следующее определение шаблона List
некорректно. Как исправить ошибку?
template class ListItem; template class List { public: List : _at_front( 0 ), _at_end( 0 ), _current( 0 ), _size( 0 ) {} List List ~List(); void insert( ListItem *ptr, elemType value ); int remove( elemType value ); ListItem *find( elemType value ); void display( ostream &os = cout ); int size() { return _size; } private: ListItem *_at_front; ListItem *_at_end; ListItem *_current; int _size
| |
};
Определенные пользователем преобразования
Мы уже видели, как преобразования типов применяются к операндам встроенных типов: в разделе 4.14 этот вопрос рассматривался на примере операндов встроенных операторов, а в разделе 9.3 – на примере фактических аргументов вызванной функции для приведения их к типам формальных параметров. Рассмотрим с этой точки зрения следующие шесть операций сложения:char ch; short sh;, int ival; /* в каждой операции один операнд * требует преобразования типа */ ch + ival; ival + ch; ch + sh; ch + ch; |
ival + sh; sh + ival;
Операнды ch и sh
расширяются до типа int. При выполнении операции складываются два значения типа int. Расширение типа неявно выполняется компилятором и для пользователя прозрачно.
В этом разделе мы рассмотрим, как разработчик может определить собственные преобразования для объектов типа класса. Такие определенные пользователем преобразования также автоматически вызываются компилятором по мере необходимости. Чтобы показать, зачем они нужны, обратимся снова к классу SmallInt, введенному в разделе 10.9.
Напомним, что SmallInt
позволяет определять объекты, способные хранить значения из того же диапазона, что unsigned char, т.е. от 0 до 255, и перехватывает ошибки выхода за его границы. Во всех остальных отношениях этот класс ведет себя точно так же, как unsigned char.
Чтобы иметь возможность складывать объекты SmallInt с другими объектами того же класса или со значениями встроенных типов, а также вычитать их, реализуем шесть операторных функций:
class SmallInt { friend operator+( const SmallInt &, int ); friend operator-( const SmallInt &, int ); friend operator-( int, const SmallInt & ); friend operator+( int, const SmallInt & ); public: SmallInt( int ival ) : value( ival ) { } operator+( const SmallInt & ); operator-( const SmallInt & ); // ... private: int value; |
};
Операторы-члены дают возможность складывать и вычитать два объекта SmallInt. Глобальные же операторы-друзья позволяют производить эти операции над объектами данного класса и объектами встроенных арифметических типов. Необходимо только шесть операторов, поскольку любой встроенный арифметический тип может быть приведен к типу int. Например, выражение
SmallInt si( 3 ); |
разрешается в два шага:
1. Константа 3.14159
типа double
преобразуется в целое число 3.
2. Вызывается operator+(const SmallInt &,int), который возвращает значение 6.
Если мы хотим поддержать битовые и логические операции, а также операции сравнения и составные операторы присваивания, то сколько же необходимо перегрузить операторов? Сразу и не сосчитаешь. Значительно удобнее автоматически преобразовать объект класса SmallInt в объект типа int.
В языке C++ имеется механизм, позволяющий в любом классе задать набор преобразований, применимых к его объектам. Для SmallInt мы определим приведение объекта к типу int. Вот его реализация:
class SmallInt { public: SmallInt( int ival ) : value( ival ) { } // конвертер // SmallInt ==> int operator int() { return value; } // перегруженные операторы не нужны private: int value; |
Оператор int() – это конвертер, реализующий определенное пользователем преобразование, в данном случае приведение типа класса к заданному типу int. Определение конвертера описывает, что означает преобразование и какие действия компилятор должен выполнить для его применения. Для объекта SmallInt
смысл преобразования в int заключается в том, чтобы вернуть число типа int, хранящееся в члене value.
Теперь объект класса SmallInt
можно использовать всюду, где допустимо использование int. Если предположить, что перегруженных операторов больше нет и в SmallInt
определен конвертер в int, операция сложения
SmallInt si( 3 ); |
разрешается двумя шагами:
1. Вызывается конвертер класса SmallInt, который возвращает целое число 3.
2. Целое число 3 расширяется до 3.0 и складывается с константой двойной точности 3.14159, что дает 6.14159.
Такое поведение больше соответствует поведению операндов встроенных типов по сравнению с определенными ранее перегруженными операторами. Когда значение типа int
складывается со значением типа double, то выполняется сложение двух чисел типа double
(поскольку тип int
расширяется до double) и результатом будет число того же типа.
В этой программе иллюстрируется применение класса SmallInt:
#include #include "SmallInt.h" int main() { cout << "Введите SmallInt, пожалуйста: "; while ( cin >> si1 ) { cout << "Прочитано значение " << si1 << "\nОно "; // SmallInt::operator int() вызывается дважды cout << ( ( si1 > 127 ) ? "больше, чем " : ( ( si1 < 127 ) ? "меньше, чем " : "равно ") ) << "127\n"; cout << "\Введите SmallInt, пожалуйста \ (ctrl-d для выхода): "; } cout <<"До встречи\n"; |
Откомпилированная программа выдает следующие результаты:
Введите SmallInt, пожалуйста: 127
Прочитано значение 127
Оно равно 127
Введите SmallInt, пожалуйста (ctrl-d для выхода): 126
Оно меньше, чем 127
Введите SmallInt, пожалуйста (ctrl-d для выхода): 128
Оно больше, чем 127
Введите SmallInt, пожалуйста (ctrl-d для выхода): 256
*** Ошибка диапазона SmallInt: 256 ***
В реализацию класса SmallInt добавили поддержку новой функциональности:
#include class SmallInt { friend istream& operator>>( istream &is, SmallInt &s ); friend ostream& operator<<( ostream &is, const SmallInt &s ) { return os << s.value; } public: SmallInt( int i=0 ) : value( rangeCheck( i ) ){} int operator=( int i ) { return( value = rangeCheck( i ) ); } operator int() { return value; } private: int rangeCheck( int ); int value; |
Ниже приведены определения функций-членов, находящиеся вне тела класса:
istream& operator>>( istream &is, SmallInt &si ) { int ix; is >> ix; si = ix; // SmallInt::operator=(int) return is; } int SmallInt::rangeCheck( int i ) { /* если установлен хотя бы один бит, кроме первых восьми, * то значение слишком велико; сообщить и сразу выйти */ if ( i & ~0377 ) { cerr << "\n*** Ошибка диапазона SmallInt: " << i << " ***" << endl; exit( -1 ); } return i; |
Открытие отдельных членов
Когда мы применили закрытое наследование класса PeekbackStack от IntArray, то все защищенные и открытые члены IntArray стали закрытыми членами PeekbackStack. Было бы полезно, если бы пользователи PeekbackStack могли узнать размер стека с помощью такой инструкции:is.size();
Разработчик способен оградить некоторые члены базового класса от эффектов неоткрытого наследования. Вот как, к примеру, открывается функция-член size() класса IntArray:
class PeekbackStack : private IntArray { public: // сохранить открытый уровень доступа using IntArray::size; // ... |
};
Еще одна причина для открытия отдельных членов заключается в том, что иногда необходимо разрешить доступ к защищенным членам закрыто унаследованного базового класса при последующем наследовании. Предположим, что пользователям нужен подтип стека PeekbackStack, который может динамически расти. Для этого классу, производному от PeekbackStack, понадобится доступ к защищенным элементам ia и _size класса IntArray:
template class PeekbackStack : private IntArray { public: using intArray::size; // ... protected: using intArray::size; using intArray::ia; // ...
| |
};
Производный класс может лишь вернуть унаследованному члену исходный уровень доступа, но не повысить или понизить его по сравнению с указанным в базовом классе.
На практике множественное наследование очень часто применяется для того, чтобы унаследовать открытый интерфейс одного класса и закрытую реализацию другого. Например, в библиотеку классов Booch Components включена следующая реализация растущей очереди Queue
(см. также статью Майкла Вило (Michaeel Vilot) и Грейди Буча (Grady Booch) в [LIPPMAN96b]):
template < class item, class container > class Unbounded_Queue: private Simple_List< item >, // ðåàëèçàöèÿ public Queue< item > // èíòåðôåéñ |
{ ... }
Открытое, закрытое и защищенное наследование
Открытое наследование называется еще наследованием типа. Производный класс в этом случае является подтипом базового; он замещает реализации всех функций-членов, специфичных для типа базового класса, и наследует общие для типа и подтипа функции. Можно сказать, что производный класс служит примером отношения “ЯВЛЯЕТСЯ”, т.е. предоставляет специализацию более общего базового класса. Медведь (Bear) является животным из зоопарка (ZooAnimal); аудиокнига (AudioBook) является предметом, выдаваемым читателям (LibraryLendingMaterial). Мы говорим, что Bear– это подтип ZooAnimal, равно как и Panda. Аналогично AudioBook – подтип LibBook (библиотечная книга), а оба они – подтипы LibraryLendingMaterial. В любом месте программы, где ожидается базовый тип, можно вместо него подставить открыто унаследованный от него подтип, и программа будет продолжать работать правильно (при условии, конечно, что подтип реализован корректно). Во всех приведенных выше примерах демонстрировалось именно наследование типа.Закрытое наследование называют также наследованием реализации. Производный класс напрямую не поддерживает открытый интерфейс базового, но пользуется его реализацией, предоставляя свой собственный открытый интерфейс.
Чтобы показать, какие здесь возникают вопросы, реализуем класс PeekbackStack, который поддерживает выборку из стека с помощью метода peekback():
bool PeekbackStack:: |
peekback( int index, type &value ) { ... }
где value
содержит элемент в позиции index, если peekback()
вернула true. Если же peekback()
возвращает false, то заданная аргументом index
позиция некорректна и в value помещается элемент из вершины стека.
В реализации PeekbackStack
возможны два типа ошибок:
· реализация абстракции PeekbackStack: некорректная реализация поведения класса;
· реализация представления данных: неправильное управление выделением и освобождением памяти, копированием объектов из стека и т.п.
Обычно стек реализуется либо как массив, либо как связанный список элементов (в стандартной библиотеке по умолчанию это делается на базе двусторонней очереди, хотя вместо нее можно использовать вектор, см. главу 6). Хотелось бы иметь гарантированно правильную (или, по крайней мере, хорошо протестированную и поддерживаемую) реализацию массива или списка, чтобы использовать ее в нашем классе PeekbackStack. Если она есть, то можно сосредоточиться на правильности поведения стека.
У нас есть класс IntArray, представленный в разделе 2.3 (мы временно откажемся от применения класса deque из стандартной библиотеки и от поддержки элементов, имеющих отличный от int
тип). Вопрос, таким образом, заключается в том, как лучше всего воспользоваться классом IntArray в нашей реализации PeekbackStack. Можно задействовать механизм наследования. (Отметим, что для этого нам придется модифицировать IntArray, сделав его члены защищенными, а не закрытыми.) Реализация выглядела бы так:
#include "IntArray.h" class PeekbackStack : public IntArray { private: const int static bos = -1; public: explicit PeekbackStack( int size ) : IntArray( size ), _top( bos ) {} bool empty() const { return _top == bos; } bool full() const { return _top == size()-1; } int top() const { return _top; } int pop() { if ( empty() ) /* îáðàáîòàòü îøèáêó */ ; return _ia[ _top-- ]; } void push( int value ) { if ( full() ) /* îáðàáîòàòü îøèáêó */ ; _ia[ ++_top ] = value; } bool peekback( int index, int &value ) const; private: int _top; }; inline bool PeekbackStack:: peekback( int index, int &value ) const { if ( empty() ) /* îáðàáîòàòü îøèáêó */ ; if ( index < 0 || index > _top ) { value = _ia[ _top ]; return false; } value = _ia[ index ]; return true; |
}
К сожалению, программа, которая работает с нашим новым классом PeekbackStack, может неправильно использовать открытый интерфейс базового IntArray:
extern void swap( IntArray&, int, int ); PeekbackStack is( 1024 ); // íåïðåäâèäåííîå îøèáî÷íîå èñïîëüçîâàíèå PeekbackStack swap(is, i, j); is.sort(); |
Абстракция PeekbackStack
должна обеспечить доступ к элементам стека по принципу “последним пришел, первым ушел”. Однако наличие дополнительного интерфейса IntArray не позволяет гарантировать такое поведение.
Проблема в том, что открытое наследование описывается как отношение “ЯВЛЯЕТСЯ”. Но PeekbackStack не является разновидностью массива IntArray, а лишь включает его как часть своей реализации. Открытый интерфейс IntArray не должен входить в открытый интерфейс PeekbackStack.
Закрытое наследование от базового класса представляет собой вид наследования, который нельзя описать в терминах подтипов. В производном классе открытый интерфейс базового становится закрытым. Все показанные выше примеры использования объекта PeekbackStack
становятся допустимыми только внутри функций-членов и друзей производного класса.
В приведенном ранее определении PeekbackStack
достаточно заменить слово public в списке базовых классов на private. Внутри же самого определения класса public и private следует оставить на своих местах:
class PeekbackStack : private IntArray { ... };
Отложенное обнаружение ошибок
Начинающие программисты часто удивляются, почему некорректные определения классов AndQuery и OrQuery (в которых отсутствуют необходимые объявления конструкторов) компилируются без ошибок. Если бы мы не попытались определить фактический объект класса AndQuery, в этой модифицированной иерархии так и осталась бы ненайденная ошибка. Дело в том, что:·
если ошибка обнаруживается в точке объявления, то мы не можем продолжать компиляцию приложения, пока не исправим ее. Если же конфликтующее объявление – это часть библиотеки, для которой у нас нет исходного текста, то разрешение конфликта может оказаться нетривиальной задачей. Более того, возможно, в нашем коде никогда и не возникнет ситуации, когда эта ошибка проявляется, так что для нас она останется лишь потенциальной угрозой;
· с другой стороны, если ошибка не найдена вплоть до момента использования, то код может оказаться замусоренным ошибками, проявляющимися в самый неподходящий момент к удивлению программиста. При такой стратегии успешная компиляция говорит не об отсутствии семантических ошибок, а лишь о том, что программа не исполняет код, нарушающий семантические правила языка.
Выдача сообщения об ошибке в точке использования – это одна из форм отложенного вычисления, распространенного метода повышения производительности программ. Он часто применяется для того, чтобы отложить потенциально дорогую операцию выделения или инициализации ресурса до момента, когда в нем возникнет реальная необходимость. Если ресурс так и не понадобится, мы сэкономим на ненужных подготовительных операциях. Если же он потребуется, но не сразу, мы растянем инициализацию программы на более длительный период.
В C++ потенциальные ошибки “комбинирования”, связанные с перегруженными функциями, шаблонами и наследованием классов, обнаруживаются в точке использования, а не в точке объявления. (Мы полагаем, что это правильно, поскольку необходимость выявлять все возможные ошибки, которые можно допустить в результате комбинирования многочисленных компонентов, – пустая трата времени). Следовательно, для обнаружения и устранения латентных ошибок необходимо тщательно тестировать код. Подобные ошибки, возникающие при комбинировании двух или более больших компонентов, допустимы; однако в пределах одного компонента, такого, как иерархия классов Query, их быть не должно.
Параметры и тип возврата
Вернемся к задаче, сформулированной в начале данного раздела. Как использовать указатели на функции для сортировки элементов? Мы можем передать в алгоритм сортировки указатель на функцию, которая выполняет сравнение:int sort( string*, string*, |
int (*)( const string &, const string & ) );
И в этом случае директива typedef
помогает сделать объявление sort() более понятным:
// Использование директивы typedef делает // объявление sort() более понятным typedef int ( *PFI2S )( const string &, const string & ); |
int sort( string*, string*, PFI2S );
Поскольку в большинстве случаев употребляется функция lexicoCompare, можно использовать значение параметра по умолчанию:
// значение по умолчанию для третьего параметра int lexicoCompare( const string &, const string & ); |
int sort( string*, string*, PFI2S = lexicoCompare );
Определение sort() выглядит следующим образом:
1 void sort( string *sl, string *s2, 2 PFI2S compare = lexicoCompare ) 3 { 4 // условие окончания рекурсии 5 if ( si < s2 ) { 6 string elem = *s1; 7 string *1ow = s1; 8 string *high = s2 + 1; 9 10 for (;;) { 11 while ( compare ( *++1ow, elem ) < 0 && low < s2) ; 12 while ( compare( elem, *--high ) < 0 && high > s1) 14 if ( low < high ) 15 1ow->swap(*high); 16 else break; 17 } // end, for(;;) 18 19 s1->swap(*high); 20 sort( s1, high - 1 ); 21 sort( high +1, s2 ); 22 } // end, if ( si < s2 ) |
23 }
sort()
реализует алгоритм быстрой сортировки Хоара
(C.A.R.Hoare). Рассмотрим ее определение детально. Она сортирует элементы массива от s1 до s2. Это рекурсивная функция, которая вызывает сама себя для последовательно уменьшающихся подмассивов. Рекурсия окончится тогда, когда s1 и s2
укажут на один и тот же элемент или s1 будет располагаться после s2
(строка 5).
elem
( строка 6) является разделяющим элементом. Все элементы, меньшие чем elem, перемещаются влево от него, а большие– вправо. Теперь массив разбит на две части. sort()
рекурсивно вызывается для каждой из них (строки 20-21).
Цикл for(;;)
проводит разделение (строки 10-17). На каждой итерации цикла индекс low
увеличивается до первого элемента, большего или равного elem
(строка 11). Аналогично high уменьшается до последнего элемента, меньшего или равного elem
(строка 12). Когда low
становится равным или большим high, мы выходим из цикла, в противном случае нужно поменять местами значения элементов и начать новую итерацию (строки 14-16). Хотя элементы разделены, elem все еще остается первым в массиве. swap() в строке 19 ставит его на место до рекурсивного вызова sort() для двух частей массива.
Сравнение производится вызовом функции, на которую указывает compare
(строки 11-12). Чтобы поменять элементы массива местами, используется операция swap() с аргументами типа string, представленная в разделе 6.11.
Вот как выглядит main(), в которой применяется наша функция сортировки:
#include #include // это должно бы находиться в заголовочном файле int lexicoCompare( const string &, const string & ); int sizeCompare( const string &, const string & ); typedef int (*PFI)( const string &, const string & ); void sort( string *, string *, PFI=lexicoCompare ); string as[10] = { "a", "light", "drizzle", "was", "falling", "when", "they", "left", "the", "museum" }; int main() { // вызов sort() с значением по умолчанию параметра compare sort( as, as + sizeof(as)/sizeof(as[0]) - 1 ); // выводим результат сортировки for ( int i = 0; i < sizeof(as)/sizeof(as[0]); ++i ) cout << as[ i ].c_str() << "\n\t"; |
}
Результат работы программы:
"a"
"drizzle"
"falling"
"left"
"light"
"museum"
"the"
"they"
"was"
"when"
Параметр функции автоматически приводится к типу указателя на функцию:
// typedef представляет собой тип функции typedef int functype( const string &, const string & ); |
sort() рассматривается компилятором как объявленная в виде
void sort( string *, string *, |
Два этих объявления sort()
эквивалентны.
Заметим, что, помимо использования в качестве параметра, указатель на функцию может быть еще и типом возвращаемого значения. Например:
int (*ff( int ))( int*, int );
ff()
объявляется как функция, имеющая один параметр типа int и возвращающая указатель на функцию типа
int (*)( int*, int );
И здесь использование директивы typedef делает объявление понятнее. Объявив PF с помощью typedef, мы видим, что ff()
возвращает указатель на функцию:
// Использование директивы typedef делает // объявления более понятными typedef int (*PF)( int*, int ); |
Типом возвращаемого значения функции не может быть тип функции. В этом случае выдается ошибка компиляции. Например, нельзя объявить ff() таким образом:
// typedef представляет собой тип функции typedef int func( int*, int ); |
Параметры-массивы
Массив в С++ никогда не передается по значению, а только как указатель на его первый, точнее нулевой, элемент. Например, объявлениеvoid putValues( int[ 10 ] );
рассматривается компилятором так, как будто оно имеет вид
void putValues( int* );
Размер массива неважен при объявлении параметра. Все три приведенные записи эквивалентны:
// три эквивалентных объявления putValues() void putValues( int* ); void putValues( int[] ); |
void putValues( int[ 10 ] );
Передача массивов как указателей имеет следующие особенности:
·
изменение значения аргумента внутри функции затрагивает сам переданный объект, а не его локальную копию. Если такое поведение нежелательно, программист должен позаботиться о сохранении исходного значения. Можно также при объявлении функции указать, что она не должна изменять значение параметра, объявив этот параметр константой:
void putValues( const int[ 10 ] );
· размер массива не является частью типа параметра. Поэтому функция не знает реального размера передаваемого массива. Компилятор тоже не может это проверить. Рассмотрим пример:
void putValues( int[ 10 ] ); // рассматривается как int* int main() { int i, j [ 2 ]; putValues( &i ); // правильно: &i is int*; // однако при выполнении возможна ошибка putValues( j ); // правильно: j - адрес 0-го элемента - int*; |
// однако при выполнении возможна ошибка
При проверке типов параметров компилятор способен распознать, что в обоих случаях тип аргумента int*
соответствует объявлению функции. Однако контроль за тем, не является ли аргумент массивом, не производится.
По принятому соглашению C-строка является массивом символов, последний элемент которого равен нулю. Во всех остальных случаях при передаче массива в качестве параметра необходимо указывать его размер. Это относится и к массивам символов, внутри которых встречается 0. Обычно для такого указания используют дополнительный параметр функции. Например:
void putValues( int[], int size ); int main() { int i, j[ 2 ]; putValues( &i, 1 ); putValues( j, 2 ); return 0; |
putValues()
печатает элементы массива в следующем формате:
( 10 )< 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 >
где 10 – это размер массива. Вот как выглядит реализация putValues(), в которой используется дополнительный параметр:
#include const lineLength =12; // количество элементов в строке void putValues( int *ia, int sz ) { cout << "( " << sz << " )< "; for (int i=0;i { if ( i % lineLength == 0 && i ) cout << "\n\t"; // строка заполнена cout << ia[ i ]; // разделитель, печатаемый после каждого элемента, // кроме последнего if ( i % lineLength != lineLength-1 && i != sz-1 ) cout << ", "; } cout << " >\n"; |
Другой способ сообщить функции размер массива-параметра – объявить параметр как ссылку. В этом случае размер становится частью типа, и компилятор может проверить аргумент в полной мере.
// параметр - ссылка на массив из 10 целых void putValues( int (&arr)[10] ); int main() { int i, j [ 2 ]; putValues(i); // ошибка: // аргумент не является массивом из 10 целых putValues(j); // ошибка: // аргумент не является массивом из 10 целых return 0; |
Поскольку размер массива теперь является частью типа параметра, новая версия putValues()
способна работать только с массивами из 10 элементов. Конечно, это ограничивает ее область применения, зато реализация значительно проще:
#include void putValues( int (&ia)[10] ) { cout << "( 10 )< "; for ( int 1 =0; i < 10; ++i ) { cout << ia[ i ]; // разделитель, печатаемый после каждого элемента, // кроме последнего if ( i != 9 ) cout << ", "; } cout << " >\n"; |
}
Еще один способ получить размер переданного массива в функции – использовать абстрактный контейнерный тип. (Такие типы были представлены в главе 6. В следующем подразделе мы поговорим об этом подробнее.)
Хотя две предыдущих реализации putValues()
правильны, они обладают серьезными недостатками. Так, первый вариант работает только с массивами типа int. Для типа double*
нужно писать другую функцию, для long* – еще одну и т.д. Второй вариант производит операции только над массивом из 10 элементов типа int. Для обработки массивов разного размера нужны дополнительные функции. Лучшим решением было бы использовать шаблон – функцию, или, скорее, обобщенную реализацию кода целого семейства функций, которые отличаются только типами обрабатываемых данных. Вот как можно сделать из первого варианта putValues()
шаблон, способный работать с массивами разных типов и размеров:
template void putValues( Type *ia, int sz ) { // так же, как и раньше |
Параметры шаблона заключаются в угловые скобки. Ключевое слово class
означает, что идентификатор Type служит именем параметра, при конкретизации шаблона функции putValues() он заменяется на реальный тип – int, double, string и т.д. (В главе 10 мы продолжим разговор о шаблонах функций.)
Параметр может быть многомерным массивом. Для такого параметра должны быть заданы правые границы всех измерений, кроме первого. Например:
putValues( int matrix[][10], int rowSize );
Здесь matrix
объявляется как двумерный массив, который содержит десять столбцов и неизвестное число строк. Эквивалентным объявлением для matrix
будет:
int (*matrix)[10]
Многомерный массив передается как указатель на его нулевой элемент. В нашем случае тип matrix – указатель на массив из десяти элементов типа int. Как и для одномерного массива, граница первого измерения не учитывается при проверке типов. Если параметры являются многомерными массивами, то контролируются все измерения, кроме первого.
Заметим, что скобки вокруг *matrix
необходимы из-за более высокого приоритета операции взятия индекса. Инструкция
int *matrix[10];
объявляет matrix как массив из десяти указателей на int.
Параметры-ссылки и параметры-указатели
Когда же лучше использовать параметры-ссылки, а когда – параметры-указатели? В конце концов, и те и другие позволяют функции модифицировать объекты, эффективно передавать в функцию большие объекты типа класса. Что выбрать: объявить параметр ссылкой или указателем?Как было сказано в разделе 3.6, ссылка может быть один раз инициализирована значением объекта, и впоследствии изменить ее нельзя. Указатель же в течение своей жизни способен адресовать разные объекты или не адресовать вообще.
Поскольку указатель может содержать, а может и не содержать адрес какого-либо объекта, перед его использованием функция должна проверить, не равен ли он нулю:
class X; void manip( X *px ) { // проверим на 0 перед использованием if ( px != 0 ) // обратимся к объекту по адресу... |
}
Параметр-ссылка не нуждается в этой проверке, так как всегда существует именуемый ею объект. Например:
class Type { }; void operate( const Type& p1, const Type& p2 ); int main() { Type obj1; // присвоим objl некоторое значение // ошибка: ссылка не может быть равной 0 Type obj2 = operate( objl, 0 ); |
}
Если параметр должен ссылаться на разные объекты во время выполнения функции или принимать нулевое значение (ни на что не ссылаться), нам следует использовать указатель.
Одна из важнейших сфер применения параметров-ссылок – эффективная реализация перегруженных операций. При этом использование операций остается простым и интуитивно понятным. (Подробнее данный вопрос рассматривается в главе 15.) Разберем маленький пример. Представим себе класс Matrix (матрица). Хорошо бы реализовать операции сложения и присваивания “привычным” способом:
Matrix a, b, c; |
c = a + b;
Эти операции реализуются с помощью перегруженных операторов – функций с немного необычным именем. Для оператора сложения такая функция будет называться operator+. Посмотрим, как ее определить:
Matrix // тип возврата - Matrix operator+( // имя перегруженного оператора Matrix m1, // тип левого операнда Matrix m2 // тип правого операнда ) { Matrix result; // необходимые действия return result; |
}
При такой реализации сложение двух объектов типа Matrix выглядит вполне привычно:
a + b;
но, к сожалению, оказывается совершенно неэффективным. Заметим, что параметры у нас передаются по значению. Содержимое двух матриц будет копироваться в область активации функции operator+(), а поскольку объекты типа Matrix весьма велики, затраты времени и памяти на создание копий могут быть совершенно неприемлемыми.
Представим себе, что мы решили использовать указатели в качестве параметров, чтобы избежать этих затрат. Вот модифицированный код operator+():
// реализация с параметрами-указателями operator+( Matrix *ml, Matrix *m2 ) { Matrix result; // необходимые действия return result; |
Да, мы добились эффективной реализации, но зато теперь применение нашей операции вряд ли можно назвать интуитивно понятным. В качестве значений параметров-указателей требуется передавать адреса складываемых объектов. Поэтому для сложения двух матриц пришлось бы написать:
&a + &b; // допустимо, хотя и плохо
Хотя такая форма не может не вызвать критику, но все-таки два объекта сложить еще удается. А вот три уже крайне затруднительно:
// а вот это не работает // &a + &b возвращает объект типа Matrix |
Для того чтобы сложить три объекта, при подобной реализации нужно написать так:
// правильно: работает, однако ... |
Трудно ожидать, что кто-нибудь согласится писать такие выражения. К счастью, параметры-ссылки дают именно то решение, которое требуется. Если параметр объявлен как ссылка, функция получает его l-значение, а не копию. Лишнее копирование исключается. И тип фактического аргумента может быть Matrix – это упрощает операцию сложения, как и для встроенных типов. Вот схема перегруженного оператора сложения для класса Matrix:
// реализация с параметрами-ссылками operator+( const Matrix &m1, const Matrix &m2 ) { Matrix result; // необходимые действия return result; |
При такой реализации сложение трех объектов Matrix выглядит вполне привычно:
a + b + c;
Ссылки были введены в С++ именно для того, чтобы удовлетворить двум требованиям: эффективная реализация и интуитивно понятное применение.
Параметры-ссылки
Использование ссылок в качестве параметров модифицирует стандартный механизм передачи по значению. При такой передаче функция манипулирует локальными копиями аргументов. Используя параметры-ссылки, она получает l-значения своих аргументов и может изменять их.В каких случаях применение параметров-ссылок оправданно? Во-первых, тогда, когда без использования ссылок пришлось бы менять типы параметров на указатели (см. приведенную выше функцию swap()). Во-вторых, при необходимости вернуть из функции несколько значений. В-третьих, для передачи большого объекта типа класса. Рассмотрим два последних случая подробнее.
Как пример функции, использующей параметр-ссылку для возврата дополнительного значения, возьмем look_up(), которая будет искать заданную величину в векторе целых чисел. В случае успеха look_up()
вернет итератор, указывающий на найденный элемент, иначе– на элемент, расположенный за конечным. Если величина содержится в векторе несколько раз, итератор будет указывать на первое вхождение. Кроме того, дополнительный параметр-ссылка occurs
возвращает количество найденных элементов.
#include // параметр-ссылка 'occurs' // содержит второе возвращаемое значение vector const vector int value, // искомое значение int &occurs ) // количество вхождений { // res_iter инициализируется значением // следующего за конечным элемента vector occurs = 0; for ( vector iter != vec.end(); ++iter ) if ( *iter == value ) { if ( res_iter == vec.end() ) res_iter = iter; ++occurs; } return res_iter;
| |
}
Третий случай, когда использование параметра-ссылки может быть полезно, – это большой объект типа класса в качестве аргумента. При передаче по значению объект будет копироваться целиком при каждом вызове функции, что для больших объектов может привести к потере эффективности. Используя параметр-ссылку, функция получает доступ к той области памяти, где размещен сам объект, без создания дополнительной копии.
Например:
class Huge { public: double stuff[1000]; }; extern int calc( const Huge & ); int main() { Huge table[ 1000 ]; // ... инициализация table int sum = 0; for ( int ix=0; ix < 1000; ++ix ) // calc() ссылается на элемент массива // типа Huge sum += calc( tab1e[ix] ); // ... |
Может возникнуть желание использовать параметр-ссылку, чтобы избежать создания копии большого объекта, но в то же время не дать вызываемой функции возможности изменять значение аргумента. Если параметр-ссылка не должен модифицироваться внутри функции, то стоит объявить его как ссылку на константу. В такой ситуации компилятор способен распознать и пресечь попытку непреднамеренного изменения значения аргумента.
В следующем примере нарушается константность параметра xx функции foo(). Поскольку параметр функции foo_bar() не является ссылкой на константу, то нет гарантии, что вызов foo_bar() не изменит значения аргумента. Компилятор сигнализирует об ошибке:
class X; extern int foo_bar( X& ); int foo( const X& xx ) { // ошибка: константа передается // функции с параметром неконстантного типа return foo_bar( xx ); |
Для того чтобы программа компилировалась, мы должны изменить тип параметра foo_bar(). Подойдет любой из следующих двух вариантов:
extern int foo_bar( const X& ); |
Вместо этого можно передать копию xx, которую позволено менять:
int foo( const X &xx ) { // ... X x2 = xx; // создать копию значения // foo_bar() может поменять x2, // xx останется нетронутым return foo_bar( x2 ); // правильно |
Параметр-ссылка может именовать любой встроенный тип данных. В частности, разрешается объявить параметр как ссылку на указатель, если программист хочет изменить значение самого указателя, а не объекта, который он адресует. Вот пример функции, обменивающей друг с другом значения двух указателей:
void ptrswap( int *&vl, int *&v2 ) { int *trnp = v2; v2 = vl; vl = tmp; |
Объявление
int *&v1;
должно читаться справа налево: v1
является ссылкой на указатель на объект типа int. Модифицируем функцию main(), которая вызывала rswap(), для проверки работы ptrswap():
#include void ptrswap( int *&vl, int *&v2 ); int main() { int i = 10; int j = 20; int *pi = &i; int *pj = &j; cout << "Перед ptrswap():\tpi: " << *pi << "\tpj: " << *pj << endl; ptrswap( pi, pj ); cout << "После ptrswap():\tpi: " << *pi << "\tpj: " << pj << endl; return 0; |
Вот результат работы программы:
Перед ptrswap(): pi: 10 pj: 20
После ptrswap(): pi: 20 pj: 10
Перечисления
Нередко приходится определять переменную, которая принимает значения из некоего набора. Скажем, файл открывают в любом из трех режимов: для чтения, для записи, для добавления.Конечно, можно определить три константы для обозначения этих режимов:
const int input = 1; const int output = 2; |
const int append = 3;
и пользоваться этими константами:
bool open_file( string file_name, int open_mode); // ... |
open_file( "Phoenix_and_the_Crane", append );
Подобное решение допустимо, но не вполне приемлемо, поскольку мы не можем гарантировать, что аргумент, передаваемый в функцию open_file() равен только 1, 2 или 3.
Использование перечислимого типа решает данную проблему. Когда мы пишем:
enum open_modes{ input = 1, output, append };
мы определяем новый тип open_modes. Допустимые значения для объекта этого типа ограничены набором 1, 2 и 3, причем каждое из указанных значений имеет мнемоническое имя. Мы можем использовать имя этого нового типа для определения как объекта данного типа, так и типа формальных параметров функции:
void open_file( string file_name, open_modes om );
input, output и append являются элементами перечисления. Набор элементов перечисления задает допустимое множество значений для объекта данного типа. Переменная типа open_modes (в нашем примере) инициализируется одним из этих значений, ей также может быть присвоено любое из них. Например:
open_file( "Phoenix and the Crane", append );
Попытка присвоить переменной данного типа значение, отличное от одного из элементов перечисления (или передать его параметром в функцию), вызовет ошибку компиляции. Даже если попробовать передать целое значение, соответствующее одному из элементов перечисления, мы все равно получим ошибку:
// ошибка: 1 не является элементом перечисления open_modes |
open_file( "Jonah", 1 );
Есть способ определить переменную типа open_modes, присвоить ей значение одного из элементов перечисления и передать параметром в функцию:
open_modes om = input; // ... |
om = append;
open_file( "TailTell", om );
Однако получить имена таких элементов невозможно. Если мы напишем оператор вывода:
cout << input << " " << om << endl;
то все равно получим:
1 3
Эта проблема решается, если определить строковый массив, в котором элемент с индексом, равным значению элемента перечисления, будет содержать его имя. Имея такой массив, мы сможем написать:
cout << open_modes_table[ input ] << " " |
Будет выведено:
input append
Кроме того, нельзя перебрать все значения перечисления:
// не поддерживается for ( open_modes iter = input; iter != append; ++inter ) |
Для определения перечисления служит ключевое слово enum, а имена элементов задаются в фигурных скобках, через запятую. По умолчанию первый из них равен 0, следующий – 1 и так далее. С помощью оператора присваивания это правило можно изменить. При этом каждый следующий элемент без явно указанного значения будет на 1 больше, чем элемент, идущий перед ним в списке. В нашем примере мы явно указали значение 1 для input, при этом output и append
будут равны 2 и 3. Вот еще один пример:
// shape == 0, sphere == 1, cylinder == 2, polygon == 3 |
Целые значения, соответствующие разным элементам одного перечисления, не обязаны отличаться. Например:
// point2d == 2, point2w == 3, point3d == 3, point3w == 4 |
Объект, тип которого – перечисление, можно определять, использовать в выражениях и передавать в функцию как аргумент. Подобный объект инициализируется только значением одного из элементов перечисления, и только такое значение ему присваивается – явно или как значение другого объекта того же типа. Даже соответствующие допустимым элементам перечисления целые значения не могут быть ему присвоены:
void mumble() { Points pt3d = point3d; // правильно: pt2d == 3 // ошибка: pt3w инициализируется типом int Points pt3w = 3; // ошибка: polygon не входит в перечисление Points pt3w = polygon; // правильно: оба объекта типа Points pt3w = pt3d; |
Однако в арифметических выражениях перечисление может быть автоматически преобразовано в тип int. Например:
const int array_size = 1024; // правильно: pt2w преобразуется int |
Передача аргументов
Функции используют память из стека программы. Некоторая область стека отводится функции и остается связанной с ней до окончания ее работы, по завершении которой отведенная ей память освобождается и может быть занята другой функцией. Иногда эту часть стека называют областью активации.Каждому параметру функции отводится место в данной области, причем его размер определяется типом параметра. При вызове функции память инициализируется значениями фактических аргументов.
Стандартным способом передачи аргументов является копирование их значений, т.е. передача по значению. При этом способе функция не получает доступа к реальным объектам, являющихся ее аргументами. Вместо этого она получает в стеке локальные копии этих объектов. Изменение значений копий никак не отражается на значениях самих объектов. Локальные копии теряются при выходе из функции.
Значения аргументов при передаче по значению не меняются. Следовательно, программист не должен заботиться о сохранении и восстановлении их значений при вызове функции. Без этого механизма любой вызов мог бы привести к нежелательному изменению аргументов, не объявленных константными явно. Передача по значению освобождает человека от лишних забот в наиболее типичной ситуации.
Однако такой способ передачи аргументов может не устраивать нас в следующих случаях:
· передача большого объекта типа класса. Временные и пространственные расходы на размещение и копирование такого объекта могут оказаться неприемлемыми для реальной программы;
· иногда значения аргументов должны быть модифицированы внутри функции. Например, swap()
должна обменять значения своих аргументов, что невозможно при передаче по значению:
// swap() не меняет значений своих аргументов! void swap( int vl, int v2 ) { int tmp = v2; v2 = vl; vl = tmp; |
}
swap()
обменивает значения локальных копий своих аргументов. Те же переменные, что были использованы в качестве аргументов при вызове, остаются неизменными. Это можно проиллюстрировать, написав небольшую программу:
#include void swap( int, int ); int main() { int i = 10; int j = 20; cout << "Перед swap():\ti: " << i << "\tj: " << j << endl; swap( i, j ); cout << "После swap():\ti: " << i << "\tj: " << j << endl; return 0; |
Результат выполнения программы:
Перед swap(): i: 10 j: 20
После swap(): i: 10 j: 20
Достичь желаемого можно двумя способами. Первый – объявление параметров указателями. Вот как будет выглядеть реализация swap() в этом случае:
// pswap() обменивает значения объектов, // адресуемых указателями vl и v2 void pswap( int *vl, int *v2 ) { int tmp = *v2; *v2 = *vl; *vl = tmp; |
Функция main()
тоже нуждается в модификации. Вместо передачи самих объектов необходимо передавать их адреса:
pswap( &i, &j );
Теперь программа работает правильно:
Перед swap(): i: 10 j: 20
После swap(): i: 20 j: 10
Альтернативой может стать объявление параметров ссылками. В данном случае реализация swap()
выглядит так:
// rswap() обменивает значения объектов, // на которые ссылаются vl и v2 void rswap( int &vl, int &v2 ) { int tmp = v2; v2 = vl; vl = tmp; |
Вызов этой функции из main() аналогичен вызову первоначальной функции swap():
rswap( i, j );
Выполнив программу main(), мы снова получим верный результат.
Передача данных через параметры и через глобальные объекты
Различные функции программы могут общаться между собой с помощью двух механизмов. (Под словом “общаться” мы подразумеваем обмен данными.) В одном случае используются глобальные объекты, в другом – передача параметров и возврат значений.Глобальный объект определен вне функции. Например:
int glob; int main() { // что угодно |
}
Объект glob
является глобальным. (В главе 8 рассмотрение глобальных объектов и глобальной области видимости будет продолжено.) Главное достоинство и одновременно один из наиболее заметных недостатков такого объекта – доступность из любого места программы, поэтому его обычно используют для общения между разными модулями. Обратная сторона медали такова:
· функции, использующие глобальные объекты, зависят от этих объектов и их типов. Использовать такую функцию в другом контексте затруднительно;
· при модификации такой программы повышается вероятность ошибок. Даже для внесения локальных изменений необходимо понимание всей программы в целом;
· если глобальный объект получает неверное значение, ошибку нужно искать по всей программе. Отсутствует локализация;
· используя глобальные объекты, труднее писать рекурсивные функции (Рекурсия возникает тогда, когда функция вызывает сама себя. Мы рассмотрим это в разделе 7.5.);
· если используются потоки (threads), то для синхронизации доступа к глобальным объектам требуется писать дополнительный код. Отсутствие синхронизации – одна из распространенных ошибок при использовании потоков. (Пример использования потоков при программировании на С++ см. в статье “Distributing Object Computing in C++” (Steve Vinoski and Doug Schmidt) в [LIPPMAN96b].)
Можно сделать вывод, что для передачи информации между функциями предпочтительнее пользоваться параметрами и возвращаемыми значениями.
Вероятность ошибок при таком подходе возрастает с увеличением списка. Считается, что восемь параметров – это приемлемый максимум. В качестве альтернативы длинному списку можно использовать в качестве параметра класс, массив или контейнер. Он способен содержать группу значений.
Аналогично программа может возвращать только одно значение. Если же логика требует нескольких, некоторые параметры объявляются ссылками, чтобы функция могла непосредственно модифицировать значения соответствующих фактических аргументов и использовать эти параметры для возврата дополнительных значений, либо некоторый класс или контейнер, содержащий группу значений, объявляется типом, возвращаемым функцией.
Упражнение 7.9
Каковы две формы инструкции return? Объясните, в каких случаях следует использовать первую, а в каких вторую форму.
Упражнение 7.10
Найдите в данной функции потенциальную ошибку времени выполнения:
vector vector string word; while ( cin >> word ) { text.push_back( word ); // ... } // .... return text; |
Упражнение 7.11
Каким способом вы вернули бы из функции несколько значений? Опишите достоинства и недостатки вашего подхода.
Перегруженные функции
Итак, мы уже знаем, как объявлять, определять и использовать функции в программах. В этой главе речь пойдет об их специальном виде– перегруженных функциях. Две функции называются перегруженными, если они имеют одинаковое имя, объявлены в одной и той же области видимости, но имеют разные списки формальных параметров. Мы расскажем, как объявляются такие функции и почему они полезны. Затем мы рассмотрим вопрос об их разрешении, т.е. о том, какая именно из нескольких перегруженных функций вызывается во время выполнения программы. Эта проблема является одной из наиболее сложных в C++. Тем, кто хочет разобраться в деталях, будет интересно прочитать два раздела в конце главы, где тема преобразования типов аргументов и разрешения перегруженных функций раскрывается более подробно.Перегруженные операторы и определенные пользователем преобразования
В главе 15 мы рассмотрим два вида специальных функций: перегруженные операторы и определенные пользователем преобразования. Они дают возможность употреблять объекты классов в выражениях так же интуитивно, как и объекты встроенных типов. В этой главе мы сначала изложим общие концепции проектирования перегруженных операторов. Затем представим понятие друзей класса со специальными правами доступа и обсудим, зачем они применяются, обратив особое внимание на то, как реализуются некоторые перегруженные операторы: присваивание, взятие индекса, вызов, стрелка для доступа к члену класса, инкремент и декремент, а также специализированные для класса операторы new и delete. Другая категория специальных функций, которая рассматривается в этой главе, – это функции преобразования членов (конвертеры), составляющие набор стандартных преобразований для типа класса. Они неявно применяются компилятором, когда объекты классов используются в качестве фактических аргументов функции или операндов встроенных или перегруженных операторов. Завершается глава развернутым изложением правил разрешения перегрузки функций с учетом передачи объектов в качестве аргументов, функций-членов класса и перегруженных операторов.Перегрузка и область видимости A
Все перегруженные функции объявляются в одной и той же области видимости. К примеру, локально объявленная функция не перегружает, а просто скрывает глобальную:#include void print( const string & ); void print( double ); // перегружает print() void fooBar( int ival ) { // отдельная область видимости: скрывает обе реализации print() extern void print( int ); // ошибка: print( const string & ) не видна в этой области print( "Value: "); print( ival ); // правильно: print( int ) видна
| |
}
Поскольку каждый класс определяет собственную область видимости, функции, являющиеся членами двух разных классов, не перегружают друг друга. (Функции-члены класса описываются в главе 13. Разрешение перегрузки для функций-членов класса рассматривается в главе 15.)
Объявлять такие функции разрешается и внутри пространства имен. С каждым из них также связана отдельная область видимости, так что функции, объявленные в разных пространствах, не перегружают друг друга. Например:
#include namespace IBM { extern void print( const string & ); extern void print( double ); // перегружает print() } namespace Disney { // отдельная область видимости: // не перегружает функцию print() из пространства имен IBM extern void print( int );
| |
}
Использование using-объявлений и using-директив помогает сделать члены пространства имен доступными в других областях видимости. Эти механизмы оказывают определенное влияние на объявления перегруженных функций. (Using-объявления и using-директивы рассматривались в разделе 8.6.)
Каким образом using-объявление сказывается на перегрузке функций? Напомним, что оно вводит псевдоним для члена пространства имен в ту область видимости, в которой это объявление встречается. Что делают такие объявления в следующей программе?
namespace libs_R_us { int max( int, int ); int max( double, double ); extern void print( int ); extern void print( double ); } // using-объявления using libs_R_us::max; using libs_R_us::print( double ); // ошибка void func() { max( 87, 65 ); // вызывает libs_R_us::max( int, int ) |
max( 35.5, 76.6 ); // вызывает libs_R_us::max( double, double )
Первое using-объявление вводит обе функции libs_R_us::max в глобальную область видимости. Теперь любую из функций max() можно вызвать внутри func(). По типам аргументов определяется, какую именно функцию вызывать. Второе using-объявление – это ошибка: в нем нельзя задавать список параметров. Функция libs_R_us::print()
объявляется только так:
using libs_R_us::print;
Using-объявление всегда делает доступными все
перегруженные функции с указанным именем. Такое ограничение гарантирует, что интерфейс пространства имен libs_R_us не будет нарушен. Ясно, что в случае вызова
print( 88 );
автор пространства имен ожидает, что будет вызвана функция libs_R_us::print(int). Если разрешить пользователю избирательно включать в область видимости лишь одну из нескольких перегруженных функций, то поведение программы становится непредсказуемым.
Что происходит, если using-объявление вводит в область видимости функцию с уже существующим именем? Эти функции выглядят так, как будто они объявлены прямо в том месте, где встречается using-объявление. Поэтому введенные функции участвуют в процессе разрешения имен всех перегруженных функций, присутствующих в данной области видимости:
#include namespace libs_R_us { extern void print( int ); extern void print( double ); } extern void print( const string & ); // libs_R_us::print( int ) и libs_R_us::print( double ) // перегружают print( const string & ) using libs_R_us::print; void fooBar( int ival ) { print( "Value: "); // вызывает глобальную функцию // print( const string & ) print( ival ); // вызывает libs_R_us::print( int ) |
Using-объявление добавляет в глобальную область видимости два объявления: для print(int) и для print(double). Они являются псевдонимами в пространстве libs_R_us и включаются в множество перегруженных функций с именем print, где уже находится глобальная print(const string &). При разрешении перегрузки print в fooBar
рассматриваются все три функции.
Если using-объявление вводит некоторую функцию в область видимости, в которой уже имеется функция с таким же именем и таким же списком параметров, это считается ошибкой. С помощью using-объявления нельзя задать псевдоним для функции print(int) в пространстве имен libs_R_us, если в глобальной области видимости уже есть print(int). Например:
namespace libs_R_us { void print( int ); void print( double ); } void print( int ); using libs_R_us::print; // ошибка: повторное объявление print(int) void fooBar( int ival ) { print( ival ); // какая print? ::print или libs_R_us::print |
Мы показали, как связаны using-объявления и перегруженные функции. Теперь рассмотрим особенности применения using-директивы. Using-директива приводит к тому, что члены пространства имен выглядят объявленными вне этого пространства, добавляя их в новую область видимости. Если в этой области уже есть функция с тем же именем, то происходит перегрузка. Например:
#include namespace libs_R_us { extern void print( int ); extern void print( double ); } extern void print( const string & ); // using-директива // print(int), print(double) и print(const string &) - элементы // одного и того же множества перегруженных функций using namespace libs_R_us; void fooBar( int ival ) { print( "Value: "); // вызывает глобальную функцию // print( const string & ) print( ival ); // вызывает libs_R_us::print( int ) |
Это верно и в том случае, когда есть несколько using-директив. Одноименные функции, являющиеся членами разных пространств, включаются в одно и то множество:
namespace IBM { int print( int ); } namespace Disney { double print( double ); |
// using-директива // формируется множество перегруженных функций из различных // пространств имен using namespace IBM; using namespace Disney; long double print(long double); int main() { print(1); // вызывается IBM::print(int) print(3.1); // вызывается Disney::print(double) return 0; |
Множество перегруженных функций с именем print в глобальной области видимости включает функции print(int), print(double) и print(long double). Все они рассматриваются в main() при разрешении перегрузки, хотя первоначально были определены в разных пространствах имен.
Итак, повторим, что перегруженные функции находятся в одной и той же области видимости. В частности, они оказываются там в результате применения using-объявлений и using-директив, делающих доступными имена из других областей.
Перегрузка оператора ввода
Перегрузка оператора ввода (>>) похожа на перегрузку оператора вывода, но, к сожалению, возможностей для ошибок гораздо больше. Вот, например, его реализация для класса WordCount:#include #include "WordCount.h" /* необходимо модифицировать определение класса WordCount, чтобы оператор ввода был другом class WordCount { friend ostream& operator<<( ostream&, const WordCount& ); friend istream& operator>>( istream&, const WordCount& ); */ istream& operator >>( istream &is, WordCount &wd ) { /* формат хранения объекта WordCount: * <2> строка * <7,3> <12,36> */ int ch; /* прочитать знак '<'. Если его нет, * перевести поток в ошибочное состояние и выйти */ if ((ch = is.get()) != '<' ) { // is.setstate( ios_base::badbit ); return is; } // прочитать длину int occurs; is >> occurs; // читать до обнаружения >; ошибки не контролируются while ( is && (ch = is.get()) != '>' ) ; is >> wd._word; // прочитать позиции вхождений; // каждая позиция имеет формат: < строка, колонка > for ( int ix = 0; ix < occurs; ++ix ) { int line, col; // извлечь значения while (is && (ch = is.get())!= '<' ) ; is >> line; while (is && (ch = is.get())!= ',' ) ; is >> col; while (is && (ch = is.get())!= '>' ) ; wd._occurList.push_back( Location( line, col )); } return is;
| |
}
На этом примере показан целый ряд проблем, имеющих отношение к возможным ошибочным состояниям входного потока:
· поток, чтение из которого невозможно из-за неправильного формата, переводится в состояние fail:
is.setstate( ios_base::failbit );
· операции вставки и извлечения из потока, находящегося в ошибочном состоянии, не работают:
while (( ch = is.get() ) != lbrace)
Инструкция зациклится, если объект istream будет находиться в ошибочном состоянии. Поэтому перед каждым обращением к get() проверяется отсутствие ошибки:
// проверить, находится ли поток "is" в "хорошем" состоянии |
Если объект istream не в “хорошем” состоянии, то его значение будет равно false. (О состояниях потока мы расскажем в разделе 20.7.)
Данная программа считывает объект класса WordCount, сохраненный оператором вывода из предыдущего раздела:
#include #include "WordCount.h" int main() { WordCount readIn; // operator>>( cin, readIn ) cin >> readIn; if ( !cin ) { cerr << "Ошибка ввода WordCount" << endl; return -1; } // operator<<( cout, readIn ) cout << readIn << endl; |
Выводится следующее:
<10> rosebud
<11,3> <11,8> <14,2> <34,6> <49,7> <67,5>
<81,2> <82,3> <91,4> <97,8>
Упражнение 20.9
Оператор ввода класса WordCount сам читает объекты класса Location. Вынесите этот код в отдельный оператор ввода класса Location.
Упражнение 20.10
Реализуйте оператор ввода для класса Date из упражнения 20.7 в разделе 20.4.
Упражнение 20.11
Реализуйте оператор ввода для класса CheckoutRecord из упражнения 20.8 в разделе 20.4.
Перегрузка оператора вывода
Если мы хотим, чтобы наш тип класса поддерживал операции ввода/вывода, то необходимо перегрузить оба соответствующих оператора. В этом разделе мы рассмотрим, как перегружается оператор вывода. (Перегрузка оператора ввода – тема следующего раздела.) Например, для класса WordCount он выглядит так:class WordCount { friend ostream& operator<<( ostream&, const WordCount& ); public: WordCount( string word, int cnt=1 ); // ... private: string word; int occurs; }; ostream& operator <<( ostream& os, const WordCount& wd ) { // формат: <счетчик> слово os << "< " << " > " > " << wd.word; return os; |
}
Проектировщик должен решить, следует ли выводить завершающий символ новой строки. Лучше этого не делать: поскольку операторы вывода для встроенных типов такой символ не печатают, пользователь ожидает аналогичного поведения и от операторов в других классах. Определенный нами в классе WordCount оператор вывода можно использовать вместе с любыми другими операторами:
#include #include "WordCount.h" int main() { WordCount wd( "sadness", 12 ); cout << "wd:\n" << wd << endl; return 0;
| |
}
Программа печатает на терминале строки:
wd:
<12> sadness
Оператор вывода – это бинарный оператор, который возвращает ссылку на объект класса ostream. В общем случае структура определения перегруженного оператора вывода выглядит так:
// структура перегруженного оператора вывода ostream& operator <<( ostream& os, const ClassType &object ) { // произвольный код для подготовки объекта // фактическое число членов os << // ... // возвращается объект ostream return os; |
}
Первый его аргумент – это ссылка на объект ostream, а второй – ссылка (обычно константная) на объект некоторого класса. Возвращается ссылка на ostream. Значением всегда является объект ostream, для которого оператор вызывался.
Поскольку первым аргументом является ссылка, оператор вывода должен быть определен как обычная функция, а не член класса. (Объяснение см. в разделе 15.1.) Если оператору необходим доступ к неоткрытым членам, то следует объявить его другом класса. (О друзьях говорилось в разделе 15.2.)
Пусть Location – это класс, в котором хранятся номера строки и колонки вхождения слова. Вот его определение:
#include class Location { friend ostream& operator<<( ostream&, const Location& ); private: short _line; short _col; }; ostream& operator <<( ostream& os, const Location& lc ) { // объект Loc выводится в виде: < 10,37 > os << "<" << lc._line << "," << lc._col << "> "; return os; |
Изменим определение класса WordCount, включив в него вектор occurList
объектов Location и объект word
класса string:
#include #include #include #include "Location.h" class WordCount { friend ostream& operator<<( ostream&, const WordCount& ); public: WordCount() {} WordCount( const string &word ) : _word( word ) {} WordCount( const string &word, int ln, int col ) : _word( word ){ insert_location( ln, col ); } string word() const { return _word; } int occurs() const { return _occurList.size(); } void found( int ln, int col ) { insert_location( ln, col ); } private: void insert_location( int ln, int col ) { _occurList.push_back( Location( ln, col )); } string _word; vector< Location > _occurList; |
В классах string и Location
определен оператор вывода operator<<(). Так выглядит измененное определение оператора вывода в WordCount:
ostream& operator <<( ostream& os, const WordCount& wd ) { os << "<" << wd._occurList.size() << "> " << wd._word << endl; int cnt = 0, onLine = 6; vector< Location >::const_iterator first = wd._occurList.begin(); vector< Location >::const_iterator last = wd._occurList.end(); for ( ; first != last; ++first ) { // os << Location os << *first << " "; // форматирование: по 6 в строке if ( ++cnt >= onLine ) { os << "\n"; cnt = 0; } } return os; |
А вот небольшая программа для тестирования нового определения класса WordCount; позиции вхождений для простоты “зашиты” в код:
int main() { WordCount search( "rosebud" ); // для простоты явно введем 8 вхождений search.found(11,3); search.found(11,8); search.found(14,2); search.found(34,6); search.found(49,7); search.found(67,5); search.found(81,2); search.found(82,3); search.found(91,4); search.found(97,8); cout << "Вхождения: " << "\n" << search << endl; return 0; |
После компиляции и запуска программа выводит следующее:
Вхождения:
<10> rosebud
<11,3> <11,8> <14,2> <34,6> <49,7> <67,5>
<81,2> <82,3> <91,4> <97,8>
Полученный результат сохранен в файле output. Далее мы определим оператор ввода, с помощью которого прочитаем данные из этого файла.
Упражнение 20.7
Дано определение класса Date:
class Date { public: // ... private: int month, day, year; |
Напишите перегруженный оператор вывода даты в формате:
(a)
// полное название месяца
September 8th, 1997
(b)
9 / 8 / 97
(c) Какой формат лучше? Объясните.
(d) Должен ли оператор вывода Date
быть функцией-другом? Почему?
Упражнение 20.8
Определите оператор вывода для следующего класса CheckoutRecord:
class CheckoutRecord { // запись о выдаче public: // ... private: double book_id; // идентификатор книги string title; // название Date date_borrowed; // дата выдачи Date date_due; // дата возврата pair vector pair |
Перегрузка операторов
В предыдущих главах мы уже показывали, что перегрузка операторов позволяет программисту вводить собственные версии предопределенных операторов (см. главу 4) для операндов типа классов. Например, в классе String из раздела 3.15 задано много перегруженных операторов. Ниже приведено его определение:#include class String; istream& operator>>( istream &, const String & ); ostream& operator<<( ostream &, const String & ); class String { public: // набор перегруженных конструкторов // для автоматической инициализации String( const char* = 0 ); String( const String & ); // деструктор: автоматическое уничтожение ~String(); // набор перегруженных операторов присваивания String& operator=( const String & ); String& operator=( const char * ); // перегруженный оператор взятия индекса char& operator[]( int ); // набор перегруженных операторов равенства // str1 == str2; bool operator==( const char * ); bool operator==( const String & ); // функции доступа к членам int size() { return _size; }; char * c_str() { return _string; } private: int _size; char *_string;
| |
};
В классе String
есть три набора перегруженных операторов. Первый – это набор операторов присваивания:
// набор перегруженных операторов присваивания String& operator=( const String & ); |
String& operator=( const char * );
Сначала идет копирующий оператор присваивания. (Подробно они обсуждались в разделе 14.7.) Следующий оператор поддерживает присваивание C-строки символов объекту типа String:
String name; |
name = "Sherlock"; // использование оператора operator=( char * )
(Операторы присваивания, отличные от копирующих, мы рассмотрим в разделе 15.3.)
Во втором наборе есть всего один оператор – взятия индекса:
// перегруженный оператор взятия индекса |
Он позволяет программе индексировать объекты класса String точно так же, как массивы объектов встроенного типа:
if ( name[0] != 'S' ) |
(Детально этот оператор описывается в разделе 15.4.)
В третьем наборе определены перегруженные операторы равенства для объектов класса String. Программа может проверить равенство двух таких объектов или объекта и C-строки:
// набор перегруженных операторов равенства // str1 == str2; bool operator==( const char * ); |
Перегруженные операторы позволяют использовать объекты типа класса с операторами, определенными в главе 4, и манипулировать ими так же интуитивно, как объектами встроенных типов. Например, желая определить операцию конкатенации двух объектов класса String, мы могли бы реализовать ее в виде функции-члена concat(). Но почему concat(), а не, скажем, append()? Выбранное нами имя логично и легко запоминается, но пользователь все же может забыть, как мы назвали функцию. Зачастую имя проще запомнить, если определить перегруженный оператор. К примеру, вместо concat() мы назвали бы новую операцию operator+=(). Такой оператор используется следующим образом:
#include "String.h" int main() { String name1 "Sherlock"; String name2 "Holmes"; name1 += " "; name1 += name2; if (! ( name1 == "Sherlock Holmes" ) ) cout << "конкатенация не сработала\n"; |
Перегруженный оператор объявляется в теле класса точно так же, как обычная функция-член, только его имя состоит из ключевого слова operator, за которым следует один из множества предопределенных в языке C++ операторов (см. табл. 15.1). Так можно объявить operator+=() в классе String:
class String { public: // набор перегруженных операторов += String& operator+=( const String & ); String& operator+=( const char * ); // ... private: // ... |
};
и определить его следующим образом:
#include inline String& String::operator+=( const String &rhs ) { // Если строка, на которую ссылается rhs, непуста if ( rhs._string ) { String tmp( *this ); // выделить область памяти, достаточную // для хранения конкатенированных строк _size += rhs._size; delete [] _string; _string = new char[ _size + 1 ]; // сначала скопировать в выделенную область исходную строку // затем дописать в конец строку, на которую ссылается rhs strcpy( _string, tmp._string ); strcpy( _string + tmp._size, rhs._string ); } return *this; } inline String& String::operator+=( const char *s ) { // Если указатель s ненулевой if ( s ) { String tmp( *this ); // выделить область памяти, достаточную // для хранения конкатенированных строк _size += strlen( s ); delete [] _string; _string = new char[ _size + 1 ]; // сначала скопировать в выделенную область исходную строку // затем дописать в конец C-строку, на которую ссылается s strcpy( _string, tmp._string ); strcpy( _string + tmp._size, s ); } return *this; |
Перегрузка шаблонов функций А
Шаблон функции может быть перегружен. В следующем примере есть три перегруженных объявления для шаблона min():// определение шаблона класса Array // (см. раздел 2.4) template class Array( /* ... */ }; // три объявления шаблона функции min() template Type min( const Array template Type min( const Type*, int ); // #2 template
| |
Type min( Type, Type ); // #3
Следующее определение main()
иллюстрирует, как могут вызываться три объявленных таким образом функции:
#include int main() { Array int ia[1024]; // Type == int; min( const Array int ival0 = min( iA, 1024 ); // Type == int; min( const int*, int ) int ival1 = min( ia, 1024 ); // Type == double; min( double, double ) double dval0 = min( sqrt( iA[0] ), sqrt( ia[0] ) ); return 0;
| |
}
Разумеется, тот факт, что три перегруженных шаблона функции успешно объявлены, не означает, что они могут быть также успешно вызваны. Такие шаблоны могут приводить к неоднозначности при вызове конкретизированного шаблона. Например, для следующего определения шаблона min5()
template
| |
int min5( T, T ) { /* ... */ }
функция не конкретизируется по шаблону, если min5()
вызывается с аргументами разных типов; при этом процесс вывода заканчивается с ошибкой, поскольку из фактических аргументов функции выводятся два разных типа для T.
int i; unsigned int ui; // правильно: для T выведен тип int min5( 1024, i ); // вывод аргументов шаблона заканчивается с ошибкой: // для T можно вывести два разных типа |
min5 ( i, ui );
Для разрешения второго вызова можно было бы перегрузить min5(), допустив два различных типа аргументов:
template
| |
int min5( T, U );
При следующем обращении производится конкретизация этого шаблона функции:
// правильно: int min5( int, usigned int ) |
К сожалению, теперь стал неоднозначным предыдущий вызов:
// ошибка: неоднозначность: две возможных конкретизации // из min5( T, T ) и min5( T, U ) |
Второе объявление min5()
допускает наличие у функции аргументов различных типов, но не требует этого. В нашем случае и T, и U
типа int. Оба объявления шаблонов могут быть конкретизированы вызовом, в котором два аргумента функции имеют один и тот же тип. Единственный способ указать, какой шаблон более предпочтителен, устранив тем самым неоднозначность, – явно задать его аргументы. (О явном задании аргументов шаблона см. раздел 10.4.) Например:
// правильно: конкретизация из min5( T, U ) |
Однако в этом случае мы можем обойтись без перегрузки шаблона функции. Поскольку шаблон min5(T,U)
подходит для всех вызовов, для которых подходит min5(T,T), то одного объявления min5(T,U)
вполне достаточно, а объявление min5(T,T) можно удалить. Мы уже говорили в
главе 9, что, хотя перегрузка допускается, при проектировании таких функций надо быть внимательным и использовать ее только при необходимости. Те же соображения применимы и к определению перегруженных шаблонов.
В некоторых ситуациях неоднозначности при вызове не возникает, хотя по шаблону можно конкретизировать две разных функции. Если имеются следующие два шаблона для функции sum(), то предпочтение будет отдано первому даже тогда, когда конкретизированы могут быть оба:
template Type sum( Type*, int ); template Type sum( Type, int ); int ia[1024]; // Type == int ; sum // Type == int*; sum |
Как это ни удивительно, такой вызов не приводит к неоднозначности. Шаблон конкретизируется из первого определения, так как выбирается наиболее специализированное определение. Поэтому для аргумента Type
принимается int, а не int*.
Для того чтобы один шаблон был более специализирован, чем другой, оба они должны иметь одни и те же имя и число параметров, а для параметров разных типов, как, скажем, T* и T в предыдущем примере, параметр в одном шаблоне должен быть способен принять более широкое множество фактических аргументов, чем соответствующий параметр в другом. Например, для шаблона sum(Type*, int) вместо первого формального параметра функции разрешается подставлять только фактические аргументы типа “указатель”. В то же время в шаблоне sum(Type, int) первому формальному параметру могут соответствовать фактические аргументы любого типа. Первый шаблон sum(Type*, int)
допускает более узкое множество аргументов, чем второй, т.е. он более специализирован, а следовательно, он и конкретизируется при вызове функции.
Перехват исключений
В языке C++ исключения обрабатываются в предложениях catch. Когда какая-то инструкция внутри try-блока возбуждает исключение, то просматривается список последующих предложений catch в поисках такого, который может его обработать.Catch-обработчик состоит из трех частей: ключевого слова catch, объявления одного типа или одного объекта, заключенного в круглые скобки (оно называется объявлением исключения), и составной инструкции. Если для обработки исключения выбрано некоторое catch-предложение, то выполняется эта составная инструкция. Рассмотрим catch-обработчики исключений pushOnFull и popOnEmpty в функции main()
более подробно:
catch ( pushOnFull ) { cerr << "trying to push value on a full stack\n"; return errorCode88; } catch ( popOnEmpty ) { cerr << "trying to pop a value on an empty stack\n"; return errorCode89; |
}
В обоих catch-обработчиках есть объявление типа класса; в первом это pushOnFull, а во втором– popOnEmpty. Для обработки исключения выбирается тот обработчик, для которого типы в объявлении исключения и в возбужденном исключении совпадают. (В главе 19 мы увидим, что типы не обязаны совпадать точно: обработчик для базового класса подходит и для исключений с производными классами.) Например, когда функция-член pop()
класса iStack
возбуждает исключение popOnEmpty, то управление попадает во второй обработчик. После вывода сообщения об ошибке в cerr,
функция main()
возвращает код errorCode89.
А если catch-обработчики не содержат инструкции return, с какого места будет продолжено выполнение программы? После завершения обработчика выполнение возобновляется с инструкции, идущей за последним catch-обработчиком в списке. В нашем примере оно продолжается с инструкции return в функции main(). После того как catch-обработчик popOnEmpty выведет сообщение об ошибке, main()
вернет 0.
int main() { iStack stack( 32 ); try { stack.display(); for ( int x = 1; ix < 51; ++ix ) { // то же, что и раньше } } catch ( pushOnFull ) { cerr << "trying to push value on a full stack\n"; } catch ( popOnEmpty ) { cerr << "trying to pop a value on an empty stack\n"; } // исполнение продолжается отсюда return 0; |
}
Говорят, что механизм обработки исключений в C++ невозвратный: после того как исключение обработано, управление не возобновляется с того места, где оно было возбуждено. В нашем примере управление не возвращается в функцию-член pop(), возбудившую исключение.
Перехват всех исключений
Иногда функции нужно выполнить определенное действие до того, как она завершит обработку исключения, даже несмотря на то, что обработать его она не может. К примеру, функция захватила некоторый ресурс, скажем открыла файл или выделила память из хипа, и этот ресурс необходимо освободить перед выходом:void manip() { resource res; res.lock(); // захват ресурса // использование ресурса // действие, в результате которого возбуждено исключение res.release(); // не выполняется, если возбуждено исключение |
}
Если исключение возбуждено, то управление не попадет на инструкцию, где ресурс освобождается. Чтобы освободить ресурс, не пытаясь перехватить все возможные исключения (тем более, что мы не всегда знаем, какие именно исключения могут возникнуть), воспользуемся специальной конструкцией, позволяющей перехватывать любые исключения. Это не что иное, как предложение catch, в котором объявление исключения имеет вид (...) и куда управление попадает при любом исключении. Например:
// управление попадает сюда при любом возбужденном исключении catch (...) { // здесь размещаем наш код |
}
Конструкция catch(...)
используется в сочетании с повторным возбуждением исключения. Захваченный ресурс освобождается внутри составной инструкции в catch-обработчике перед тем, как передать исключение по цепочке вложенных вызовов в результате повторного возбуждения:
void manip() { resource res; res.lock(); try { // использование ресурса // действие, в результате которого возбуждено исключение } catch (...) { res.release(); throw; } res.release(); // не выполняется, если возбуждено исключение |
}
Чтобы гарантировать освобождение ресурса в случае, когда выход из manip()
происходит в результате исключения, мы освобождаем его внутри catch(...) до того, как исключение будет передано дальше. Можно также управлять захватом и освобождением ресурса путем инкапсуляции в класс всей работы с ним. Тогда захват будет реализован в конструкторе, а освобождение – в автоматически вызываемом деструкторе. (С этим подходом мы познакомимся в главе 19.)
Предложение catch(...) используется самостоятельно или в сочетании с другими catch-обработчиками. В последнем случае следует позаботиться о правильной организации обработчиков, ассоциированных с try-блоком.
Catch-обработчики исследуются по очереди, в том порядке, в котором они записаны. Как только найден подходящий, просмотр прекращается. Следовательно, если предложение catch(...)
употребляется вместе с другими catch-обработчиками, то оно должно быть последним в списке, иначе компилятор выдаст сообщение об ошибке:
try { stack.display(); for ( int ix = 1; ix < 51; ++x ) { // то же, что и выше } } catch ( pushOnFull ) { } catch ( popOnEmpty ) { } |
Упражнение 11.4
Объясните, почему модель обработки исключений в C++ называется невозвратной.
Упражнение 11.5
Даны следующие объявления исключений. Напишите выражения throw, создающие объект-исключение, который может быть перехвачен указанными обработчиками:
(a) class exceptionType { }; catch( exceptionType *pet ) { } (b) catch(...) { } (c) enum mathErr { overflow, underflow, zeroDivide }; catch( mathErr &ref ) { } (d) typedef int EXCPTYPE; |
Упражнение 11.6
Объясните, что происходит во время раскрутки стека.
Упражнение 11.7
Назовите две причины, по которым объявление исключения в предложении catch следует делать ссылкой.
Упражнение 11.8
На основе кода, написанного вами в упражнении 11.3, модифицируйте класс созданного исключения: неправильный индекс, использованный в операторе operator[](), должен сохраняться в объекте-исключении и затем выводиться catch-обработчиком. Измените программу так, чтобы operator[]() возбуждал при ее выполнении исключение.
Переменные
Представим себе, что мы решаем задачу возведения 2 в степень 10. Пишем:#include
| |
int main() {
// a first solution
cout << "2 raised to the power of 10: ";
cout << 2 * 2 * 2 * 2 * 2 * 2 * 2 * 2 * 2 * 2;
cout << endl;
return 0;
}
Задача решена, хотя нам и пришлось неоднократно проверять, действительно ли 10 раз повторяется литерал 2. Мы не ошиблись в написании этой длинной последовательности двоек, и программа выдала правильный результат – 1024.
Но теперь нас попросили возвести 2 в 17 степень, а потом в 23. Чрезвычайно неудобно каждый раз модифицировать текст программы! И, что еще хуже, очень просто ошибиться, написав лишнюю двойку или пропустив ее... А что делать, если нужно напечатать таблицу степеней двойки от 0 до 15? 16 раз повторить две строки, имеющие общий вид:
cout << "2 в степени X\t"; |
cout << 2 * ... * 2;
где Х последовательно увеличивается на 1, а вместо отточия подставляется нужное число литералов?
Да, мы справились с задачей. Заказчик вряд ли будет вникать в детали, удовлетворившись полученным результатом. В реальной жизни такой подход достаточно часто срабатывает, более того, бывает оправдан: задача решена далеко не самым изящным способом, зато в желаемый срок. Искать более красивый и грамотный вариант может оказаться непрактичной тратой времени.
В данном случае “метод грубой силы” дает правильный ответ, но как же неприятно и скучно решать задачу подобным образом! Мы точно знаем, какие шаги нужно сделать, но сами эти шаги просты и однообразны.
Привлечение более сложных механизмов для той же задачи, как правило, значительно увеличивает время подготовительного этапа. Кроме того, чем более сложные механизмы применяются, тем больше вероятность ошибок. Но даже несмотря на неизбежные ошибки и неверные ходы, применение “высоких технологий” может принести выигрыш в скорости разработки, не говоря уже о том, что эти технологии значительно расширяют наши возможности. И – что интересно! – сам процесс решения может стать привлекательным.
Вернемся к нашему примеру и попробуем “ технологически усовершенствовать” его реализацию. Мы можем воспользоваться именованным объектом для хранения значения степени, в которую нужно возвести наше число. Кроме того, вместо повторяющейся последовательности литералов применим оператор цикла. Вот как это будет выглядеть:
#include int main() { // objects of type int int value = 2; int pow = 10; cout << value << " в степени " << pow << ": \t"; int res = 1; // оператор цикла: // повторить вычисление res // до тех пор пока cnt не станет больше pow for ( int cnt=1; cnt <= pow; ++cnt ) res = res * value; cout << res << endl; |
value, pow, res и cnt – это переменные, которые позволяют хранить, модифицировать и извлекать значения. Оператор цикла for
повторяет строку вычисления результата pow раз.
Несомненно, мы создали гораздо более гибкую программу. Однако это все еще не функция. Чтобы получить настоящую функцию, которую можно использовать в любой программе для вычисления степени числа, нужно выделить общую часть вычислений, а конкретные значения задать параметрами.
int pow( int val, int exp ) |
for ( int res = 1; exp > 0; --exp )
res = res * val;
return res;
}
Теперь получить любую степень нужного числа не составит никакого труда. Вот как реализуется последняя наша задача – напечатать таблицу степеней двойки от 0 до 15:
#include extern int pow(int,int); |
{
int val = 2;
int exp = 15;
cout << "Степени 2\n";
for ( int cnt=0; cnt <= exp; ++cnt )
cout << cnt << ": "
<< pow( val, cnt ) << endl;
return 0;
}
Конечно, наша функция pow() все еще недостаточно обобщена и недостаточно надежна. Она не может оперировать вещественными числами, неправильно возводит числа в отрицательную степень – всегда возвращает 1. Результат возведения большого числа в большую степень может не поместиться в переменную типа int, и тогда будет возвращено некоторое случайное неправильное значение. Видите, как непросто, оказывается, писать функции, рассчитанные на широкое применение? Гораздо сложнее, чем реализовать конкретный алгоритм, направленный на решение конкретной задачи.
Первый взгляд на ввод/вывод
Частью стандартной библиотеки С++ является библиотека iostream, которая реализована как иерархия классов и обеспечивает базовые возможности ввода/вывода.Ввод с терминала, называемый стандартным вводом, “привязан” к предопределенному объекту cin. Вывод на терминал, или стандартный вывод, привязан к объекту cout. Третий предопределенный объект, cerr, представляет собой стандартный вывод для ошибок. Обычно он используется для вывода сообщений об ошибках и предупреждений.
Для использования библиотеки ввода/вывода необходимо включить соответствующий заголовочный файл:
#include
Чтобы
значение поступило в стандартный вывод или в стандартный вывод для ошибок используется оператор <<:
int v1, v2; // ... cout << "сумма v1 и v2 = "; cout << v1 + v2; |
cout << "\n";
Последовательность "\n"
представляет собой символ перехода на новую строку. Вместо "\n" мы можем использовать предопределенный манипулятор endl.
cout << endl;
Манипулятор endl не просто выводит данные (символ перехода на новую строку), но и производит сброс буфера вывода. (Предопределенные манипуляторы рассматриваются в главе 20.)
Операторы вывода можно сцеплять. Так, три строки в предыдущем примере заменяются одной:
cout << "сумма v1 и v2 = " << v1 + v2 << "\n";
Для чтения значения из стандартного ввода применяется оператор ввода (>>):
string file_name; // ... cout << "Введите имя файла: "; |
cin >> file_name;
Операторы ввода, как и операторы вывода, можно сцеплять:
string ifile, ofile; |
// ...
cout << "Введите имя входного и выходного файлов: ";
cin >> ifile >> ofile;
Каким образом ввести заранее неизвестное число значений? Мы вернемся к этому вопросу в конце раздела 2.2, а пока скажем, что последовательность инструкций
string word; |
while ( cin >> word )
// ...
считывает по одному слову из стандартного ввода до тех пор, пока не считаны все слова. Выражение
( cin >> word )
возвращает false, когда достигнут конец файла. (Подробнее об этом – в главе 20.) Вот пример простой законченной программы, считывающей по одному слову из cin и выводящей их в cout:
#include #include int main () { string word; while ( cin >> word ) cout << "Прочитано слово: " << word << "\n"; cout << "Все слова прочитаны!"; |
Вот первое предложение из произведения Джеймса Джойса “Пробуждение Финнегана”:
riverrun, past Eve and Adam's
Если запустить приведенную выше программу и набрать с клавиатуры данное предложение, мы увидим на экране терминала следующее:
Прочитано слово: riverrun, Прочитано слово: past Прочитано слово: Eve, Прочитано слово: and Прочитано слово: Adam's |
(В главе 6 мы рассмотрим вопрос о том, как убрать знаки препинания из вводимых слов.)
Пять категорий итераторов
Для поддержки полного набора обобщенных алгоритмов стандартная библиотека определяет пять категорий итераторов, положив в основу классификации множество операций. Это итераторы чтения (InputIterator), записи (OutputIterator), однонаправленные (ForwardIterator) и двунаправленные итераторы (BidirectionalIterator), а также итераторы с произвольным доступом (RandomAccessIterators). Ниже приводится краткое обсуждение характеристик каждой категории:·
итератор чтения можно использовать для получения элементов из контейнера, но поддержка записи в контейнер не гарантируется. Такой итератор должен обеспечивать следующие операции (итераторы, поддерживающие также дополнительные операции, можно употреблять в качестве итераторов чтения при условии, что они удовлетворяют минимальным требованиям): сравнение двух итераторов на равенство и неравенство, префиксная и постфиксная форма инкремента итератора для адресации следующего элемента (оператор ++), чтение элемента с помощью оператора разыменования (*). Такого уровня поддержки требуют, в частности, алгоритмы find(), accumulate() и equal(). Любому алгоритму, которому необходим итератор чтения, можно передавать также и итераторы категорий, описанных в пунктах 3, 4 и 5;
· итератор записи можно представлять себе как противоположный по функциональности итератору чтения. Иными словами, его можно использовать для записи элементов контейнера, но поддержка чтения из контейнера не гарантируется. Такие итераторы обычно применяются в качестве третьего аргумента алгоритма (например, copy()) и указывают на позицию, с которой надо начинать копировать. Любому алгоритму, которому необходим итератор записи, можно передавать также и итераторы других категорий, перечисленных в пунктах 3, 4 и 5;
· однонаправленный итератор можно использовать для чтения и записи в контейнер, но только в одном направлении обхода (обход в обоих направлениях поддерживается итераторами следующей категории). К числу обобщенных алгоритмов, требующих как минимум однонаправленного итератора, относятся adjacent_find(), swap_range() и replace(). Конечно, любому алгоритму, которому необходим подобный итератор, можно передавать также и итераторы описанных ниже категорий;
· двунаправленный итератор может читать и записывать в контейнер, а также перемещаться по нему в обоих направлениях. Среди обобщенных алгоритмов, требующих как минимум двунаправленного итератора, выделяются place_merge(), next_permutation() и reverse();
· итератор с произвольным доступом, помимо всей функциональности, поддерживаемой двунаправленным итератором, обеспечивает доступ к любой позиции внутри контейнера за постоянное время. Подобные итераторы требуются таким обобщенным алгоритмам, как binary_search(), sort_heap() и nth-element().
Упражнение 12.6
Объясните, почему некорректны следующие примеры. Какие ошибки обнаруживаются во время компиляции?
(a) const vector vector (b) const vector fill( ivec.begin(), ivec.end(), ival ); (c) sort( ivec.begin(), ivec.end() ); (d) list binary_search( ilist.begin(), ilist.end() ); |
Упражнение 12.7
Напишите программу, которая читает последовательность целых чисел из стандартного ввода с помощью потокового итератора чтения istream_iterator. Нечетные числа поместите в один файл посредством ostream_iterator, разделяя значения пробелом. Четные числа таким же образом запишите в другой файл, при этом каждое значение должно размещаться в отдельной строке.
Побитовые операторы
Таблица 4.3. Побитовые операторы| Символ операции | Значение | Использование | |||
| ~ | Побитовое НЕ | ~expr | |||
| << | Сдвиг влево | expr1 << expr2 | |||
| >> | Сдвиг вправо | expr1 >> expr2 | |||
| & | Побитовое И | expr1 & expr2 | |||
| ^ | Побитовое ИСКЛЮЧАЮЩЕЕ ИЛИ | expr1 ^ expr2 | |||
| | | Побитовое ИЛИ | expr1 | expr2 | |||
| &= | Побитовое И с присваиванием | expr1 &= expr2 | |||
| ^= | Побитовое ИСКЛЮЧАЮЩЕЕ ИЛИ с присваиванием | expr1 ^= expr2 | |||
| |= | Побитовое ИЛИ с присваиванием | expr1 |= expr2 | |||
| <<= | Сдвиг влево с присваиванием | expr1 <<= expr2 | |||
| >>= | Сдвиг вправо с присваиванием | expr1 >>= expr2 |
Побитовые операции рассматривают операнды как упорядоченные наборы битов, каждый бит может иметь одно из двух значений – 0 или 1. Такие операции позволяют программисту манипулировать значениями отдельных битов. Объект, содержащий набор битов, иногда называют битовым вектором. Он позволяет компактно хранить набор флагов – переменных, принимающих значение “да” “нет”. Например, компиляторы зачастую помещают в битовые векторы спецификаторы типов, такие, как const и volatile. Библиотека iostream
использует эти векторы для хранения состояния формата вывода.
Как мы видели, в С++ существуют два способа работы со строками: использование C-строк и объектов типа string стандартной библиотеки – и два подхода к массивам: массивы встроенного типа и объект vector. При работе с битовыми векторами также можно применять подход, заимствованный из С, – использовать для представления такого вектора объект встроенного целого типа, обычно unsigned int, или
класс bitset
стандартной библиотеки С++. Этот класс инкапсулирует семантику вектора, предоставляя операции для манипулирования отдельными битами. Кроме того, он позволяет ответить на вопросы типа: есть ли “взведенные” биты (со значением 1) в векторе? Сколько битов “взведено”?
В общем случае предпочтительнее пользоваться классом bitset, однако, понимание работы с битовыми векторами на уровне встроенных типов данных очень полезно. В этом разделе мы рассмотрим применение встроенных типов для представления битовых векторов, а в следующем – класс bitset.
При использовании встроенных типов для представления битовых векторов можно пользоваться как знаковыми, так и беззнаковыми целыми типами, но мы настоятельно советуем пользоваться беззнаковыми: поведение побитовых операторов со знаковыми типами может различаться в разных реализациях компиляторов.
Побитовое НЕ (~) меняет значение каждого бита операнда. Бит, установленный в 1, меняет значение на 0 и наоборот.
Операторы сдвига (<<, >>) сдвигают биты в левом операнде на указанное правым операндом количество позиций. “Выталкиваемые наружу” биты пропадают, освобождающиеся биты (справа для сдвига влево, слева для сдвига вправо) заполняются нулями. Однако нужно иметь в виду, что для сдвига вправо заполнение левых битов нулями гарантируется только для беззнакового операнда, для знакового в некоторых реализациях возможно заполнение значением знакового (самого левого) бита.
Побитовое И (&) применяет операцию И ко всем битам своих операндов. Каждый бит левого операнда сравнивается с битом правого, находящимся в той же позиции. Если оба бита равны 1, то бит в данной позиции получает значение 1, в любом другом случае – 0. (Побитовое И (&) не надо путать с логическим И (&&),но, к сожалению, каждый программист хоть раз в жизни совершал подобную ошибку.)
Побитовое ИСКЛЮЧАЮЩЕЕ ИЛИ (^) сравнивает биты операндов. Соответствующий бит результата равен 1, если операнды различны (один равен 0, а другой 1). Если же оба операнда равны, результата равен 0.
Побитовое ИЛИ (|) применяет операцию логического сложения к каждому биту операндов. Бит в позиции результата получает значение 1, если хотя бы один из соответствующих битов операндов равен 1, и 0, если биты обоих операндов равны 0. (Побитовое ИЛИ не нужно смешивать с логическим ИЛИ.)
Рассмотрим простой пример. Пусть у нас есть класс из 30 студентов. Каждую неделю преподаватель проводит зачет, результат которого – сдал/не сдал. Итоги можно представить в виде битового вектора. (Заметим, что нумерация битов начинается с нуля, первый бит на самом деле является вторым по счету. Однако для удобства мы не будем использовать нулевой бит; таким образом, студенту номер 1 соответствует бит номер 1. В конце концов, наш преподаватель – не специалист в области программирования.)
unsigned int quiz1 = 0;
Нам нужно иметь возможность менять значение каждого бита и проверять это значение. Предположим, студент 27 сдал зачет. Бит 27 необходимо выставить в 1, не меняя значения других битов. Это можно сделать за два шага. Сначала нужно начать с числа, содержащего 1 в 27-м бите и 0 в остальных. Для этого используем операцию сдвига:
1 << 27;
Применив побитовую операцию ИЛИ к переменной quiz1 и нашей константе, получим нужный результат: значение 27-й бита станет равным значение 1, а другие биты останутся неизменными.
quiz1 |= 1<<27;
Теперь представим себе, что преподаватель перепроверил результаты теста и выяснил, что студент 27 зачет не сдал. Теперь нужно присвоить нуль 27-му биту, не трогая остальных. Сначала применим побитовое НЕ к предыдущей константе и получим число, в котором все биты, кроме 27-го, равны 1:
~(1<<27 );
Теперь побитово умножим (И) эту константу на quiz1 и получим нужный результат: 0 в 27-м бите и неизменные значения остальных.
quiz1 &= ~(1<<27);
Как проверить значение того же 27-го бита? Побитовое И дает true, если 27-й бит равен 1, и false, если 0:
bool hasPassed = quiz1 & (1<<27);
При использовании побитовых операций подобным образом очень легко допустить ошибку. Поэтому чаще всего такие операции инкапсулируются в макросы препроцессора или встроенные функции:
inline boo1 bit_on (unsigned int ui, int pos) { return u1 & ( 1 << pos ); |
Вот пример использования:
enum students { Danny = 1, Jeffrey, Ethan, Zev, Ebie, // ... AnnaP = 26, AnnaL = 27 }; const int student_size = 27; // наш битовый вектор начинается с 1 boo1 has_passed_quiz[ student_size+l ]; for ( int index = 1; index <= student_size; ++-index ) |
Раз уж мы начали инкапсулировать действия с битовым вектором в функции, следующим шагом нужно создать класс. Стандартная библиотека С++ включает такой класс bitset, его использование описано ниже.
Упражнение 4.12
Даны два целых числа:
unsigned int ui1 = 3, ui2 = 7;
Каков результат следующих выражений?
(a) ui1 & ui2 (c) uil | ui2
(b) ui1 && ui2 (d) uil || ui2
Упражнение 4.13
Используя пример функции bit_on(), создайте функции bit_turn_on()
(выставляет бит в 1), bit_turn_off()
(сбрасывает бит в 0), flip_bit()
(меняет значение на противоположное) и bit_off() (возвращает true, если бит равен 0). Напишите программу, использующую ваши функции.
Упражнение 4.14
В чем недостаток функций из предыдущего упражнения, использующих тип unsigned int? Их реализацию можно улучшить, используя определение типа с помощью typedef или механизм функций-шаблонов. Перепишите функцию bit_on(),применив сначала typedef, а затем механизм шаблонов.
Почленная инициализация A
Инициализация одного объекта класса другим объектом того же класса, как, например:Account oldAcct( "Anna Livia Plurabelle" ); |
Account newAcct( oldAcct );
называется почленной инициализацией по умолчанию. По умолчанию – потому, что она производится автоматически, независимо от того, есть явный конструктор или нет. Почленной – потому, что единицей инициализации является отдельный нестатический член, а не побитовая копия всего объекта класса.
Такую инициализацию проще всего представить, если считать, что компилятор создает специальный внутренний копирующий конструктор, где поочередно, в порядке объявления, инициализируются все нестатические члены. Если рассмотреть первое определение нашего класса Account:
class Account { public: // ... private: char *_name; unsigned int _acct_nmbr; double _balance; |
};
то можно представить, что копирующий конструктор по умолчанию определен так:
inline Account:: Account( const Account &rhs ) { _name = rhs._name; _acct_nmbr = rhs._acct_nmbr; _balance = rhs._balance; |
}
Почленная инициализация одного объекта класса другим встречается в следующих ситуациях:
· явная инициализация одного объекта другим:
Account newAcct( oldAcct );
· передача объекта класса в качестве аргумента функции:
extern bool cash_on_hand( Account acct ); if ( cash_on_hand( oldAcct )) |
// ...
· передача объекта класса в качестве возвращаемого функцией значения:
extern Account consolidate_accts( const vector< Account >& ) { Account final_acct; // выполнить финансовую операцию return final_acct; |
}
· определение непустого последовательного контейнера:
// вызывается пять копирующих конструкторов класса string |
(В этом примере с помощью конструктора string по умолчанию создается один временный объект, который затем копируется в пять элементов вектора посредством копирующего конструктора string.)
· вставка объекта класса в контейнер:
svec.push_back( string( "pooh" ));
Для большинства определений реальных классов почленная инициализация по умолчанию не соответствует семантике класса. Чаще всего это случается, когда его член представляет собой указатель, который адресует освобождаемую деструктором память в хипе, как, например, в нашем Account.
В результате такой инициализации newAcct._name и oldAcct._name
указывают на одну и ту же C-строку. Если oldAcct выходит из области видимости и к нему применяется деструктор, то newAcct._name указывает на освобожденную область памяти. С другой стороны, если newAcct модифицирует строку, адресуемую _name, то она изменяется и для oldAcct. Подобные ошибки очень трудно найти.
Одно из решений псевдонимов указателей заключается в том, чтобы выделить область памяти для копии строки и инициализировать newAcct._name адресом этой области. Следовательно, почленную инициализацию по умолчанию для класса Account
нужно подавить за счет предоставления явного копирующего конструктора, который реализует правильную семантику инициализации.
Внутренняя семантика класса также может не соответствовать почленной инициализации по умолчанию. Ранее мы уже объясняли, что два разных объекта Account не должны иметь одинаковые номера счетов. Чтобы гарантировать такое поведение, мы должны подавить почленную инициализацию по умолчанию для класса Account. Вот как выглядит копирующий конструктор, решающий обе эти проблемы:
inline Account:: Account( const Account &rhs ) { // решить проблему псевдонима указателя _name = new char[ strlen(rhs._name)+1 ]; strcpy( _name, rhs._name ); // решить проблему уникальности номера счета _acct_nmbr = get_unique_acct_nmbr(); // копирование этого члена и так работает _balance = rhs._balance; |
}
Альтернативой написанию копирующего конструктора является полный запрет почленной инициализации. Это можно сделать следующим образом:
1. Объявить копирующий конструктор закрытым членом. Это предотвратит почленную инициализацию всюду, кроме функций-членов и друзей класса.
2. Запретить почленную инициализацию в функциях-членах и друзьях класса, намеренно не предоставляя определения копирующего конструктора (однако объявить его так, как описано на шаге 1, все равно нужно). Язык не дает нам возможности ограничить доступ к закрытым членам класса со стороны функций-членов и друзей. Но если определение отсутствует, то любая попытка вызвать копирующий конструктор, законная с точки зрения компилятора, приведет к ошибке во время редактирования связей, поскольку не удастся найти определение символа.
Чтобы запретить почленную инициализацию, класс Account можно объявить так:
class Account { public: Account(); Account( const char*, double=0.0 ); // ... private: Account( const Account& ); // ... |
Почленная инициализация и присваивание A
При проектировании класса мы должны позаботиться о том, чтобы почленная инициализация (см. раздел 14.6) и почленное присваивание (см. раздел 14.7) были реализованы правильно и эффективно. Рассмотрим связь этих операций с наследованием.До сих пор мы не занимались явной обработкой почленной инициализации. Посмотрим, что происходит в нашей иерархии классов Query по умолчанию.
В абстрактном базовом классе Query
определены три нестатических члена:
class Query { public: // ... protected: int _paren; set vector // ...
| |
};
Член _solution, если он установлен, адресует множество, память для которого выделена в хипе функцией-членом _vec2set(). Деструктор Query применяет к _solution
оператор delete.
Класс Query
должен предоставлять как явный копирующий конструктор, так и явный копирующий оператор присваивания. (Если вам это непонятно, перечитайте раздел 14.6.) Но сначала посмотрим, как почленное копирование по умолчанию происходит без них.
Производный класс NameQuery
содержит объект-член типа string и подобъект базового Query. Если есть объект folk класса NameQuery:
NameQuery folk( "folk" );
то инициализация music с помощью folk
NameQuery music = folk;
осуществляется так:
1. Компилятор проверяет, есть ли в NameQuery
явный копирующий конструктор. (Его нет. Поэтому необходимо применить почленную инициализацию по умолчанию.)
2. Далее компилятор проверяет, содержит ли объект NameQuery
подобъекты базового класса. (Да, в нем имеется подобъект Query.)
3. Компилятор проверяет, определен ли в классе Query
явный копирующий конструктор. (Нет, поэтому компилятор применит почленную инициализацию по умолчанию.)
4. Компилятор проверяет, содержит ли объект Query
подобъекты базового класса. (Нет.)
5. Компилятор просматривает все нестатические члены Query
На этот раз почленная инициализация music с помощью folk
корректна. Реализовывать явный копирующий конструктор в NameQuery нет необходимости.
Объект производного класса NotQuery
содержит подобъект базового Query и член _op
типа Query*, который указывает на операнд, размещенный в хипе. Деструктор NotQuery
применяет к этому операнду оператор delete.
Для класса NotQuery
почленная инициализация по умолчанию члена _op небезопасна, поэтому необходим явный копирующий конструктор. В его реализации используется виртуальная функция clone(), которую мы определили в предыдущем разделе.
inline NotQuery:: NotQuery( const NotQuery &rhs ) // вызывается Query::Query( const Query &rhs ) : Query( rhs ) |
При почленной инициализации одного объекта класса NotQuery другим выполняются два шага:
1. Компилятор проверяет, определен ли в NotQuery
явный копирующий конструктор. Да, определен.
2. Этот конструктор вызывается для почленной инициализации.
Вот и все. Ответственность за правильную инициализацию подобъекта базового класса и нестатических членов возлагается на копирующий конструктор NotQuery. (Классы AndQuery и OrQuery
сходны с NotQuery, поэтому мы оставляем их в качестве упражнения для читателей.)
Почленное присваивание аналогично почленной инициализации. Если имеется явный копирующий оператор присваивания, то он вызывается для выполнения присваивания одного объекта класса другому. В противном случае применяется почленное присваивание по умолчанию.
Если базовый класс есть, то сначала с помощью копирующего оператора присваивания почленно присваивается подобъект данного класса, иначе такое присваивание рекурсивно применяется к базовым классам и членам подобъекта базового класса.
Просматриваются все нестатические члены в порядке их объявления. Если член не является объектом класса, то его значение справа от знака равенства копируется в значение соответствующего члена слева от знака равенства. Если же член является объектом класса, в котором определен явный копирующий оператор присваивания, то он и вызывается. В противном случае к базовым классам и членам объекта-члена применяется почленное присваивание по умолчанию.
Ниже предложена небольшая программа для тестирования данной реализации. Мы создаем или копируем объект, а затем распечатываем его.
#include "Query.h" int main() { NameQuery nm( "alice" ); NameQuery nm( "emma" ); NotQuery nq1( &nm ); cout << "notQuery 1: " << nq1 << endl; NotQuery nq2( nq1 ); cout << "notQuery 2: " << nq2 << endl; NotQuery nq3( &nm2 ); cout << "notQuery 3: " << nq3 << endl; nq3 = nq2; cout << "notQuery 3 присвоено значение nq2: " << nq3 << endl; AndQuery aq( &nq1, &nm2 ); cout << "AndQuery : " << aq << endl; AndQuery aq2( aq ); cout << "AndQuery 2: " << aq2 << endl; AndQuery aq3( &nm, &nm2 ); cout << "AndQuery 3: " << aq3 << endl; aq2 = aq3; cout << "AndQuery 2 после присваивания: " << aq2 << endl; |
После компиляции и запуска программа печатает следующее:
notQuery 1: ! alice
notQuery 2: ! alice
notQuery 3: ! emma
notQuery 3 присвоено значение nq2: ! alice
AndQuery : ! alice && emma
AndQuery 2: ! alice && emma
AndQuery 3: alice && emma
AndQuery 2 после присваивания: alice && emma
Упражнение 17.18
Реализуйте копирующие конструкторы в классах AndQuery и OrQuery.
Упражнение 17.19
Реализуйте копирующие операторы присваивания в классах AndQuery и OrQuery.
Упражнение 17.20
Что указывает на необходимость реализации явных копирующего конструктора и копирующего оператора присваивания?
Вот как выглядит копирующий оператор присваивания для нашего объекта Query. Еще раз отметим, что в этом месте необязательно копировать разрешающее множество, достаточно предотвратить копирование по умолчанию:
Query& Query:: operator=( const Query &rhs ) { // предотвратить присваивание самому себе if ( &rhs != this ) { _paren = rhs._paren; _loc = rhs._loc; delete _solution; _solution = 0; } return *this; |
В классе NameQuery
явный копирующий оператор присваивания не нужен. Присваивание одного объекта NameQuery
другому выполняется в два шага:
1. Для присваивания подобъектов Query
двух объектов NameQuery
вызывается явный копирующий оператор присваивания класса Query.
2. Для присваивания членов string
вызывается явный копирующий оператор присваивания этого класса.
Для объектов NameQuery
вполне достаточно почленного присваивания по умолчанию.
В каждом из классов NotQuery, AndQuery и OrQuery для безопасного копирования операндов требуется явный копирующий оператор присваивания. Вот его реализация для NotQuery:
inline NotQuery& NotQuery:: operator=( const NotQuery &rhs ) { // предотвратить присваивание самому себе if ( &rhs != this ) { // вызвать копирующий оператор присваивания Query this->Query::operator=( rhs ); // скопировать операнд _op = rhs._op->clone(); } return *this; |
В отличие от копирующего конструктора, в копирующем операторе присваивания нет специальной части, через которую вызывается аналогичный оператор базового класса. Для этого используются две синтаксических конструкции: явный вызов, продемонстрированный выше, и явное приведение типа, как в следующем примере:
(*static_cast
(Реализация копирующих операторов присваивания в классах AndQuery и OrQuery выглядит так же, поэтому мы оставим ее в качестве упражнения.)
Почленное присваивание A
Присваивание одному объекту класса значения другого объекта того же класса реализуется почленным присваиванием по умолчанию. От почленной инициализации по умолчанию оно отличается только использованием копирующего оператора присваивания вместо копирующего конструктора:newAcct = oldAcct;
по умолчанию присваивает каждому нестатическому члену newAcct значение соответственного члена oldAcct. Компилятор генерирует следующий копирующий оператор присваивания:
inline Account& Account:: operator=( const Account &rhs ) { _name = rhs._name; _balance = rhs._balance; _acct_nmbr = rhs._acct_nmbr; |
}
Как правило, если для класса не подходит почленная инициализация по умолчанию, то не подходит и почленное присваивание по умолчанию. Например, для первоначального определения класса Account, где член _name был объявлен как char*, такое присваивание не годится ни для _name, ни для _acct_nmbr.
Мы можем подавить его, если предоставим явный копирующий оператор присваивания, где будет реализована подходящая для класса семантика:
// общий вид копирующего оператора присваивания className& className:: operator=( const className &rhs ) { // не надо присваивать самому себе if ( this != &rhs ) { // здесь реализуется семантика копирования класса } // вернуть объект, которому присвоено значение return *this; |
}
Здесь условная инструкция
if ( this != &rhs )
предотвращает присваивание объекта класса самому себе, что особенно неприятно в ситуации, когда копирующий оператор присваивания сначала освобождает некоторый ресурс, ассоциированный с объектом в левой части, чтобы назначить вместо него ресурс, ассоциированный с объектом в правой части. Рассмотрим копирующий оператор присваивания для класса Account:
Account& Account:: operator=( const Account &rhs ) { // не надо присваивать самому себе if ( this != &rhs ) { delete [] _name; _name = new char[strlen(rhs._name)+1]; strcpy( _name,rhs._name ); _balance = rhs._balance; _acct_nmbr = rhs._acct_nmbr; } return *this; |
}
Когда один объект класса присваивается другому, как, например, в инструкции:
newAcct = oldAcct;
выполняются следующие шаги:
1. Выясняется, есть ли в классе явный копирующий оператор присваивания.
2. Если есть, проверяются права доступа к нему, чтобы понять, можно ли его вызывать в данном месте программы.
3. Оператор вызывается для выполнения присваивания; если же он недоступен, компилятор выдает сообщение об ошибке.
4. Если явного оператора нет, выполняется почленное присваивание по умолчанию.
5. При почленном присваивании каждому члену встроенного или составного члена объекта в левой части присваивается значение соответственного члена объекта в правой части.
6. Для каждого члена, являющегося объектом класса, рекурсивно применяются шаги 1-6, пока не останутся только члены встроенных и составных типов.
Если мы снова модифицируем определение класса Account так, что _name
будет иметь тип string, то почленное присваивание по умолчанию
newAcct = oldAcct;
будет выполняться так же, как при создании компилятором следующего оператора присваивания:
inline Account& Account:: operator=( const Account &rhs ) { _balance = rhs._balance; _acct_nmbr = rhs._acct_nmbr; // этот вызов правилен и с точки зрения программиста name.string::operator=( rhs._name ); |
Однако почленное присваивание по умолчанию для объектов класса Account не подходит из-за _acct_nmbr. Нужно реализовать явный копирующий оператор присваивания с учетом того, что _name – это объект класса string:
Account& Account:: operator=( const Account &rhs ) { // не надо присваивать самому себе if ( this != &rhs ) { // вызывается string::operator=( const string& ) _name = rhs._name; _balance = rhs._balance; } return *this; |
Чтобы запретить почленное копирование, мы поступаем так же, как и в случае почленной инициализации: объявляем оператор закрытым и не предоставляем его определения.
Копирующий конструктор и копирующий оператор присваивания обычно рассматривают вместе. Если необходим один, то, как правило, необходим и другой. Если запрещается один, то, вероятно, следует запретить и другой.
Упражнение 14.17
Реализуйте копирующий оператор присваивания для каждого из классов, определенных в упражнении 14.14 из раздела 14.6.
Упражнение 14.18
Нужен ли копирующий оператор присваивания для того класса, который вы выбрали в упражнении 14.3 из раздела 14.2? Если да, реализуйте его. В противном случае объясните, почему он не нужен.
Почти виртуальный оператор new
Если дан указатель на один из конкретных подтипов запроса, то разместить в хипе дубликат объекта несложно:NotQuery *pnq; // установить pnq ... // оператор new вызывает // копирующий конструктор NotQuery ... |
NotQuery *pnq2 = new NotQuery( *pnq );
Если же у нас есть только указатель на абстрактный класс Query, то задача создания дубликата становится куда менее тривиальной:
const Query *pq = pnq->op(); |
// как получить дубликат pq?
Если бы позволялось объявить виртуальный экземпляр оператора new, то проблема была бы решена, поскольку автоматически вызывался бы нужный экземпляр. К сожалению, это невозможно: new – статическая функция-член, которая применяется к неструктурированной памяти еще до конструирования объекта класса (см. раздел 15.8).
Но хотя оператор new
нельзя сделать виртуальным, разрешается создать его суррогат, который будет выделять память из хипа и копировать туда объекты, – clone():
class Query { public: virtual Query *clone() = 0; // ... |
};
Вот как он может быть реализован в классе NameQuery:
class NameQuery : public Query { public: virtual Query *clone() // вызывается копирующий конструктор класса NameQuery { return new NameQuery( *this ); } // ... |
};
Это работает правильно, если тип целевого указателя Query*:
Query *pq = new NameQuery( "valery" ); |
Query *pq2 = pq->clone();
Если же его тип равен NameQuery*, нужно привести возвращенный указатель типа Query* назад к типу NameQuery*:
NameQuery *pnq = new NameQuery( "Rilke" ); NameQuery *pnq2 = |
static_cast
(Причина, по которой необходимо преобразование типа, объясняется в разделе 19.1.1.)
Как правило, тип значения, возвращаемого реализацией виртуальной функции в производном классе, должен совпадать с типом, возвращаемым ее реализацией в базовом. Исключение, о котором мы уже упоминали, призвано поддержать рассмотренную ситуацию. Если виртуальная функция в базовом классе возвращает значение некоторого типа класса (либо указатель или ссылку на тип класса), то ее реализация в производном может возвращать значение, тип которого является производным от этого класса с открытым типом наследования (то же относится к ссылкам и указателям):
class NameQuery : public Query { public: virtual NameQuery *clone() { return new NameQuery( *this ); } // ... |
Теперь pq2 и pnq2
можно инициализировать без явного приведения типов:
// Query *pq = new NameQuery( "Broch" ); Query *pq2 = pq->clone(); // правильно // NameQuery *pnq = new NameQuery( "Rilke" ); |
Так выглядит реализация clone() в классе NotQuery:
class NotQuery : public Query { public: virtual NotQuery *clone() { return new NotQuery( *this ); } // ... |
Реализации в AndQuery и OrQuery
аналогичны. Чтобы эти реализации clone() работали правильно, в классах NotQuery, AndQuery и OrQuery должны быть явно определены копирующие конструкторы. (Мы займемся этим в разделе 17.6.)
Подробнее о расширении типов
Под расширением типа понимается одно из следующих преобразований:·
фактический аргумент типа char, unsigned char или short
расширяется до типа int. Фактический аргумент типа unsigned short
расширяется до типа int, если машинный размер int
больше, чем размер short, и до типа unsigned int в противном случае;
· аргумент типа float
расширяется до типа double;
· аргумент перечислимого типа расширяется до первого из следующих типов, который способен представить все значения элементов перечисления: int, unsigned int, long, unsigned long;
· аргумент типа bool
расширяется до типа int.
Подобное расширение применяется, когда тип фактического аргумента совпадает с одним из только что перечисленных типов, а формальный параметр относится к соответствующему расширенному типу:
extern void manip( int ); int main() { manip( 'a' ); // тип char расширяется до int return 0; |
}
Символьный литерал имеет тип char. Он расширяется до int. Поскольку расширенный тип соответствует типу формального параметра функции manip(), мы говорим, что ее вызов требует расширения типа аргумента.
Рассмотрим следующий пример:
extern void print( unsigned int ); extern void print( int ); extern void print( char ); unsigned char uc; |
print( uc ); // print( int ); для uc требуется только расширение типа
Для аппаратной платформы, на которой unsigned char
занимает один байт памяти, а int – четыре байта, расширение преобразует unsigned char в int, так как с его помощью можно представить все значения типа unsigned char. Для такой машинной архитектуры из приведенного в примере множества перегруженных функций наилучшее соответствие аргументу типа unsigned char
обеспечивает print(int). Для двух других функций установление соответствия требует стандартного приведения.
Следующий пример иллюстрирует расширение фактического аргумента перечислимого типа:
enum Stat ( Fail, Pass ); extern void ff( int ); extern void ff( char ); int main() { // правильно: элемент перечисления Pass расширяется до типа int ff( Pass ); // ff( int ) ff( 0 ); // ff( int ) |
Иногда расширение перечислений преподносит сюрпризы. Компиляторы часто выбирают представление перечисления в зависимости от значений его элементов. Предположим, что в вышеупомянутой архитектуре (один байт для char и четыре байта для int) определено такое перечисление:
enum e1 { a1, b1, c1 };
Поскольку есть всего три элемента: a1, b1 и c1 со значениями 0, 1 и 2
соответственно – и поскольку все эти значения можно представить типом
char, то компилятор, как правило, и выбирает char для представления типа e1. Рассмотрим, однако, перечисление e2 со следующим множеством элементов:
enum e2 { a2, b2, c2=0x80000000 };
Так как одна из констант имеет значение 0x80000000, то компилятор обязан выбрать для представления e2
такой тип, который достаточен для хранения значения 0x80000000, то есть unsigned int.
Итак, хотя и e1, и e2
являются перечислениями, их представления различаются. Из-за этого e1 и e2
расширяются до разных типов:
#include string format( int ); string format( unsigned int ); int main() { format(a1); // вызывается format( int ) format(a2); // вызывается format( unsigned int ) return 0; |
При первом обращении к format()
фактический аргумент расширяется до типа int, так как для представления типа e1
используется char, и, следовательно, вызывается перегруженная функция format(int). При втором обращении тип фактического аргумента e2
представлен типом unsigned int и аргумент расширяется до unsigned int, из-за чего вызывается перегруженная функция format(unsigned int). Поэтому следует помнить, что поведение двух перечислений по отношению к процессу разрешения перегрузки может быть различным и зависеть от значений элементов, определяющих, как происходит расширение типа.
Подробнее о стандартном преобразовании
Имеется пять видов стандартных преобразований, а именно:1. преобразования целых типов: приведение от целого типа или перечисления к любому другому целому типу (исключая трансформации, которые выше были отнесены к категории расширения типов);
2. преобразования типов с плавающей точкой: приведение от любого типа с плавающей точкой к любому другому типу с плавающей точкой (исключая трансформации, которые выше были отнесены к категории расширения типов);
3. преобразования между целым типом и типом с плавающей точкой: приведение от любого типа с плавающей точкой к любому целому типу или наоборот;
4. преобразования указателей: приведение целого значения 0 к типу указателя или трансформация указателя любого типа в тип void*;
5. преобразования в тип bool: приведение от любого целого типа, типа с плавающей точкой, перечислимого типа или указательного типа к типу bool.
Вот несколько примеров:
extern void print( void* ); extern void print( double ); int main() { int i; print( i ); // соответствует print( double ); // i подвергается стандартному преобразованию из int в double print( &i ); // соответствует print( void* ); // &i подвергается стандартному преобразованию // из int* в void* return 0; |
}
Преобразования, относящиеся к группам 1, 2 и 3, потенциально опасны, так как целевой тип может и не обеспечивать представления всех значений исходного. Например, с помощью float
нельзя адекватно представить все значения типа int. Именно по этой причине трансформации, входящие в эти группы, отнесены к категории стандартных преобразований, а не расширений типов.
int i; void calc( float ); int main() { calc( i ); // стандартное преобразование между целым типом и типом с // плавающей точкой потенциально опасно в зависимости от // значения i return 0; |
}
При вызове функции calc()
применяется стандартное преобразование из целого типа int в тип с плавающей точкой float. В зависимости от значения переменной i
может оказаться, что его нельзя сохранить в типе float без потери точности.
Предполагается, что все стандартные изменения требуют одного объема работы. Например, преобразование из char в unsigned char не более приоритетно, чем из char в double. Близость типов не принимается во внимание. Если две устоявших функции требуют для установления соответствия стандартной трансформации фактического аргумента, то вызов считается неоднозначным и помечается компилятором как ошибка. Например, если даны две перегруженные функции:
extern void manip( long ); |
то следующий вызов неоднозначен:
int main() { manip( 3.14 ); // ошибка: неоднозначность // manip( float ) не лучше, чем manip( int ) return 0; |
Константа 3.14 имеет тип double. С помощью того или иного стандартного преобразования соответствие может быть установлено с любой из перегруженных функций. Поскольку есть две трансформации, приводящие к цели, вызов считается неоднозначным. Ни одно преобразование не имеет преимущества над другим. Программист может разрешить неоднозначность либо путем явного приведения типа:
manip ( static_cast
либо используя суффикс, обозначающий, что константа принадлежит к типу float:
manip ( 3.14F ) ); // manip( float )
Вот еще несколько примеров неоднозначных вызовов, которые помечаются как ошибки, поскольку соответствуют нескольким перегруженным функциям:
extern void farith( unsigned int ); extern void farith( float ); int main() { // каждый из последующих вызовов неоднозначен farith( 'a' ); // аргумент имеет тип char farith( 0 ); // аргумент имеет тип int farith( 2uL ); // аргумент имеет тип unsigned long farith( 3.14159 ); // аргумент имеет тип double farith( true ); // аргумент имеет тип bool |
}
Стандартные преобразования указателей иногда противоречат интуиции. В частности, значение 0
приводится к указателю на любой тип; полученный таким образом указатель называется нулевым. Значение 0
может быть представлено как константное выражение целого типа:
void set(int*); int main() { // преобразование указателя из 0 в int* применяется к аргументам // в обоих вызовах set( 0L ); set( 0x00 ); return 0; |
Константное выражение 0L
(значение 0
типа long int) и константное выражение 0x00
(шестнадцатеричное целое значение 0) имеют целый тип и потому могут быть преобразованы в нулевой указатель типа int*.
Но поскольку перечисления не относятся к целым типам, элемент, равный 0, не приводим к типу указателя:
enum EN { zr = 0 }; |
Вызов функции set()
является ошибкой, так как не существует преобразования между значением zr
элемента перечисления и формальным параметром типа int*, хотя zr
равно 0.
Следует отметить, что константное выражение 0 имеет тип int. Для его приведения к типу указателя требуется стандартное преобразование. Если в множестве перегруженных функций есть функция с формальным параметром типа int, то именно в ее пользу будет разрешена перегрузка в случае, когда фактический аргумент равен 0:
void print( int ); void print( void * ); void set( const char * ); void set( char * ); int main () { print( 0 ); // вызывается print( int ); set( 0 ); // неоднозначность return 0; |
При вызове print(int)
имеет место точное соответствие, тогда как для вызова print(void*)
необходимо приведение значения 0 к типу указателя. Поскольку соответствие лучше преобразования, для разрешения этого вызова выбирается функция print(int). Обращение к set()
неоднозначно, так как 0
соответствует формальным параметрам обеих перегруженных функций за счет применения стандартной трансформации. Раз обе функции одинаково хороши, фиксируется неоднозначность.
Последнее из возможных преобразований указателя позволяет привести указатель любого типа к типу void*, поскольку void* – это родовой указатель на любой тип данных. Вот несколько примеров:
#include extern void reset( void * ); void func( int *pi, string *ps ) { // ... reset( pi ); // преобразование указателя: int* в void* /// ... reset( ps ); // преобразование указателя: string* в void* |
Только указатели на типы данных могут быть приведены к типу void* с помощью стандартного преобразования, с указателями на функции так поступать нельзя:
typedef int (*PFV)(); extern PFV testCases[10]; // массив указателей на функции extern void reset( void * ); int main() { // ... reset( textCases[0] ); // ошибка: нет стандартного преобразования // между int(*)() и void* return 0; |
Подробнее о точном соответствии
Самый простой случай возникает тогда, когда типы фактических аргументов совпадают с типами формальных параметров. Например, есть две показанные ниже перегруженные функции max(). Тогда каждый из вызовов max() точно соответствует одному из объявлений:int max( int, int ); double max( double, double ); int i1; void calc( double d1 ) { max( 56, i1 ); // точно соответствует max( int, int ); max( d1, 66.9 ); // точно соответствует max( double, double ); |
}
Перечислимый тип точно соответствует только определенным в нем элементам перечисления, а также объектам, которые объявлены как принадлежащие к этому типу:
enum Tokens { INLINE = 128; VIRTUAL = 129; }; Tokens curTok = INLINE; enum Stat { Fail, Pass }; extern void ff( Tokens ); extern void ff( Stat ); extern void ff( int ); int main() { ff( Pass ); // точно соответствует ff( Stat ) ff( 0 ); // точно соответствует ff( int ) ff( curTok ); // точно соответствует ff( Tokens ) // ... |
}
Выше уже упоминалось, что фактический аргумент может точно соответствовать формальному параметру, даже если для приведения их типов необходимо некоторое тривиальное преобразование, первое из которых – преобразование l-значения в r-значение. Под l-значением понимается объект, удовлетворяющий следующим условиям:
· можно получить адрес объекта;
· можно получить значение объекта;
· это значение легко модифицировать (если только в объявлении объекта нет спецификатора const).
Напротив, r-значение – это выражение, значение которого вычисляется, или выражение, обозначающее временный объект, для которого нельзя получить адрес и значение которого нельзя модифицировать. Вот простой пример:
int calc( int ); int main() { int lval, res; lval = 5; // lvalue: lval; rvalue: 5 res = calc( lval ); // lvalue: res // rvalue: временный объект для хранения значения, // возвращаемого функцией calc() return 0; |
В первом операторе присваивания переменная lval – это l-значение, а литерал 5 – r-значение. Во втором операторе присваивания res – это l-значение, а временный объект, в котором хранится результат, возвращаемый функцией calc(), – это r-значение.
В некоторых ситуациях в контексте, где ожидается значение, можно использовать выражение, представляющее собой l-значение:
int obj1; int obj2; int main() { // ... int local = obj1 + obj2; return 0; |
Здесь obj1 и obj2 – это l-значения. Однако для выполнения сложения в функции main() из переменных obj1 и obj2 извлекаются их значения. Действие, состоящее в извлечении значения объекта, представленного выражением вида l-значение, называется преобразованием l-значения в r-значение.
Когда функция ожидает аргумент, переданный по значению, то в случае, если аргумент является l-значением, выполняется его преобразование в r-значение:
#include string color( "purple" ); void print( string ); int main() { print( color ); // точное соответствие: преобразование lvalue // в rvalue return 0; |
Так как аргумент в вызове print(color)
передается по значению, то производится преобразование l-значения в r-значение для извлечения значения color и передачи его в функцию с прототипом print(string). Однако несмотря на то, что такое приведение имело место, считается, что фактический аргумент color
точно соответствует объявлению print(string).
При вызове функций не всегда требуется применять к аргументам подобное преобразование. Ссылка представляет собой l-значение; если у функции есть параметр-ссылка, то при вызове функция получает l-значение. Поэтому к фактическому аргументу, которому соответствует формальный параметр-ссылка, описанное преобразование не применяется. Например, пусть объявлена такая функция:
#include |
В вызове ниже li – это l-значение, представляющее объект list
list int main() { // ... print( li ); // точное соответствие: нет преобразования lvalue в // rvalue return 0; |
Сопоставление li с параметром-ссылкой считается точным соответствием.
Второе преобразование, при котором все же фиксируется точное соответствие, – это преобразование массива в указатель. Как уже отмечалось в разделе 7.3, параметр функции никогда не имеет тип массива, трансформируясь вместо этого в указатель на его первый элемент. Аналогично фактический аргумент типа массива из NT (где N – число элементов в массиве, а T – тип каждого элемента) всегда приводится к типу указателя на T. Такое преобразование типа фактического аргумента и называется преобразованием массива в указатель. Несмотря на это, считается, что фактический аргумент точно соответствует формальному параметру типа “указатель на T”. Например:
int ai[3]; void putValues(int *); int main() { // ... putValues(ai); // точное соответствие: преобразование массива в // указатель return 0; |
Перед вызовом функции putValues()
массив преобразуется в указатель, в результате чего фактический аргумент ai
(массив из трех целых) приводится к указателю на int. Хотя формальным параметром функции putValues() является указатель и фактический аргумент при вызове преобразован, между ними устанавливается точное соответствие.
При установлении точного соответствия допустимо также преобразование функции в указатель. (Оно упоминалось в разделе 7.9.) Как и параметр-массив, параметр-функция становится указателем на функцию. Фактический аргумент типа “функция” также автоматически приводится к типу указателя на функцию. Такое преобразование типа фактического аргумента и называется преобразованием функции в указатель. Хотя трансформация производится, считается, что фактический аргумент точно соответствует формальному параметру. Например:
int lexicoCompare( const string &, const string & ); typedef int (*PFI)( const string &, const string & ); void sort( string *, string *, PFI ); string as[10]; int main() { // ... sort( as, as + sizeof(as)/sizeof(as[0] - 1 ), lexicoCompare // точное соответствие // преобразование функции в указатель ); return 0; |
Перед вызовом sort()
применяется преобразование функции в указатель, которое приводит аргумент lexicoCompare от типа “функция” к типу “указатель на функцию”. Хотя формальным параметром функции является указатель, а фактическим – имя функции и, следовательно, было произведено преобразование функции в указатель, считается, что фактический аргумент точно третьему формальному параметру функции sort().
Последнее из перечисленных выше – это преобразование спецификаторов. Оно относится только к указателям и заключается в добавлении спецификаторов const или volatile
(или обоих) к типу, который адресует данный указатель:
int a[5] = { 4454, 7864, 92, 421, 938 }; int *pi = a; bool is_equal( const int * , const int * ); void func( int *parm ) { // точное соответствие между pi и parm: преобразование спецификаторов if ( is_equal( pi, parm ) ) // ... return 0; |
Перед вызовом функции is_equal()
фактические аргументы pi и parm
преобразуются из типа “указатель на int” в тип “указатель на const int”. Эта трансформация заключается в добавлении спецификатора const к адресуемому типу, поэтому относится к категории преобразований спецификаторов. Несмотря на то, что функция ожидает получить два указателя на const int, а фактические аргументы являются указателями на int, считается, что точное соответствие между формальными и фактическими параметрами функции is_equal()
установлено.
Преобразование спецификаторов применимо только к типу, который адресует указатель. Оно не употребляется в случае, когда формальный параметр имеет спецификатор const или volatile, а фактический аргумент – нет.
extern void takeCI( const int ); int main() { int ii = ...; takeCI(ii); // преобразование спецификаторов не применяется return 0; |
Хотя формальный параметр функции takeCI()
имеет тип const int, а вызывается она с аргументом ii типа int, преобразование спецификаторов не производится: есть точное соответствие между фактическим аргументом и формальным параметром.
Все сказанное верно и для случая, когда аргумент является указателем, а спецификаторы const или volatile
относятся к этому указателю:
extern void init( int *const ); extern int *pi; int main() { // ... init(pi); // преобразование спецификаторов не применяется return 0; |
Спецификатор const при формальном параметре функции init() относится к самому указателю, а не к типу, который он адресует. Поэтому компилятор при анализе преобразований, которые должны быть применены к фактическому аргументу, не учитывает этот спецификатор. К аргументу pi не применяется преобразование спецификатора: считается, что этот аргумент и формальный параметр точно соответствуют друг другу.
Первые три из рассмотренных преобразований (l-значения в r-значение, массива в указатель и функции в указатель) часто называют трансформациями l-значений. (В разделе 9.4 мы увидим, что хотя и трансформации l-значений, и преобразования спецификаторов относятся к категории преобразований, не нарушающих точного соответствия, его степень считается выше в случае, когда необходима лишь первая трансформация. В следующем разделе мы поговорим об этом несколько подробнее.)
Точное соответствие можно установить принудительно, воспользовавшись явным приведением типов. Например, если есть две перегруженные функции:
extern void ff(int); |
то вызов
ff( 0xffbc ); // вызывается ff(int)
будет точно соответствовать ff(int), хотя литерал 0xffbc
записан в виде шестнадцатеричной константы. Программист может заставить компилятор вызвать функцию ff(void *), если явно выполнит операцию приведения типа:
ff( reinterpret_cast
Если к фактическому аргументу применяется такое приведение, то он приобретает тип, в который преобразуется. Явные приведения типов помогают в управлении процессом разрешения перегрузки. Например, если при разрешении перегрузки получается неоднозначный результат (фактические аргументы одинаково хорошо соответствуют двум или более устоявшим функциям), то для устранения неоднозначности можно применить явное приведение типа, заставив компилятор выбрать конкретную функцию.
Поиск и извлечение элемента отображения
Оператор взятия индекса является простейшим способом извлечения элемента. Например:// map
| |
int count = word_count[ "wrinkles" ];
Однако этот способ работает так, как надо, только при условии, что запрашиваемый ключ действительно содержится в отображении. Иначе оператор взятия индекса поместит в отображение элемент с таким ключом. В данном случае в word_count
занесется пара
string( "wrinkles" ), 0
Класс map
предоставляет две операции для того, чтобы выяснить, содержится ли в нем определенное значение ключа.
· count(keyValue): функция-член count()
возвращает количество элементов с данным ключом. (Для отображения оно равно только 0 или 1). Если count()
вернула 1, мы можем смело использовать индексацию:
int count = 0; if ( word_count.count( "wrinkles" )) |
count = word_count[ "wrinkles" ];
· find(keyValue): функция-член find()
возвращает итератор, указывающий на элемент, если ключ найден, и итератор end() в противном случае. Например:
int count = 0; map if ( it != word_count.end() )
| |
count = (*it).second;
Значением итератора является указатель на объект pair, в котором first
содержит ключ, а second – значение. (Мы вернемся к этому в следующем подразделе.)
Поиск элемента
Две операции, позволяющие отыскать в наборе определенное значение,– это find() и count(). find() возвращает итератор, указывающий на найденный элемент, или значение, равное end(), если он отсутствует. count() возвращает 1 при наличии элемента и 0 в противном случае. Добавим проверку на существование в функцию build_word_map():if ( exclusion_set.count( textword )) continue; |
// добавим отсутствующее слово
ПОО и члены пространства имен
Как уже было сказано, определение пространства имен может состоять из разрозненных частей и размещаться в разных файлах. Следовательно, член пространства разрешено объявлять во многих файлах. Например:// primer.h namespace cplusplus_primer { // ... void inverse( matrix & ); } // usel.C #include "primer.h" // объявление cplusplus_primer::inverse() в use1.C // use2.C #include "primer.h" |
// объявление cplusplus_primer::inverse() в use2.C
Объявление cplusplus::inverse() в primer.h
ссылается на одну и ту же функцию в обоих исходных файлах use1.C и use2.C.
Член пространства имен является глобальной сущностью, хотя его имя квалифицировано. Требование ПОО (правило одного определения, см. раздел 8.2) распространяется и на него. Чтобы удовлетворить этому требованию, программы, в которых используются пространства имен, обычно организуют следующим образом:
1. Объявления функций и объектов, являющихся членами пространства имен, помещают в заголовочный файл, который включается в каждый исходный файл, где они используются.
// ---- primer.h ---- namespace cplusplus_primer { class matrix { /* ... */ }; // объявления функций extern matrix operator+ ( const matrix &m1, const matrix &m2 ); extern void inverse( matrix & ); // объявления объектов extern bool error_state; |
}
2. Определения этих членов помещают в исходный файл, содержащий реализацию:
// ---- primer.C ---- #include "primer.h" namespace cplusplus_primer { // определения функций void inverse( matrix & ) { /* ... */ } matrix operator+ ( const matrix &ml, const matrix &m2 ) { /" ... */ } // определения объектов bool error_state = false; |
}
Для объявления объекта без его определения используется ключевое слово extern, как и в случае такого объявления в глобальной области видимости.
Порядок выполнения инструкций
По умолчанию инструкции программы выполняются одна за другой, последовательно. В программеint main() { readIn(); sort(); compact(); print(); return 0; |
}
первой будет выполнена инструкция readIn(), за ней sort(), compact() и наконец print().
Однако представим себе ситуацию, когда количество продаж невелико: оно равно 1 или даже 0. Вряд ли стоит вызывать функции sort() и compact() для такого случая. Но вывести результат все-таки нужно, поэтому функцию print() следует вызывать в любом случае. Для этого случая мы можем использовать условную инструкцию if. Нам придется переписать функцию readIn() так, чтобы она возвращала количество прочитанных записей:
// readIn() возвращает количество прочитанных записей // возвращаемое значение имеет тип int int readIn() { ... } // ... int main() { int count = readIn(); // если количество записей больше 1, // то вызвать sort() и compact() if ( count > 1 ) { sort(); compact(); } if ( count == 0 ) cout << "Продаж не было\n"; else print(); return 0; |
}
Первая инструкция if
обеспечивает условное выполнение блока программы: функции sort() и compact()
вызываются только в том случае, если count больше 1. Согласно второй инструкции if на терминал выводится сообщение “Продаж не было”, если условие истинно, т.е. значение count
равно 0. Если же это условие ложно, производится вызов функции print(). (Детальное описание инструкции if
приводится в разделе 5.3.)
Другим распространенным способом непоследовательного выполнения программы является итерация, или инструкция цикла. Такая инструкция предписывает повторять блок программы до тех пор, пока некоторое условие не изменится с true на false. Например:
int main() { int iterations = 0; bool continue_loop = true; while ( continue_loop != false ) { iterations++; cout << "Цикл был выполнен " << iterations << "раз\n"; if ( iterations == 5 ) continue_loop = false; } return 0; |
}
В этом надуманном примере цикл while выполняется пять раз, до тех пор пока переменная iterations не получит значение 5 и переменная continue_loop не станет равной false. Инструкция
iterations++;
увеличивает значение переменной iterations на единицу. (Инструкции цикла детально рассматриваются в главе 5.)
Порядок вызова конструкторов и деструкторов
Виртуальные базовые классы всегда конструируются перед невиртуальными, вне зависимости от их расположения в иерархии наследования. Например, в приведенной иерархии у класса TeddyBear(плюшевый мишка) есть два виртуальных базовых: непосредственный– ToyAnimal
(игрушечное животное) и экземпляр ZooAnimal, от которого унаследован класс Bear:
| class Character { ... }; // персонаж class BookCharacter : public Character { ... }; // литературный персонаж class ToyAnimal { ... }; // игрушка class TeddyBear : public BookCharacter, public Bear, public virtual ToyAnimal |
{ ... };
Эта иерархия изображена на рис. 18.5, где виртуальное наследование показано пунктирной стрелкой, а невиртуальное – сплошной.
![]() |
![]() |
![]() |
Character ZooAnimal ToyAnimal
![]() |

TeddyBear
¾¾> невиртуальное наследование
- - - -> виртуальноe наследование
Рис. 18.5. Иерархия виртуального наследования класса TeddyBear
Непосредственные базовые классы просматриваются в порядке их объявления при поиске среди них виртуальных. В нашем примере сначала анализируется поддерево наследования BookCharacter, затем Bear и наконец ToyAnimal. Каждое поддерево обходится в глубину, т.е. поиск начинается с корневого класса и продвигается вниз. Так, для поддерева BookCharacter сначала просматривается Character, а затем BookCharacter. Для поддерева Bear
– ZooAnimal, а потом Bear.
При описанном алгоритме поиска порядок вызова конструкторов виртуальных базовых классов для TeddyBear
таков: ZooAnimal, потом ToyAnimal.
После того как вызваны конструкторы виртуальных базовых классов , настает черед конструкторов невиртуальных, которые вызываются в порядке объявления: BookCharacter, затем Bear. Перед выполнением конструктора BookCharacter вызывается конструктор его базового класса Character.
Порождение класса, контролирующего выход за границы массива
В функции try_array() из раздела 16.13, предназначенной для тестирования нашей предыдущей реализации шаблона класса Array, есть две инструкции:int index = iA.find( find_val ); |
Type value = iA[ index ];
find()
возвращает индекс первого вхождения значения find_val или -1, если значение в массиве не найдено. Этот код некорректен, поскольку в нем не проверяется, что не была возвращена -1. Поскольку -1
находится за границей массива, то каждая инициализация value
может привести к ошибке. Поэтому мы создадим подтип Array, который будет контролировать выход за границы массива, – Array_RC и поместим его определение в заголовочный файл Array_RC.h:
#ifndef ARRAY_RC_H #define ARRAY_RC_H #include "Array.h" template class Array_RC : public virtual Array public: Array_RC( int sz = ArraySize ) : Array Array_RC( const Array_RC& r ); Array_RC( const Type *ar, int sz ); Type& operator[]( int ix ); };
| |
#endif
Внутри определения производного класса каждая ссылка на спецификатор типа шаблона базового должна быть квалифицирована списком формальных параметров:
Array_RC( int sz = ArraySize ) |
: Array
Такая запись неправильна:
// ошибка: Array - это не спецификатор типа |
Array_RC( int sz = ArraySize ) : Array( sz ) {}
Единственное отличие поведения класса Array_RC от базового состоит в том, что оператор взятия индекса контролирует выход за границы массива. Во всех остальных отношениях можно воспользоваться уже имеющейся реализацией шаблона класса Array. Напомним, однако, что конструкторы не наследуются, поэтому в Array_RC
определен собственный набор из трех конструкторов. Мы сделали класс Array_RC
виртуальным наследником класса Array, поскольку предвидели необходимость множественного наследования.
Вот полная реализация функций-членов Array_RC, находящаяся в файле Array_RC.C
(определения функций класса Array помещены в заголовочный файл Array.C, поскольку мы пользуемся моделью конкретизации шаблонов с включением, описанной в разделе 16.18):
#include "Array_RC.h" #include "Array.C" #include template Array_RC : Array template Array_RC : Array template Type &Array_RC assert( ix >= 0 && ix < Array return ia[ ix ]; |
Мы квалифицировали обращения к членам базового класса Array, например к _size, чтобы предотвратить просмотр Array до момента конкретизации шаблона:
Array
Мы достигаем этого, включая в обращение параметр шаблона. Таким образом, имена в определении Array_RC
разрешаются тогда, когда определяется шаблон (за исключением имен, явно зависящих от его параметра). Если встречается неквалифицированное имя _size, то компилятор должен найти его определение, если только это имя не зависит явно от параметра шаблона. Мы сделали имя _size зависящим от параметра шаблона, предварив его именем базового класса Array
Каждая конкретизация Array_RC
порождает экземпляр класса Array. Например:
Array_RC
конкретизирует параметром string как шаблон Array_RC, так и шаблон Array. Приведенная ниже программа вызывает try_array() (реализацию см. в разделе 16.13), передавая ей объекты подтипа Array_RC. Если все сделано правильно, то выходы за границы массивы будут замечены:
#include "Array_RC.C" #include "try_array.C" int main() { static int ia[] = { 12,7,14,9,128,17,6,3,27,5 }; cout << "конкретизация шаблона класса Array_RC try_array( iA ); return 0; |
После компиляции и запуска программа печатает следующее:
конкретизация шаблона класса Array_RC
try_array: начальные значения массива
( 10 )< 12, 7, 14, 9, 128, 17
6, 3, 27, 5 >
try_array: после присваиваний
( 10 )< 128, 7, 14, 9, 128, 128
6, 3, 27, 3 >
try_array: почленная инициализация
( 10 )< 12, 7, 14, 9, 128, 128
6, 3, 27, 3 >
try_array: после почленного копирования
( 10 )< 12, 7, 128, 9, 128, 128
6, 3, 27, 3 >
try_array: после вызова grow
( 10 )< 12, 7, 128, 9, 128, 128
6, 3, 27, 3, 0, 0
0, 0, 0, 0 >
искомое значение: 5 возвращенный индекс: -1
Assertion failed: ix >= 0 && ix < _size
Порождение класса отсортированного массива
Вторая наша специализация класса Array– отсортированный подтип Array_Sort. Мы поместим его определение в заголовочный файл Array_S.h:#ifndef ARRAY_S_H_ #define ARRAY_S_H_ #include "Array.h" template class Array_Sort : public virtual Array protected: void set_bit() { dirty_bit = true; } void clear_bit() { dirty_bit = false; } void check_bit() { if ( dirty_bit ) { sort( 0, Array clear_bit(); } } public: Array_Sort( const Array_Sort& ); Array_Sort( int sz = Array : Array { clear_bit(); } Array_Sort( const Type* arr, int sz ) : Array { sort( 0,Array Type& operator[]( int ix ) { set_bit(); return ia[ ix ]; } void print( ostream& os = cout ) const { check_bit(); Array Type min() { check_bit(); return ia[ 0 ]; } Type max() { check_bit(); return ia[ Array bool is_dirty() const { return dirty_bit; } int find( Type ); void grow(); protected: bool dirty_bit; };
| |
#endif
Array_Sort
включает дополнительный член – dirty_bit. Если он установлен в true, то не гарантируется, что массив по-прежнему отсортирован. Предоставляется также ряд вспомогательных функций доступа: is_dirty() возвращает значение dirty_bit; set_bit()
устанавливает dirty_bit в true; clear_bit() сбрасывает dirty_bit в false; check_bit()
пересортировывает массив, если dirty_bit равно true, после чего сбрасывает его в false. Все операции, которые потенциально могут перевести массив в неотсортированное состояние, вызывают set_bit().
При каждом обращении к шаблону Array
template Array_Sort |
а не
template Array_Sort |
поскольку второе вхождение Array_Sort
синтаксически является именем функции, а не спецификатором типа.
Есть две причины, по которым правильна такая запись:
if ( as.is_dirty() ) |
а не просто
as.check_bit();
Первая причина связана с типизацией: check_bit() – это неконстантная функция-член, которая модифицирует объект класса. В качестве аргумента передается ссылка на константный объект. Применение check_bit() к аргументу as
нарушает его константность и потому воспринимается компилятором как ошибка.
Вторая причина: копирующий конструктор рассматривает массив, ассоциированный с as, только для того, чтобы выяснить, нуждается ли вновь созданный объект класса Array_Sort в сортировке. Напомним, однако, что член dirty_bit нового объекта еще не инициализирован. К началу выполнения тела конструктора Array_Sort
инициализированы только члены ia и _size, унаследованные от класса Array. Этот конструктор должен с помощью clear_bit() задать начальные значения дополнительных членов и, вызвав sort(), обеспечить специальное поведение подтипа. Конструктор Array_Sort
можно было бы инициализировать и по-другому:
// альтернативная реализация template Array_Sort Array_Sort( const Array_Sort : Array { dirty_bit = as.dirty_bit; clear_bit(); |
Ниже приведена реализация функции-члена grow().1
Наша стратегия состоит в том, чтобы воспользоваться имеющейся в базовом классе Array
реализацией для выделения дополнительной памяти, а затем пересортировать элементы и сбросить dirty_bit:
template void Array_Sort { Array sort( 0, Array clear_bit(); |
Tigger >
try_array: после присваиваний
( 7 )< Eeyore, Gopher, Owl, Piglet, Pooh, Pooh
Pooh >
try_array: почленная инициализация
( 7 )< Eeyore, Gopher, Owl, Piglet, Pooh, Pooh
Pooh >
try_array: после почленного копирования
( 7 )< Eeyore, Piglet, Owl, Piglet, Pooh, Pooh
Pooh >
try_array: после вызова grow
( 7 )<
Piglet, Piglet, Pooh, Pooh, Pooh >
искомое значение: Tigger возвращенный индекс: -1
Memory fault (coredump)
После почленного копирования массив не
отсортирован, поскольку виртуальная функция вызывалась через объект, а не через указатель или ссылку. Как было сказано в разделе 17.5, в таком случае вызывается экземпляр функции из класса именно этого объекта, а не того подтипа, который может находиться в переменной. Поэтому функция sort()
никогда не будет вызвана через объект Array. (Разумеется, мы реализовали такое поведение только в целях демонстрации.)
}
Так выглядит реализация двоичного поиска в функции-члене find() класса Array_Sort:
template int Array_Sort { int low = 0; int high = Array check_bit(); while ( low <= high ) { int mid = ( low + high )/2; if ( val == ia[ mid ] ) return mid; if ( val < ia[ mid ] ) high = mid-1; else low = mid+1; } return -1; |
Протестируем нашу реализацию класса Array_Sort с помощью функции try_array(). Показанная ниже программа тестирует шаблон этого класса для конкретизаций типами int и string:
#include "Array_S.C" #include "try_array.C" #include main() { static int ia[ 10 ] = { 12,7,14,9,128,17,6,3,27,5 }; static string sa[ 7 ] = { "Eeyore", "Pooh", "Tigger", "Piglet", "Owl", "Gopher", "Heffalump" }; Array_Sort Array_Sort cout << "êîíêðåòèçàöèÿ êëàññà Array_Sort << endl; try_array( iA ); cout << "êîíêðåòèçàöèÿ êëàññà Array_Sort << endl; try_array( SA ); return 0; |
При конкретизации типом string
после компиляции и запуска программа печатает следующий текст (обратите внимание, что попытка вывести элемент с индексом -1
заканчивается крахом):
конкретизация класса Array_Sort
try_array: начальные значения массива
( 7 )< Eeyore, Gopher, Heffalump, Owl, Piglet, Pooh
Построение набора стоп-слов
Отображение состоит из пар ключ/значение. Множество (set), напротив, содержит неупорядоченную совокупность ключей. Например, бизнесмен может составить “черный список” bad_checks, содержащий имена лиц, в течение последних двух лет присылавших фальшивые чеки. Множество полезно тогда, когда нужно узнать, содержится ли определенное значение в списке. Скажем, наш бизнесмен, принимая чек от кого-либо, может проверить, есть ли его имя в bad_checks.Для нашей поисковой системы мы построим набор стоп-слов– слов, имеющих семантически нейтральное значение (артикли, союзы, предлоги), таких, как the, and, into, with, but и т.д. (это улучшает качество системы, однако мы уже не сможем найти первое предложение из знаменитого монолога Гамлета: “To be or not to be?”). Прежде чем добавлять слово к word_map, проверим, не содержится ли оно в списке стоп-слов. Если содержится, проигнорируем его.
Потоковые итераторы
Стандартная библиотека предоставляет средства для работы потоковых итераторов чтения и записи совместно со стандартными контейнерами и обобщенными алгоритмами. Класс istream_iteratorподдерживает итераторные операции с классом istream или одним из производных от него, например ifstream для работы с потоком ввода из файла. Аналогично ostream_iterator
поддерживает итераторные операции с классом ostream или одним из производных от него, например ofstream для работы с потоком вывода в файл. Для использования любого из этих итераторов следует включить заголовочный файл
#include
В следующей программе мы пользуемся потоковым итератором чтения для получения из стандартного ввода последовательности целых чисел в вектор, а затем применяем потоковый итератор записи в качестве целевого в обобщенном алгоритме unique_copy():
#include #include #include #include #include /* * вход: * 23 109 45 89 6 34 12 90 34 23 56 23 8 89 23 * * выход: * 109 90 89 56 45 34 23 12 8 6 */ int main() { istream_iterator< int > input( cin ); istream_iterator< int > end_of_stream; vector copy ( input, end_of_stream, inserter( vec, vec.begin() )); sort( vec.begin(), vec.end(), greater ostream_iterator< int > output( cout, " " ); unique_copy( vec.begin(), vec.end(), output );
| |
}
Повторное возбуждение исключения
Может оказаться так, что в одном предложении catch не удалось полностью обработать исключение. Выполнив некоторые корректирующие действия, catch-обработчик может решить, что дальнейшую обработку следует поручить функции, расположенной “выше” в цепочке вызовов. Передать исключение другому catch-обработчику можно с помощью повторного возбуждения исключения. Для этой цели в языке предусмотрена конструкцияthrow;
которая вновь генерирует объект-исключение. Повторное возбуждение возможно только внутри составной инструкции, являющейся частью catch-обработчика:
catch ( exception eObj ) { if ( canHandle( eObj ) ) // обработать исключение return; else // повторно возбудить исключение, чтобы его перехватил другой // catch-обработчик throw; |
}
При повторном возбуждении новый объект-исключение не создается. Это имеет значение, если catch-обработчик модифицирует объект, прежде чем возбудить исключение повторно. В следующем фрагменте исходный объект-исключение не изменяется. Почему?
enum EHstate { noErr, zeroOp, negativeOp, severeError }; void calculate( int op ) { try { // исключение, возбужденное mathFunc(), имеет значение zeroOp mathFunc( op ); } catch ( EHstate eObj ) { // что-то исправить // пытаемся модифицировать объект-исключение eObj = severeErr; // предполагалось, что повторно возбужденное исключение будет // иметь значение severeErr throw; } |
}
Так как eObj не является ссылкой, то catch-обработчик получает копию объекта-исключения, так что любые модификации eObj
относятся к локальной копии и не отражаются на исходном объекте-исключении, передаваемом при повторном возбуждении. Таким образом, переданный далее объект по-прежнему имеет тип zeroOp.
Чтобы модифицировать исходный объект-исключение, в объявлении исключения внутри catch-обработчика должна фигурировать ссылка:
catch ( EHstate &eObj ) { // модифицируем объект-исключение eObj = severeErr; // повторно возбужденное исключение имеет значение severeErr throw; |
}
Теперь eObj
ссылается на объект-исключение, созданный выражением throw, так что все изменения относятся непосредственно к исходному объекту. Поэтому при повторном возбуждении исключения далее передается модифицированный объект.
Таким образом, другая причина для объявления ссылки в catch-обработчике заключается в том, что сделанные внутри обработчика модификации объекта-исключения в таком случае будут видны при повторном возбуждении исключения. (Третья причина будет рассмотрена в разделе 19.2, где мы расскажем, как catch-обработчик вызывает виртуальные функции класса.)
Предопределенные объекты-функции
Предопределенные объекты-функции подразделяются на арифметические, логические и сравнительные. Каждый объект– это шаблон класса, параметризованный типами операндов. Для использования любого из них необходимо включить заголовочный файл:#include
Например, объект-функция, поддерживающий сложение, – это шаблон класса с именем plus. Для определения экземпляра, способного складывать два целых числа, нужно написать:
#include
| |
plus< int > intAdd;
Для выполнения операции сложения мы применяем перегруженный оператор вызова к intAdd
точно так же, как и к классу AddImage в предыдущем разделе:
int ival1 = 10, ival2 = 20; // эквивалентно int sum = ival1 + ival2; |
int sum = intAdd( ival1, ival2 );
Реализация шаблона класса plus
вызывает оператор сложения, ассоциированный с типом своего параметра – int. Этот и другие предопределенные объекты-функции применяются прежде всего в качестве аргументов обобщенных алгоритмов и обычно замещают подразумеваемую по умолчанию операцию. Например, по умолчанию алгоритм sort() располагает элементы контейнера в порядке возрастания с помощью оператора “меньше” базового типа. Для сортировки по убыванию мы передаем предопределенный шаблон класса greater, который вызывает оператор “больше”:
vector< string > svec; // ... |
sort( svec.begin(), svec.end(), greater
Предопределенные объекты-функции перечислены в следующих разделах и разбиты на категории: арифметические, логические и сравнительные. Применение каждого из них иллюстрируется как в качестве именованного, так и в качестве безымянного объекта, передаваемого функции. Мы пользуемся следующими определениями объектов, включая и определение простого класса (перегрузка операторов подробно рассматривается в главе 15):
class Int { public: Int( int ival = 0 ) : _val( ival ) {} int operator-() { return -_val; } int operator%(int ival) { return -_val % ival; } bool operator<(int ival) { return -_val < ival; } bool operator!() { return -_val == 0; } private: int _val; }; vector< string > svec; string sval1, sval2, sres; complex cval1, cval2, cres; int ival1, ival2, ires; Int Ival1, Ival2, Ires; |
double dval1, dval2, dres;
Кроме того, мы определяем два шаблона функций, которым передаем различные безымянные объекты-функции:
template Type UnaryFunc( FuncObject fob, const Type &val ) { return fob( val ); } template Type BinaryFunc( FuncObject fob, const Type &val1, const Type &val2 )
| |
{ return fob( val1, val2 ); }
Преобразования типов аргументов A
На втором шаге процесса разрешения перегрузки функции компилятор идентифицирует и ранжирует преобразования, которые следует применить к каждому фактическому аргументу вызванной функции для приведения его к типу соответствующего формального параметра любой из устоявших функций. Ранжирование может дать один из трех возможных результатов:·
точное соответствие. Тип фактического аргумента точно соответствует типу формального параметра. Например, если в множестве перегруженных функций print()
есть такие:
void print( unsigned int ); void print( const char* ); |
void print( char );
то каждый из следующих трех вызовов дает точное соответствие:
unsigned int a; print( 'a' ); // соответствует print( char ); print( "a" ); // соответствует print( const char* ); |
print( a ); // соответствует print( unsigned int );
· соответствие с
преобразованием типа. Тип фактического аргумента не соответствует типу формального параметра, но может быть преобразован в него:
void ff( char ); |
ff( 0 ); // аргумент типа int приводится к типу char
· отсутствие соответствия. Тип фактического аргумента не может быть приведен к типу формального параметра в объявлении функции, поскольку необходимого преобразования не существует. Для каждого из следующих двух вызовов функции print() соответствия нет:
// функции print() объявлены так же, как и выше int *ip; class SmallInt { /* ... */ }; SmallInt si; print( ip ); // ошибка: нет соответствия |
print( si ); // ошибка: нет соответствия
Для установления точного соответствия тип фактического аргумента необязательно должен совпадать с типом формального параметра. К аргументу могут быть применены некоторые тривиальные преобразования, а именно:
· преобразование l-значения в r-значение;
· преобразование массива в указатель;
· преобразование функции в указатель;
· преобразования спецификаторов.
(Подробнее они рассмотрены ниже.)
Категория соответствия с преобразованием типа является наиболее сложной. Необходимо рассмотреть несколько видов такого приведения: расширение типов (promotions), стандартные преобразования и определенные пользователем преобразования. (Расширения типов и стандартные преобразования изучаются в этой главе. Определенные пользователем преобразования будут представлены позднее, после детального рассмотрения классов; они
выполняются конвертером, функцией-членом, которая позволяет определить в классе собственный набор “стандартных” трансформаций. В главе 15 мы познакомимся с такими конвертерами и с тем, как они влияют на разрешение перегрузки функций.)
При выборе лучшей из устоявших функций для данного вызова компилятор ищет функцию, для которой применяемые к фактическим аргументам преобразования являются “наилучшими”. Преобразования типов ранжируются следующим образом: точное соответствие лучше расширения типа, расширение типа лучше стандартного преобразования, а оно, в свою очередь, лучше определенного пользователем преобразования. Мы еще вернемся к ранжированию в разделе 9.4, а пока на простых примерах покажем, как оно помогает выбрать наиболее подходящую функцию.
Преобразования типов
Представим себе следующий оператор присваивания:int ival = 0; // обычно компилируется с предупреждением |
ival = 3.541 + 3;
В результате ival
получит значение 6. Вот что происходит: мы складываем литералы разных типов – 3.541
типа double и 3
типа int. C++ не может непосредственно сложить подобные операнды, сначала ему нужно привести их к одному типу. Для этого существуют правила преобразования арифметических типов. Общий принцип таков: перейти от операнда меньшего типа к большему, чтобы не потерять точность вычислений.
В нашем случае целое значение 3 трансформируется в тип double, и только после этого производится сложение. Такое преобразование выполняется независимо от желания программиста, поэтому оно получило название неявного преобразования типов.
Результат сложения двух чисел типа double тоже имеет тип double. Значение равно 6.541. Теперь его нужно присвоить переменной ival. Типы переменной и результата 6.541 не совпадают, следовательно, тип этого значения приводится к типу переменной слева от знака равенства. В нашем случае это int. Преобразование double в int
производится автоматически, отбрасыванием дробной части (а не округлением). Таким образом, 6.541
превращается в 6, и этот результат присваивается переменной ival. Поскольку при таком преобразовании может быть потеряна точность, большинство компиляторов выдают предупреждение.
Так как компилятор не округляет числа при преобразовании double в int, при необходимости мы должны позаботиться об этом сами. Например:
double dva1 = 8.6; int iva1 = 5; |
ival += dva1 + 0.5; // преобразование с округлением
При желании мы можем произвести явное преобразование типов:
// инструкция компилятору привести double к int |
ival = static_cast< int >( 3.541 ) + 3;
В этом примере мы явно даем указание компилятору привести величину 3.541 к типу int, а не следовать правилам по умолчанию.
В этом разделе мы детально обсудим вопросы и неявного (как в первом примере), и явного преобразования типов (как во втором).
Применение наследования в C++
При использовании наследования указатель или ссылка на тип базового класса способен адресовать объект любого производного от него класса. Возможность манипулировать такими указателями или ссылками независимо от фактического типа адресуемого объекта называется полиморфизмом. В этой главе мы рассмотрим три функции языка, обеспечивающие специальную поддержку полиморфизма. Сначала мы познакомимся с идентификацией типов во время выполнения (RTTI– Run-time Type Identification), которая позволяет программе узнать истинный производный тип объекта, адресованного ссылкой или указателем на тип базового класса. Затем расскажем о влиянии наследования на обработку исключений: покажем, как можно определять их в виде иерархии классов и как обработчики для типа базового класса могут перехватывать исключения производных типов. В конце главы мы вернемся к правилам разрешения перегрузки функций и посмотрим, как наследование влияет на то, какие преобразования типов можно применять к аргументам функции, и на выбор наилучшей из устоявших.Пример множественного виртуального наследования A
Мы продемонстрируем определение и использование множественного виртуального наследования, реализовав иерархию шаблонов классов Array (см. раздел 2.4) на основе шаблона Array(см. главу 16), модифицированного так, чтобы он стал конкретным базовым классом. Перед тем как приступать к реализации, поговорим о взаимосвязях между шаблонами классов и наследованием.
Конкретизированный экземпляр такого шаблона может выступать в роли явного базового класса:
class IntStack : private Array
Разрешается также произвести его от не шаблонного базового класса:
class Base {}; template
| |
class Derived : public Base {};
Шаблон может выступать одновременно в роли базового и производного классов:
template
| |
class Array_RC : public virtual Array
В первом примере конкретизированный типом int шаблон Array
служит закрытым базовым классом для не шаблонного IntStack. Во втором примере не шаблонный Base служит базовым для любого класса, конкретизированного из шаблона Derived. В третьем примере любой конкретизированный из шаблона Array_RC класс является производным от класса, конкретизированного из шаблона Array. Так, инструкция
Array_RC
конкретизирует экземпляры шаблонов Array и Array_RC.
Кроме того, сам параметр-шаблон может служить базовым классом [MURRAY93]:
template < typename Type > |
class Persistent : public Type { ... };
в данном примере определяется производный устойчивый (persistent) подтип для любого конкретизированного типа. Как отмечает Мюррей (Murray), на Type
налагается неявное ограничение: он должен быть типом класса. Например, инструкция
Persistent< int > pi; // ошибка
приводит к ошибке компиляции, поскольку встроенный тип не может быть объектом наследования.
Шаблон, выступающий в роли базового класса, должен квалифицироваться полным списком параметров. Если имеется определение:
template
то необходимо писать:
template < class Type > |
class Derived : public Base
Такая запись неправильна:
// ошибка: Base - это шаблон, // так что должны быть заданы его аргументы template < class Type > |
class Derived : public Base {};
В следующем разделе шаблон Array, определенный в главе 16, выступает в роли виртуального базового класса для подтипа Array, контролирующего выход за границы массива; для отсортированного подтипа Array; для подтипа Array, который обладает обоими указанными свойствами. Однако первоначальное определение шаблона класса Array для наследования не подходит:
·
все его члены и вспомогательные функции объявлены закрытыми, а не защищенными;
· ни одна из зависящих от типа функций-членов, скажем оператор взятия индекса, не объявлена виртуальной.
Означает ли это, что наша первоначальная реализация была неправильной? Нет. Она была верной на том уровне понимания, которым мы тогда обладали. При реализации шаблона класса Array мы еще не осознали необходимость специализированных подтипов. Теперь, однако, определение шаблона придется изменить так (реализации функций-членов при этом останутся теми же):
#ifndef ARRAY_H #define ARRAY_H #include // необходимо для опережающего объявления operator<< template |





















