Три шага разрешения перегрузки
Разрешением перегрузки функции
называется процесс выбора той функции из множества перегруженных, которую следует вызвать. Этот процесс основывается на указанных при вызове аргументах. Рассмотрим пример:
T t1, t2;
void f( int, int );
void f( float, float );
int main() {
f( t1, t2 );
return 0;
}
Здесь в ходе процесса разрешения перегрузки в зависимости от типа T определяется, будет ли при обработке выражения f(t1,t2) вызвана функция f(int,int) или f(float,float) или зафиксируется ошибка.
Разрешение перегрузки функции – один и самых сложных аспектов языка C++. Пытаясь разобраться во всех деталях, начинающие программисты столкнутся с серьезными трудностями. Поэтому в данном разделе мы представим лишь краткий обзор того, как происходит разрешение перегрузки, чтобы у вас составилось хоть какое-то впечатление об этом процессе. Для тех, кто хочет узнать больше, в следующих двух разделах приводится более подробное описание.
Процесс разрешения перегрузки функции состоит из трех шагов, которые мы покажем на следующем примере:
void f();
void f( int );
void f( double, double = 3.4 );
void f( char *, char * );
void main() {
f( 5.6 );
return 0;
}
При разрешении перегрузки функции выполняются следующие шаги:
1. Выделяется множество перегруженных функций для данного вызова, а также свойства списка аргументов, переданных функции.
2. Выбираются те из перегруженных функций, которые могут быть вызваны с данными аргументами, с учетом их количества и типов.
3. Находится функция, которая лучше всего соответствует вызову.
Рассмотрим последовательно каждый пункт.
На первом шаге необходимо идентифицировать множество перегруженных функций, которые будут рассматриваться при данном вызове. Вошедшие в это множество функции называются кандидатами. Функция-кандидат – это функция с тем же именем, что и вызванная, причем ее объявление видимо в точке вызова. В нашем примере есть четыре таких кандидата: f(), f(int), f(double, double) и f(char*, char*).
После этого идентифицируются свойства списка переданных аргументов, т.е. их количество и типы. В нашем примере список состоит из двух аргументов типа double.
На втором шаге среди множества кандидатов отбираются устоявшие (viable) – такие, которые могут быть вызваны с данными аргументами, Устоявшая функция либо имеет столько же формальных параметров, сколько фактических аргументов передано вызванной функции, либо больше, но тогда для каждого дополнительного параметра должно быть задано значение по умолчанию. Чтобы функция считалась устоявшей, для любого фактического аргумента, переданного при вызове, обязано существовать преобразование к типу формального параметра, указанного в объявлении.
В нашем примере есть две устоявших функции, которые могут быть вызваны с приведенными аргументами:
функция f(int) устояла, потому что у нее есть всего один параметр и существует преобразование фактического аргумента типа double к формальному параметру типа int;
функция f(double,double) устояла, потому что для второго аргумента есть значение по умолчанию, а первый формальный параметр имеет тип double, что в точности соответствует типу фактического аргумента.
Если после второго шага не нашлось устоявших функций, то вызов считается ошибочным. В таких случаях мы говорим, что имеет место отсутствие соответствия.
Третий шаг заключается в выборе функции, лучше всего отвечающей контексту вызова. Такая функция называется наилучшей из устоявших (или наиболее подходящей). На этом шаге производится ранжирование преобразований, использованных для приведения типов фактических аргументов к типам формальных параметров устоявшей функции. Наиболее подходящей считается функция, для которой выполняются следующие условия:
преобразования, примененные к фактическим аргументам, не хуже преобразований, необходимых для вызова любой другой устоявшей функции;
для некоторых аргументов примененные преобразования лучше, чем преобразования, необходимые для приведения тех же аргументов в вызове других устоявших функций.
Преобразования типов и их ранжирование более подробно обсуждаются в разделе 9.3. Здесь мы лишь кратко рассмотрим ранжирование преобразований для нашего примера. Для устоявшей функции f(int) должно быть применено приведение фактического аргумента типа double к типу int, относящееся к числу стандартных. Для устоявшей функции f(double,double) тип фактического аргумента double в точности соответствует типу формального параметра. Поскольку точное соответствие лучше стандартного преобразования (отсутствие преобразования всегда лучше, чем его наличие), то наиболее подходящей функцией для данного вызова считается f(double,double).
Если на третьем шаге не удается отыскать единственную лучшую из устоявших функцию, иными словами, нет такой устоявшей функции, которая подходила бы больше всех остальных, то вызов считается неоднозначным, т.е. ошибочным.
(Более подробно все шаги разрешения перегрузки функции обсуждаются в разделе 9.4. Процесс разрешения используется также при вызовах перегруженной функции-члена класса и перегруженного оператора. В разделе 15.10 рассматриваются правила разрешения перегрузки, применяемые к функциям-членам класса, а в разделе 15.11 – правила для перегруженных операторов. При разрешении перегрузки следует также принимать во внимание функции, конкретизированные из шаблонов. В разделе 10.8 обсуждается, как шаблоны влияют на такое разрешение.)
Упражнение 9.5
Что происходит на последнем (третьем) шаге процесса разрешения перегрузки функции?
Try-блок
В нашей программе тестируется определенный в предыдущем разделе класс iStack и его функции-члены pop() и push(). Выполняется 50 итераций цикла for. На каждой итерации в стек помещается значение, кратное 3: 3, 6, 9 и т.д. Если значение кратно 4 (4, 8, 12...), то выводится текущее содержимое стека, а если кратно 10 (10, 20, 30...), то с вершины снимается один элемент, после чего содержимое стека выводится снова. Как нужно изменить функцию main(), чтобы она обрабатывала исключения, возбуждаемые функциями-членами класса iStack?
#include <iostream>
#include "iStack.h"
int main() {
iStack stack( 32 );
stack.display();
for ( int ix = 1; ix < 51; ++ix )
{
if ( ix % 3 == 0 )
stack.push( ix );
if ( ix % 4 == 0 )
stack.display();
if ( ix % 10 == 0 ) {
int dummy;
stack.pop( dummy );
stack.display();
}
}
return 0;
}
Инструкции, которые могут возбуждать исключения, должны быть заключены в try-блок. Такой блок начинается с ключевого слова try, за которым идет последовательность инструкций, заключенная в фигурные скобки, а после этого – список обработчиков, называемых catch-предложениями. Try-блок группирует инструкции программы и ассоциирует с ними обработчики исключений. Куда нужно поместить try-блоки в функции main(), чтобы были обработаны исключения popOnEmpty и pushOnFull?
for ( int ix = 1; ix < 51; ++ix ) {
try { // try-блок для исключений pushOnFull
if ( ix % 3 == 0 )
stack.push( ix );
}
catch ( pusOnFull ) { ... }
if ( ix % 4 == 0 )
stack.display();
try { // try-блок для исключений popOnEmpty
if ( ix % 10 == 0 ) {
int dummy;
stack.pop( dummy );
stack.display();
}
}
catch ( popOnEmpty ) { ... }
}
В таком виде программа выполняется корректно. Однако обработка исключений в ней перемежается с кодом, использующимся при нормальных обстоятельствах, а такая организация несовершенна. В конце концов, исключения – это аномальные ситуации, возникающие только в особых случаях. Желательно отделить код для обработки аномалий от кода, реализующего операции со стеком. Мы полагаем, что показанная ниже схема облегчает чтение и сопровождение программы:
try {
for ( int ix = 1; ix < 51; ++ix )
{
if ( ix % 3 == 0 )
stack.push( ix );
if ( ix % 4 == 0 )
stack.display();
if ( ix % 10 == 0 ) {
int dummy;
stack.pop( dummy );
stack.display();
}
}
}
catch ( pushOnFull ) { ... }
catch ( popOnEmpty ) { ... }
С try-блоком ассоциированы два catch-предложения, которые могут обработать исключения pushOnFull и popOnEmpty, возбуждаемые функциями-членами push() и pop() внутри этого блока. Каждый catch-обработчик определяет тип “своего” исключения. Код для обработки исключения помещается внутрь составной инструкции (между фигурными скобками), которая является частью catch-обработчика. (Подробнее catch-предложения мы рассмотрим в следующем разделе.)
Исполнение программы может пойти по одному из следующих путей:
если исключение не возбуждено, то выполняется код внутри try-блока, а ассоциированные с ним обработчики игнорируются. Функция main() возвращает 0;
если функция-член push(), вызванная из первой инструкции if внутри цикла for, возбуждает исключение, то вторая и третья инструкции if игнорируются, управление покидает цикл for и try-блок, и выполняется обработчик исключений типа pushOnFull;
если функция-член pop(), вызванная из третьей инструкции if внутри цикла for, возбуждает исключение, то вызов display() игнорируется, управление покидает цикл for и try-блок, и выполняется обработчик исключений типа popOnEmpty.
Когда возбуждается исключение, пропускаются все инструкции, следующие за той, где оно было возбуждено. Исполнение программы возобновляется в catch-обработчике этого исключения. Если такого обработчика не существует, то управление передается в функцию terminate(), определенную в стандартной библиотеке C++.
Try-блок может содержать любую инструкцию языка C++: как выражения, так и объявления. Он вводит локальную область видимости, так что объявленные внутри него переменные недоступны вне этого блока, в том числе и в catch-обработчиках. Например, функцию main() можно переписать так, что объявление переменной stack окажется в try-блоке. В таком случае обращаться к этой переменной в catch-обработчиках нельзя:
int main() {
try {
iStack stack( 32 ); // правильно: объявление внутри try-блока
stack.display();
for ( int ix = 1; ix < 51; ++ix )
{
// то же, что и раньше
}
}
catch ( pushOnFull ) {
// здесь к переменной stack обращаться нельзя
}
catch ( popOnEmpty ) {
// здесь к переменной stack обращаться нельзя
}
// и здесь к переменной stack обращаться нельзя
return 0;
}
Можно объявить функцию так, что все ее тело будет заключено в try-блок. При этом не обязательно помещать try-блок внутрь определения функции, удобнее заключить ее тело в функциональный try-блок. Такая организация поддерживает наиболее чистое разделение кода для нормальной обработки и кода для обработки исключений. Например:
int main()
try {
iStack stack( 32 ); // правильно: объявление внутри try-блока
stack.display();
for ( int ix = 1; ix < 51; ++ix )
{
// то же, что и раньше
}
return 0;
}
catch ( pushOnFull ) {
// здесь к переменной stack обращаться нельзя
}
catch ( popOnEmpty ) {
// здесь к переменной stack обращаться нельзя
}
Обратите внимание, что ключевое слово try находится перед фигурной скобкой, открывающей тело функции, а catch-обработчики перечислены после закрывающей его скобки. Как видим, код, осуществляющий нормальную обработку, находится внутри тела функции и четко отделен от кода для обработки исключений. Однако к переменным, объявленным в main(), нельзя обратиться из обработчиков исключений.
Функциональный try-блок ассоциирует группу catch-обработчиков с телом функции. Если инструкция возбуждает исключение, то поиск обработчика, способного перехватить это исключение, ведется среди тех, что идут за телом функции. Функциональные try-блоки особенно полезны в сочетании с конструкторами классов. (Мы еще вернемся к этой теме в главе 19.)
Упражнение 11.3
Напишите программу, которая определяет объект IntArray (тип класса IntArray рассматривался в разделе 2.3) и выполняет описанные ниже действия.
Пусть есть три файла, содержащие целые числа.
1. Прочитать первый файл и поместить в объект IntArray первое, третье, пятое, ..., n-ое значение (где n нечетно). Затем вывести содержимое объекта IntArray.
2. Прочитать второй файл и поместить в объект IntArray пятое, десятое, ..., n-ое значение (где n кратно 5). Вывести содержимое объекта.
3. Прочитать третий файл и поместить в объект IntArray второе, четвертое, ..., n-ое значение (где n четно). Вывести содержимое объекта.
Воспользуйтесь оператором operator[]() класса IntArray, определенным в упражнении 11.2, для сохранения и получения значений из объекта IntArray. Так как operator[]() может возбуждать исключения, обработайте их, поместив необходимое количество try-блоков и catch-обработчиков. Объясните, почему вы разместили try-блоки именно так, а не иначе.
Удаление
В общем случае удаление осуществляется двумя формами функции-члена erase(). Первая форма удаляет единственный элемент, вторая– диапазон, отмеченный парой итераторов. Для последнего элемента можно воспользоваться функцией-членом pop_back().
При вызове erase() параметром является итератор, указывающий на нужный элемент. В следующем фрагменте кода мы воспользуемся обобщенным алгоритмом find() для нахождения элемента и, если он найден, передадим его адрес функции-члену erase().
string searchValue( "Quasimodo" );
list< string >::iterator iter =
find( slist.begin(), slist.end(), searchValue );
if ( iter != slist.end() )
slist.erase( iter );
Для удаления всех элементов контейнера или некоторого диапазона можно написать следующее:
// удаляем все элементы контейнера
slist.erase( slist.begin(), slist.end() );
// удаляем элементы, помеченные итераторами
list< string >::iterator first, last;
first = find( slist. begin(), slist.end(), vail );
last = find( slist.begin(), slist.end(), va12 );
// ... проверка first и last
slist.erase( first, last );
Парной по отношению к push_back() является функция-член pop_back(), удаляющая из контейнера последний элемент, не возвращая его значения:
vector< string >::iterator iter = buffer.begin();
for ( ; iter != buffer.end(), iter++ )
{
slist.push_back( *iter );
if ( ! do_something( slist ))
slist.pop_back();
}
Удаление элементов map
Существуют три формы функции-члена erase() для удаления элементов отображения. Для единственного элемента используется erase() с ключом или итератором в качестве аргумента, а для последовательности эта функция вызывается с двумя итераторами. Например, мы могли бы позволить удалять элементы из text_map таким образом:
string removal_word;
cout << "введите удаляемое слово: ";
cin >> removal_word;
if ( text_map->erase( remova1_word ))
cout << "ok: " << remova1_word << " удалено\n";
else cout << "увы: " << remova1_word << " не найдено!\n";
Альтернативой является проверка: действительно ли слово содержится в text_map?
map<string,loc*>::iterator where;
where = text_map.find( remova1_word );
if ( where == text_map->end() )
cout << "увы: " << remova1_word << " не найдено!\n";
else {
text_map->erase( where );
cout << "ok: " << remova1_word << " удалено!\n";
}
В нашей реализации text_map с каждым словом сопоставляется множество позиций, что несколько усложняет их хранение и извлечение. Вместо этого можно было бы иметь по одной позиции на слово. Но контейнер map не допускает дублирующиеся ключи. Нам следовало бы воспользоваться классом multimap, который рассматривается в разделе 6.15.
Упражнение 6.20
Определите отображение, где ключом является фамилия, а значением – вектор с именами детей. Поместите туда как минимум шесть элементов. Реализуйте возможность делать запрос по фамилии, добавлять имена и распечатывать содержимое.
Упражнение 6.21
Измените программу из предыдущего упражнения так, чтобы вместе с именем ребенка записывалась дата его рождения: пусть вектор-значение хранит пары строк – имя и дата.
Упражнение 6.22
Приведите хотя бы три примера, в которых нужно использовать отображение. Напишите определение объекта map для каждого примера и укажите наиболее вероятный способ вставки и извлечения элементов.
Указатель на член класса
Предположим, что в нашем классе Screen определены четыре новых функции-члена: forward(), back(), up() и down(), которые перемещают курсор соответственно вправо, влево, вверх и вниз. Сначала мы должны объявить их в теле класса:
class Screen {
public:
inline Screen& forward();
inline Screen& back();
inline Screen& end();
inline Screen& up();
inline Screen& down();
// другие функции-члены не изменяются
private:
inline int row();
// другие функции-члены не изменяются
};
Функции-члены forward() и back() перемещают курсор на один символ. По достижении правого нижнего или левого верхнего угла экрана курсор переходит в противоположный угол.
inline Screen& Screen::forward()
{ // переместить _cursor вперед на одну экранную позицию
++_cursor;
// если достигли конца экрана, перепрыгнуть в противоположный угол
if ( _cursor == _screen.size() )
home();
return *this;
}
inline Screen& Screen::back()
{ // переместить _cursor назад на одну экранную позицию
// если достигли начала экрана, перепрыгнуть в противоположный угол
if ( _cursor == 0 )
end();
else
--_cursor;
return *this;
}
end() перемещает курсор в правый нижний угол экрана и является парной по отношению к функции-члену home():
inline Screen& Screen::end()
{
_cursor = _width * _height - 1;
return *this;
}
Функции up() и down() перемещают курсор вверх и вниз на одну строку. По достижении верхней или нижней строки курсор остается на месте и подается звуковой сигнал:
const char BELL = '\007';
inline Screen& Screen::up()
{ // переместить _cursor на одну строку вверх
// если уже наверху, остаться на месте и подать сигнал
if ( row() == 1 ) // наверху?
cout << BELL << endl;
else
_cursor -= _width;
return *this;
}
inline Screen& Screen::down()
{
if ( row() == _height ) //внизу?
cout << BELL << endl;
else
_cursor += _width;
return *this;
}
row() – это закрытая функция-член, которая используется в функциях up() и down(), возвращая номер строки, где находится курсор:
inline int Screen::row()
{ // вернуть текущую строку
return ( _cursor + _width ) / height;
}
Пользователи класса Screen попросили нас добавить функцию repeat(), которая повторяет указанное действие n раз. Ее реализация могла бы выглядеть так:
Screen &repeat( char op, int times )
{
switch( op ) {
case DOWN: // n раз вызвать Screen::down()
break;
case DOWN: // n раз вызвать Screen::up()
break;
// ...
}
}
Такая реализация имеет ряд недостатков. В частности, предполагается, что функции-члены класса Screen останутся неизменными, поэтому при добавлении или удалении функции-члена repeat() необходимо модифицировать. Вторая проблема – размер функции. Поскольку приходится проверять все возможные функции-члены, то исходный текст становится громоздким и неоправданно сложным.
В более общей реализации параметр op заменяется параметром типа указателя на функцию-член класса Screen. Теперь repeat() не должна сама устанавливать, какую операцию следует выполнить, и всю инструкцию switch можно удалить. Определение и использование указателей на члены класса – тема последующих подразделов.
Указатели
Указатели и динамическое выделение памяти были вкратце представлены в разделе 2.2. Указатель – это объект, содержащий адрес другого объекта и позволяющий косвенно манипулировать этим объектом. Обычно указатели используются для работы с динамически созданными объектами, для построения связанных структур данных, таких, как связанные списки и иерархические деревья, и для передачи в функции больших объектов – массивов и объектов классов – в качестве параметров.
Каждый указатель ассоциируется с некоторым типом данных, причем их внутреннее представление не зависит от внутреннего типа: и размер памяти, занимаемый объектом типа указатель, и диапазон значений у них одинаков[5]. Разница состоит в том, как компилятор воспринимает адресуемый объект. Указатели на разные типы могут иметь одно и то же значение, но область памяти, где размещаются соответствующие типы, может быть различной:
указатель на int, содержащий значение адреса 1000, направлен на область памяти 1000-1003 (в 32-битной системе);
указатель на double, содержащий значение адреса 1000, направлен на область памяти 1000-1007 (в 32-битной системе).
Вот несколько примеров:
int *ip1, *ip2;
complex<double> *cp;
string *pstring;
vector<int> *pvec;
double *dp;
Указатель обозначается звездочкой перед именем. В определении переменных списком звездочка должна стоять перед каждым указателем (см. выше: ip1 и ip2). В примере ниже lp – указатель на объект типа long, а lp2 – объект типа long:
long *lp, lp2;
В следующем случае fp интерпретируется как объект типа float, а fp2 – указатель на него:
float fp, *fp2;
Оператор разыменования (*) может отделяться пробелами от имени и даже непосредственно примыкать к ключевому слову типа. Поэтому приведенные определения синтаксически правильны и совершенно эквивалентны:
string *ps;
string* ps;
Однако рекомендуется использовать первый вариант написания: второй способен ввести в заблуждение, если добавить к нему определение еще одной переменной через запятую:
//внимание: ps2 не указатель на строку! |
Можно предположить, что и ps, и ps2 являются указателями, хотя указатель – только первый из них.
Если значение указателя равно 0, значит, он не содержит никакого адреса объекта.
Пусть задана переменная типа int:
int ival = 1024;
Ниже приводятся примеры определения и использования указателей на int pi и pi2:
//pi инициализирован нулевым адресом
int *pi = 0;
// pi2 инициализирован адресом ival
int *pi2 = &ival;
// правильно: pi и pi2 содержат адрес ival
pi = pi2;
// pi2 содержит нулевой адрес
pi2 = 0;
Указателю не может быть присвоена величина, не являющаяся адресом:
// ошибка: pi не может принимать значение int
pi = ival
Точно так же нельзя присвоить указателю одного типа значение, являющееся адресом объекта другого типа. Если определены следующие переменные:
double dval;
double *ps = &dval;
то оба выражения присваивания, приведенные ниже, вызовут ошибку компиляции:
// ошибки компиляции
// недопустимое присваивание типов данных: int* <== double*
pi = pd
pi = &dval;
Дело не в том, что переменная pi не может содержать адреса объекта dval – адреса объектов разных типов имеют одну и ту же длину. Такие операции смешения адресов запрещены сознательно, потому что интерпретация объектов компилятором зависит от типа указателя на них.
Конечно, бывают случаи, когда нас интересует само значение адреса, а не объект, на который он указывает (допустим, мы хотим сравнить этот адрес с каким-то другим). Для разрешения таких ситуаций введен специальный указатель void, который может указывать на любой тип данных, и следующие выражения будут правильны:
// правильно: void* может содержать
// адреса любого типа
void *pv = pi;
pv = pd;
Тип объекта, на который указывает void*, неизвестен, и мы не можем манипулировать этим объектом. Все, что мы можем сделать с таким указателем, – присвоить его значение другому указателю или сравнить с какой-либо адресной величиной. (Более подробно мы расскажем об указателе типа void в разделе 4.14.)
Для того чтобы обратиться к объекту, имея его адрес, нужно применить операцию разыменования,
или косвенную адресацию, обозначаемую звездочкой (*). Имея следующие определения переменных:
int ival = 1024;, ival2 = 2048;
int *pi = &ival;
мы можем читать и сохранять значение ival, применяя операцию разыменования к указателю pi:
// косвенное присваивание переменной ival значения ival2
*pi = ival2;
// косвенное использование переменной ival как rvalue и lvalue
*pi = abs(*pi); // ival = abs(ival);
*pi = *pi + 1; // ival = ival + 1;
Когда мы применяем операцию взятия адреса (&) к объекту типа int, то получаем результат типа int*
int *pi = &ival;
Если ту же операцию применить к объекту типа int* (указатель на int), мы получим указатель на указатель на int, т.е. int**. int** – это адрес объекта, который содержит адрес объекта типа int. Разыменовывая ppi, мы получаем объект типа int*, содержащий адрес ival. Чтобы получить сам объект ival, операцию разыменования к ppi необходимо применить дважды.
int **ppi = π
int *pi2 = *ppi;
cout << "Значение ival\n"
<< "явное значение: " << ival << "\n"
<< "косвенная адресация: " << *pi << "\n"
<< "дважды косвенная адресация: " << **ppi << "\n"
<< endl;
Указатели могут быть использованы в арифметических выражениях. Обратите внимание на следующий пример, где два выражения производят совершенно различные действия:
int i, j, k;
int *pi = &i;
// i = i + 2
*pi = *pi + 2;
// увеличение адреса, содержащегося в pi, на 2
pi = pi + 2;
К указателю можно прибавлять целое значение, можно также вычитать из него. Прибавление к указателю 1 увеличивает содержащееся в нем значение на размер области памяти, отводимой объекту соответствующего типа. Если тип char занимает 1 байт, int – 4 и double – 8, то прибавление 2 к указателям на char, int и double увеличит их значение соответственно на 2, 8 и 16. Как это можно интерпретировать? Если объекты одного типа расположены в памяти друг за другом, то увеличение указателя на 1 приведет к тому, что он будет указывать на следующий объект. Поэтому арифметические действия с указателями чаще всего применяются при обработке массивов; в любых других случаях они вряд ли оправданы.
Вот как выглядит типичный пример использования адресной арифметики при переборе элементов массива с помощью итератора:
int ia[10];
int *iter = &ia[0];
int *iter_end = &ia[10];
while (iter != iter_end) {
do_something_with_value (*iter);
++iter;
}
Упражнение 3.8
Даны определения переменных:
int ival = 1024, ival2 = 2048;
int *pi1 = &ival, *pi2 = &ival2, **pi3 = 0;
Что происходит при выполнении нижеследующих операций присваивания? Допущены ли в данных примерах ошибки?
(a) ival = *pi3; (e) pi1 = *pi3;
(b) *pi2 = *pi3; (f) ival = *pi1;
(c) ival = pi2; (g) pi1 = ival;
(d) pi2 = *pi1; (h) pi3 = &pi2;
Упражнение 3.9
Работа с указателями – один из важнейших аспектов С и С++, однако в ней легко допустить ошибку. Например, код
pi = &ival;
pi = pi + 1024;
почти наверняка приведет к тому, что pi будет указывать на случайную область памяти. Что делает этот оператор присваивания и в каком случае он не приведет к ошибке?
Упражнение 3.10
Данная программа содержит ошибку, связанную с неправильным использованием указателей:
int foobar(int *pi) {
*pi = 1024;
return *pi;
}
int main() {
int *pi2 = 0;
int ival = foobar(pi2);
return 0;
}
В чем состоит ошибка? Как можно ее исправить?
Упражнение 3.11
Ошибки из предыдущих двух упражнений проявляются и приводят к фатальным последствиям из-за отсутствия в С++ проверки правильности значений указателей во время работы программы. Как вы думаете, почему такая проверка не была реализована? Можете ли вы предложить некоторые общие рекомендации для того, чтобы работа с указателями была более безопасной?
Указатели на функции
Предположим, что нам нужно написать функцию сортировки, вызов которой выглядит так:
sort( start, end, compare );
где start и end являются указателями на элементы массива строк. Функция sort() сортирует элементы между start и end, а аргумент compare задает операцию сравнения двух строк этого массива.
Какую реализацию выбрать для compare? Мы можем сортировать строки лексикографически, т.е. в том порядке, в котором слова располагаются в словаре, или по длине – более короткие идут раньше более длинных. Нам нужен механизм для задания альтернативных операций сравнения.
(Заметим, что в главе 12 описан алгоритм sort() и другие обобщенные алгоритмы из стандартной библиотеки С++. В этом разделе мы покажем свою собственную версию sort() как пример употребления указателей на функции. Наша функция будет упрощенным вариантом стандартного алгоритма.)
Один из способов удовлетворить наши потребности – использовать в качестве третьего аргумента compare указатель на функцию, применяемую для сравнения.
Для того чтобы упростить использование функции sort(), не жертвуя гибкостью, можно задать операцию сравнению по умолчанию, подходящую для большинства случаев. Предположим, что чаще всего нам требуется лексикографическая сортировка, поэтому в качестве такой операции возьмем функцию compare() для строк (эта функция впервые встретилась в разделе 6.10).
Указатели на функции, объявленные как extern "C"
Можно объявлять указатели на функции, написанные на других языках программирования. Это делается с помощью директивы связывания. Например, указатель pf ссылается на С-функцию:
extern "C" void (*pf)(int);
Через pf вызывается функция, написанная на языке С.
extern "C" void exit(int);
// pf ссылается на C-функцию exit()
extern "C" void (*pf)(int) = exit;
int main() {
// ...
// вызов С-функции, а именно exit()
(*pf)(99);
}
Вспомним, что присваивание и инициализация указателя на функцию возможны лишь тогда, когда тип в левой части оператора присваивания в точности соответствует типу в правой его части. Следовательно, указатель на С-функцию не может адресовать функцию С++ (и инициализация его таким адресом не допускается), и наоборот. Подобная попытка вызывает ошибку компиляции:
void (*pfl)(int);
extern "C" void (*pf2)(int);
int main() {
pfl = pf2; // ошибка: pfl и pf2 имеют разные типы
// ...
}
Отметим, что в некоторых реализациях С++ характеристики указателей на функции С и С++ одинаковы. Отдельные компиляторы могут допустить подобное присваивание, рассматривая это как расширение языка.
Если директива связывания применяется к объявлению, она затрагивает все функции, участвующие в данном объявлении.
В следующем примере параметр pfParm также служит указателем на С-функцию. Директива связывания применяется к объявлению функции, к которой этот параметр относится:
// pfParm - указатель на С-функцию
extern "C" void f1( void(*pfParm)(int) );
Следовательно, f1() является С-функцией с одним параметром – указателем на С-функцию. Значит, передаваемый ей аргумент должен быть либо такой же функцией, либо указателем на нее, поскольку считается, что указатели на функции, написанные на разных языках, имеют разные типы. (Снова заметим, что в тех реализациях С++, где указатели на функции С и С++ имеют одинаковые характеристики, компилятор может поддерживать расширение языка, позволяющее не различать эти два типа указателей.)
Коль скоро директива связывания относится ко всем функциям в объявлении, то как же объявить функцию С++, имеющую в качестве параметра указатель на С-функцию? С помощью директивы typedef. Например:
// FC представляет собой тип:
// С-функция с параметром типа int, не возвращающая никакого значения
extern "C" typedef void FC( int );
// f2() - C++ функция с параметром -
// указателем на С-функцию
void f2( FC *pfParm );
Упражнение 7.21
В разделе 7.5 приводится определение функции factorial(). Напишите объявление указателя на нее. Вызовите функцию через этот указатель для вычисления факториала 11.
Упражнение 7.22
Каковы типы следующих объявлений:
(a) int (*mpf)(vector<int>&);
(b) void (*apf[20])(doub1e);
(c) void (*(*papf)[2])(int);
Как сделать эти объявления более понятными, используя директивы typedef?
Упражнение 7.23
Вот функции из библиотеки С, определенные в заголовочном файле <cmath>:
double abs(double);
double sin(double);
double cos(double);
double sqrt(double);
Как бы вы объявили массив указателей на С-функции и инициализировали его этими четырьмя функциями? Напишите main(), которая вызывает sqrt() с аргументом 97.9 через элемент массива.
Упражнение 7.24
Вернемся к примеру sort(). Напишите определение функции
int sizeCompare( const string &, const string & );
Если передаваемые в качестве параметров строки имеют одинаковую длину, то sizeCompare() возвращает 0; если первая строка короче второй, то отрицательное число, а если длиннее, то положительное. Напоминаем, что длина строки возвращается операцией size() класса string. Измените main() для вызова sort(), передав в качестве третьего аргумента указатель на sizeCompare().
Указатели на перегруженные функции *
Можно объявить указатель на одну из множества перегруженных функций. Например:
extern void ff( vector<double> );
extern void ff( unsigned int );
// на какую функцию указывает pf1?
void ( *pf1 )( unsigned int ) = &ff;
Поскольку функция ff() перегружена, одного инициализатора &ff недостаточно для выбора правильного варианта. Чтобы понять, какая именно функция инициализирует указатель, компилятор ищет в множестве всех перегруженных функций ту, которая имеет тот же тип возвращаемого значения и список параметров, что и функция, на которую ссылается указатель. В нашем случае будет выбрана функция ff(unsigned int).
А что если не найдется функции, в точности соответствующей типу указателя? Тогда компилятор выдаст сообщение об ошибке:
extern void ff( vector<double> );
extern void ff( unsigned int );
// ошибка: соответствие не найдено: неверный список параметров
void ( *pf2 )( int ) = &ff;
// ошибка: соответствие не найдено: неверный тип возвращаемого значения
double ( *pf3 )( vector<double> ) = &ff;
Присваивание работает аналогично. Если значением указателя должен стать адрес перегруженной функции , то для выбора операнда в правой части оператора присваивания используется тип указателя на функцию. И если компилятор не находит функции, в точности соответствующей нужному типу, он выдает сообщение об ошибке. Таким образом, преобразование типов между указателями на функции никогда не производится.
matrix calc( const matrix & );
int calc( int, int );
int ( *pc1 )( int, int ) = 0;
int ( *pc2 )( int, double ) = 0;
// ...
// правильно: выбирается функция calc( int, int )
pc1 = &calc;
// ошибка: нет соответствия: неверный тип второго параметра
pc2 = &calc;
Указатели на статические члены класса
Между указателями на статические и нестатические члены класса есть разница. Синтаксис указателя на член класса не используется для обращения к статическому члену. Статические члены– это глобальные объекты и функции, принадлежащие классу. Указатели на них – это обычные указатели. (Напомним, что статической функции-члену не передается указатель this.)
Объявление указателя на статический член класса выглядит так же, как и для указателя на объект, не являющийся членом класса. Для разыменования указателя никакой объект не требуется. Рассмотрим класс Account:
class Account {
public:
static void raiseInterest( double incr );
static double interest() { return _interestRate ; }
double amount() { return _amount; }
private:
static double _interestRate;
double _amount;
string _owner;
};
inline void Account::raiseInterest( double incr )
{
_interestRate += incr;
}
Тип &_interestRate – это double*:
// это неправильный тип для &_interestRate
double Account::*
Определение указателя на &_interestRate имеет вид:
// правильно: double*, а не double Account::*
double *pd = &Account::_interestRate;
Этот указатель разыменовывается так же, как и обычный, объект класса для этого не требуется:
Account unit;
// используется обычный оператор разыменования
double daily = *pd / 365 * unit._amount;
Однако, поскольку _interestRate и _amount – закрытые члены, необходимо иметь статическую функцию-член interest() и нестатическую amount().
Указатель на interest() – это обычный указатель на функцию:
// правильно
double (*)()
а не на функцию-член класса Account:
// неправильно
double (Account::*)()
Определение указателя и косвенный вызов interest() реализуются так же, как и для обычных указателей:
// правильно: double(*pf)(), а не double(Account::*pf)()
double(*pf)() = &Account::interest;
double daily = pf() / 365 * unit.amount();
Упражнение 13.11
К какому типу принадлежат члены _screen и _cursor класса Screen?
Упражнение 13.12
Определите указатель на член и инициализируйте его значением Screen::_screen; присвойте ему значение Screen::_cursor.
Упражнение 13.13
Определите typedef для каждой из функций-членов класса Screen.
Упражнение 13.14
Указатели на члены можно также объявлять как данные-члены класса. Модифицируйте определение класса Screen так, чтобы оно содержало указатель на его функцию-член того же типа, что home() и end().
Упражнение 13.15
Модифицируйте имеющийся конструктор класса Screen (или напишите новый) так, чтобы он принимал параметр типа указателя на функцию-член класса Screen, для которой список формальных параметров и тип возвращаемого значения такие же, как у home() и end(). Реализуйте для этого параметра значение по умолчанию и используйте параметр для инициализации члена класса, описанного в упражнении 13.14. Напишите функцию-член Screen, позволяющую пользователю задать ее значение.
Упражнение 13.16
Определите перегруженный вариант repeat(), который принимает параметр типа cursorMovements.
Управляющий класс UserQuery
Если имеется запрос такого типа:
fiery && ( bird || potato )
то в нашу задачу входит построение эквивалентной иерархии классов:
AndQuery
NameQuery( "fiery" )
OrQuery
NameQuery( "bird" )
NameQuery( "potato" )
Как лучше всего это сделать? Процедура вычисления ответа на запрос напоминает функционирование конечного автомата. Мы начинаем с пустого состояния и при обработке каждого элемента запроса переходим в новое состояние, пока весь запрос не будет разобран. В основе нашей реализации лежит одна инструкция switch внутри операции, которую мы назвали eval_query(). Слова запроса считываются одно за другим из вектора строк и сравниваются с каждым из возможных значений:
vector<string>::iterator
it = _query->begin(),
end_it = _query->end();
for ( ; it != end_it; ++it )
switch( evalQueryString( *it ))
{
case WORD:
evalWord( *it );
break;
case AND:
evalAnd();
break;
case OR:
evalOr();
break;
case NOT:
evalNot();
break;
case LPAREN:
++_paren;
++_lparenOn;
break;
case RPAREN:
--_paren;
++_rparenOn;
evalRParen();
break;
}
Пять операций eval: evalWord(), evalAnd(), evalOr(), evalNot и evalRParen() – как раз и строят иерархию классов Query. Прежде чем обратиться к деталям их реализации, рассмотрим общую организацию программы.
Нам нужно определить каждую операцию в виде отдельной функции, как это было сделано в главе 6 при построении процедур обработки запроса. Пользовательский запрос и производные от Query классы представляют независимые данные, которыми оперируют эти функции. От такой модели программирования (она называется процедурной) мы предпочли отказаться.
В разделе 6. 14 мы ввели класс TextQuery, где инкапсулировали операции и данные, изучавшиеся в главе 6. Здесь нам потребуется класс UserQuery, решающий аналогичные задачи.
Одним из членов этого класса должен быть вектор строк, содержащий сам запрос пользователя. Другой член – это указатель типа Query* на иерархическое представление запроса, построенное в eval_query(). Еще три члена служат для обработки скобок:
_paren помогает изменить подразумеваемый порядок вычисления операторов (чуть позже мы продемонстрируем это на примере);
_lparenOn и _rparenOn содержат счетчики левых и правых скобок, ассоциированные с текущим узлом дерева разбора запроса (мы показывали, как они используются, при обсуждении виртуальной функции print() в разделе 17.5.1).
Помимо этих пяти членов, нам понадобятся еще два. Рассмотрим следующий запрос:
fiery || untamed
Наша цель – представить его в виде следующего объекта OrQuery:
OrQuery
NameQuery( "fiery" )
NameQuery( "untamed" )
Однако порядок обработки такого запроса вызывает некоторые проблемы. Когда мы определяем объект NameQuery, объект OrQuery , к которому его надо добавить, еще не определен. Поэтому необходимо место, где можно временно сохранить объект NameQuery.
Чтобы сохранить что-либо для последующего использования, традиционно применяется стек. Поместим туда наш объект NameQuery. А когда позже встретим оператор ИЛИ (объект OrQuery), то достанем NameQuery из стека и присоединим его к OrQuery в качестве левого операнда.
Объект OrQuery неполон: в нем не хватает правого операнда. До тех пор пока этот операнд не будет построен, работу с данным объектом придется прекратить.
Его можно поместить в тот же самый стек, что и NameQuery. Однако OrQuery представляет другое состояние обработки запроса: это неполный оператор. Поэтому мы определим два стека: _query_stack для хранения объектов, представляющих сконструированные операнды составного запроса (туда мы помещаем объект NameQuery), а второй для хранения неполных операторов с отсутствующим правым операндом. Второй стек можно трактовать как место для хранения текущей операции, подлежащей завершению, поэтому назовем его _current_op. Сюда мы и поместим объект OrQuery. После того как второй объект NameQuery будет определен, мы достанем объект OrQuery из стека _current_op и добавим к нему NameQuery в качестве правого операнда. Теперь объект OrQuery завершен и мы можем поместить его в стек _query_stack.
Если обработка запроса завершилась нормально, то стек _current_op пуст, а в стеке _query_stack содержится единственный объект, который и представляет весь пользовательский запрос. В нашем случае это объект класса OrQuery.
Рассмотрим несколько примеров. Первый из них – простой запрос типа NotQuery:
! daddy
Ниже показана трассировка его обработки. Финальным объектом в стеке _query_stack является объект класса NotQuery:
evalNot() : incomplete!
push on _current_op ( size == 1 )
evalWord() : daddy
pop _current_op : NotQuery
add operand: WordQuery : NotQuery complete!
push NotQuery on _query_stack
Текст, расположенный с отступом под функциями eval, показывает, как выполняется операция.
Во втором примере – составном запросе типа OrQuery – встречаются оба случая. Здесь же иллюстрируется помещение полного оператора в стек _query_stack:
==> fiery || untamed || shyly
evalWord() : fiery
push word on _query_stack
evalOr() : incomplete!
pop _query_stack : fiery
add operand : WordQuery : OrQuery incomplete!
push OrQuery on _current_op ( size == 1 )
evalWord() : untamed
pop _current_op : OrQuery
add operand : WordQuery : OrQuery complete!
push OrQuery on _query_stack
evalOr() : incomplete!
pop _query_stack : OrQuery
add operand : OrQuery : OrQuery incomplete!
push OrQuery on _current_op ( size == 1 )
evalWord() : shyly
pop _current_op : OrQuery
add operand : WordQuery : OrQuery complete!
push OrQuery on _query_stack
В последнем примере рассматривается составной запрос и применение скобок для изменения порядка вычислений:
==> fiery && ( bird || untamed )
evalWord() : fiery
push word on _query_stack
evalAnd() : incomplete!
pop _query_stack : fiery
add operand : WordQuery : AndQuery incomplete!
push AndQuery on _current_op ( size == 1 )
evalWord() : bird
_paren is set to 1
push word on _query_stack
evalOr() : incomplete!
pop _query_stack : bird
add operand : WordQuery : OrQuery incomplete!
push OrQuery on _current_op ( size == 2 )
evalWord() : untamed
pop _current_op : OrQuery
add operand : WordQuery : OrQuery complete!
push OrQuery on _query_stack
evalRParen() :
_paren: 0 _current_op.size(): 1
pop _query_stack : OrQuery
pop _current_op : AndQuery
add operand : OrQuery : AndQuery complete!
push AndQuery on _query_stack
Реализация системы текстового поиска состоит из трех компонентов:
класс TextQuery, где производится обработка текста (подробно он рассматривался в разделе 16.4). Для него нет производных классов;
объектно-ориентированная иерархия Query для представления и обработки различных типов запросов;
класс UserQuery, с помощью которого представлен конечный автомат для построения иерархии Query.
До настоящего момента мы реализовали эти три компонента практически независимо друг от друга и без каких бы то ни было конфликтов. Но, к сожалению, иерархия классов Query не поддерживает требований к конструированию объектов, предъявляемых реализацией UserQuery:
классы AndQuery, OrQuery и NotQuery требуют, чтобы каждый операнд присутствовал в момент определения объекта. Однако принятая нами схема обработки подразумевает наличие неполных объектов;
наша схема предполагает отложенное добавление операнда к объектам AndQuery, OrQuery и NotQuery. Более того, такая операция должна быть виртуальной. Операнд приходится добавлять через указатель типа Query*, находящийся в стеке _current_op. Однако способ добавления операнда зависит от типа: для унарных (NotQuery) и бинарных (AndQuery и OrQuery) операций он различен. Наша иерархия классов Query подобные операции не поддерживает.
Оказалось, что анализ предметной области был неполон, в результате чего разработанный интерфейс не согласуется с конкретной реализацией проекта. Нельзя сказать, что анализ был неправильным, он просто неполон. Эта проблема связана с тем, что этапы анализа, проектирования и реализации отделены друг от друга и не допускают обратной связи и пересмотра. Но хотя мы не можем все продумать и все предвидеть, необходимо отличать неизбежные неправильные шаги от ошибок, обусловленных собственной невнимательностью или нехваткой времени.
В таком случае мы должны либо сами модифицировать иерархию классов Query, либо договориться, чтобы это сделали за нас. В данной ситуации мы, как авторы всей системы, сами изменим код, модифицировав конструкторы подтипов и включив виртуальную функцию-член add_op() для добавления операндов после определения оператора (мы покажем, как она применяется, чуть ниже, при рассмотрении функций evalRParen() и evalWord()).
Using-директивы
Пространства имен появились в стандартном С++. Предыдущие версии С++ их не поддерживали, и, следовательно, поставляемые библиотеки не помещали глобальные объявления в пространства имен. Множество программ на С++ было написано еще до того, как компиляторы стали поддерживать такую опцию. Заключая содержимое библиотеки в пространство имен, мы можем испортить старое приложение, использующее ее предыдущие версии: все имена из этой библиотеки становятся квалифицированными, т.е. должны включать имя пространства вместе с оператором разрешения области видимости. Те приложения, в которых эти имена употребляются в неквалифицированной форме, перестают компилироваться.
Сделать видимыми имена из библиотеки, используемой в нашей программе, можно с помощью using-объявления. Предположим, что файл primer.h содержит интерфейс новой версии библиотеки, в котором глобальные объявления помещены в пространство имен cplusplus_primer. Нужно заставить нашу программу работать с новой библиотекой. Два using-объявления сделают видимыми имена класса matrix и функции inverse() из пространства cplusplus_primer:
#include "primer.h"
using cplusplus_primer::matrix;
using cplusplus_primer::inverse;
// using-объявления позволяют использовать
// имена matrix и inverse без спецификации
void func( matrix &m ) {
// ...
inverse( m );
return m;
}
Но если библиотека достаточно велика и приложение часто использует имена из нее, то для подгонки имеющегося кода к новой библиотеке может потребоваться много using-объявлений. Добавлять их все только для того, чтобы старый код скомпилировался и заработал, утомительно и чревато ошибками. Решить эту проблему помогают using-директивы, облегчающие переход на новую версию библиотеки, где впервые стали применяться пространства имен.
Using-директива начинается ключевым словом using, за которым следует ключевое слово namespace, а затем имя некоторого пространства имен. Это имя должно ссылаться на определенное ранее пространство, иначе компилятор выдаст ошибку. Using-директива позволяет сделать все имена из этого пространства видимыми в неквалифицированной форме.
Например, предыдущий фрагмент кода может быть переписан так:
#include "pnmer.h"
// using-директива: все члены cplusplus_primer
// становятся видимыми
using namespace cplusplus_primer;
// имена matrix и inverse можно использовать без спецификации
void func( matrix &m ) {
// ...
inverse( m );
return m;
}
Using-директива делает имена членов пространства имен видимыми за его пределами, в том месте, где она использована. Например, приведенная using-директива создает иллюзию того, что все члены cplusplus_primer объявлены в глобальной области видимости перед определением func(). При этом члены пространства имен не получают локальных псевдонимов, а как бы перемещаются в новую область видимости. Код
namespace A {
int i, j;
}
выглядит как
int i, j;
для фрагмента программы, содержащего в области видимости следующую using-директиву:
using namespace A;
Рассмотрим пример, позволяющий подчеркнуть разницу между using-объявлением (которое сохраняет пространство имен, но создает ассоциированные с его членами локальные синонимы) и using-директивой (которая полностью удаляет границы пространства имен).
namespace blip {
int bi = 16, bj = 15, bk = 23;
// прочие объявления
}
int bj = 0;
void manip() {
using namespace blip; // using-директива -
// коллизия имен ::bj and blip::bj
// обнаруживается только при
// использовании bj
++bi; // blip::bi == 17
++bj; // ошибка: неоднозначность
// глобальная bj или blip::bj?
++::bj; // правильно: глобальная bj == 1
++blip::bj; // правильно: blip::bj == 16
int bk = 97; // локальная bk скрывает blip::bk
++bk; // локальная bk == 98
}
Во-первых, using-директивы имеют область видимости. Такая директива в функции manip() относится только к блоку этой функции. Для manip() члены пространства имен blip выглядят так, как будто они объявлены в глобальной области видимости, а следовательно, можно использовать их неквалифицированные имена. Вне этой функции необходимо употреблять квалифицированные.
Во-вторых, ошибки неоднозначности, вызванные применением using-директивы, обнаруживают себя при реальном обращении к такому имени, а не при встрече в тексте самой этой директивы. Например, переменная bj, член пространства blib, выглядит для manip() как объявленная в глобальной области видимости, вне blip. Однако в глобальной области уже есть такая переменная. Возникает неоднозначность имени bj в функции manip(): оно относится и к глобальной переменной, и к члену пространства blip. Ошибка проявляется только при упоминании bj в функции manip(). Если бы это имя вообще не использовалось в manip(), коллизия не проявилась бы.
В-третьих, using-директива не затрагивает употребление квалифицированных имен. Когда в manip() упоминается ::bj, имеется в виду переменная из глобальной области видимости, а blip::bj обозначает переменную из пространства имен blip.
И наконец члены пространства blip выглядят для функции manip() так, как будто они объявлены в глобальной области видимости. Это означает, что локальные объявления внутри manip() могут скрывать имена членов пространства blip. Локальная переменная bk скрывает blip::bk. Ссылка на bk внутри manip() не является неоднозначной – речь идет о локальной переменной.
Using-директивы использовать очень просто: стоит написать одну такую директиву, и все члены пространства имен сразу становятся видимыми. Однако чрезмерное увлечение ими возвращает нас к старой проблеме засорения глобального пространства имен:
namespace cplusplus_primer {
class matrix { };
// прочие вещи ...
}
namespace DisneyFeatureAnimation {
class matrix { };
// здесь тоже ...
using namespace cplusplus_primer;
using namespace DisneyFeatureAnimation;
matrix m; //ошибка, неоднозначность:
// cplusplus_primer::matrix или DisneyFeatureAnimation::matrix?
Ошибки неоднозначности, вызываемые using-директивой, обнаруживаются только в момент использования. В данном случае – при употреблении имени matrix. Такая ошибка, найденная не сразу, может стать сюрпризом: заголовочные файлы не менялись и никаких новых объявлений в программу добавлено не было. Ошибка появилась после того, как мы решили воспользоваться новыми средствами из библиотеки.
Using-директивы очень полезны при переводе приложений на новые версии библиотек, использующие пространства имен. Однако употребление большого числа using-директив возвращает нас к проблеме засорения глобального пространства имен. Эту проблему можно свести к минимуму, если заменить using-директивы более селективными using-объявлениями. Ошибки неоднозначности, вызываемые ими, обнаруживаются в момент объявления. Мы рекомендуем пользоваться using-объявлениями, а не using-директивами, чтобы избежать засорения глобального пространства имен в своей программе.
Using-объявления
Имеется механизм, позволяющий обращаться к членам пространства имен, используя их имена без квалификатора, т.е. без префикса namespace_name::. Для этого применяются using-объявления.
Using-объявление начинается ключевым словом using, за которым следует квалифицированное имя члена пространства. Например:
namespace cplusplus_primer {
namespace MatrixLib {
class matrix { /* ... */ };
// ...
}
}
// using-объявление для члена matrix
using cplusplus_primer::MatrixLib::matrix;
Using-объявление вводит имя в ту область видимости, в которой оно использовано. Так, предыдущее using-объявление делает имя matrix глобально видимым.
После того как это объявление встретилось в программе, использование имени matrix в глобальной области видимости или во вложенных в нее областях относится к этому члену пространства имен. Пусть далее идет следующее объявление:
void func( matrix &m );
Оно вводит функцию func() с параметром типа cplusplus_primer:: MatrixLib::matrix.
Using-объявление ведет себя подобно любому другому объявлению: оно имеет область видимости, и имя, введенное им, можно употреблять начиная с места объявления и до конца области видимости. Using-объявление может использоваться в глобальной области видимости, равно как и в области видимости любого пространства имен. Оно употребляется и в локальной области. Имя, вводимое using-объявлением, как и любым другим, имеет следующие характеристики:
оно должно быть уникальным в своей области видимости;
оно скрывает одноименную сущность во внешней области;
оно скрывается объявлением одноименной сущности во вложенной области.
Например:
namespace blip {
int bi = 16, bj = 15, bk = 23;
// прочие объявления
}
int bj = 0;
void manip() {
using blip::bi; // bi в функции manip() ссылается на blip::bi
++bi; // blip::bi == 17
using blip::bj; // скрывает глобальную bj
// bj в функции manip()ссылается на blip::bj
++bj; // blip::bj == 16
int bk; // объявление локальной bk
using blip::bk; // ошибка: повторное определение bk в manip()
}
int wrongInit = bk; // ошибка: bk невидима
// надо использовать blip::bk
Using-объявления в функции manip() позволяют ссылаться на членов пространства blib с помощью неквалифицированных имен. Такие объявления не видны вне manip(), и неквалифицированные имена могут применяться только внутри этой функции. Вне ее необходимо употреблять квалифицированные имена.
Using-объявление упрощает использование членов пространства имен. Оно вводит только одно имя. Using-объявление может находиться в определенной области видимости, и, значит, мы способны точно указать, в каком месте программы те или иные члены разрешается употреблять без дополнительной квалификации.
В следующем подразделе мы расскажем, как ввести в определенную область видимости все члены некоторого пространства имен.
Условное выражение
Условное выражение, или оператор выбора, предоставляет возможность более компактной записи текстов, включающих инструкцию if-else. Например, вместо:
bool is_equal;
if (!strcmp(str1,str2)) is_equal = true;
else is_equal = false;
можно употребить более компактную запись:
bool is_equa1 = !strcmp( strl, str2 ) ? true : false;
Условный оператор имеет следующий синтаксис:
expr11 ? expr2 : expr3;
Вычисляется выражение expr1. Если его значением является true, оценивается expr2, если false, то expr3. Данный фрагмент кода:
int min( int ia, int ib )
{ return ( ia < ib ) ? ia : ib; }
эквивалентен
int min(int ia, int ib) {
if (ia < ib)
return ia;
else
return ib;
}
Приведенная ниже программа иллюстрирует использование условного оператора:
#include <iostream>
int main()
{
int i = 10, j = 20, k = 30;
cout << "Большим из "
<< i << " и " << j << " является "
<< ( i > j ? i : j ) << end1;
cout << "Значение " << i
<< ( i % 2 ? " нечетно." : " четно." )
<< endl;
/* условный оператор может быть вложенным,
* но глубокая вложенность трудна для восприятия.
* В данном примере max получает значение
* максимальной из трех величин
*/
int max = ( (i > j)
? (( i > k) ? i : k)
: ( j > k ) ? j : k);
cout << "Большим из "
<< i << ", " << j << " и " << k
<< " является " << max << endl;
}
Результатом работы программы будет:
Большим из 10 и 20 является 20
Значение 10 четно.
Устаревшая форма явного преобразования
Операторы явного преобразования типов, представленные в предыдущем разделе, появились только в стандарте С++; раньше использовалась форма, теперь считающаяся устаревшей. Хотя стандарт допускает и эту форму, мы настоятельно не рекомендуем ею пользоваться. (Только если ваш компилятор не поддерживает новый вариант.)
Устаревшая форма явного преобразования имеет два вида:
// появившийся в C++ вид
type (expr);
// вид, существовавший в C
(type) expr;
и может применяться вместо операторов static_cast, const_cast и reinterpret_cast.
Вот несколько примеров такого использования:
const char *pc = (const char*) pcom;
int ival = (int) 3.14159;
extern char *rewrite_str( char* );
char *pc2 = rewrite_str( (char*) pc );
int addr_va1ue = int( &iva1 );
Эта форма сохранена в стандарте С++ только для обеспечения обратной совместимости с программами, написанными для С и предыдущих версий С++.
Упражнение 4.21
Даны определения переменных:
char cval; int ival;
float fval; double dva1;
unsigned int ui;
Какие неявные преобразования типов будут выполнены?
(a) cva1 = 'a' + 3;
(b) fval = ui - ival * 1.0;
(c) dva1 = ui * fval;
(d) cva1 = ival + fvat + dva1;
Упражнение 4.22
Даны определения переменных:
void *pv; int ival;
char *pc; double dval;
const string *ps;
Перепишите следующие выражения, используя операторы явного преобразования типов:
(a) pv = (void*)ps;
(b) ival = int( *pc );
(c) pv = &dva1;
(d) pc = (char*) pv;
Устоявшие функции
Устоявшая функция относится к числу кандидатов. В списке ее формальных параметров либо то же самое число элементов, что и в списке фактических аргументов вызванной функции, либо больше. В последнем случае для дополнительных параметров задаются значения по умолчанию, иначе функцию нельзя будет вызвать с данным числом аргументов. Чтобы функция считалась устоявшей, должно существовать преобразование каждого фактического аргумента в тип соответствующего формального параметра. (Такие преобразования были рассмотрены в разделе 9.3.)
В следующем примере для вызова f(5.6) есть две устоявшие функции: f(int) и f(double).
void f();
void f( int );
void f( double );
void f( char*, char* );
int main() {
f( 5.6 ); // 2 устоявшие функции: f( int ) и f( double )
return 0;
}
Функция f(int) устояла, так как она имеет всего один формальный параметр, что соответствует числу фактических аргументов в вызове. Кроме того, существует стандартное преобразование аргумента типа double в int. Функция f(double) также устояла; она тоже имеет один параметр типа double, и он точно соответствует фактическому аргументу. Функции-кандидаты f() и f(char*, char*) исключены из списка устоявших, так как они не могут быть вызваны с одним аргументом.
В следующем примере единственной устоявшей функцией для вызова format(3) является format(double). Хотя кандидата format(char*) можно вызывать с одним аргументом, не существует преобразования из типа фактического аргумента int в тип формального параметра char*, а следовательно, функция не может считаться устоявшей.
char* format( int );
void g() {
// глобальная функция format( int ) скрыта
char* format( double );
char* format( char* );
format(3); // есть только одна устоявшая функция: format( double )
}
В следующем примере все три функции-кандидата оказываются устоявшими для вызова max() внутри func(). Все они могут быть вызваны с двумя аргументами. Поскольку фактические аргументы имеют тип int, они точно соответствуют формальным параметрам функции libs_R_us::max(int, int) и могут быть приведены к типам параметров функции libs_R_us::max(double, double) с помощью трансформации целых в плавающие, а также к типам параметров функции libs_R_us::max(char, char) посредством преобразования целых типов.
Устоявшие функции
Устоявшей называется функция из множества кандидатов, которая может быть вызвана с данными фактическими аргументами. Чтобы она устояла, должны существовать неявные преобразования между типами фактических аргументов и формальных параметров. Например:
class myClass {
public:
void mf( double );
void mf( char, char = '\n' );
static void mf( int* );
// ...
};
int main() {
myClass mc;
int iobj;
mc.mf( iobj ); // какая именно функция-член mf()? Неоднозначно
}
В этом фрагменте для вызова mf() из main() есть две устоявшие функции:
void mf( double );
void mf( char, char = '\n' );
mf(double) устояла потому, что у нее только один параметр и существует стандартное преобразование аргумента iobj типа int в параметр типа double;
mf(char,char) устояла потому, что для второго параметра имеется значение по умолчанию и существует стандартное преобразование аргумента iobj типа int в тип char первого формального параметра.
При выборе наилучшей из устоявших функции преобразования типов, применяемые к каждому фактическому аргументу, ранжируются. Лучшей считается та, для которое все использованные преобразования не хуже, чем для любой другой устоявшей функции, и хотя бы для одного аргумента такое преобразование лучше, чем для всех остальных функций.
В предыдущем примере в каждой из двух устоявших функций для приведения типа фактического аргумента к типу формального параметра применено стандартное преобразование. Вызов считается неоднозначным, так как обе функции-члена разрешают его одинаково хорошо.
Независимо от вида вызова функции, в множество устоявших могут быть включены как статические, так и нестатические члены:
class myClass {
public:
static void mf( int );
char mf( char );
};
int main() {
char cobj;
myClass::mf( cobj ); // какая именно функция-член?
}
Здесь функция-член mf() вызывается с указанием имени класса и оператора разрешения области видимости myClass::mf(). Однако не задан ни объект (с оператором “точка”), ни указатель на объект (с оператором “стрелка”). Несмотря на это, нестатическая функция-член mf(char) все же включается в множество устоявших наряду со статическим членом mf(int).
Затем процесс разрешения перегрузки продолжается: на основе ранжирования преобразований типов, примененных к фактическим аргументам, чтобы выбрать наилучшую из устоявших функций. Аргумент cobj типа char точно соответствует формальному параметру mf(char) и может быть расширен до типа формального параметра mf(int). Поскольку ранг точного соответствия выше, то выбирается функция mf(char).
Однако эта функция-член не является статической и, следовательно, вызывается только через объект или указатель на объект класса myClass с помощью одного из операторов доступа. В такой ситуации, если объект не указан и, значит, вызов функции невозможен (как раз наш случай), компилятор считает его ошибкой.
Еще одна особенность функций-членов, которую надо принимать во внимание при формировании множества устоявших функций, – это наличие спецификаторов const или volatile у нестатических членов. (Они рассматривались в разделе 13.3.) Как они влияют на процесс разрешения перегрузки? Пусть в классе myClass есть следующие функции-члены:
class myClass {
public:
static void mf( int* );
void mf( double );
void mf( int ) const;
// ...
};
Тогда и статическая функция-член mf(int*), и константная функция mf(int), и неконстантная функция mf(double) включаются в множество кандидатов для показанного ниже вызова. Но какие из них войдут в множество устоявших?
int main() {
const myClass mc;
double dobj;
mc.mf( dobj ); // какая из функций-членов mf()?
}
Исследуя преобразования, которые надо применить к фактическим аргументам, мы обнаруживаем, что устояли функции mf(double) и mf(int). Тип double фактического аргумента dobj точно соответствует типу формального параметра mf(double) и может быть приведен к типу параметра mf(int) с помощью стандартного преобразования.
Если при вызове функции-члена используются операторы доступа “точка” или “стрелка”, то при отборе функций в множество устоявших принимается во внимание тип объекта или указателя, для которого вызвана функция.
Устоявшие функции
Множество устоявших операторных функций формируется из множества кандидатов путем отбора лишь тех операторов, которые могут быть вызваны с заданными операндами. Например, какие из семи найденных выше кандидатов устоят? Оператор использован в следующем контексте:
NS::SmallInt si(15);
si + 5.66;
Левый операнд имеет тип SmallInt, а правый – double.
Первый кандидат является устоявшей функцией для данного использования operator+():
NS::SmallInt NS::operator+( const SmallInt &, double );
Левый операнд типа SmallInt в качестве инициализатора точно соответствует формальному параметру-ссылке этого перегруженного оператора. Правый, имеющий тип double, также точно соответствует второму формальному параметру.
Следующая функция-кандидат также устоит:
NS::SmallInt NS::operator+( const SmallInt &, int );
Левый операнд si типа SmallInt в качестве инициализатора точно соответствует формальному параметру-ссылке перегруженного оператора. Правый имеет тип int и может быть приведен к типу второго формального параметра с помощью стандартного преобразования.
Устоит и третья функция-кандидат:
NS::SmallInt NS::SmallInt::operator+( const myFloat & );
Левый операнд si имеет тип SmallInt, т.е. тип того класса, членом которого является перегруженный оператор. Правый имеет тип int и приводится к типу класса myFloat с помощью определенного пользователем преобразования в виде конструктора myFloat(double).
Четвертой и пятой устоявшими функциями являются встроенные операторы:
int operator+( int, int );
double operator+( double, double );
Класс SmallInt содержит конвертер, который может привести значение типа SmallInt к типу int. Этот конвертер используется вместе с первым встроенным оператором для преобразования левого операнда в тип int. Второй операнд типа double трансформируется в тип int с помощью стандартного преобразования. Что касается второго встроенного оператора, то конвертер приводит левый операнд от типа SmallInt к типу int, после чего результат стандартно преобразуется в double. Второй же операнд типа double точно соответствует второму параметру.
Лучшей из этих пяти устоявших функций является первая, operator+(), объявленная в пространстве имен NS:
NS::SmallInt NS::operator+( const SmallInt &, double );
Оба ее операнда точно соответствуют параметрам.
Устоявшие функции и последовательности пользовательских преобразований
Наследование оказывает влияние и на второй шаг разрешения перегрузки функции: отбор устоявших из множества кандидатов. Устоявшей называется функция, для которой существуют приведения типа каждого фактического аргумента к типу соответственного формального параметра.
В разделе 15.9 мы показали, как разработчик класса может предоставить пользовательские преобразования для объектов этого класса, которые неявно вызываются компилятором для трансформации фактического аргумента функции в тип соответственного формального параметра. Пользовательские преобразования бывают двух видов: конвертер или конструктор с одним параметром без ключевого слова explicit. При наследовании на втором шаге разрешения перегрузки рассматривается более широкое множество таких преобразований.
Конвертеры наследуются, как и любые другие функции-члены класса. Например, мы можем написать следующий конвертер для ZooAnimal:
class ZooAnimal {
public:
// конвертер: ZooAnimal ==> const char*
operator const char*();
// ...
};
Производный класс Bear наследует его от своего базового ZooAnimal. Если значение типа Bear используется в контексте, где ожидается const char*, то неявно вызывается конвертер для преобразования Bear в const char*:
extern void display( const char* );
Bear yogi;
// правильно: yogi ==> const char*
display( yogi );
Конструкторы с одним аргументом без ключевого слова explicit образуют другое множество неявных преобразований: из типа параметра в тип своего класса. Определим такой конструктор для ZooAnimal:
class ZooAnimal {
public:
// преобразование: int ==> ZooAnimal
ZooAnimal( int );
// ...
};
Его можно использовать для приведения значения типа int к типу ZooAnimal. Однако конструкторы не наследуются. Конструктор ZooAnimal нельзя применять для преобразования объекта в случае, когда целевым является тип производного класса:
const int cageNumber = 8788l
void mumble( const Bear & );
// ошибка: ZooAnimal( int ) не используется
mumble( cageNumber );
Поскольку целевым типом является Bear – тип параметра функции mumble(), то рассматриваются только его конструкторы.
Вектор или список?
Первая задача, которую должна решить наша программа, – это считывание из файла заранее неизвестного количества слов. Слова хранятся в объектах типа string. Возникает вопрос: в каком контейнере мы будем хранить слова – в последовательном или ассоциативном?
С одной стороны, мы должны обеспечить возможность поиска слова и, в случае успеха, извлечь относящуюся к нему информацию. Отображение map является самым удобным для этого классом.
Но сначала нам нужно просто сохранить слова для предварительной обработки – исключения знаков препинания, суффиксов и т.п. Для этой цели последовательный контейнер подходит гораздо больше. Что же нам использовать: вектор или список?
Если вы уже писали программы на С или на С++ прежних версий, для вас, скорее всего, решающим фактором является возможность заранее узнать количество элементов. Если это количество известно на этапе компиляции, вы используете массив, в противном случае – список, выделяя память под очередной его элемент.
Однако это правило неприменимо к стандартным контейнерам: и vector, и deque допускают динамическое изменение размера. Выбор одного из этих трех классов должен зависеть от способов, с помощью которых элементы добавляются в контейнер и извлекаются из него.
Вектор представляет собой область памяти, где элементы хранятся друг за другом. Для этого типа произвольный доступ (возможность извлечь, например, элемент 5, затем 15, затем 7 и т.д.) можно реализовать очень эффективно, поскольку каждый из них находится на некотором фиксированном расстоянии от начала. Однако вставка, кроме случая добавления в конец, крайне неэффективна: операция вставки в середину вектора потребует перемещения всего, что следует за вставляемым. Особенно это сказывается на больших векторах. (Класс deque устроен аналогично, однако операции вставки и удаления самого первого элемента работают в нем быстрее; это достигается двухуровневым представлением контейнера, при котором один уровень представляет собой реальное размещение элементов, а второй уровень адресует первый и последний из них.)
Список располагается в памяти произвольным образом. Каждый элемент содержит указатели на предыдущий и следующий, что позволяет перемещаться по списку вперед и назад. Вставка и удаление реализованы эффективно: изменяются только указатели. С другой стороны, произвольный доступ поддерживается плохо: чтобы прийти к определенному элементу, придется посетить все предшествующие. Кроме того, в отличие от вектора, дополнительно расходуется память под два указателя на каждый элемент списка.
Вот некоторые критерии для выбора одного из последовательных контейнеров:
если требуется произвольный доступ к элементам, вектор предпочтительнее;
если количество элементов известно заранее, также предпочтительнее вектор;
если мы должны иметь возможность вставлять и удалять элементы в середину, предпочтительнее список;
если нам не нужна возможность вставлять и удалять элементы в начало контейнера, вектор предпочтительнее, чем deque.
Как быть, если нам нужна возможность и произвольного доступа, и произвольного добавления/удаления элементов? Приходится выбирать: тратить время на поиск элемента или на его перемещение в случае вставки/удаления. В общем случае мы должны исходить из того, какую основную задачу решает приложение: поиск или добавление элементов? (Для выбора подхода может потребоваться измерение производительности для обоих типов контейнеров.) Если ни один из стандартных контейнеров не удовлетворяет нас, может быть, стоит разработать свою собственную, более сложную, структуру данных.
Какой из контейнеров выбрать, если мы не знаем количества его элементов (он будет динамически расти) и у нас нет необходимости ни в произвольном доступе, ни в добавлении элементов в середину? Что в таком случае более эффективно: список или вектор? (Мы отложим ответ на этот вопрос до следующего раздела.)
Список растет очень просто: добавление каждого нового элемента приводит к тому, что указатели на предыдущий и следующий для тех элементов, между которыми вставляется новый, меняют свои значения. В новом элементе таким указателям присваиваются значения адресов соседних элементов. Список использует только тот объем памяти, который нужен для имеющегося количества элементов. Накладными расходами являются два указателя в каждом элементе и необходимость использования указателя для получения значения элемента.
Внутреннее представление вектора и управление занимаемой им памятью более сложны. Мы рассмотрим это в следующем разделе.
Упражнение 6.1
Что лучше выбрать в следующих примерах: вектор, список или двустороннюю очередь? Или ни один из контейнеров не является предпочтительным?
1. Неизвестное заранее количество слов считывается из файла для генерации случайных предложений.
2. Считывается известное количество слов, которые вставляются в контейнер в алфавитном порядке.
3. Считывается неизвестное количество слов. Слова добавляются в конец контейнера, а удаляются всегда из начала.
4. Считывается неизвестное количество целых чисел. Числа сортируются и печатаются.
Вектор объектов
Когда определяется вектор из пяти объектов класса, например:
vector< Point > vec( 5 );
то инициализация элементов производится в следующем порядке5:
1. С помощью конструктора по умолчанию создается временный объект типа класса, хранящегося в векторе. .
2. К каждому элементу вектора применяется копирующий конструктор, в результате чего каждый объект инициализируется копией временного объекта.
3. Временный объект уничтожается.
Хотя конечный результат оказывается таким же, как при определении массива из пяти объектов класса:
Point pa[ 5 ];
эффективность подобной инициализации вектора ниже, так как, во-первых, на конструирование и уничтожение временного объекта, естественно, нужны ресурсы, а во-вторых, копирующий конструктор обычно оказывается вычислительно более сложным, чем конструктор по умолчанию.
Общее правило проектирования таково: вектор объектов класса удобнее только для вставки элементов, т.е. в случае, когда изначально определяется пустой вектор. Если мы заранее вычислили, сколько придется вставлять элементов, или имеем на этот счет обоснованное предположение, то надо зарезервировать необходимую память, а затем приступать к вставке. Например:
vector< Point > cvs; // пустой
int cv_cnt = calc_control_vertices();
// зарезервировать память для хранения cv_cnt объектов класса Point
// cvs все еще пуст ...
cvs.reserve( cv_cnt );
// открыть файл и подготовиться к чтению из него
ifstream infile( "spriteModel" );
istream_iterator<Point> cvfile( infile ),eos;
// вот теперь можно вставлять элементы
copy( cvfile, eos, inserter( cvs, cvs.begin() ));
(Алгоритм copy(), итератор вставки inserter и потоковый итератор чтения istream_iterator рассматривались в главе 12.) Поведение объектов list (список) и deque (двусторонняя очередь) аналогично поведению объектов vector (векторов). Вставка объекта в любой из этих контейнеров осуществляется с помощью копирующего конструктора.
Упражнение 14.9
Какие из приведенных инструкций неверны? Исправьте их.
(a) Account *parray[10] = new Account[10];
(b) Account iA[1024] = {
"Nhi", "Le", "Jon", "Mike", "Greg", "Brent", "Hank"
"Roy", "Elena" };
(c) string *ps=string[5]("Tina","Tim","Chyuan","Mira","Mike");
(d) string as[] = *ps;
Упражнение 14.10
Что лучше применить в каждой из следующих ситуаций: статический массив (такой, как Account pA[10]), динамический массив или вектор? Объясните свой выбор.
Внутри функции Lut() нужен набор из 256 элементов для хранения объектов класса Color. Значения являются константами.
Необходимо хранить набор из неизвестного числа объектов класса Account. Данные счетов читаются из файла.
Функция gen_words(elem_size) должна сгенерировать и передать обработчику текста набор из elem_size строк.
Упражнение 14.11
Потенциальным источником ошибок при использовании динамических массивов является пропуск пары квадратных скобок, говорящей, что указатель адресует массив, т.е. неверная запись
// печально: не проверяется, что parray адресует массив
delete parray;
вместо
// правильно: определяется размер массива, адресуемого parray
delete [] parray;
Наличие пары скобок заставляет компилятор найти размер массива. Затем к каждому элементу по очереди применяется деструктор (всего size раз). Если же скобок нет, уничтожается только один элемент. В любом случае освобождается вся память, занятая массивом.
При обсуждении первоначального варианта языка С++ много спорили о том, должно ли наличие квадратных скобок инициировать поиск или же (как было в исходной спецификации) лучше поручить программисту явно указывать размер массива:
// в первоначальном варианте языка размер массива требовалось задавать явно
delete p[10] parray;
Как вы думаете, почему язык был изменен таким образом, что явного задания размера не требуется (а значит, нужно уметь его сохранять и извлекать), но скобки, хотя и пустые, в операторе delete остались (так что компилятор не должен запоминать, адресует указатель единственный объект или массив)? Какой вариант языка предложили бы вы?
Вернемся в классу iStack
У класса iStack, разработанного нами в разделе 4.15, два недостатка:
он поддерживает только тип int. Мы хотим обеспечить поддержку любых типов. Это можно сделать, преобразовав наш класс в шаблон класса Stack;
он имеет фиксированную длину. Это неудобно в двух отношениях: заполненный стек становится бесполезным, а в попытке избежать этого мы окажемся перед необходимостью отвести ему изначально слишком много памяти. Разумным выходом будет разрешить динамический рост стека. Это можно сделать, пользуясь тем, что лежащий в основе стека вектор способен динамически расти.
Напомним определение нашего класса iStack:
#include <vector>
class iStack {
public:
iStack( int capacity )
: _stack( capacity ), _top( 0 ) {};
bool pop( int &value );
bool push( int value );
bool full();
bool empty();
void display();
int size();
private:
int _top;
vector< int > _stack;
};
Сначала реализуем динамическое выделение памяти. Тогда вместо использования индекса при вставке и удалении элемента нам нужно будет применять соответствующие функции-члены. Член _top больше не нужен: функции push_back() и pop_back() автоматически работают в конце массива. Вот модифицированный текст функций pop() и push():
bool iStack::pop( int &top_value )
{
if ( empty() )
return false;
top_value = _stack.back(); _stack.pop_back();
return true;
}
bool iStack::push( int value )
{
if ( full() )
return false;
_stack.push_back( value );
return true;
}
Функции-члены empty(), size() и full() также нуждаются в изменении: в этой версии они теснее связаны с лежащим в основе стека вектором.
inline bool iStack::empty(){ return _stack.empty(); }
inline bool iStack::size() { return _stack.size(); }
inline bool iStack::full() {
return _stack.max_size() == _stack.size(); }
Надо немного изменить функцию-член display(), чтобы _top больше не фигурировал в качестве граничного условия цикла.
void iStack::display()
{
cout << "( " << size() << " )( bot: ";
for ( int ix=0; ix < size(); ++ix )
cout << _stack[ ix ] << " ";
cout << " stop )\n";
}
Наиболее существенным изменениям подвергнется конструктор iStack. Никаких действий от него теперь не требуется. Можно было бы определить пустой конструктор:
inline iStack::iStack() {}
Однако это не совсем приемлемо для пользователей нашего класса. До сих пор мы строго сохраняли интерфейс класса iStack, и если мы хотим сохранить его до конца, необходимо оставить для конструктора один необязательный параметр. Вот как будет выглядеть объявление конструктора с таким параметром типа int:
class iStack {
public:
iStack( int capacity = 0 );
// ...
};
Что делать с аргументом, если он задан? Используем его для указания емкости вектора:
inline iStack::iStack( int capacity )
{
if ( capacity )
_stack.reserve( capacity );
}
Превращение класса в шаблон еще проще, в частности потому, что лежащий в основе вектор сам является шаблоном. Вот модифицированное объявление:
#include <vector>
template <class elemType>
class Stack {
public:
Stack( int capacity=0 );
bool pop( elemType &value );
bool push( elemType value );
bool full();
bool empty();
void display();
int size();
private:
vector< elemType > _stack;
};
Для обеспечения совместимости с программами, использующими наш прежний класс iStack, определим следующий typedef:
typedef Stack<int> iStack;
Модификацию операторов класса мы оставим читателю для упражнения.
Упражнение 6.29
Модифицируйте функцию peek() (упражнение 4.23 из раздела 4.15) для шаблона класса Stack.
Упражнение 6.30
Модифицируйте операторы для шаблона класса Stack. Запустите тестовую программу из раздела 4.15 для новой реализации
Упражнение 6.31
По аналогии с классом List из раздела 5.11.1 инкапсулируйте наш шаблон класса Stack в пространство имен Primer_Third_Edition
Часть III
Процедурно-ориентированное программирование
В части II были представлены базовые компоненты языка С++: встроенные типы данных (int и double), типы классов (string и vector) и операции, которые можно совершать над данными. В части III мы увидим, как из этих компонентов строятся функции, служащие для реализации алгоритмов.
В каждой программе на С++ должна присутствовать функция main(), которая получает управление при запуске программы. Все остальные функции, необходимые для решения задачи, вызываются из main(). Они обмениваются информацией при помощи параметров, которые получают при вызове, и возвращаемых значений. В главе 7 представлен соответствующие механизмы С++.
Функции используются для того, чтобы организовать программу в виде совокупности небольших и не зависящих друг от друга частей. Она инкапсулирует алгоритм или набор алгоритмов, применяемых к некоторому набору данных. Объекты и типы можно определить так, что они будут использоваться в течение всего времени работы программы. Однако, если некоторые объекты или типы применяются только в части программы, предпочтительнее ограничить область их использования именно этой частью и объявить внутри той функции, где они нужны. Понятие видимости
предоставляет в распоряжение программиста механизм, позволяющий ограничивать область применения объектов. Различные области видимости, поддерживаемые языком С++, мы рассмотрим в главе 8.
Для облегчения использования функций С++ предлагает множество средств, рассматриваемых нами в части III. Первым из них является перегрузка. Функции, которые выполняют семантически одну и ту же операцию, но работают с разными типами данных и потому имеют несколько отличающиеся реализации, могут иметь общее имя. Например, все функции для печати значений разных типов, таких, как int, string и т.д., называются print(). Поскольку программисту не приходится запоминать много разных имен для одной и той же операции, пользоваться ими становится проще. Компилятор сам подставляет нужное в зависимости от типов фактических аргументов. В главе 9 объясняется, как объявлять и использовать перегруженные функции и как компилятор выбирает подходящую из набора перегруженных.
Вторым средством, облегчающим использование функций, является механизм шаблонов. Шаблон – это обобщенное определение, которое используется для конкретизации –
автоматической генерации потенциально бесконечного множества функций, различающихся только типами входных данных, но не действиями над ними. Этот механизм описывается в главе 10.
Функции обмениваются информацией с помощью значений, которые они получают при вызове (параметров), и значений, которые они возвращают. Однако этот механизм может оказаться недостаточным при возникновении непредвиденной ситуации в работе программы. Такие ситуации называются исключениями, и, поскольку они требуют немедленной реакции, необходимо иметь возможность послать сообщение вызывающей программе. Язык С++ предлагает механизм обработки исключений, который позволяет функциям общаться между собой в таких условиях. Этот механизм рассматривается в главе 11.
Наконец, стандартная библиотека предоставляет нам обширный набор часто используемых функций – обобщенных алгоритмов. В главе 12 описываются эти алгоритмы и способы их использования с контейнерными типами из главы 6 и со встроенными массивами.
Видимость членов виртуального базового класса
Изменим наш класс Bear так, чтобы он имел собственную реализацию функции-члена onExhibit(), предоставляемой также ZooAnimal:
bool Bear::onExhibit() { ... }
Теперь обращение к onExhibit() через объект Bear разрешается в пользу экземпляра, определенного в этом классе:
Bear winnie( "любитель меда" );
winnie.onExhibit(); // Bear::onExhibit()
Обращение же к onExhibit() через объект Raccoon разрешается в пользу функции-члена, унаследованной из ZooAnimal:
Raccoon meeko( "любитель всякой еды" );
meeko.onExhibit(); // ZooAnimal::onExhibit()
Производный класс Panda наследует члены своих базовых классов. Их можно отнести к одной из трех категорий:
члены виртуального базового класса ZooAnimal, такие, как name() и family(), не замещенные ни в Bear, ни в Raccoon;
член onExhibit() виртуального базового класса ZooAnimal, наследуемый при обращении через Raccoon и замещенный в классе Bear;
специализированные в классах Bear и Raccoon экземпляры функции print() из ZooAnimal.
Можно ли, не опасаясь неоднозначности, напрямую обращаться к унаследованным членам из области видимости класса Panda? В случае невиртуального наследования – нет: все неквалифицированные ссылки на имя неоднозначны. Что касается виртуального наследования, то прямое обращение допустимо к любым членам из первой и второй категорий. Например, дан объект класса Panda:
Panda spot( "Spottie" );
Тогда инструкция
spot.name();
вызывает разделяемую функцию-член name() виртуального базового ZooAnimal, а инструкция
spot.onExhibit();
вызывает функцию-член onExhibit() производного класса Bear.
Когда два или более экземпляров члена наследуются разными путями (это относится не только к функциям-членам, но и к данным-членам, а также к вложенным типам) и все они представляют один и тот же член виртуального базового класса, неоднозначности не возникает, поскольку существует единственный разделяемый экземпляр (первая категория). Если один экземпляр представляет член виртуального базового, а другой – член унаследованного от него класса, то неоднозначности также не возникает: специализированному экземпляру из производного класса отдается предпочтение по сравнению с разделяемым экземпляром из виртуального базового (вторая категория). Но если оба экземпляра представляют члены производных классов, то прямое обращение неоднозначно. Лучше всего разрешить эту ситуацию, предоставив замещающий экземпляр в производном классе (третья категория).
(i) pb = new Class; (iii) pmi = pb;
(ii) pc = new Final; (iv) pd2 = pmi;
Упражнение 18.14
Дана иерархия классов:
class Base {
public:
bar( int );
// ...
protected:
int ival;
// ...
};
class Derived1 : virtual public Base {
public:
bar( char );
foo( char );
// ...
protected:
char cval;
// ...
};
class Derived2 : virtual public Base {
public:
foo( int );
// ...
protected:
int ival;
char cval;
// ...
};
class VMI : public Derived1, public Derived2 {};
К каким из унаследованных членов можно обращаться из класса VMI, не квалифицируя имя? А какие требуют квалификации?
Упражнение 18.15
Дан класс Base с тремя конструкторами:
class Base {
public:
Base();
Base( string );
Base( const Base& );
// ...
protected:
string _name;
};
Определите соответствующие конструкторы для каждого из следующих классов:
(a) любой из
class Derived1 : virtual public Vase { ... };
class Derived2 : virtual public Vase { ... };
(b) class VMI : public Derived1, public Derived2 { ... };
(c) class Final : public VMI { ... };
Виртуальная функция eval()
В основе иерархии классов Query лежит виртуальная функция eval() (но с точки зрения возможностей языка она наименее интересна). Как и для других функций-членов, разумной реализации eval() в абстрактном классе Query нет, поэтому мы объявляем ее чисто виртуальной:
class Query {
public:
virtual void eval() = 0;
// ...
};
Реальное разрешение имени eval() происходит при построении отображения слов на вектор позиций. Если слово есть в тексте, то в отображении будет его вектор позиций. В нашей реализации вектор позиций, если он имеется, передается конструктору NameQuery вместе с самим словом. Поэтому в классе NameQuery функция eval() пуста.
Однако мы не можем унаследовать чисто виртуальную функцию из Query. Почему? Потому что NameQuery– это конкретный класс, объекты которого разрешается создавать в приложении. Если бы мы унаследовали чисто виртуальную функцию, то он стал бы абстрактным классом, так что создать объект такого типа не удалось бы. Поэтому мы объявим eval() пустой функцией:
class NameQuery : public Query {
public:
virtual void eval() {}
// ...
};
Для запроса NotQuery отыскиваются все строки текста, где указанное слово отсутствует. Для таких строк в член _loc класса NotQuery помещаются все пары (строка, колонка). Наша реализация выглядит следующим образом:
void NotQuery::eval()
{
// вычислим операнд
_op->eval();
// _all_locs - это вектор, содержащий начальные позиции всех слов,
// он является статическим членом NotQuery:
// static const vector<locations>* _all_locs
vector< location >::const_iterator
iter = _all_locs->begin(),
iter_end = _all_locs->end();
// получить множество строк, в которых операнд встречается
set<short> *ps = _vec2set( _op->locations() );
// для каждой строки, где операнд не найден,
// скопировать все позиции в _loc
for ( ; iter != iter_end; ++iter )
{
if ( ! ps->count( (*iter).first )) {
_loc.push_back( *iter );
}
}
}
Ниже приводится трассировка выполнения запроса NotQuery. Операнд встречается в 0, 3 и 5 строках текста. (Напомним, что внутри программы строки текста в векторе нумеруются с 0; а когда мы предъявляем строки пользователю, мы нумеруем их с единицы.) Поэтому при вычислении ответа создается вектор, содержащий начальные позиции слов в строках 1,2 и 4. (Мы отредактировали вектор позиций, чтобы он занимал меньше места.)
==> ! daddy
daddy ( 3 ) lines match
display_location_vector:
first: 0 second: 8
first: 3 second: 3
first: 5 second: 5
! daddy ( 3 ) lines match
display_location_vector:
first: 1 second: 0
first: 1 second: 1
first: 1 second: 2
...
first: 1 second: 10
first: 2 second: 0
first: 2 second: 1
...
first: 2 second: 12
first: 4 second: 0
first: 4 second: 1
...
first: 4 second: 12
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.
При обработке запроса OrQuery векторы позиций обоих операндов объединяются. Для этого применяется обобщенный алгоритм merge(). Чтобы merge() мог упорядочить пары (строка, колонка), мы определяем объект-функцию для их сравнения. Ниже приведена наша реализация:
class less_than_pair {
public:
bool operator()( location loc1, location loc2 )
{
return (( loc1.first < loc2.first ) ||
( loc1.first == loc2.first ) &&
( loc1.second < loc2.second ));
}
};
void OrQuery::eval()
{
// вычислить левый и правый операнды
_lop->eval();
_rop->eval();
// подготовиться к объединению двух векторов позиций
vector< location, allocator >::const_iterator
riter = _rop->locations()->begin(),
liter = _lop->locations()->begin(),
riter_end = _rop->locations()->end(),
liter_end = _lop->locations()->end();
merge( liter, liter_end, riter, riter_end,
inserter( _loc, _loc.begin() ),
less_than_pair() );
}
А вот трассировка выполнения запроса OrQuery, в которой мы выводим вектор позиций каждого из двух операндов и результат их объединения. (Напомним еще раз, что для пользователя строки нумеруются с 1, а внутри программы – с 0.)
==> fiery || untamed
fiery ( 1 ) lines match
display_location vector:
first: 2 second: 2
first: 2 second: 8
untamed ( 1 ) lines match
display_location vector:
first: 3 second: 2
fiery || untamed ( 2 ) lines match
display_location vector:
first: 2 second: 2
first: 2 second: 8
first: 3 second: 2
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,"
При обработке запроса AndQuery мы обходим векторы позиций обоих операндов и ищем соседние слова. Каждая найденная пара вставляется в вектор _loc. Основная трудность связана с тем, что эти векторы нужно просматривать синхронно, чтобы можно было установить соседство слов.
void AndQuery::eval()
{
// вычислить левый и правый операнды
_lop->eval();
_rop->eval();
// установить итераторы
vector< location, allocator >::const_iterator
riter = _rop->locations()->begin(),
liter = _lop->locations()->begin(),
riter_end = _rop->locations()->end(),
liter_end = _lop->locations()->end();
// продолжать цикл, пока есть что сравнивать
while ( liter != liter_end &&
riter != riter_end )
{
// пока номер строки в левом векторе больше, чем в правом
while ( (*liter).first > (*riter).first )
{
++riter;
if ( riter == riter_end ) return;
}
// пока номер строки в левом векторе меньше, чем в правом
while ( (*liter).first < (*riter).first )
{
// если соответствие найдено для последнего слова
// в одной строке и первого слова в следующей
// _max_col идентифицирует последнее слово в строке
if ( ((*liter).first == (*riter).first-1 ) &&
((*riter).second == 0 ) &&
((*liter).second == (*_max_col)[ (*liter).first ] ))
{
_loc.push_back( *liter );
_loc.push_back( *riter );
++riter;
if ( riter == riter_end ) return;
}
++liter;
if ( liter == liter_end ) return;
}
// пока оба в одной и той же строке
while ( (*liter).first == (*riter).first )
{
if ( (*liter).second+1 == ((*riter).second) )
{ // соседние слова
_loc.push_back( *liter ); ++liter;
_loc.push_back( *riter ); ++riter;
}
else
if ( (*liter).second <= (*riter).second )
++liter;
else ++riter;
if ( liter == liter_end || riter == riter_end )
return;
}
}
}
А так выглядит трассировка выполнения запроса AndQuery, в которой мы выводим векторы позиций обоих операндов и результирующий вектор:
==> fiery && bird
fiery ( 1 ) lines match
display_location vector:
first: 2 second: 2
first: 2 second: 8
bird ( 1 ) lines match
display_location vector:
first: 2 second: 3
first: 2 second: 9
fiery && bird ( 1 ) lines match
display_location vector:
first: 2 second: 2
first: 2 second: 3
first: 2 second: 8
first: 2 second: 9
Requested query: fiery && bird
( 3 ) like a fiery bird in flight. A beautiful fiery bird, he tells her,
Приведем трассировку выполнения составного запроса, включающего как И, так и ИЛИ. Показаны векторы позиций каждого операнда, а также результирующий вектор:
==> fiery && ( bird || untamed )
fiery ( 1 ) lines match
display_location vector:
first: 2 second: 3
first: 2 second: 8
bird ( 1 ) lines match
display_location vector:
first: 2 second: 3
first: 2 second: 9
untamed ( 1 ) lines match
display_location vector:
first: 3 second: 2
( bird || untamed ) ( 2 ) lines match
display_location vector:
first: 2 second: 3
first: 2 second: 9
first: 3 second: 2
fiery && ( bird || untamed ) ( 1 ) lines match
display_location vector:
first: 2 second: 2
first: 2 second: 3
first: 2 second: 8
first: 2 second: 9
Requested query: fiery && ( bird || untamed )
( 3 ) like a fiery bird in flight. A beautiful fiery bird, he tells her,
Виртуальное наследование *
По умолчанию наследование в C++ является специальной формой композиции по значению. Когда мы пишем:
class Bear : public ZooAnimal { ... };
каждый объект Bear содержит все нестатические данные-члены подобъекта своего базового класса ZooAnimal, а также нестатические члены, объявленные в самом Bear. Аналогично, если производный класс является базовым для какого-то другого:
class PolarBear : public Bear { ... };
то каждый объект PolarBear содержит все нестатические члены, объявленные в PolarBear, Bear и ZooAnimal.
В случае одиночного наследования эта форма композиции по значению, поддерживаемая механизмом наследования, обеспечивает компактное и эффективное представление объекта. Проблемы возникают только при множественном наследовании, когда некоторый базовый класс неоднократно встречается в иерархии наследования. Самый известный реальный пример такого рода – это иерархия классов iostream. Взгляните еще раз на рис. 18.2: istream и ostream наследуют одному и тому абстрактному базовому классу ios, а iostream является производным как от istream, так и от ostream.
class iostream :
public istream, public ostream { ... };
По умолчанию каждый объект iostream содержит два подобъекта ios: из istream и из ostream. Почему это плохо? С точки зрения эффективности хранение двух копий подобъекта ios – пустая трата памяти, поскольку объекту iostream нужен только один экземпляр. Кроме того, конструктор вызывается для каждого подобъекта. Более серьезной проблемой является неоднозначность, к которой приводит наличие двух экземпляров. Например, любое неквалифицированное обращение к члену класса ios дает ошибку компиляции. Какой экземпляр имеется в виду? Что будет, если классы istream и ostream инициализируют свои подобъекты ios по-разному? Можно ли гарантировать, что в классе iostream используется согласованная пара членов ios? Применяемый по умолчанию механизм композиции по значению не дает таких гарантий.
Для решения данной проблемы язык предоставляет альтернативный механизм композиции по ссылке: виртуальное наследование. В этом случае наследуется только один разделяемый подобъект базового класса, независимо от того, сколько раз базовый класс встречается в иерархии наследования. Этот разделяемый подобъект называется виртуальным базовым классом. С помощью виртуального наследования снимаются проблемы дублирования подобъектов базового класса и неоднозначностей, к которым такое дублирование приводит.
Для изучения синтаксиса и семантики виртуального наследования мы выбрали класс Panda. В зоологических кругах уже на протяжении ста лет периодически вспыхивают ожесточенные споры по поводу того, к какому семейству относить панду: к медведям или к енотам. Поскольку проектирование программного обеспечения призвано обслуживать, в основном, интересы прикладных областей, то самое правильное – произвести класс Panda от обоих классов:
class Panda : public Bear,
public Raccoon, public Endangered { ... };
Наша виртуальная иерархия наследования Panda показана на рис. 18.4: две пунктирные стрелки обозначают виртуальное наследование классов Bear и Raccoon от ZooAnimal, а три сплошные – невиртуальное наследование Panda от Bear, Raccoon и, на всякий случай, от класса Endangered из раздела 18.2.
ZooAnimal Endangered
Bear Raccoon
Panda
¾¾> невиртуальное наследование
- - - -> виртуальное наследование
Рис. 18.4. Иерархия виртуального наследования класса Panda
На данном рисунке показан интуитивно неочевидный аспект виртуального наследования: оно (в нашем случае наследование классов Bear и Raccoon) должно появиться в иерархии раньше, чем в нем возникнет реальная необходимость. Необходимым виртуальное наследование становится только при объявлении класса Panda, но если перед этим базовые классы Bear и Raccoon не наследуют своему базовому виртуально, то проектировщику класса Panda не повезло.
Должны ли мы производить свои базовые классы виртуально просто потому, что где-то ниже в иерархии может потребоваться виртуальное наследование? Нет, это не рекомендуется: снижение производительности и усложнение дальнейшего наследования может оказаться существенным (см. [LIPPMAN96a], где приведены и обсуждаются результаты измерения производительности).
Когда же использовать виртуальное наследование? Чтобы его применение было успешным, иерархия, например библиотека iostream или наше дерево классов Panda, должна проектироваться целиком либо одним человеком, либо коллективом разработчиков.
В общем случае мы не рекомендуем пользоваться виртуальным наследованием, если только оно не решает конкретную проблему проектирования. Однако посмотрим, как все-таки можно его применить.
Виртуальные деструкторы
В данной функции мы применяем оператор delete:
void doit_and_bedone( vector< Query* > *pvec )
{
// ...
for ( ; it != end_it; ++it )
{
Query *pq = *it;
// ...
delete pq;
}
}
Чтобы функция выполнялась правильно, применение delete должно вызывать деструктор того класса, на который указывает pq. Следовательно, необходимо объявить деструктор Query виртуальным:
class Query {
public:
virtual ~Query() { delete _solution; }
// ...
};
Деструкторы всех производных от Query классов автоматически считаются виртуальными. doit_and_bedone() выполняется правильно.
Поведение деструктора при наследовании таково: сначала вызывается деструктор производного класса, в случае pq – виртуальная функция. По завершении вызывается деструктор непосредственного базового класса – статически. Если деструктор объявлен встроенным, то в точке вызова производится подстановка. Например, если pq указывает на объект класса AndQuery, то
delete pq;
приводит к вызову деструктора класса AndQuery за счет механизма виртуализации. После этого статически вызывается деструктор BinaryObject, а затем – снова статически – деструктор Query.
В следующей иерархии классов
class Query {
public: // ...
protected:
virtual ~Query();
// ...
};
class NotQuery : public Query {
public:
~NotQuery();
// ...
};
уровень доступа к конструктору NotQuery открытый при вызове через объект NotQuery, но защищенный – при вызове через указатель или ссылку на объект Query. Таким образом, виртуальная функция подразумевает уровень доступа того класса, через объект которого вызывается:
int main()
{
Query *pq = new NotQuery;
// ошибка: деструктор является защищенным
delete pq;
}
Эвристическое правило: если в корневом базовом классе иерархии объявлены одна или несколько виртуальных функций, рекомендуем объявлять таковым и деструктор. Однако, в отличие от конструктора базового класса, его деструктор не стоит делать защищенным.
Виртуальные функции и аргументы по умолчанию
Рассмотрим следующую простую иерархию классов:
#include <iostream>
class base {
public:
virtual int foo( int ival = 1024 ) {
cout << "base::foo() -- ival: " << ival << endl;
return ival;
}
// ...
};
class derived : public base {
public:
virtual int foo( int ival = 2048 ) {
cout << "derived::foo() -- ival: " << ival << endl;
return ival;
}
// ...
};
Проектировщик класса хотел, чтобы при вызове без параметров реализации foo() из базового класса по умолчанию передавался аргумент 1024:
base b;
base *pb = &b;
// вызывается base::foo( int )
// предполагалось, что будет возвращено 1024
pb->foo();
Кроме того, разработчик хотел, чтобы при вызове его реализации foo() без параметров использовался аргумент по умолчанию 2048:
derived d;
base *pb = &d;
// вызывается derived::foo( int )
// предполагалось, что будет возвращено 2048
pb->foo();
Однако в C++ принята другая семантика механизма виртуализации. Вот небольшая программа для тестирования нашей иерархии классов:
int main()
{
derived *pd = new derived;
base *pb = pd;
int val = pb->foo();
cout << "main() : val через base: "
<< val << endl;
val = pd->foo();
cout << "main() : val через derived: "
<< val << endl;
}
После компиляции и запуска программа выводит следующую информацию:
derived::foo() -- ival: 1024
main() : val через base: 1024
derived::foo() -- ival: 2048
main() : val через derived: 2048
При обоих обращениях реализация foo() из производного класса вызывается корректно, поскольку фактически вызываемый экземпляр определяется во время выполнения на основе типа класса, адресуемого pd и pb. Но передаваемый foo() аргумент по умолчанию определяется не во время выполнения, а во время компиляции на основе типа объекта, через который вызывается функция. При вызове foo() через pb аргумент по умолчанию извлекается из объявления base::foo() и равен 1024. Если же foo() вызывается через pd, то аргумент по умолчанию извлекается из объявления derived::foo() и равен 2048.
Если реализации из производного класса при вызове через указатель или ссылку на базовый класс по умолчанию передается аргумент, указанный в базовом классе, то зачем задавать аргумент по умолчанию для реализации из производного класса?
Нам могут понадобиться различные аргументы по умолчанию в зависимости не от реализации foo() в конкретном производном классе, а от типа указателя или ссылки, через которые функция вызвана. Например, значения 1024 и 2048 – это размеры изображений. Когда нужно получить менее детальное изображение, вызываем foo() через класс base, а когда более детальное – через derived.
Но если мы все-таки хотим, чтобы аргумент по умолчанию, передаваемый foo(), зависел от фактически вызванного экземпляра? К сожалению, механизм виртуализации такую возможность не поддерживает. Однако разрешается задать такой аргумент по умолчанию, который для вызванной функции означает, что пользователь не передал никакого значения. Тогда реальное значение, которое функция хотела бы видеть в качестве аргумента по умолчанию, объявляется локальной переменной и используется, если ничего другого не передано:
void
base::
foo( int ival = base_default_value )
{
int real_default_value = 1024; // настоящее значение по умолчанию
if ( ival == base_default_value )
ival = real_default_value;
// ...
}
Здесь base_default_value – значение, согласованное между всеми классами иерархии, которое явно говорит о том, что пользователь не передал никакого аргумента. Производный класс может быть реализован аналогично:
void
derived::
foo( int ival = base_default_value )
{
int real_default_value = 2048;
if ( ival == base_default_value )
ival = real_default_value;
// ...
}
Виртуальные функции, конструкторы и деструкторы
Как мы видели в разделе 17.4, для объекта производного класса сначала вызывается конструктор базового, а затем производного класса. Например, при таком определении объекта NameQuery
NameQuery poet( "Orlen" );
сначала будет вызван конструктор Query, а потом NameQuery.
При выполнении конструктора базового класса Query часть объекта, соответствующая классу NameQuery, остается неинициализированной. По существу, poet– это еще не объект NameQuery, сконструирован лишь его подобъект.
Что должно происходить, если внутри конструктора базового класса вызывается виртуальная функция, реализации которой существуют как в базовом, так и в производном классах? Какая из них должна быть вызвана? Результат вызова реализации из производного класса в случае, когда необходим доступ к его членам, оказался бы неопределенным. Вероятно, выполнение программы закончилось бы крахом.
Чтобы этого не случилось, в конструкторе базового класса всегда вызывается реализация виртуальной функции, определенная именно в базовом. Иными словами, внутри такого конструктора объект производного класса рассматривается как имеющий тип базового.
То же самое справедливо и внутри деструктора базового класса, вызываемого для объекта производного. И в этом случае часть объекта, относящаяся к производному классу, не определена: не потому, что еще не сконструирована, а потому, что уже уничтожена.
Упражнение 17.12
Внутри объекта NameQuery естественное внутреннее представление вектора позиций – это указатель, который инициализируется указателем, хранящимся в отображении слов. Оно же является и наиболее эффективным, так как нам нужно скопировать лишь один адрес, а не каждую пару координат. Классы AndQuery, OrQuery и NotQuery должны конструировать собственные векторы позиций на основе вычисления своих операндов. Когда время жизни объекта любого из этих классов завершается, ассоциированный с ним вектор позиций необходимо удалить. Когда же заканчивается время жизни объекта NameQuery, вектор позиций удалять не следует. Как сделать так, чтобы вектор позиций был представлен указателем в базовом классе Query и при этом его экземпляры для объектов AndQuery, OrQuery и NotQuery удалялись, а для объектов NameQuery – нет? (Заметим, что нам не разрешается добавить в класс Query признак, показывающий, нужно ли применять оператор delete к вектору позиций!)
Упражнение 17.13
Что неправильно в приведенном определении класса:
class AbstractObject {
public:
~AbstractObject();
virtual void doit() = 0;
// ...
};
Упражнение 17.14
Даны такие определения:
NameQuery nq( "Sneezy" );
Query q( nq );
Query *pq = &nq;
Почему в инструкции
pq->eval();
вызывается экземпляр eval() из класса NameQuery, а в инструкции
q.eval();
экземпляр из Query?
Упражнение 17.15
Какие из повторных объявлений виртуальных функций в классе Derived неправильны:
(a) Base* Base::copy( Base* );
Base* Derived::copy( Derived* );
(b) Base* Base::copy( Base* );
Derived* Derived::copy( Vase* );
(c) ostream& Base::print( int, ostream&=cout );
ostream& Derived::print( int, ostream& );
(d) void Base::eval() const;
void Derived::eval();
Упражнение 17.16
Маловероятно, что наша программа заработает при первом же запуске и в первый раз, когда прогоняется с реальными данными. Средства отладки полезно включать уже на этапе проектирования классов. Реализуйте в нашей иерархии классов Query виртуальную функцию debug(), которая будет отображать члены соответствующих классов. Поддержите управление уровнем детализации двумя способами: с помощью аргумента, передаваемого функции debug(), и с помощью члена класса. (Последнее позволяет включать или отключать выдачу отладочной информации в отдельных объектах.)
Упражнение 17.17
Найдите ошибку в следующей иерархии классов:
class Object {
public:
virtual void doit() = 0;
// ...
protected:
virtual ~Object();
};
class MyObject : public Object {
public:
MyObject( string isA );
string isA() const;
protected:
string _isA;
};
Виртуальные функции в базовом и производном классах
По умолчанию функции-члены класса не являются виртуальными. В подобных случаях при обращении вызывается функция, определенная в статическом типе объекта класса (или указателя, или ссылки на объект), для которого она вызвана:
void Query::display( Query *pb )
{
set<short> *ps = pb->solutions();
// ...
display();
}
Статический тип pb– это Query*. При обращении к невиртуальному члену solutions() вызывается функция-член класса Query. Невиртуальная функция display() вызывается через неявный указатель this. Статическим типом указателя this также является Query*, поэтому вызвана будет функция-член класса Query.
Чтобы объявить функцию виртуальной, нужно добавить ключевое слово virtual:
class Query {
public:
virtual ostream& print( ostream* = cout ) const;
// ...
};
Если функция-член виртуальна, то при обращении к ней вызывается функция, определенная в динамическом типе объекта класса (или указателя, или ссылки на объект), для которого она вызвана. Однако для самих объектов класса статический и динамический тип – это одно и то же. Механизм виртуальных функций правильно работает только для указателей и ссылок на объекты.
Таким образом, полиморфизм проявляется только тогда, когда объект производного класса адресуется косвенно, через указатель или ссылку на базовый. Использование самого объекта базового класса не сохраняет идентификацию типа производного. Рассмотрим следующий фрагмент кода:
NameQuery nq( "lilacs" );
// правильно: но nq "усечено" до подобъекта Query
Query qobject = nq;
Инициализация qobject переменной nq абсолютно законна: теперь qobject равняется подобъекту nq, который соответствует базовому классу Query, однако qobject не является объектом NameQuery. Часть nq, принадлежащая NameQuery, “усечена” перед инициализацией qobject, поскольку она не помещается в область памяти, отведенную под объект Query. Для поддержки этой парадигмы приходится использовать указатели и ссылки, но не сами объекты:
void print ( Query object,
const Query *pointer,
const Query &reference )
{
// до момента выполнения невозможно определить,
// какой экземпляр print() вызывается
pointer->print();
reference.print();
// всегда вызывается Query::print()
object.print();
}
int main()
{
NameQuery firebird( "firebird" );
print( firebird, &firebird, firebird );
}
В данном примере оба обращения через указатель pointer и ссылку reference разрешаются своим динамическим типом; в обоих случаях вызывается NameQuery::print(). Обращение же через объект object всегда приводит к вызову Query::print(). (Пример программы, в которой используется эффект “усечения”, приведен в разделе 18.6.2.)
В следующих подразделах мы продемонстрируем определение и использование виртуальных функций в разных обстоятельствах. Каждая такая функция-член будет иллюстрировать один из аспектов объектно-ориентированного проектирования.
Виртуальный ввод/вывод
Первая виртуальная операция, которую мы хотели реализовать, – это печать запроса на стандартный вывод либо в файл:
ostream& print( ostream &os = cout ) const;
Функцию print() следует объявить виртуальной, поскольку ее реализации зависят от типа, но нам нужно вызывать ее через указатель типа Query*. Например, для класса AndQuery эта функция могла бы выглядеть так:
ostream&
AndQuery::print( ostream &os ) const
{
_lop->print( os );
os << " && ";
_rop->print( os );
}
Необходимо объявить print() виртуальной функцией в абстрактном базовом Query, иначе мы не сможем вызвать ее для членов классов AndQury, OrQuery и NotQuery, являющихся указателями на операнды соответствующих запросов типа Query*. Однако для самого Query разумной реализации print() не существует. Поэтому мы определим ее как пустую функцию, а потом сделаем чисто виртуальной:
class Query {
public:
virtual ostream& print( ostream &os=cout ) const {}
// ...
};
В базовом классе, где виртуальная функция появляется в первый раз, ее объявлению должно предшествовать ключевое слово virtual. Если же ее определение находится вне этого класса, повторно употреблять virtual не следует. Так, данное определение print() приведет к ошибке компиляции:
// ошибка: ключевое слово virtual может появляться
// только в определении класса
virtual ostream& Query::print( ostream& ) const { ... }
Правильный вариант не должен включать слово virtual.
Класс, в котором впервые появляется виртуальная функция, должен определить ее или объявить чисто виртуальной (напомним, что пока мы определили ее как пустую). В производном классе может быть либо определена собственная реализация той же функции, которая в таком случае становится активной для всех объектов этого класса, либо унаследована реализация из базового класса. Если в производном классе определена собственная реализация, то говорят, что она замещает реализацию из базового.
Прежде чем приступать к рассмотрению реализаций print() для наших четырех производных классов, обратим внимание на употребление скобок в запросе. Например, с помощью
fiery && bird || shyly
пользователь ищет вхождения пары слов
fiery bird
или одного слова
shyly
С другой стороны, запрос
fiery && ( bird || hair )
найдет все вхождения любой из пар
fiery bird
или
fiery hair
Если наши реализации print() не будут показывать скобки в исходном запросе, то для пользователя они окажутся почти бесполезными. Чтобы сохранить эту информацию, введем в наш абстрактный базовый класс Query два нестатических члена, а также функции доступа к ним (подобное расширение класса – естественная часть эволюции иерархии):
class Query {
public:
// ...
// установить _lparen и _rparen
void lparen( short lp ) { _lparen = lp; }
void rparen( short rp ) { _rparen = rp; }
// получить значения_lparen и _rparen
short lparen() { return _lparen; }
short rparen() { return _rparen; }
// напечатать левую и правую скобки
void print_lparen( short cnt, ostream& os ) const;
void print_rparen( short cnt, ostream& os ) const;
protected:
// счетчики левых и правых скобок
short _lparen;
short _rparen;
// ...
};
_lparen – это количество левых, а _rparen – правых скобок, которое должно быть выведено при распечатке объекта. (В разделе 17.7 мы покажем, как вычисляются такие величины и как происходит присваивание обоим членам.) Вот пример обработки запроса с большим числом скобок:
==> ( untamed || ( fiery || ( shyly ) ) )
evaluate word: untamed
_lparen: 1
_rparen: 0
evaluate Or
_lparen: 0
_rparen: 0
evaluate word: fiery
_lparen: 1
_rparen: 0
evaluate 0r
_lparen: 0
_rparen: 0
evaluate word: shyly
_lparen: 1
_rparen: 0
evaluate right parens:
_rparen: 3
( untamed ( 1 ) lines match
( fiery ( 1 ) lines match
( shyly ( 1 ) lines match
( fiery || (shyly ( 2 ) lines match3
( untamed || ( fiery || ( shyly ))) ( 3 ) lines match
Requested query: ( untamed || ( fiery || ( shyly ) ) )
( 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,"
( 6 ) Shyly, she asks, "I mean, Daddy, is there?"
Реализация print() для класса NameQuery:
ostream&
NameQuery::
print( ostream &os ) const
{
if ( _lparen )
print_lparen( _lparen, os );
os << _name;
if ( _rparen )
print_rparen( _rparen, os );
return os;
}
А так выглядит объявление:
class NameQuery : public Query {
public:
virtual ostream& print( ostream &os ) const;
// ...
};
Чтобы реализация виртуальной функции в производном классе замещала реализацию из базового, прототипы функций обязаны совпадать. Например, если бы мы опустили слово const или объявили еще один параметр, то реализация print() в NameQuery не заместила бы реализацию из базового класса. Возвращаемые значения также должны быть одинаковыми за одним исключением: значение, возвращенное реализацией в производном классе, может принадлежать к типу класса, который открыто наследует классу значения, возвращаемого реализацией в базовом классе. Если бы реализация из базового класса возвращала значение типа Query*, то реализация из производного могла бы возвращать NameQuery*. (Позже при работе с функцией clone() мы покажем, зачем это нужно.) Вот объявление и реализация print() в NotQuery:
class NotQuery : public Query {
public:
virtual ostream& print( ostream &os ) const;
// ...
};
ostream&
NotQuery::
print( ostream &os ) const
{
os << " ! ";
if ( _lparen )
print_lparen( _lparen, os );
_op->print( os );
if ( _rparen )
print_rparen( _rparen, os );
return os;
}
Разумеется, вызов print() через _op – виртуальный.
Объявления и реализации этой функции в классах AndQuery и OrQuery практически дублируют друг друга. Поэтому приведем их только для AndQuery:
class AndQuery : public Query {
public:
virtual ostream& print( ostream &os ) const;
// ...
};
ostream&
AndQuery::
print( ostream &os ) const
{
if ( _lparen )
print_lparen( _lparen, os );
_lop->print( os );
os << " && ";
_rop->print( os );
if ( _rparen )
print_rparen( _rparen, os );
return os;
}
Такая реализация виртуальной функции print() позволяет вывести любой подтип Query в поток класса ostream или любого другого, производного от него:
cout << "Был сформулирован запрос ";
Query *pq = retrieveQuery();
pq->print( cout );
Однако такой возможности недостаточно. Еще нужно уметь распечатывать любой производный от Query тип, который уже есть или может появиться в будущем, с помощью оператора вывода из библиотеки iostream:
Query *pq = retrieveQuery();
cout << "В ответ на запрос "
<< *pq
<< " получены следующие результаты:\n";
Мы не можем непосредственно предоставить виртуальный оператор вывода, поскольку они являются членами класса ostream. Вместо этого мы должны написать косвенную виртуальную функцию:
inline ostream&
operator<<( ostream &os, const Query &q )
{
// виртуальный вызов print()
return q.print( os );
}
Строки
AndQuery query;
// сформулировать запрос ...
cout << query << endl;
вызывают наш оператор вывода в ostream, который в свою очередь вызывает
q.print( os )
где q привязано к объекту query класса AndQuery, а os – к cout. Если бы вместо этого мы написали:
NameQuery query2( "Salinger" );
cout << query2 << endl;
то была бы вызвана реализация print() из класса NameQuery. Обращение
Query *pquery = retrieveQuery();
cout << *pquery << endl;
приводит к вызову той функции print(), которая ассоциирована с объектом, адресуемым указателем pquery в данной точке выполнения программы.
Вложенные классы *
Класс, объявленный внутри другого класса, называется вложенным. Он является членом объемлющего класса, а его определение может находиться в любой из секций public, private или protected объемлющего класса.
Имя вложенного класса известно в области видимости объемлющего класса, но ни в каких других областях. Это означает, что оно не конфликтует с таким же именем, объявленным в объемлющей области видимости. Например:
class Node { /* ... */ }
class Tree {
public:
// Node инкапсулирован внутри области видимости класса Tree
// В этой области Tree::Node скрывает ::Node
class Node {...};
// правильно: разрешается в пользу вложенного класса: Tree::Node
Node *tree;
};
// Tree::Node невидима в глобальной области видимости
// Node разрешается в пользу глобального объявления Node
Node *pnode;
class List {
public:
// Node инкапсулирован внутри области видимости класса List
// В этой области List::Node скрывает ::Node
class Node {...};
// правильно: разрешается в пользу вложенного класса: List::Node
Node *list;
};
Для вложенного класса допустимы такие же виды членов, как и для невложенного:
// Не идеально, будем улучшать
class List {
public:
class ListItem {
friend class List; // объявление друга
ListItem( int val=0 ); // конструктор
ListItem *next; // указатель на собственный класс
int value;
};
// ...
private:
ListItem *list;
ListItem *at_end;
};
Закрытым называется член, который доступен только в определениях членов и друзей класса. У объемлющего класса нет права доступа к закрытым членам вложенного. Чтобы в определениях членов List можно было обращаться к закрытым членам ListItem, класс ListItem объявляет List как друга. Равно и вложенный класс не имеет никаких специальных прав доступа к закрытым членам объемлющего класса. Если бы нужно было разрешить ListItem доступ к закрытым членам класса List, то в объемлющем классе List следовало бы объявить вложенный класс как друга. В приведенном выше примере этого не сделано, поэтому ListItem не может обращаться к закрытым членам List.
Объявление ListItem открытым членом класса List означает, что вложенный класс можно использовать как тип во всей программе, в том числе и за пределами определений членов и друзей класса. Например:
// правильно: объявление в глобальной области видимости
List::ListItem *headptr;
Это дает более широкую область видимости, чем мы планировали. Вложенный ListItem поддерживает абстракцию класса List и не должен быть доступен во всей программе. Поэтому лучше объявить вложенный класс ListItem закрытым членом List:
// Не идеально, будем улучшать
class List {
public:
// ...
private:
class ListItem {
// ...
};
ListItem *list;
ListItem *at_end;
};
Теперь тип ListItem доступен только из определений членов и друзей класса List, поэтому все члены класса ListItem можно сделать открытыми. При таком подходе объявление List как друга ListItem становится ненужным. Вот новое определение класса List:
// так лучше
class List {
public:
// ...
private:
// Теперь ListItem закрытый вложенный тип
class ListItem {
// а его члены открыты
public:
ListItem( int val=0 );
ListItem *next;
int value;
};
ListItem *list;
ListItem *at_end;
};
Конструктор ListItem не задан как встроенный внутри определения класса и, следовательно, должен быть определен вне него. Но где именно? Конструктор класса ListItem не является членом List и, значит, не может быть определен в теле последнего; его нужно определить в глобальной области видимости – той, которая содержит определение объемлющего класса. Когда функция-член вложенного класса не определяется как встроенная в теле, она должна быть определена вне самого внешнего из объемлющих классов.
Вот как могло бы выглядеть определение конструктора ListItem. Однако показанный ниже синтаксис в глобальной области видимости некорректен:
class List {
public:
// ...
private:
class ListItem {
public:
ListItem( int val=0 );
// ...
};
};
// ошибка: ListItem вне области видимости
ListItem:: ListItem( int val ) { ... }
Проблема в том, что имя ListItem отсутствует в глобальной области видимости. При использовании его таким образом следует указывать, что ListItem – вложенный класс в области видимости List. Это делается путем квалификации имени ListItem именем объемлющего класса. Следующая конструкция синтаксически правильна:
// имя вложенного класса квалифировано именем объемлющего
List::ListItem::ListItem( int val ) {
value = val;
next = 0;
}
Заметим, что квалифицировано только имя вложенного класса. Первый квалификатор List:: именует объемлющий класс и квалифицирует следующее за ним имя вложенного ListItem. Второе вхождение ListItem – это имя конструктора, а не вложенного класса. В данном определении имя члена некорректно:
// ошибка: конструктор называется ListItem, а не List::ListItem
List::ListItem::List::ListItem( int val ) {
value = val;
next = 0;
}
Если бы внутри ListItem был объявлен статический член, то его определение также следовало бы поместить в глобальную область видимости. Имя этого члена могло бы выглядеть так:
int List::ListItem::static_mem = 1024;
Обратите внимание, что функции-члены и статические данные-члены не обязаны быть открытыми членами вложенного класса для того, чтобы их можно было определить вне его тела. Закрытые члены ListItem также определяются в глобальной области видимости.
Вложенный класс разрешается определять вне тела объемлющего. Например, определение ListItem могло бы находиться и в глобальной области видимости:
class List {
public:
// ...
private:
// объявление необходимо
class ListItem;
ListItem *list;
ListItem *at_end;
};
// имя вложенного класса квалифицировано именем объемлющего класса
class List::ListItem {
public:
ListItem( int val=0 );
ListItem *next;
int value;
};
В глобальном определении имя вложенного ListItem должно быть квалифицировано именем объемлющего класса List. Заметьте, что объявление ListItem в теле List опустить нельзя. Определение вложенного класса не может быть задано в глобальной области видимости, если предварительно оно не было объявлено членом объемлющего класса. Но при этом вложенный класс не обязательно должен быть открытым членом объемлющего.
Пока компилятор не увидел определения вложенного класса, разрешается объявлять лишь указатели и ссылки на него. Объявления членов list и at_end класса List правильны несмотря на то, что ListItem определен в глобальной области видимости, поскольку оба члена – указатели. Если бы один из них был объектом, то его объявление в классе List привело бы к ошибке компиляции:
class List {
public:
// ...
private:
// объявление необходимо
class ListItem;
ListItem *list;
ListItem at_end; // ошибка: неопределенный вложенный класс ListItem
};
Зачем определять вложенный класс вне тела объемлющего? Возможно, он поддерживает некоторые детали реализации ListItem, а нам нужно скрыть их от пользователей класса List. Поэтому мы помещаем определение вложенного класса в заголовочный файл, содержащий интерфейс List. Таким образом, определение ListItem может находиться лишь внутри исходного файла, включающего реализацию класса List и его членов.
Вложенный класс можно сначала объявить, а затем определить в теле объемлющего. Это позволяет иметь во вложенных классах члены, ссылающиеся друг на друга:
class List {
public:
// ...
private:
// объявление List::ListItem
class ListItem;
class Ref {
// pli имеет тип List::ListItem*
ListItem *pli;
};
определение List::ListItem
class ListItem {
// pref имеет тип List::Ref*
Ref *pref;
};
};
Если бы ListItem не был объявлен перед определением класса Ref, то объявление члена pli было бы ошибкой.
Вложенный класс не может напрямую обращаться к нестатическим членам объемлющего, даже если они открыты. Любое такое обращение должно производиться через указатель, ссылку или объект объемлющего класса. Например:
class List {
public:
int init( int );
private:
class List::ListItem {
public:
ListItem( int val=0 );
void mf( const List & );
int value;
};
};
List::ListItem::ListItem { int val )
{
// List::init() - нестатический член класса List
// должен использоваться через объект или указатель на тип List
value = init( val ); // ошибка: неверное использование init
};
При использовании нестатических членов класса компилятор должен иметь возможность идентифицировать объект, которому принадлежит такой член. Внутри функции-члена класса ListItem указатель this неявно применяется лишь к его членам. Благодаря неявному this мы знаем, что член value относится к объекту, для которого вызван конструктор. Внутри конструктора ListItem указатель this имеет тип ListItem*. Для доступа же к функции-члену init() нужен объект типа List или указатель типа List*.
Следующая функция-член mf() обращается к init() с помощью параметра-ссылки. Таким образом, init() вызывается для объекта, переданного в аргументе функции:
void List::ListItem::mf( List &i1 ) {
memb = i1.init(); // правильно: обращается к init() по ссылке
}
Хотя для доступа к нестатическим членам объемлющего класса нужен объект, указатель или ссылка, к статическим его членам, именам типов и элементам перечисления вложенный класс может обращаться напрямую (если, конечно, эти члены открыты). Имя типа – это либо имя typedef, либо имя перечисления, либо имя класса. Например:
class List {
public:
typedef int (*pFunc)();
enum ListStatus { Good, Empty, Corrupted };
//...
private:
class ListItem {
public:
void check_status();
ListStatus status; // правильно
pFunc action; // правильно
// ...
};
// ...
};
pFunc, ListStatus и ListItem – все это вложенные имена типов в области видимости объемлющего класса List. К ним, а также к элементам перечисления ListStatus можно обращаться в области видимости класса ListItem даже без квалификации:
void List::ListItem::check_status()
{
ListStatus s = status;
switch ( s ) {
case Empty: ...
case Corrupted: ...
case Good: ...
}
}
Вне области видимости ListItem и List при обращении к статическим членам, именам типов и элементам перечисления объемлющего класса требуется оператор разрешения области видимости:
List::pFunc myAction; // правильно
List::ListStatus stat = List::Empty; // правильно
При обращении к элементам перечисления мы не пишем:
List::ListStatus::Empty
поскольку они доступны непосредственно в той области видимости, в которой определено само перечисление. Почему? Потому что с ним, в отличие от класса, не связана отдельная область.
Вложенные пространства имен
Мы уже упоминали, что пользовательские пространства имен могут быть вложенными. Такие пространства применяются для дальнейшего структурирования кода нашей библиотеки. Например:
// ---- 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 & );
// ...
}
// второе вложенное пространство имен:
// зоологическая часть библиотеки
namespace AnimalLib {
class ZooAnimal { /* ... */ };
class Bear : public ZooAnimal { /* ... */ };
class Raccoon : public Bear { /* ... */ };
// ...
}
}
Пространство имен cplusplus_primer содержит два вложенных: MatrixLib и AnimalLib.
cplusplus_primer предотвращает конфликт между именами из нашей библиотеки и именами из глобального пространства вызывающей программы. Вложенность позволяет делить библиотеку на части, в которых сгруппированы связанные друг с другом объявления и определения. MatrixLib содержит сущности, имеющие отношение к классу matrix, а AnimalLib– к классу ZooAnimal.
Объявление члена вложенного пространства скрыто в этом пространстве. Имя такого члена автоматически дополняется поставленными спереди именами самого внешнего и вложенного пространств.
Например, класс, объявленный во вложенном пространстве MatrixLib, имеет имя
cplusplus_primer::MatrixLib::matrix
а функция
cplusplus_primer::MatrixLib::inverse
Программа, использующая члены вложенного пространства cplusplus_primer::MatrixLib, выглядит так:
#include "primer.h"
// да, это ужасно...
// скоро мы рассмотрим механизмы, облегчающие
// использование членов пространств имен!
void func( cplusplus_primer::MatrixLib::matrix &m )
{
// ...
cplusplus_primer::MatrixLib::inverse( m );
return m;
}
Вложенное пространство имен является вложенной областью видимости внутри пространства, содержащего его. В процессе разрешения имен вложенные пространства ведут себя так же, как вложенные блоки. Когда некоторое имя употребляется в пространстве имен, поиск его объявление проводится во всех объемлющих пространствах. В следующем примере разрешение имени Type происходит в таком порядке: сначала ищем его в пространстве имен MatrixLib, затем в cplusplus_primer и наконец в глобальной области видимости:
typedef double Type;
namespace cplusplus_primer {
typedef int Type; // скрывает ::Type
namespace MatrixLib {
int val;
// Type: объявление найдено в cplusplus_primer
int func(Type t) {
double val; // скрывает MatrixLib::val
val = ...;
}
// ...
}
}
Если некоторая сущность объявляется во вложенном пространстве имен, она скрывает объявление одноименной сущности из объемлющего пространства.
В предыдущем примере имя Type из глобальной области видимости скрыто объявлением Type в пространстве cplusplus_primer. При разрешении имени Type, упоминаемого в MatrixLib, оно будет найдено в cplusplus_primer, поэтому у функции func() параметр имеет тип int.
Аналогично сущность, объявленная в пространстве имен, скрывается одноименной сущностью из вложенной локальной области видимости. В предыдущем примере имя val из MatrixLib скрыто новым объявлением val. При разрешении имени val внутри func() будет найдено его объявление в локальной области видимости, и потому присваивание в func() относится именно к локальной переменной.
Вложенные типы шаблонов классов
Шаблон класса QueueItem применяется только как вспомогательное средство для реализации Queue. Чтобы запретить любое другое использование, в шаблоне QueueItem имеется закрытый конструктор, позволяющий создавать объекты этого класса исключительно функциям-членам класса Queue, объявленным друзьями QueueItem. Хотя шаблон QueueItem виден во всей программе, создать объекты этого класса или обратиться к его членам можно только при посредстве функций-членов Queue.
Альтернативный подход к реализации состоит в том, чтобы вложить определение шаблона класса QueueItem в закрытую секцию шаблона Queue. Поскольку QueueItem является вложенным закрытым типом, он становится недоступным вызывающей программе, и обратиться к нему можно лишь из шаблона класса Queue и его друзей (например, оператора вывода). Если же сделать члены QueueItem открытыми, то объявлять Queue другом QueueItem не понадобится.
Семантика исходной реализации при этом сохраняется, но отношение между шаблонами QueueItem и Queue моделируется более элегантно.
Поскольку при любой конкретизации шаблона Queue требуется конкретизировать тем же типом и QueueItem, то вложенный класс должен быть шаблоном. Вложенные классы шаблонов сами являются шаблонами классов, а параметры объемлющего шаблона можно использовать во вложенном:
template <class Type>
class Queue:
// ...
private:
class QueueItem {
public:
QueueItem( Type val )
: item( val ), next( 0 ) { ... }
Type item;
QueueItem *next;
};
// поскольку QueueItem - вложенный тип,
// а не шаблон, определенный вне Queue,
// то аргумент шаблона <Type> после QueueItem можно опустить
QueueItem *front, *back;
// ...
};
При каждой конкретизации Queue создается также класс QueueItem с подходящим аргументом для Type. Между конкретизациями шаблонов QueueItem и Queue имеется взаимно однозначное соответствие.
Вложенный в шаблон класс конкретизируется только в том случае, если он используется в контексте, где требуется полный тип класса. В разделе 16.2 мы упоминали, что конкретизация шаблона класса Queue типом int не означает автоматической конкретизации и класса QueueItem<int>. Члены front и back – это указатели на QueueItem<int>, а если объявлены только указатели на некоторый тип, то конкретизировать соответствующий класс не обязательно, хотя QueueItem вложен в шаблон класса Queue. QueueItem<int> конкретизируется только тогда, когда указатели front или back разыменовываются в функциях-членах класса Queue<int>.
Внутри шаблона класса можно также объявлять перечисления и определять типы (с помощью typedef):
template <class Type, int size>
class Buffer:
public:
enum Buf_vals { last = size-1, Buf_size };
typedef Type BufType;
BufType array[ size ];
// ...
}
Вместо того чтобы явно включать член Buf_size, в шаблоне класса Buffer объявляется перечисление с двумя элементами, которые инициализируются значением параметра шаблона. Например, объявление
Buffer<int, 512> small_buf;
устанавливает Buf_size в 512, а last – в 511. Аналогично
Buffer<int, 1024> medium_buf;
устанавливает Buf_size в 1024, а last – в 1023.
Открытый вложенный тип разрешается использовать и вне определения объемлющего класса. Однако вызывающая программа может ссылаться лишь на конкретизированные экземпляры подобного типа (или элементов вложенного перечисления). В таком случае имени вложенного типа должно предшествовать имя конкретизированного шаблона класса:
// ошибка: какая конкретизация Buffer?
Buffer::Buf_vals bfv0;
Buffer<int,512>::Buf_vals bfv1; // правильно
Это правило применимо и тогда, когда во вложенном типе не используются параметры включающего шаблона:
template <class T> class Q {
public:
enum QA { empty, full }; // не зависит от параметров
QA status;
// ...
};
#include <iostream>
int main() {
Q<double> qd;
Q<int> qi;
qd.status = Q::empty; // ошибка: какая конкретизация Q?
qd.status = Q<double>::empty; // правильно
int val1 = Q<double>::empty;
int val2 = Q<int>::empty;
if ( val1 != val2 )
cerr << "ошибка реализации!" << endl;
return 0;
}
Во всех конкретизациях Q значения empty одинаковы, но при ссылке на empty необходимо указывать, какому именно экземпляру Q принадлежит перечисление.
Упражнение 16.8
Определите класс List и вложенный в него ListItem из раздела 13.10 как шаблоны. Реализуйте аналогичные определения для ассоциированных членов класса.
Возбуждение исключения
Исключение– это аномальное поведение во время выполнения, которое программа может обнаружить, например: деление на 0, выход за границы массива или истощение свободной памяти. Такие исключения нарушают нормальный ход работы программы, и на них нужно немедленно отреагировать. В C++ имеются встроенные средства для их возбуждения и обработки. С помощью этих средств активизируется механизм, позволяющий двум несвязанным (или независимо разработанным) фрагментам программы обмениваться информацией об исключении.
Когда встречается аномальная ситуация, та часть программы, которая ее обнаружила, может сгенерировать, или возбудить, исключение. Чтобы понять, как это происходит, реализуем по-новому класс iStack, представленный в разделе 4.15, используя исключения для извещения об ошибках при работе со стеком. Определение класса iStack выглядит следующим образом:
#include <vector>
class iStack {
public:
iStack( int capacity )
: _stack( capacity ), _top( 0 ) { }
bool pop( int &top_value );
bool push( int value );
bool full();
bool empty();
void display();
int size();
private:
int _top;
vector< int > _stack;
};
Стек реализован на основе вектора из элементов типа int. При создании объекта класса iStack его конструктор создает вектор из int, размер которого (максимальное число элементов, хранящихся в стеке) задается с помощью начального значения. Например, следующая инструкция создает объект myStack, который способен содержать не более 20 элементов типа int:
iStack myStack(20);
При манипуляциях с объектом myStack могут возникнуть две ошибки:
запрашивается операция pop(), но стек пуст;
запрашивается операция push(), но стек полон.
Вызвавшую функцию нужно уведомить об этих ошибках посредством исключений. С чего же начать?
Во-первых, мы должны определить, какие именно исключения могут быть возбуждены. В C++ они чаще всего реализуются с помощью классов. Хотя в полном объеме классы будут представлены в главе 13, мы все же определим здесь два из них, чтобы использовать их как исключения для класса iStack. Эти определения мы поместим в заголовочный файл stackExcp.h:
// stackExcp.h
class popOnEmpty { /* ... */ };
class pushOnFull { /* ... */ };
В главе 19 исключения в виде классов обсуждаются более подробно, там же рассматривается иерархия таких классов, предоставляемая стандартной библиотекой C++.
Затем надо изменить определения функций-членов pop() и push() так, чтобы они возбуждали эти исключения. Для этого предназначена инструкция throw, которая во многих отношениях напоминает return. Она состоит из ключевого слова throw, за которым следует выражение того же типа, что и тип возбуждаемого исключения. Как выглядит инструкция throw для функции pop()? Попробуем такой вариант:
// увы, это не совсем правильно
throw popOnEmpty;
К сожалению, так нельзя. Исключение – это объект, и функция pop() должна генерировать объект класса соответствующего типа. Выражение в инструкции throw не может быть просто типом. Для создания нужного объекта необходимо вызвать конструктор класса. Инструкция throw для функции pop() будет выглядеть так:
// инструкция является вызовом конструктора
throw popOnEmpty();
Эта инструкция создает объект исключения типа popOnEmpty.
Напомним, что функции-члены pop() и push() были определены как возвращающие значение типа bool: true означало, что операция завершилась успешно, а false – что произошла ошибка. Поскольку теперь для извещения о неудаче pop() и push() используют исключения, возвращать значение необязательно. Поэтому мы будем считать, что эти функции-члены имеют тип void:
class iStack {
public:
// ...
// больше не возвращают значения
void pop( int &value );
void push( int value );
private:
// ...
};
Теперь функции, пользующиеся нашим классом iStack, будут предполагать, что все хорошо, если только не возбуждено исключение; им больше не надо проверять возвращенное значение, чтобы узнать, как завершилась операция. В двух следующих разделах мы покажем, как определить функцию для обработки исключений, а сейчас представим новые реализации функций-членов pop() и push() класса iStack:
#include "stackExcp.h"
void iStack::pop( int &top_value )
{
if ( empty() )
throw popOnEmpty();
top_value = _stack[ --_top ];
cout << "iStack::pop(): " << top_value << endl;
}
void iStack::push( int value )
{
cout << "iStack::push( " << value << " )\n";
if ( full() )
throw pushOnFull( value );
_stack[ _top++ ] = value;
}
Хотя исключения чаще всего представляют собой объекты типа класса, инструкция throw может генерировать объекты любого типа. Например, функция mathFunc() в следующем примере возбуждает исключение в виде объекта-перечисления . Это корректный код C++:
enum EHstate { noErr, zeroOp, negativeOp, severeError };
int mathFunc( int i ) {
if ( i == 0 )
throw zeroOp; // исключение в виде объекта-перечисления
// в противном случае продолжается нормальная обработка
}
Упражнение 11.1
Какие из приведенных инструкций throw ошибочны? Почему? Для правильных инструкций укажите тип возбужденного исключения:
(a) class exceptionType { };
throw exceptionType();
(b) int excpObj;
throw excpObj;
(c) enum mathErr { overflow, underflow, zeroDivide };
throw mathErr zeroDivide();
(d) int *pi = excpObj;
throw pi;
Упражнение 11.2
У класса IntArray, определенного в разделе 2.3, имеется функция-оператор operator[](), в которой используется assert() для извещения о том, что индекс вышел за пределы массива. Измените определение этого оператора так, чтобы в подобной ситуации он генерировал исключение. Определите класс, который будет употребляться как тип возбужденного исключения.
Возбуждение исключения типа класса
Теперь, познакомившись с классами, посмотрим, что происходит, когда функция-член push() нашего iStack возбуждает исключение:
void iStack::push( int value )
{
if ( full() )
// value сохраняется в объекте-исключении
throw pushOnFull( value );
// ...
}
Выполнение инструкции throw инициирует несколько последовательных действий:
1. Инструкция throw создает временный объект типа класса pushOnFull, вызывая его конструктор.
2. С помощью копирующего конструктора генерируется объект-исключение типа pushOnFull – копия временного объекта, полученного на шаге 1. Затем он передается обработчику исключения.
3. Временный объект, созданный на шаге 1, уничтожается до начала поиска обработчика.
Зачем нужно генерировать объект-исключение (шаг 2)? Инструкция
throw pushOnFull( value );
создает временный объект, который уничтожается в конце работы throw. Но исключение должно существовать до тех пор, пока не будет найден его обработчик, а он может находиться намного выше в цепочке вызовов. Поэтому необходимо скопировать временный объект в некоторую область памяти (объект-исключение), которая гарантированно существует, пока исключение не будет обработано. Иногда компилятор создает объект-исключение сразу, минуя шаг 1. Однако стандарт этого не требует, да и не всегда такое возможно.
Поскольку объект-исключение создается путем копирования значения, переданного инструкции throw, то возбужденное исключение всегда имеет такой же тип, как и это значение:
void iStack::push( int value ) {
if ( full() ) {
pushOnFull except( value );
stackExcp *pse = &except;
throw *pse; // объект-исключение имеет тип stackExcp
}
// ...
}
Выражение *pse имеет тип stackExcp. Тип созданного объекта-исключения – stackExcp, хотя pse ссылается на объект с фактическим типом pushOnFull. Фактический тип объекта, на который ссылается throw, при создании объекта-исключения не учитывается. Поэтому исключение не будет перехвачено catch-обработчиком pushOnFull.
Действия, выполняемые инструкцией throw, налагают определенные ограничения на то, какие классы можно использовать для создания объектов-исключений. Оператор throw в функции-члене push() класса iStack вызовет ошибку компиляции, если:
в классе pushOnFull нет конструктора, принимающего аргумент типа int, или этот конструктор недоступен;
в классе pushOnFull есть копирующий конструктор или деструктор, но хотя бы один из них недоступен;
pushOnFull – это абстрактный базовый класс. Напомним, что программа не может создавать объекты абстрактных классов (см. раздел 17.1).
Возврат значения
В теле функции может встретиться инструкция return. Она завершает выполнение функции. После этого управление возвращается той функции, из которой была вызвана данная. Инструкция return может употребляться в двух формах:
return;
return expression;
Первая форма используется в функциях, для которых типом возвращаемого значения является void. Использовать return в таких случаях обязательно, если нужно принудительно завершить работу. (Такое применение return напоминает инструкцию break, представленную в разделе 5.8.) После конечной инструкции функции подразумевается наличие return. Например:
void d_copy( double "src, double *dst, int sz )
{
/* копируем массив "src" в "dst"
* для простоты предполагаем, что они одного размера
*/
// завершение, если хотя бы один из указателей равен 0
if ( !src || !dst )
return;
// завершение,
// если указатели адресуют один и тот же массив
if ( src == dst )
return;
// копировать нечего
if ( sz == 0 )
return;
// все еще не закончили?
// тогда самое время что-то сделать
for ( int ix = 0; ix < sz; ++ix )
dst[ix] = src[ix];
// явного завершения не требуется
}
Во второй форме инструкции return указывается то значение, которое функция должна вернуть. Это значение может быть сколь угодно сложным выражением, даже содержать вызов функции. В реализации функции factorial(), которую мы рассмотрим в следующем разделе, используется return следующего вида:
return val * factorial(val-1);
В функции, не объявленная с void в качестве типа возвращаемого значения, обязательно использовать вторую форму return, иначе произойдет ошибка компиляции. Хотя компилятор не отвечает за правильность результата, он сможет гарантировать его наличие. Следующая программа не компилируется из-за двух мест, где программа завершается без возврата значения:
// определение интерфейса класса Matrix
#include "Matrix.h"
bool is_equa1( const Matrix &ml, const Matrix &m2 )
{
/* Если содержимое двух объектов Matrix одинаково,
* возвращаем true;
* в противном случае - false
*/
// сравним количество столбцов
if ( ml.colSize() != m2.co1Size() )
// ошибка: нет возвращаемого значения
return;
// сравним количество строк
if ( ml.rowSize() != m2.rowSize() )
// ошибка: нет возвращаемого значения
return;
// пробежимся по обеим матрицам, пока
// не найдем неравные элементы
for ( int row = 0; row < ml.rowSize(); ++row )
for ( int col = 0; co1 < ml.colSize(); ++co1 )
if ( ml[row][col] != m2[row][col] )
return false;
// ошибка: нет возвращаемого значения
// для случая равенства
}
Если тип возвращаемого значения не точно соответствует указанному в объявлении функции, то применяется неявное преобразование типов. Если же стандартное приведение невозможно, происходит ошибка компиляции. (Преобразования типов рассматривались в разделе 4.1.4.)
По умолчанию возвращаемое значение передается по значению, т.е. вызывающая функция получает копию результата вычисления выражения, указанного в инструкции return. Например:
Matrix grow( Matrix* p ) {
Matrix val;
// ...
return val;
}
grow() возвращает вызывающей функции копию значения, хранящегося в переменной val.
Такое поведение можно изменить, если объявить, что возвращается указатель или ссылка. При возврате ссылки вызывающая функция получает l-значение для val и потому может модифицировать val или взять ее адрес. Вот как можно объявить, что grow() возвращает ссылку:
Matrix& grow( Matrix* p ) {
Matrix *res;
// выделим память для объекта Matrix
// большого размера
// res адресует этот новый объект
// скопируем содержимое *p в *res
return *res;
}
Если возвращается большой объект, то гораздо эффективнее перейти от возврата по значению к использованию ссылки или указателя. В некоторых случаях компилятор может сделать это автоматически. Такая оптимизация получила название именованное возвращаемое значение. (Она описывается в разделе 14.8.)
Объявляя функцию как возвращающую ссылку, программист должен помнить о двух возможных ошибках:
возврат ссылки на локальный объект, время жизни которого ограничено временем выполнения функции. (О времени жизни локальных объектов речь пойдет в разделе 8.3.) По завершении функции такой ссылке соответствует область памяти, содержащая неопределенное значение. Например:
// ошибка: возврат ссылки на локальный объект
Matrix& add( Matrix &m1, Matrix &m2 )
{
Matrix result:
if ( m1.isZero() )
return m2;
if ( m2.isZero() )
return m1;
// сложим содержимое двух матриц
// ошибка: ссылка на сомнительную область памяти
// после возврата
return result;
}
В таком случае тип возврата не должен быть ссылкой. Тогда локальная переменная может быть скопирована до окончания времени своей жизни:
Matrix add( ... )
функция возвращает l-значение. Любая его модификация затрагивает сам объект. Например:
#include <vector>
int &get_val( vector<int> &vi, int ix ) {
return vi [ix];
}
int ai[4] = { 0, 1, 2, 3 };
vector<int> vec( ai, ai+4 ); // копируем 4 элемента ai в vec
int main() {
// увеличивает vec[0] на 1
get_val( vec.0 )++;
// ...
}
Для предотвращения нечаянной модификации возвращенного объекта нужно объявить тип возврата как const:
const int &get_val( ... )
Примером ситуации, когда l-значение возвращается намеренно, чтобы позволить модифицировать реальный объект, может служить перегруженный оператор взятия индекса для класса IntArray из раздела 2.3.
Встроенные функции
Рассмотрим следующую функцию min():
int min( int vl, int v2 )
{
return( vl < v2 ? vl : v2 );
}
Преимущества определения функции для такой небольшой операции таковы:
как правило, проще прочесть и интерпретировать вызов min(), чем читать условный оператор и вникать в смысл его действий, особенно если v1 и v2 являются сложными выражениями;
модифицировать одну локализованную реализацию в приложении легче, чем 300. Например, если будет решено изменить проверку на:
( vl == v2 || vl < v2 )
поиск каждого ее вхождения будет утомительным и с большой долей вероятности приведет к ошибкам;
семантика единообразна. Все проверки выполняются одинаково;
функция может быть повторно использована в другом приложении.
Однако этот подход имеет один недостаток: вызов функции происходит медленнее, чем непосредственное вычисление условного оператора. Необходимо скопировать два аргумента, запомнить содержимое машинных регистров и передать управление в другое место программы. Решение дают встроенные функции. Встроенная функция “подставляется по месту” в каждой точке своего вызова. Например:
int minVa12 = min( i, j );
заменяется при компиляции на
int minVal2 = i < j ? i : j;
Таким образом, не требуется тратить время на реализацию min() в виде функции.
Функция min() объявляется как встроенная с помощью ключевого слова inline перед типом возвращаемого значения в объявлении или определении:
inline int min( int vl, int v2 ) { /* ... */ }
Заметим, однако, что спецификация inline – это только подсказка компилятору. Компилятор может проигнорировать ее, если функция плохо подходит для встраивания по месту. Например, рекурсивная функция (такая, как rgcd()) не может быть полностью встроена в месте вызова (хотя для самого первого вызова это возможно). Функция из 1200 строк также скорее всего не подойдет. В общем случае такой механизм предназначен для оптимизации небольших, простых, часто используемых функций. Он крайне важен для поддержки концепции сокрытия информации при разработке абстрактных типов данных. Например, встроенной объявлена функция-член size() в классе IntArray из раздела 2.3.
Встроенная функция должна быть видна компилятору в месте вызова. В отличие от обычной, такая функция определяется в каждом исходном файле, где есть обращения к ней. Конечно же, определения одной и той же встроенной функции в разных файлах должны совпадать. Если программа содержит два исходных файла compute.C и draw.C, не нужно писать для них разные реализации функции min(). Если определения функции различаются, программа становится нестабильной: неизвестно, какое из них будет выбрано для каждого вызова, если компилятор не стал встраивать эту функцию.
Рекомендуется помещать определение встроенной функции в заголовочный файл и включать его во все файлы, где есть обращения к ней. Такой подход гарантирует, что для встроенной функции существует только одно определение и код не дублируется; дублирование может привести к непреднамеренному расхождению текстов в течение жизненного цикла программы.
Поскольку min() является общеупотребительной операцией, реализация ее входит в стандартную библиотеку С++; это один из обобщенных алгоритмов, описанных в главе 12 и в Приложении. Функция min() реализована как шаблон, что позволяет ей работать с операндами арифметического типа, отличного от int. (Шаблоны функций рассматриваются в главе 10.)
Встроенный строковый тип
Как уже было сказано, встроенный строковый тип перешел к С++ по наследству от С. Строка символов хранится в памяти как массив, и доступ к ней осуществляется при помощи указателя типа char*. Стандартная библиотека С предоставляет набор функций для манипулирования строками. Например:
// возвращает длину строки
int strlen( const char* );
// сравнивает две строки
int strcmp( const char*, const char* );
// копирует одну строку в другую
char* strcpy( char*, const char* );
Стандартная библиотека С является частью библиотеки С++. Для ее использования мы должны включить заголовочный файл:
#include <cstring>
Указатель на char, с помощью которого мы обращаемся к строке, указывает на соответствующий строке массив символов. Даже когда мы пишем строковый литерал, например
const char *st = "Цена бутылки вина\n";
компилятор помещает все символы строки в массив и затем присваивает st адрес первого элемента массива. Как можно работать со строкой, используя такой указатель?
Обычно для перебора символов строки применяется адресная арифметика. Поскольку строка всегда заканчивается нулевым символом, можно увеличивать указатель на 1, пока очередным символом не станет нуль. Например:
while (*st++ ) { ... }
st разыменовывается, и получившееся значение проверяется на истинность. Любое отличное от нуля значение считается истинным, и, следовательно, цикл заканчивается, когда будет достигнут символ с кодом 0. Операция инкремента ++ прибавляет 1 к указателю st и таким образом сдвигает его к следующему символу.
Вот как может выглядеть реализация функции, возвращающей длину строки. Отметим, что, поскольку указатель может содержать нулевое значение (ни на что не указывать), перед операцией разыменования его следует проверять:
int string_length( const char *st )
{
int cnt = 0;
if ( st )
while ( *st++ )
++cnt;
return cnt;
}
Строка встроенного типа может считаться пустой в двух случаях: если указатель на строку имеет нулевое значение (тогда у нас вообще нет никакой строки) или указывает на массив, состоящий из одного нулевого символа (то есть на строку, не содержащую ни одного значимого символа).
// pc1 не адресует никакого массива символов
char *pc1 = 0;
// pc2 адресует нулевой символ
const char *pc2 = "";
Для начинающего программиста использование строк встроенного типа чревато ошибками из-за слишком низкого уровня реализации и невозможности обойтись без адресной арифметики. Ниже мы покажем некоторые типичные погрешности, допускаемые новичками. Задача проста: вычислить длину строки. Первая версия неверна. Исправьте ее.
#include <iostream>
const char *st = "Цена бутылки вина\n";
int main() {
int len = 0;
while ( st++ ) ++len;
cout << len << ": " << st;
return 0;
}
В этой версии указатель st не разыменовывается. Следовательно, на равенство 0 проверяется не символ, на который указывает st, а сам указатель. Поскольку изначально этот указатель имел ненулевое значение (адрес строки), то он никогда не станет равным нулю, и цикл будет выполняться бесконечно.
Во второй версии программы эта погрешность устранена. Программа успешно заканчивается, однако полученный результат неправилен. Где мы не правы на этот раз?
#include <iostream>
const char *st = "Цена бутылки вина\n";
int main()
{
int len = 0;
while ( *st++ ) ++len;
cout << len << ": " << st << endl;
return 0;
}
Ошибка состоит в том, что после завершения цикла указатель st адресует не исходный символьный литерал, а символ, расположенный в памяти после завершающего нуля этого литерала. В этом месте может находиться что угодно, и выводом программы будет случайная последовательность символов.
Можно попробовать исправить эту ошибку:
st = st – len;
cout << len << ": " << st;
Теперь наша программа выдает что-то осмысленное, но не до конца. Ответ выглядит так:
18: ена бутылки вина
Мы забыли учесть, что заключительный нулевой символ не был включен в подсчитанную длину. st должен быть смещен на длину строки плюс 1. Вот, наконец, правильный оператор:
st = st – len - 1;
а вот и и правильный результат:
18: Цена бутылки вина
Однако нельзя сказать, что наша программа выглядит элегантно. Оператор
st = st – len - 1;
добавлен для того, чтобы исправить ошибку, допущенную на раннем этапе проектирования программы, – непосредственное увеличение указателя st. Этот оператор не вписывается в логику программы, и код теперь трудно понять. Исправления такого рода часто называют заплатками – нечто, призванное заткнуть дыру в существующей программе. Гораздо лучшим решением было бы пересмотреть логику. Одним из вариантов в нашем случае может быть определение второго указателя, инициализированного значением st:
const char *p = st;
Теперь p можно использовать в цикле вычисления длины, оставив значение st неизменным:
while ( *p++ )
Встроенный тип данных “массив”
Как было показано в главе 1, С++ предоставляет встроенную поддержку для основных типов данных– целых и вещественных чисел, логических значений и символов:
// объявление целого объекта ival
// ival инициализируется значением 1024
int ival = 1024;
// объявление вещественного объекта двойной точности dval
// dval инициализируется значением 3.14159
double dval = 3.14159;
// объявление вещественного объекта одинарной точности fval
// fval инициализируется значением 3.14159
float fval = 3.14159;
К числовым типам данных могут применяться встроенные арифметические и логические операции: объекты числового типа можно складывать, вычитать, умножать, делить и т.д.
int ival2 = ival1 + 4096; // сложение
int ival3 = ival2 - ival; // вычитание
dval = fval * ival; // умножение
ival = ival3 / 2; // деление
bool result = ival2 == ival3; // сравнение на равенство
result = ival2 + ival != ival3; // сравнение на неравенство
result = fval + ival2 < dval; // сравнение на меньше
result = ival > ival2; // сравнение на больше
В дополнение к встроенным типам стандартная библиотека С++ предоставляет поддержку для расширенного набора типов, таких, как строка и комплексное число. (Мы отложим рассмотрение класса vector из стандартной библиотеки до раздела 2.7.)
Промежуточное положение между встроенными типами данных и типами данных из стандартной библиотеки занимают составные типы – массивы и указатели. (Указатели рассмотрены в разделе 2.2.)
Массив – это упорядоченный набор элементов одного типа. Например, последовательность
0 1 1 2 3 5 8 13 21
представляет собой первые 9 элементов последовательности Фибоначчи. (Выбрав начальные два числа, вычисляем каждый из следующих элементов как сумму двух предыдущих.)
Для того чтобы объявить массив и проинициализировать его данными элементами, мы должны написать следующую инструкцию С++:
int fibon[9] = { 0, 1, 1, 2, 3, 5, 8, 13, 21 };
Здесь fibon – это имя массива. Элементы массива имеют тип int, размер (длина) массива равна 9. Значение первого элемента – 0, последнего – 21. Для работы с массивом мы индексируем (нумеруем) его элементы, а доступ к ним осуществляется с помощью операции взятия индекса. Казалось бы, для обращения к первому элементу массива естественно написать:
int first_elem = fibon[1];
Однако это не совсем правильно: в С++ (как и в С) индексация массивов начинается с 0, поэтому элемент с индексом 1 на самом деле является вторым элементом массива, а индекс первого равен 0.Таким образом, чтобы обратиться к последнему элементу массива, мы должны вычесть единицу из размера массива:
fibon[0]; // первый элемент
fibon[1]; // второй элемент
...
fibon[8]; // последний элемент
fibon[9]; // ... ошибка
Девять элементов массива fibon имеют индексы от 0 до 8. Употребление вместо этого индексов 1-9 является одной из самых распространенных ошибок начинающих программистов на С++.
Для перебора элементов массива обычно употребляют инструкцию цикла. Вот пример программы, которая инициализирует массив из десяти элементов числами от 0 до 9 и затем печатает их в обратном порядке:
int main()
{
int ia[10];
int index;
for (index=0; index<10; ++index)
// ia[0] = 0, ia[1] = 1 и т.д.
ia[index] = index;
for (index=9; index>=0; --index)
cout << ia[index] << " ";
cout << endl;
}
Оба цикла выполняются по 10 раз. Все управление циклом for осуществляется инструкциями в круглых скобках за ключевым словом for. Первая присваивает начальное значение переменной index. Это производится один раз перед началом цикла:
index = 0;
Вторая инструкция:
index < 10;
представляет собой условие окончания цикла. Оно проверяется в самом начале каждой итерации цикла. Если результатом этой инструкции является true, то выполнение цикла продолжается; если же результатом является false, цикл заканчивается. В нашем примере цикл продолжается до тех пор, пока значение переменной index меньше 10. На каждой итерации цикла выполняется некоторая инструкция или группа инструкций, составляющих тело цикла. В нашем случае это инструкция
ia[index] = index;
Третья управляющая инструкция цикла
++index
выполняется в конце каждой итерации, по завершении тела цикла. В нашем примере это увеличение переменной index на единицу. Мы могли бы записать то же действие как
index = index + 1
но С++ дает возможность использовать более короткую (и более наглядную) форму записи. Этой инструкцией завершается итерация цикла. Описанные действия повторяются до тех пор, пока условие цикла не станет ложным.
Вторая инструкция for в нашем примере печатает элементы массива. Она отличается от первой только тем, что в ней переменная index уменьшается от 9 до 0. (Подробнее инструкция for рассматривается в главе 5.)
Несмотря на то, что в С++ встроена поддержка для типа данных “массив”, она весьма ограничена. Фактически мы имеем лишь возможность доступа к отдельным элементам массива. С++ не поддерживает абстракцию массива, не существует операций над массивами в целом, таких, например, как присвоение одного массива другому или сравнение двух массивов на равенство, и даже такой простой, на первый взгляд, операции, как получение размера массива. Мы не можем скопировать один массив в другой, используя простой оператор присваивания:
int array0[10]; array1[10];
...
array0 = array1; // ошибка
Вместо этого мы должны программировать такую операцию с помощью цикла:
for (int index=0; index<10; ++index)
array0[index] = array1[index];
Массив “не знает” собственный размер. Поэтому мы должны сами следить за тем, чтобы случайно не обратиться к несуществующему элементу массива. Это становится особенно утомительным в таких ситуациях, как передача массива функции в качестве параметра. Можно сказать, что этот встроенный тип достался языку С++ в наследство от С и процедурно-ориентированной парадигмы программирования. В оставшейся части главы мы исследуем разные возможности “улучшить” массив.
Упражнение 2.1
Как вы думаете, почему для встроенных массивов не поддерживается операция присваивания? Какая информация нужна для того, чтобы поддержать эту операцию?
Упражнение 2.2
Какие операции должен поддерживать “полноценный” массив?
Введение
Функцию можно рассматривать как операцию, определенную пользователем. В общем случае она задается своим именем. Операнды функции, или формальные параметры, задаются в списке параметров, через запятую. Такой список заключается в круглые скобки. Результатом функции может быть значение, которое называют возвращаемым. Об отсутствии возвращаемого значения сообщают ключевым словом 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 <iostream>
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 );
int gcd( 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<v2 ? vl : v2 );
}
В объявлении функции описывается ее интерфейс. Он содержит все данные о том, какую информацию должна получать функция (список параметров) и какую информацию она возвращает. Для пользователей важны только эти данные, поскольку лишь они фигурируют в точке вызова. Интерфейс помещается в заголовочный файл, как мы поступили с функциями min(), abs() и gcd().
При выполнении наша программа main.C, получив от пользователя значения:
Введите первое значение: 15
Введите второе значение: 123
выдаст следующий результат:
mm: 15
НОД: 3
Ввод
Основное средство реализации ввода– это оператор сдвига вправо (>>). Например, в следующей программе из стандартного ввода читается последовательность значений типа int и помещается в вектор:
#include <iostream>
#include <vector>
int main()
{
vector<int> ivec;
int ival;
while ( cin >> ival )
ivec.push_back( ival );
// ...
}
Подвыражение
cin >> ival;
читает целое число из стандартного ввода и копирует его в переменную ival. Результатом является левый операнд – объект класса istream, в данном случае cin. (Как мы увидим, это позволяет сцеплять операторы ввода.)
Выражение
while ( cin >> ival )
читает последовательность значений, пока cin не станет равно false. Значение istream может быть равно false в двух случаях: достигнут конец файла (т.е. все значения из файла прочитаны успешно) или встретилось неверное значение, скажем 3.14159 (десятичная точка недопустима в целом числе), 1e-1 (буква e недопустима) или любой строковый литерал. Если вводится неверное значение, объект istream переводится в состояние ошибки и чтение прекращается. (В разделе 20.7 мы подробнее расскажем о таких состояниях.)
Есть набор предопределенных операторов ввода, принимающих аргументы любого встроенного типа, включая C-строки, а также стандартных библиотечных типов string и complex:
#include <iostream>
#include <string>
int main()
{
int item_number;
string item_name;
double item_price;
cout << "Пожалуйста, введите item_number, item_name и price: "
<< endl;
cin >> item_number;
cin >> item_name;
cin >> item_price;
cout << "Введены значения: item# "
<< item_number << " "
<< item_name << " @$"
<< item_price << endl;
}
Вот пример выполнения этой программы:
Пожалуйста, введите item_number, item_name и price:
10247 widget 19.99
Введены значения: item# 10247 widget @$19.99
Можно ввести каждый элемент на отдельной строке. По умолчанию оператор ввода отбрасывает все разделяющие пустые символы: пробел, символ табуляции, символ перехода на новую строку, символ перевода страницы и символ возврата каретки. (О том, как отменить это поведение, см. в разделе 20.9.)
Пожалуйста, введите item_number, item_name и price:
10247
widget
19.99
Введены значения: item# 10247 widget @$19.99
При чтении ошибка iostream более вероятна, чем при записи. Если мы вводим такую последовательность:
// ошибка: item_name должно быть вторым
BuzzLightyear 10009 8.99
то инструкция
cin >> item_number;
закончится ошибкой ввода, поскольку BuzzLightyear не принадлежит типу int. При проверке объекта istream будет возвращено false, поскольку возникло состояние ошибки. Более устойчивая к ошибкам реализация выглядит так:
cin >> item_number;
if ( ! cin )
cerr << "ошибка: введено некорректное значение item_number!\n";
Хотя сцепление операторов ввода поддерживается, проверить корректность каждой отдельной операции нельзя, поэтому пользоваться таким приемом следует лишь тогда, когда ошибка невозможна. Наша программа теперь выглядит так:
#include <iostream>
#include <string>
int main()
{
int item_number;
string item_name;
double item_price;
cout << "Пожалуйста, введите item_number, item_name и price: "
<< endl;
// хорошо, но легче допустить ошибку
cin >> item_number >> item_name >> item_price;
cout << "Введены значения: item# "
<< item_number << " "
<< item_name << " @$"
<< item_price << endl;
}
Последовательность
ab c
d e
составлена из девяти символов: 'a', 'b', ' ' (пробел), 'c', '\n' (переход на новую строку), 'd', '\t' (табуляция), 'e' и '\n'. Однако приведенная программа читает лишь пять букв:
#include <iostream>
int main()
{
char ch;
// прочитать и вывести каждый символ
while ( cin >> ch )
cout << ch;
cout << endl;
// ...
}
И печатает следующее:
abcde
По умолчанию все пустые символы отбрасываются. Если нам нужны и они, например для сохранения формата входного текста или обработки пустых символов (скажем, для подсчета количества символов перехода на новую строку), то можно воспользоваться функцией-членом get() класса istream (обычно в паре с ней употребляется функция-член put() класса ostream; они будут рассмотрены ниже). Например:
#include <iostream>
int main()
{
char ch;
// читать все символы, в том числе пробельные
while ( cin.get( ch ))
cout.put( ch );
// ...
}
Другая возможность сделать это – использовать манипулятор noskipws.
Каждая из двух данных последовательностей считается составленной из пяти строк, разделенных пробелами, если для чтения используются операторы ввода с типами const char* или string:
A fine and private place
"A fine and private place"
Наличие кавычек не делает пробелы внутри закавыченной строки ее частью. Просто открывающая кавычка становится начальным символом первого слова, а закрывающая – конечным символом последнего.
Вместо того чтобы читать из стандартного ввода по одному символу, можно воспользоваться потоковым итератором istream_iterator:
#include <algorithm>
#include <string>
#include <vector>
#include <iostream>
int main()
{
istream_iterator< string > in( cin ), eos ;
vector< string > text ;
// копировать прочитанные из стандартного ввода значения
// в вектор text
copy( in , eos , back_inserter( text ) ) ;
sort( text.begin() , text.end() ) ;
// удалить дубликаты
vector< string >::iterator it;
it = unique( text.begin() , text.end() ) ;
text.erase( it , text.end() ) ;
// вывести получившийся вектор
int line_cnt = 1 ;
for ( vector< string >::iterator iter = text.begin() ;
iter != text.end() ; ++iter , ++line_cnt )
cout << *iter
<< ( line_cnt % 9 ? " " : "\n" ) ;
cout << endl;
}
Пусть входом для этой программы будет файл istream_iter.C с исходным текстом. В системе UNIX мы можем перенаправить стандартный ввод на файл следующим образом (istream_iter – имя исполняемого файла программы):
istream_iter < istream_iter.C
(Для других систем необходимо изучить документацию.) В результате программа выводит:
!= " " "\n" #include % ( ) *iter ++iter
++line_cnt , 1 9 : ; << <algorithm> <iostream.h>
<string> <vector> = > >::difference_type >::iterator ? allocator
back_inserter(
cin copy( cout diff_type eos for in in( int
istream_iterator< it iter line_cnt main() sort( string test test.begin()
test.end() test.erase( typedef unique( vector< { }
(Потоковые итераторы ввода/вывода iostream рассматривались в разделе 12.4.)
Помимо предопределенных операторов ввода, можно определить и собственные перегруженные экземпляры для считывания в пользовательские типы данных. (Подробнее мы расскажем об этом в разделе 20.5.)
Выбор преобразования *
Определенное пользователем преобразование реализуется в виде конвертера или конструктора. Как уже было сказано, после преобразования, выполненного конвертером, разрешается использовать стандартное преобразование для приведения возвращенного значения к целевому типу. Трансформации, выполненной конструктором, также может предшествовать стандартное преобразование для приведения типа аргумента к типу формального параметра конструктора.
Последовательность определенных пользователем преобразований– это комбинация определенного пользователем и стандартного преобразования, которая необходима для приведения значения к целевому типу. Такая последовательность имеет вид:
Последовательность стандартных преобразований ->
Определенное пользователем преобразование ->
Последовательность стандартных преобразований
где определенное пользователем преобразование реализуется конвертером либо конструктором.
Не исключено, что для трансформации исходного значения в целевой тип существует две разных последовательности пользовательских преобразований, и тогда компилятор должен выбрать из них лучшую. Рассмотрим, как это делается.
В классе разрешается определять много конвертеров. Например, в нашем классе Number их два: operator int() и operator float(), причем оба способны преобразовать объект типа Number в значение типа float. Естественно, можно воспользоваться конвертером Token::operator float() для прямой трансформации. Но и Token::operator int() тоже подходит, так как результат его применения имеет тип int и, следовательно, может быть преобразован в тип float с помощью стандартного преобразования. Является ли трансформация неоднозначной, если имеется несколько таких последовательностей? Или какую-то из них можно предпочесть остальным?
class Number {
public:
operator float();
operator int();
// ...
};
Number num;
float ff = num; // какой конвертер? operator float()
В таких случаях выбор наилучшей последовательности определенных пользователем преобразований основан на анализе последовательности преобразований, которая применяется после конвертера. В предыдущем примере можно применить такие две последовательности:
1. operator float() -> точное соответствие
2. operator int() -> стандартное преобразование
Как было сказано в разделе 9.3, точное соответствие лучше стандартного преобразования. Поэтому первая последовательность лучше второй, а значит, выбирается конвертер Token::operator float().
Может случиться так, что для преобразования значения в целевой тип применимы два разных конструктора. В этом случае анализируется последовательность стандартных преобразований, предшествующая вызову конструктора:
class SmallInt {
public:
SmallInt( int ival ) : value( ival ) { }
SmallInt( double dval )
: value( static_cast< int >( dval ) );
{ }
};
extern void manip( const SmallInt & );
int main() {
double dobj;
manip( dobj ); // правильно: SmallInt( double )
}
Здесь в классе SmallInt определено два конструктора – SmallInt(int) и SmallInt(double), которые можно использовать для изменения значения типа double в объект типа SmallInt: SmallInt(double) трансформирует double в SmallInt напрямую, а SmallInt(int) работает с результатом стандартного преобразования double в int. Таким образом, имеются две последовательности определенных пользователем преобразований:
1. точное соответствие -> SmallInt( double )
2. стандартное преобразование -> SmallInt( int )
Поскольку точное соответствие лучше стандартного преобразования, то выбирается конструктор SmallInt(double).
Не всегда удается решить, какая последовательность лучше. Может случиться, что все они одинаково хороши, и тогда мы говорим, что преобразование неоднозначно. В таком случае компилятор не применяет никаких неявных трансформаций. Например, если в классе Number есть два конвертера:
class Number {
public:
operator float();
operator int();
// ...
};
то невозможно неявно преобразовать объект типа Number в тип long. Следующая инструкция вызывает ошибку компиляции, так как выбор последовательности определенных пользователем преобразований неоднозначен:
// ошибка: можно применить как float(), так и int()
long lval = num;
Для трансформации num в значение типа long применимы две такие последовательности:
1. operator float() -> стандартное преобразование
2. operator int() -> стандартное преобразование
Поскольку в обоих случаях за использованием конвертера следует применение стандартного преобразования, то обе последовательности одинаково хороши и компилятор не может выбрать ни одну из них.
С помощью явного приведения типов программист способен задать нужное изменение:
// правильно: явное приведение типа
long lval = static_cast< int >( num );
Вследствие такого указания выбирается конвертер Token::operator int(), за которым следует стандартное преобразование в long.
Неоднозначность при выборе последовательности трансформаций может возникнуть и тогда, когда два класса определяют преобразования друг в друга. Например:
class SmallInt {
public:
SmallInt( const Number & );
// ...
};
class Number {
public:
operator SmallInt();
// ...
};
extern void compute( SmallInt );
extern Number num;
compute( num ); // ошибка: возможно два преобразования
Аргумент num преобразуется в тип SmallInt двумя разными способами: с помощью конструктора SmallInt::SmallInt(const Number&) либо с помощью конвертера Number::operator SmallInt(). Поскольку оба изменения одинаково хороши, вызов считается ошибкой.
Для разрешения неоднозначности программист может явно вызвать конвертер класса Number:
// правильно: явный вызов устраняет неоднозначность
compute( num.operator SmallInt() );
Однако для разрешения неоднозначности не следует использовать явное приведение типов, поскольку при отборе преобразований, подходящих для приведения типов, рассматриваются как конвертер, так и конструктор:
compute( SmallInt( num ) ); // ошибка: по-прежнему неоднозначно
Как видите, наличие большого числа подобных конвертеров и конструкторов небезопасно, поэтому их. следует применять с осторожностью. Ограничить использование конструкторов при выполнении неявных преобразований (а значит, уменьшить вероятность неожиданных эффектов) можно путем объявления их явными.
Выделяем слова в строке
Нашей первой задачей является разбиение строки на слова. Мы будем вычленять слова, находя разделяющие их пробелы с помощью функции find(). Например, в строке
Alice Emma has long flowing red hair.
насчитывается шесть пробелов, следовательно, эта строка содержит семь слов.
Класс string имеет несколько функций поиска. find() – наиболее простая из них. Она ищет образец, заданный как параметр, и возвращает позицию его первого символа в строке, если он найден, или специальное значение string::npos в противном случае. Например:
#include <string>
#include <iostream>
int main() {
string name( "AnnaBelle" );
int pos = name.find( "Anna" );
if ( pos == string::npos )
cout << "Anna не найдено!\n";
else cout << "Anna найдено в позиции: " << pos << endl;
}
Хотя позиция подстроки почти всегда имеет тип int, более правильное и переносимое объявление типа результата, возвращаемого find(), таково:
string::size_type
Например:
string::size_type pos = name.find( "Anna" );
Функция find() делает не совсем то, что нам надо. Требуемая функциональность обеспечивается функцией find_first_of(), которая возвращает позицию первого символа, соответствующего одному из заданных в строке-параметре. Вот как найти первый символ, являющийся цифрой:
#include <string>
#include <iostream>
int main() {
string numerics( "0123456789" );
string name( "r2d2" );
string:: size_type pos = name.find_first_of( numerics );
cout << "найдена цифра в позиции: "
<< pos << "\tэлемент равен "
<< name[pos] << endl;
}
В этом примере pos получает значение 1 (напоминаем, что символы строки нумеруются с 0).
Но нам нужно найти все вхождения символа, а не только первое. Такая возможность реализуется передачей функции find_first_of() второго параметра, указывающего позицию, с которой начать поиск. Изменим предыдущий пример. Можете ли вы сказать, что в нем все еще не вполне удовлетворительно?
#include <string>
#include <iostream>
int main() {
string numerics( "0123456789" );
string name( "r2d2" );
string::size_type pos = 0;
// где-то здесь ошибка!
while (( pos = name.find_first_of( numerics, pos ))
!= string::npos )
cout << "найдена цифра в позиции: "
<< pos << "\tэлемент равен "
<< name[pos] << endl;
}
В начале цикла pos равно 0, поэтому поиск идет с начала строки. Первое вхождение обнаружено в позиции 1. Поскольку найденное значение не совпадает с string::npos, выполнение цикла продолжается. Для второго вызова find_first_of()значение pos равно 1. Поиск начнется с 1-й позиции. Вот ошибка! Функция find_first_of() снова найдет цифру в первой позиции, и снова, и снова... Получился бесконечный цикл. Нам необходимо увеличивать pos на 1 в конце каждой итерации:
// исправленная версия цикла
while (( pos = name.find_first_of( numerics, pos ))
!= string::npos )
{
cout << "найдена цифра в позиции: "
<< pos << "\tэлемент равен "
<< name[pos] << endl;
// сдвинуться на 1 символ
++pos;
}
Чтобы найти все пустые символы (к которым, помимо пробела, относятся символы табуляции и перевода строки), нужно заменить строку numerics в этом примере строкой, содержащей все эти символы. Если же мы уверены, что используется только символ пробела и никаких других, то можем явно задать его в качестве параметра функции:
// фрагмент программы
while (( pos = textline.find_first_of( ' ', pos ))
!= string::npos )
// ...
Чтобы узнать длину слова, введем еще одну переменную:
// фрагмент программы
// pos: позиция на 1 большая конца слова
// prev_pos: позиция начала слова
string::size_type pos = 0, prev_pos = 0;
while (( pos = textline.find_first_of( ' ', pos ))
!= string::npos )
{
// ...
// запомнить позицию начала слова
prev_pos = ++pos;
}
На каждой итерации prev_pos указывает позицию начала слова, а pos – позицию следующего символа после его конца. Соответственно, длина слова равна:
pos - prev_pos; // длина слова
После того как мы выделили слово, необходимо поместить его в строковый вектор. Это можно сделать, копируя в цикле символы из textline с позиции prev_pos до pos -1. Функция substr() сделает это за нас:
// фрагмент программы
vector<string> words;
while (( pos = textline.find_first_of( ' ', pos ))
!= string::npos )
{
words.push_back( textline.substr(
prev_pos, pos-prev_pos));
prev_pos = ++pos;
}
Функция substr() возвращает копию подстроки. Первый ее аргумент обозначает первую позицию, второй – длину подстроки. (Второй аргумент можно опустить, тогда подстрока включит в себя остаток исходной строки, начиная с указанной позиции.)
В нашей реализации допущена ошибка: последнее слово не будет помещено в контейнер. Почему? Возьмем строку:
seaspawn and seawrack
После каждого из первых двух слов поставлен пробел. Два вызова функции find_first_of() вернут позиции этих пробелов. Третий же вызов вернет string::npos, и цикл закончится. Таким образом, последнее слово останется необработанным.
Вот полный текст функции, названной нами separate_words(). Помимо сохранения слов в векторе строк, она вычисляет координаты каждого слова – номер строки и колонки (нам эта информация потребуется впоследствии).
typedef pair<short,short> location;
typedef vector<location> loc;
typedef vector<string> text;
typedef pair<text* ,loc*> text_loc;
text_loc*
separate_words( const vector<string> *text_file )
{
// words: содержит набор слов
// locations: содержит информацию о строке и позиции
// каждого слова
vector<string> *words = new vector<string>;
vector<location> * locations = new vector<location>;
short line_pos = 0; // текущий номер строки
// iterate through each line of text
for ( ; line_pos < text_file->size(); ++line_pos )
// textline: обрабатываемая строка
// word_pos: позиция в строке
short word_pos = 0;
string textline = (*text_file) [ line_pos ];
string::size_type pos = 0, prev_pos = 0;
while (( pos = textline.find_first_of( ' ', pos ))
!= string::npos )
{
// сохраним слово
words->push_back(
textline.substr( prev_pos, pos - prev_pos ));
// сохраним информацию о его строке и позиции
locations->push_back(
make_pair( line_pos, word_pos ));
// сместим позицию для следующей итерации
++word_pos; prev_pos = ++pos;
}
// обработаем последнее слово
words->push_back(
textline.substr( prev_pos, pos - prev_pos ));
locations->push_back(
make_pair( line_pos, word_pos ));
}
return new text_loc( words, locations );
}
Теперь функция main()выглядит следующим образом:
int main()
{
vector<string> *text_file = retrieve_text();
text_loc *text_locations = separate_words( text_file );
// ...
}
Вот часть распечатки, выданной тестовой версией separate_words():
textline: Alice Emma has long flowing red hair. Her Daddy
says
eol: 52 pos: 5 line: 0 word: 0 substring: Alice
eol: 52 pos: 10 line: 0 word: 1 substring: Emma
eol: 52 pos: 14 line: 0 word: 2 substring: has
eol: 52 pos: 19 line: 0 word: 3 substring: long
eol: 52 pos: 27 line: 0 word: 4 substring: flowing
eol: 52 pos: 31 line: 0 word: 5 substring: red
eol: 52 pos: 37 line: 0 word: 6 substring: hair.
eol: 52 pos: 41 line: 0 word: 7 substring: Her
eol: 52 pos: 47 line: 0 word: 8 substring: Daddy
last word on line substring: says
...
textline: magical but untamed. "Daddy, shush, there is no
such thing,"
eol: 60 pos: 7 line: 3 word: 0 substring: magical
eol: 60 pos: 11 line: 3 word: 1 substring: but
eol: 60 pos: 20 line: 3 word: 2 substring: untamed
eol: 60 pos: 28 line: 3 word: 3 substring: "Daddy,
eol: 60 pos: 35 line: 3 word: 4 substring: shush,
eol: 60 pos: 41 line: 3 word: 5 substring: there
eol: 60 pos: 44 line: 3 word: 6 substring: is
eol: 60 pos: 47 line: 3 word: 7 substring: no
eol: 60 pos: 52 line: 3 word: 8 substring: such
last word on line substring: thing,":
...
textline: Shy1y, she asks, "I mean, Daddy: is there?"
eol: 43 pos: 6 line: 5 word: 0 substring: Shyly,
eol: 43 pos: 10 line: 5 word: 1 substring: she
eol: 43 pos: 16 line: 5 word: 2 substring: asks,
eol: 43 pos: 19 line: 5 word: 3 substring: "I
eol: 43 pos: 25 line: 5 word: 4 substring: mean,
eol: 43 pos: 32 line: 5 word: 5 substring: Daddy,
eol: 43 pos: 35 line: 5 word: 6 substring: is
last word on line substring: there?":
Прежде чем продолжить реализацию поисковой системы, вкратце рассмотрим оставшиеся функции-члены класса string, предназначенные для поиска. Функция rfind() ищет последнее, т.е. самое правое, вхождение указанной подстроки:
string river( "Mississippi" );
string::size_type first_pos = river.find( "is" );
string::size_type 1ast_pos = river.rfind( "is" );
find() вернет 1, указывая позицию первого вхождения подстроки "is", а rfind() – 4 (позиция последнего вхождения "is").
find_first_not_of() ищет первый символ, не содержащийся в строке, переданной как параметр. Например, чтобы найти первый символ, не являющийся цифрой, можно написать:
string elems( "0123456789" );
string dept_code( "03714p3" );
// возвращается позиция символа 'p'
string::size_type pos = dept_code.find_first_not_of(elems) ;
find_last_of() ищет последнее вхождение одного из указанных символов. find_last_not_of() – последний символ, не совпадающий ни с одним из заданных. Все эти функции имеют второй необязательный параметр – позицию в исходной строке, с которой начинается поиск.
Упражнение 6.13
Напишите программу, которая ищет в строке
"ab2c3d7R4E6"
цифры, а затем буквы, используя сначала find_first_of(), а потом find_first_not_of().
Упражнение 6.14
Напишите программу, которая подсчитывает все слова и определяет самое длинное и самое короткое из них в строке sentence:
string linel = "We were her pride of 10 she named us --";
string line2 = "Benjamin, Phoenix, the Prodigal"
string line3 = "and perspicacious pacific Suzanne";
string sentence = linel + line2 + line3;
Если несколько слов имеют длину, равную максимальной или минимальной, учтите их все.
Выражения
В главе 3 мы рассмотрели типы данных– как встроенные, так и предоставленные стандартной библиотекой. Здесь мы разберем предопределенные операции, такие, как сложение, вычитание, сравнение и т.п., рассмотрим их приоритеты. Скажем, результатом выражения 3+4*5 является 23, а не 35 потому, что операция умножения (*) имеет более высокий приоритет, чем операция сложения (+). Кроме того, мы обсудим вопросы преобразований типов данных – и явных, и неявных. Например, в выражении 3+0.7 целое значение 3 станет вещественным перед выполнением операции сложения.
Вывод аргументов шаблона *
При вызове шаблона функции типы и значения его аргументов определяются путем исследования типов фактических аргументов функции. Этот процесс называется выводом аргументов шаблона.
Параметром функции в шаблоне min() является ссылка на массив элементов типа Type:
template <class Type, int size>
Type min( Type (&r_array)[size] ) { /* ... */ }
Для сопоставления с формальным параметром функции фактический аргумент также должен быть l-значением, представляющим тип массива. Следующий вызов ошибочен, так как pval имеет тип int*, а не является l-значением типа “массив int”.
void f( int pval[9] ) {
// ошибка: Type (&)[] != int*
int jval = min( pval );
}
При выводе аргументов шаблона не принимается во внимание тип значения, возвращаемого конкретизированным шаблоном функции. Например, если вызов min() записан так:
double da[8] = { 10.3, 7.2, 14.0, 3.8, 25.7, 6.4, 5.5, 16.8 };
int i1 = min( da );
то конкретизированный экземпляр min() имеет параметр типа “указатель на массив из восьми double” и возвращает значение типа double. Перед инициализацией i1 это значение приводится к типу int. Однако тот факт, что результат вызова min() используется для инициализации объекта типа int, не влияет на вывод аргументов шаблона.
Чтобы процесс такого вывода завершился успешно, тип фактического аргумента функции не обязательно должен совпадать с типом соответствующего формального параметра. Допустимы три вида преобразований типа: трансформация l-значения, преобразование спецификаторов и приведение к базовому классу, конкретизированному из шаблона класса. Рассмотрим последовательно каждое из них.
Напомним, что трансформация l-значения– это либо преобразование l-значения в r-значение, либо преобразование массива в указатель, либо преобразование функции в указатель (все они рассматривались в разделе 9.3). Для иллюстрации влияния такой трансформации на вывод аргументов шаблона рассмотрим функцию min2() c одним параметром шаблона Type и двумя параметрами функции. Первый параметр min2() – это указатель на тип Type*. size теперь не является параметром шаблона, как в определении min(), вместо этого он стал параметром функции, а его значение должно быть явно передано при вызове:
template <class Type>
// первый параметр имеет тип Type*
Type min2( Type* array, int size )
{
Type min_val = array[0];
for ( int i = 1; i < size; ++i )
if ( array[i] < min_val )
min_val = array[i];
return min_val;
}
min2() можно вызвать, передав в качестве первого аргумента массив из четырех int, как в следующем примере:
int ai[4] = { 12, 8, 73, 45 };
int main() {
int size = sizeof (ai) / sizeof (ai[0]);
// правильно: преобразование массива в указатель
min2( ai, size );
}
Фактический аргумент функции ai имеет тип “массив из четырех int” и не совпадает с типом соответствующего формального параметра Type*. Однако, поскольку преобразование массива в указатель допустимо, то аргумент ai приводится к типу int* еще до вывода аргумента шаблона Type, для которого затем выводится тип int, и шаблон конкретизирует функцию min2(int*, int).
Преобразование спецификаторов добавляет const или volatile к указателям (такие трансформации также рассматривались в разделе 9.3). Для иллюстрации влияния преобразования спецификаторов на вывод аргументов шаблона рассмотрим min3() с первым параметром функции типа const Type*:
template <class Type>
// первый параметр имеет тип const Type*
Type min3( const Type* array, int size ) {
// ...
}
min3() можно вызвать, передав int* в качестве первого фактического аргумента, как в следующем примере:
int *pi = &ai;
// правильно: приведение спецификаторов к типу const int*
int i = min3( pi, 4 );
Фактический аргумент функции pi имеет тип “указатель на int” и не совпадает с типом формального параметра const Type*. Однако, поскольку преобразование спецификаторов допустимо, то он приводится к типу const int* еще до вывода аргумента шаблона Type, для которого затем выводится тип int, и шаблон конкретизирует функцию min3(const int*, int).
Теперь обратимся к преобразованию в базовый класс, конкретизированный из шаблона класса. Вывод аргументов шаблона можно выполнить, если тип формального параметра функции является таким шаблоном, а фактический аргумент – базовый класс, конкретизированный из него. Чтобы проиллюстрировать такое преобразование, рассмотрим новый шаблон функции min4() с параметром типа Array<Type>&, где Array – это шаблон класса, определенный в разделе 2.5. (В главе 16 шаблоны классов обсуждаются во всех деталях.)
template <class Type>
class Array { /* ... */ }
template <class Type>
Type min4( Array<Type>& array )
{
Type min_val = array[0];
for ( int i = 1; i < array.size(); ++i )
if ( array[i] < min_val )
min_val = array[i];
return min_val;
}
min4() можно вызвать, передав в качестве первого аргумента ArrayRC<int>, как показано в следующем примере. (ArrayRC – это шаблон класса, также определенный в главе 2; наследование классов подробно рассматривается в главах 17 и 18.)
template <class Type>
class ArrayRC : public Array<Type> { /* ... */ };
int main() {
ArrayRC<int> ia_rc(10);
min4( ia_rc );
}
Фактический аргумент ia_rc имеет тип ArrayRC<int>. Он не совпадает с типом формального параметра Array<Type>&. Но одним из базовых классов для ArrayRC<int> является Array<int>, так как он конкретизирован из шаблона класса, указанного в качестве формального параметра функции. Поскольку фактический аргумент является производным классом, то его можно использовать при выводе аргументов шаблона. Таким образом, перед выводом аргумент функции ArrayRC<int> преобразуется в тип Array<int>, после чего для аргумента шаблона Type выводится тип int и конкретизируется функция min4(Array<int>&).
В процессе вывода одного аргумента шаблона могут принимать участие несколько аргументов функции. Если параметр шаблона встречается в списке параметров функции более одного раза, то каждый выведенный тип должен точно соответствовать типу, выведенному для того же аргумента шаблона в первый раз:
template <class T> T min5( T, T ) { /* ... */ }
unsigned int ui;
int main() {
// ошибка: нельзя конкретизировать min5( unsigned int, int )
// должно быть: min5( unsigned int, unsigned int ) или
// min5( int, int )
min5( ui, 1024 );
}
Оба фактических аргумента функции должны иметь один и тот же тип: либо int, либо unsigned int, поскольку в шаблоне они принадлежат к одному типу T. Аргумент шаблона T, выведенный из первого аргумента функции, – это int. Аргумент же шаблона T, выведенный из второго аргумента функции, – это unsigned int. Поскольку они оказались разными, процесс вывода завершается неудачей и при конкретизации шаблона выдается сообщение об ошибке. (Избежать ее можно, если явно задать аргументы шаблона при вызове функции min5(). В разделе 10.4 мы увидим, как это делается.)
Ограничение на допустимые типы преобразований относится только к тем фактическим параметрам функции, которые принимают участие в выводе аргументов шаблона. К остальным аргументам могут применяться любые трансформации. В следующем шаблоне функции sum() есть два формальных параметра. Фактический аргумент op1 для первого параметра участвует в выводе аргумента Type шаблона, а второй фактический аргумент op2 – нет.
template <class Type>
Type sum( Type op1, int op2 ) { /* ... */ }
Поэтому при конкретизации шаблона функции sum() его можно подвергать любым трансформациям. (Преобразования типов, применимые к фактическим аргументам функции, описываются в разделе 9.3.) Например:
int ai[] = { ... };
double dd;
int main() {
// конкретизируется sum( int, int )
sum( ai[0], dd );
}
Тип второго фактического аргумента функции dd не соответствует типу формального параметра int. Но это не мешает конкретизировать шаблон функции sum(), поскольку тип второго аргумента фиксирован и не зависит от параметров шаблона. Для этого вызова конкретизируется функция sum(int,int). Аргумент dd приводится к типу int с помощью преобразования целого типа в тип с плавающей точкой.
Таким образом, общий алгоритм вывода аргументов шаблона можно сформулировать следующим образом:
1. По очереди исследуется каждый фактический аргумент функции, чтобы выяснить, присутствует ли в соответствующем формальном параметре какой-нибудь параметр шаблона.
2. Если параметр шаблона найден, то путем анализа типа фактического аргумента выводится соответствующий аргумент шаблона.
3. Тип фактического аргумента функции не обязан точно соответствовать типу формального параметра. Для приведения типов могут быть применены следующие преобразования:
трансформации l-значения
преобразования спецификаторов
приведение производного класса к базовому при условии, что формальный параметр функции имеет вид T<args>& или T<args>*, где список аргументов args содержит хотя бы один параметр шаблона.
4. Если один и тот же параметр шаблона найден в нескольких формальных параметрах функций, то аргумент шаблона, выведенный по каждому из соответствующих фактических аргументов, должен быть одним и тем же.
Упражнение 10.4
Назовите два типа преобразований, которые можно применять к фактическим аргументам функций, участвующим в процессе вывода аргументов шаблона.
Упражнение 10.5
Пусть даны следующие определения шаблонов:
template <class Type>
Type min3( const Type* array, int size ) { /* ... */ }
template <class Type>
Type min5( Type p1, Type p2 ) { /* ... */ }
Какие из приведенных ниже вызовов ошибочны? Почему?
double dobj1, dobj2;
float fobj1, fobj2;
char cobj1, cobj2;
int ai[5] = { 511, 16, 8, 63, 34 };
(a) min5( cobj2, 'c' );
(b) min5( dobj1, fobj1 );
(c) min3( ai, cobj1 );
Вызов
Указатель на функцию применяется для вызова функции, которую он адресует. Включать оператор разыменования при этом необязательно. И прямой вызов функции по имени, и косвенный вызов по указателю записываются одинаково:
#include <iostream>
int min( int*, int );
int (*pf)( int*, int ) = min;
const int iaSize = 5;
int ia[ iaSize ] = { 7, 4, 9, 2, 5 };
int main() {
cout << "Прямой вызов: min: "
<< min( ia, iaSize ) << endl;
cout << "Косвенный вызов: min: "
<< pf( ia, iaSize ) << endl;
return 0;
}
int min( int* ia, int sz ) {
int minVal = ia[ 0 ];
for ( int ix = 1; ix < sz; ++ix )
if ( minVal > ia[ ix ] )
minVal = ia[ ix ];
return minVal;
}
Вызов
pf( ia, iaSize );
может быть записан также и с использованием явного синтаксиса указателя:
(*pf)( ia, iaSize );
Результат в обоих случаях одинаковый, но вторая форма говорит читателю, что вызов осуществляется через указатель на функцию.
Конечно, если такой указатель имеет нулевое значение, то любая форма вызова приведет к ошибке во время выполнения. Использовать можно только те указатели, которые адресуют какую-либо функцию или были проинициализированы таким значением.
Взаимосвязь массивов и указателей
Если мы имеем определение массива:
int ia[] = { 0, 1, 1, 2, 3, 5, 8, 13, 21 };
то что означает простое указание его имени в программе?
ia;
Использование идентификатора массива в программе эквивалентно указанию адреса его первого элемента:
ia;
&ia[0]
Аналогично обратиться к значению первого элемента массива можно двумя способами:
// оба выражения возвращают первый элемент
*ia;
ia[0];
Чтобы взять адрес второго элемента массива, мы должны написать:
&ia[1];
Как мы уже упоминали раньше, выражение
ia+1;
также дает адрес второго элемента массива. Соответственно, его значение дают нам следующие два способа:
*(ia+1);
ia[1];
Отметим разницу в выражениях:
*ia+1
и
*(ia+1);
Операция разыменования имеет более высокий приоритет, чем операция сложения (о приоритетах операций говорится в разделе 4.13). Поэтому первое выражение сначала разыменовывает переменную ia и получает первый элемент массива, а затем прибавляет к нему 1. Второе же выражение доставляет значение второго элемента.
Проход по массиву можно осуществлять с помощью индекса, как мы делали это в предыдущем разделе, или с помощью указателей. Например:
#include <iostream>
int main()
{
int ia[9] = { 0, 1, 1, 2, 3, 5, 8, 13, 21 };
int *pbegin = ia;
int *pend = ia + 9;
while ( pbegin != pend ) {
cout << *pbegin <<;
++pbegin;
}
Указатель pbegin инициализируется адресом первого элемента массива. Каждый проход по циклу увеличивает этот указатель на 1, что означает смещение его на следующий элемент. Как понять, где остановиться? В нашем примере мы определили второй указатель pend и инициализировали его адресом, следующим за последним элементом массива ia. Как только значение pbegin станет равным pend, мы узнаем, что массив кончился.
Перепишем эту программу так, чтобы начало и конец массива передавались параметрами в некую обобщенную функцию, которая умеет печатать массив любого размера:
#inc1ude <iostream>
void ia_print( int *pbegin, int *pend )
{
while ( pbegin != pend ) {
cout << *pbegin << ' ';
++pbegin;
}
}
int main()
{
int ia[9] = { 0, 1, 1, 2, 3, 5, 8, 13, 21 };
ia_print( ia, ia + 9 );
}
Наша функция стала более универсальной, однако, она умеет работать только с массивами типа int. Есть способ снять и это ограничение: преобразовать данную функцию в шаблон (шаблоны были вкратце представлены в разделе 2.5):
#inc1ude <iostream>
template <c1ass e1emType>
void print( elemType *pbegin, elemType *pend )
{
while ( pbegin != pend ) {
cout << *pbegin << ' ';
++pbegin;
}
}
Теперь мы можем вызывать нашу функцию print() для печати массивов любого типа:
int main()
{
int ia[9] = { 0, 1, 1, 2, 3, 5, 8, 13, 21 };
double da[4] = { 3.14, 6.28, 12.56, 25.12 };
string sa[3] = { "piglet", "eeyore", "pooh" };
print( ia, ia+9 );
print( da, da+4 );
print( sa, sa+3 );
}
Мы написали обобщенную функцию. Стандартная библиотека предоставляет набор обобщенных алгоритмов (мы уже упоминали об этом в разделе 3.4), реализованных подобным образом. Параметрами таких функций являются указатели на начало и конец массива, с которым они производят определенные действия. Вот, например, как выглядят вызовы обобщенного алгоритма сортировки:
#include <a1gorithm>
int main()
{
int ia[6] = { 107, 28, 3, 47, 104, 76 };
string sa[3] = { "piglet", "eeyore", "pooh" };
sort( ia, ia+6 );
sort( sa, sa+3 );
};
(Мы подробно остановимся на обобщенных алгоритмах в главе 12; в Приложении будут приведены примеры их использования.)
В стандартной библиотеке С++ содержится набор классов, которые инкапсулируют использование контейнеров и указателей. (Об этом говорилось в разделе 2.8.) В следующем разделе мы займемся стандартным контейнерным типом vector, являющимся объектно-ориентированной реализацией массива.
Зачем нужно перегружать имя функции
Как и в случае со встроенной операцией сложения, нам может понадобиться набор функций, выполняющих одно и то же действие, но над параметрами различных типов. Предположим, что мы хотим определить функции, возвращающие наибольшее из переданных значений параметров. Если бы не было перегрузки, пришлось бы каждой такой функции присвоить уникальное имя. Например, семейство функций max() могло бы выглядеть следующим образом:
int i_max( int, int );
int vi_max( const vector<int> & );
int matrix_max( const matrix & );
Однако все они делают одно и то же: возвращают наибольшее из значений параметров. С точки зрения пользователя, здесь лишь одна операция– вычисление максимума, а детали ее реализации большого интереса не представляют.
Отмеченная лексическая сложность отражает ограничение программной среды: всякое имя, встречающееся в одной и той же области видимости, должно относиться к уникальной сущности (объекту, функции, классу и т.д.). Такое ограничение на практике создает определенные неудобства, поскольку программист должен помнить или каким-то образом отыскивать все имена. Перегрузка функций помогает справиться с этой проблемой.
Применяя перегрузку, программист может написать примерно так:
int ix = max( j, k );
vector<int> vec;
//...
int iy = max( vec );
Этот подход оказывается чрезвычайно полезным во многих ситуациях.
Закрытые и открытые функции-члены
Функцию-член можно объявить в любой из секций public, private или protected тела класса. Где именно это следует делать? Открытая функция-член задает операцию, которая может понадобиться пользователю. Множество открытых функций-членов составляет интерфейс класса. Например, функции-члены home(), move() и get() класса Screen определяют операции, с помощью которых программа манипулирует объектами этого типа.
Поскольку мы прячем от пользователей внутреннее представление класса, объявляя его члены закрытыми, то для манипуляции объектами типа Screen необходимо предоставить открытые функции-члены. Такой прием – сокрытие информации – защищает написанный пользователем код от изменений во внутреннем представлении.
Внутреннее состояние объекта класса также защищено от случайных изменений. Все модификации объекта производятся с помощью небольшого набора функций, что существенно облегчает сопровождение и доказательство правильности программы.
До сих пор мы встречались лишь с функциями, поддерживающими доступ к закрытым членам только для чтения. Ниже приведены две функции set(), позволяющие пользователю модифицировать объект Screen. Добавим их объявления в тело класса:
class Screen {
public:
void set( const string &s );
void set( char ch );
// объявления других функций-членов не изменяются
};
Далее следуют определения функций:
void Screen::set( const string &s )
{ // писать в строку, начиная с текущей позиции курсора
int space = remainingSpace();
int len = s.size();
if ( space < len ) {
cerr << "Screen: warning: truncation: "
<< "space: " << space
<< "string length: " << len << endl;
len = space;
}
_screen.replace( _cursor, len, s );
_cursor += len - 1;
}
void Screen::set( char ch )
{
if ( ch == '\0' )
cerr << "Screen: warning: "
<< "null character (ignored).\n";
else _screen[_cursor] = ch;
}
В реализации класса Screen мы предполагаем, что объект Screen не содержит двоичных нулей. По этой причине set() не позволяет записать на экран нуль.
Представленные до сих пор функции-члены были открытыми, их можно вызывать из любого места программы, а закрытые вызываются только из других функций-членов (или друзей) класса, но не из программы, обеспечивая поддержку другим операциям в реализации абстракции класса. Примером может служить функция-член remainingSpace класса Screen(), использованная в set(const string&).
class Screen {
public:
// объявления других функций-членов не изменяются
private:
inline int remainingSpace();
};
remainingSpace() сообщает, сколько места осталось на экране:
inline int Screen::remainingSpace()
{
int sz = _width * _height;
return ( sz - _cursor );
}
(Детально защищенные функции-члены будут рассмотрены в главе 17.)
Следующая программа предназначена для тестирования описанных к настоящему моменту функций-членов:
#include "Screen.h"
#include <iostream>
int main() {
Screen sobj(3,3); // конструктор определен в разделе 13.3.4
string init("abcdefghi");
cout << "Screen Object ( "
<< sobj.height() << ", "
<< sobj.width() << " )\n\n";
// Задать содержимое экрана
string::size_type initpos = 0;
for ( int ix = 1; ix <= sobj.width(); ++ix )
for ( int iy = 1; iy <= sobj.height(); ++iy )
{
sobj.move( ix, iy );
sobj.set( init[ initpos++ ] );
}
// Напечатать содержимое экрана
for ( int ix = 1; ix <= sobj.width(); ++ix )
{
for ( int iy = 1; iy <= sobj.height(); ++iy )
cout << sobj.get( ix, iy );
cout << "\n";
}
return 0;
}
Откомпилировав и запустив эту программу, мы получим следующее:
Screen Object ( 3, 3 )
abc
def
ghi
Защищенное наследование
Третья форма наследования – это защищенное наследование. В таком случае все открытые члены базового класса становятся в производном классе защищенными, т.е. доступными из его дальнейших наследников, но не из любого места программы вне иерархии классов. Например, если бы нужно было унаследовать PeekbackStack от Stack, то закрытое наследование
// увы: при этом не поддерживается дальнейшее наследование
// PeekbackStack: все члены IntArray теперь закрыты
class Stack : private IntArray { ... }
было бы чересчур ограничительным, поскольку закрытие членов IntArray в классе Stack делает невозможным их последующее наследование. Для того чтобы поддержать наследование вида:
class PeekbackStack : public Stack { ... };
класс Stack должен наследовать IntArray защищенно:
class Stack : protected IntArray { ... };
Значения параметров по умолчанию
Значение параметра по умолчанию– это значение, которое разработчик считает подходящим в большинстве случаев употребления функции, хотя и не во всех. Оно освобождает программиста от необходимости уделять внимание каждой детали интерфейса функции.
Значения по умолчанию для одного или нескольких параметров функции задаются с помощью того же синтаксиса, который употребляется при инициализации переменных. Например, функция для создания и инициализации двумерного массива, моделирующего экран терминала, может использовать такие значения для высоты, ширины и символа фона экрана:
char *screenInit( int height = 24, int width = 80,
char background = ' ' );
Функция, для которой задано значение параметра по умолчанию, может вызываться по-разному. Если аргумент опущен, используется значение по умолчанию, в противном случае – значение переданного аргумента. Все следующие вызовы screenInit() корректны:
char *cursor;
// эквивалентно screenInit(24,80,' ')
cursor = screenInit();
// эквивалентно screenInit(66,80,' ')
cursor = screenlnit(66);
// эквивалентно screenInit(66,256,' ')
cursor = screenlnit(66, 256);
cursor = screenlnit(66, 256, '#');
Фактические аргументы сопоставляются с формальными параметрами позиционно (в порядке следования), и значения по умолчанию могут использоваться только для подстановки вместо отсутствующих последних аргументов. В нашем примере невозможно задать значение для background, не задавая его для height и width.
// эквивалентно screenInit('?',80,' ')
cursor = screenInit('?');
// ошибка, неэквивалентно screenInit(24,80,'?')
cursor = screenInit( , ,'?');
При разработке функции с параметрами по умолчанию придется позаботиться об их расположении. Те, для которых значения по умолчанию вряд ли будут употребляться, необходимо поместить в начало списка. Функция screenInit() предполагает (возможно, основываясь на опыте применения), что параметр height будет востребован пользователем наиболее часто.
Значения по умолчанию могут задаваться для всех параметров или только для некоторых. При этом параметры без таких значений должны идти раньше тех, для которых они указаны.
// ошибка: width должна иметь значение по умолчанию,
// если такое значение имеет height
char *screenlnit( int height = 24, int width,
char background = ' ' );
Значение по умолчанию может указываться только один раз в файле. Следующая запись ошибочна:
// tf.h
int ff( int = 0 );
// ft.С
#include "ff.h"
int ff( int i = 0) { ... } // ошибка
По соглашению значение задается в объявлении функции, которое размещается в общедоступном заголовочном файле (описывающем интерфейс), а не в ее определении. Если же указать его в определении, это значение будет доступно только для вызовов функции внутри исходного файла, содержащего это определение.
Можно объявить функцию повторно и таким образом задать дополнительные параметры по умолчанию. Это удобно при настройке универсальной функции для конкретного приложения. Скажем, в системной библиотеке UNIX есть функция chmod(), изменяющая режим доступа к файлу. Ее объявление содержится в системном заголовочном файле <cstdlib>:
int chmod( char *filePath, int protMode );
protMode представляет собой режим доступа, а filePath – имя и каталог файла. Если в некотором приложении файл только читается, можно переобъявить функцию chmod(), задав для соответствующего параметра значение по умолчанию, чтобы не указывать его при каждом вызове:
#include <cstdlib>
int chmod( char *filePath, int protMode=0444 );
Если функция объявлена в заголовочном файле так:
file int ff( int a, int b, int с = 0 ); // ff.h
то как переобъявить ее, чтобы присвоить значение по умолчанию для параметра b? Следующая строка ошибочна, поскольку она повторно задает значение для с:
#include "ff.h"
int ff( int a, int b = 0, int с = 0 ); // ошибка
Так выглядит правильное объявление:
#include "ff.h"
int ff( int a, int b = 0, int с ); // правильно
В том месте, где мы переобъявляем функцию ff(), параметр b расположен правее других, не имеющих значения по умолчанию. Поэтому требование присваивать такие значения справа налево не нарушается. Теперь мы можем переобъявить ff() еще раз:
#include "ff.h"
int ff( int a, int b = 0, int с ); // правильно
int ff( int a = 0, int b, int с ); // правильно
Значение по умолчанию не обязано быть константным выражением, можно использовать любое:
int aDefault();
int bDefault( int );
int cDefault( double = 7.8 );
int glob;
int ff( int a = aDefault() ,
int b = bDefau1t( glob ) ,
int с = cDefault() );
Если такое значение является выражением, то оно вычисляется во время вызова функции. В примере выше cDefault() работает каждый раз, когда происходит вызов функции ff() без указания третьего аргумента.