Конкретизация шаблона функции
Шаблон функции описывает, как следует строить конкретные функции, если задано множество фактических типов или значений. Процесс конструирования называется конкретизацией шаблона. Выполняется он неявно, как побочный эффект вызова или взятия адреса шаблона функции. Например, в следующей программе min() конкретизируется дважды: один раз для массива из пяти элементов типа int, а другой – для массива из шести элементов типа double:
// определение шаблона функции min()
// с параметром-типом Type и параметром-константой size
template <typename Type, int size>
Type min( Type (&r_array)[size] )
{
Type min_val = r_array[0];
for ( int i = 1; i < size; ++i )
if ( r_array[i] < min_val )
min_val = r_array[i];
return min_val;
}
// size не задан -- ok
// size = число элементов в списке инициализации
int ia[] = { 10, 7, 14, 3, 25 };
double da[6] = { 10.2, 7.1, 14.5, 3.2, 25.0, 16.8 };
#include <iostream>
int main()
{
// конкретизация min() для массива из 5 элементов типа int
// подставляется Type => int, size => 5
int i = min( ia );
if ( i != 3 )
cout << "??oops: integer min() failed\n";
else cout << "!!ok: integer min() worked\n";
// конкретизация min() для массива из 6 элементов типа double
// подставляется Type => double, size => 6
double d = min( da );
if ( d != 3.2 )
cout << "??oops: double min() failed\n";
else cout << "!!ok: double min() worked\n";
return 0;
}
Вызов
int i = min( ia );
приводит к конкретизации следующего экземпляра функции min(), в котором Type заменено на int, а size на 5:
int min( int (&r_array)[5] )
{
int min_val = r_array[0];
for ( int i = 1; i < 5; ++i )
if ( r_array[i] < min_val )
min_val = r_array[i];
return min_val;
}
Аналогично вызов
double d = min( da );
конкретизирует экземпляр min(), в котором Type заменено на double, а size на 6:
В качестве формальных параметров шаблона функции используются параметр-тип и параметр-константа. Для определения фактического типа и значения константы, которые надо подставить в шаблон, исследуются фактические аргументы, переданные при вызове функции. В нашем примере для идентификации аргументов шаблона при конкретизации используются тип ia (массив из пяти int) и da (массив из шести double). Процесс определения типов и значений аргументов шаблона по известным фактическим аргументам функции называется выведением (deduction) аргументов шаблона. (В следующем разделе мы расскажем об этом подробнее. А в разделе 10.4 речь пойдет о возможности явного задания аргументов.)
Шаблон конкретизируется либо при вызове, либо при взятии адреса функции. В следующем примере указатель pf инициализируется адресом конкретизированного экземпляра шаблона. Его аргументы определяются путем исследования типа параметра функции, на которую указывает pf:
template <typename Type, int size>
Type min( Type (&p_array)[size] ) { /* ... */ }
// pf указывает на int min( int (&)[10] )
int (*pf)(int (&)[10]) = &min;
Тип pf – это указатель на функцию с параметром типа int(&)[10], который определяет тип аргумента шаблона Type и значение аргумента шаблона size при конкретизации min(). Аргумент шаблона Type будет иметь тип int, а значением аргумента шаблона size будет 10. Конкретизированная функция представляется как min(int(&)[10]), и указатель pf адресует именно ее.
Когда берется адрес шаблона функции, контекст должен быть таким, чтобы можно было однозначно определить типы и значения аргументов шаблона. Если сделать это не удается, компилятор выдает сообщение об ошибке:
template <typename Type, int size>
Type min( Type (&r_array)[size] ) { /* ... */ }
typedef int (&rai)[10];
typedef double (&rad)[20];
void func( int (*)(rai) );
void func( double (*)(rad) );
int main() {
// ошибка: как конкретизировать min()?
func( &min );
}
Функция func() перегружена и тип ее параметра не позволяет однозначно определить ни аргумент шаблона Type, ни значение аргумента шаблона size. Результатом конкретизации вызова func() может быть любая из следующих функций:
min( int (*)(int(&)[10]) )
min( double (*)(double(&)[20]) )
Поскольку однозначно определить аргументы функции func() нельзя, взятие адреса конкретизированного шаблона в таком контексте приводит к ошибке компиляции.
Этого можно избежать, если использовать явное приведение типов для указания типа аргумента:
int main() {
// правильно: с помощью явного приведения указывается тип аргумента
func( static_cast< double(*)(rad) >(&min) );
}
Лучше, однако, применять явное задание аргументов шаблона, как будет показано в разделе 10.4.
Конкретизация шаблона класса
В определении шаблона указывается, как следует строить индивидуальные классы, если заданы один или более фактических типов или значений. По шаблону Queue автоматически генерируются экземпляры классов Queue с разными типами элементов. Например, если написать:
Queue<int> qi;
то из обобщенного определения шаблона автоматически создается класс Queue для объектов типа int.
Генерация конкретного класса из обобщенного определения шаблона называется конкретизацией шаблона. При такой конкретизации Queue для объектов типа int каждое вхождение параметра Type в определении шаблона заменяется на int, так что определение класса Queue принимает вид:
template <class int>
class Queue {
public:
Queue() : front( 0 ), back ( 0 ) { }
~Queue();
int& remove();
void add( const int & );
bool is_empty() const {
return front == 0;
}
private:
QueueItem<int> *front;
QueueItem<int> *back;
};
Чтобы создать класс Queue для объектов типа string, надо написать:
Queue<string> qs;
При этом каждое вхождение Type в определении шаблона будет заменено на string. Объекты qi и qs являются объектами автоматически созданных классов.
Каждый конкретизированный по одному и тому же шаблону экземпляр класса совершенно не зависит от всех остальных. Так, у Queue для типа int нет никаких прав доступа к неоткрытым членам того же класса для типа string.
Конкретизированный экземпляр шаблона будет иметь соответственно имя Queue<int> или Queue<string>. Части <int> и <string>, следующие за именем Queue, называются фактическими аргументами шаблона. Они должны быть заключены в угловые скобки и отделяться друг от друга запятыми. В имени конкретизируемого шаблона аргументы всегда должны задаваться явно. В отличие от аргументов шаблона функции, аргументы шаблона класса никогда не выводятся из контекста:
Queue qs; // ошибка: как конкретизируется шаблон?
Конкретизированный шаблон класса Queue можно использовать в программе всюду, где допустимо употребление типа обычного класса:
// типы возвращаемого значения и обоих параметров конкретизированы из
// шаблона класса Queue
extern Queue< complex<double> >
foo( Queue< complex<double> > &, Queue< complex<double> > & );
// указатель на функцию- член класса, конкретизированного из шаблона Queue
bool (Queue<double>::*pmf)() = 0;
// явное приведение 0 к указателю на экземпляр Queue
Queue<char*> *pqc = static_cast< Queue<char*>* > ( 0 );
Объекты типа класса, конкретизированного по шаблону Queue, объявляются и используются так же, как объекты обычных классов:
extern Queue<double> eqd;
Queue<int> *pqi = new Queue<int>;
Queue<int> aqi[1024];
int main() {
int ix;
if ( ! pqi->is_empty() )
ix = pqi->remove();
// ...
for ( ix = 0; ix < 1024; ++ix )
eqd[ ix ].add( ix );
// ...
}
В объявлении и определении шаблона можно ссылаться как на сам шаблон, так и на конкретизированный по нему класс:
// объявление шаблона функции
template <class Type>
void bar( Queue<Type> &, // ссылается на обобщенный шаблон
Queue<double> & // ссылается на конкретизированный шаблон
)
Однако вне такого определения употребляются только конкретизированные экземпляры. Например, в теле обычной функции всегда надо задавать фактические аргументы шаблона Queue:
void foo( Queue<int> &qi )
{
Queue<int> *pq = &qi;
// ...
}
Шаблон класса конкретизируется только тогда, когда имя полученного экземпляра употребляется в контексте, где требуется определение шаблона. Не всегда определение класса должно быть известно. Например, перед объявлением указателей и ссылок на класс его знать необязательно:
class Matrix;
Matrix *pm; // правильно: определение класса Matrix знать необязательно
void inverse( Matrix & ); // тоже правильно
Поэтому объявление указателей и ссылок на конкретизированный шаблон класса не приводит к его конкретизации. (Отметим, что в некоторых компиляторах, написанных до принятия стандарта C++, шаблон конкретизируется при первом упоминании имени конкретизированного класса в тексте программы.) Так, в функции foo() объявляются указатель и ссылка на Queue<int>, но это не вызывает конкретизации шаблона Queue:
// Queue<int> не конкретизируется при таком использовании в foo()
void foo( Queue<int> &qi )
{
Queue<int> *pqi = &qi;
// ...
}
Определение класса необходимо знать, когда определяется объект этого типа. В следующем примере определение obj1 ошибочно: чтобы выделить для него память, компилятору необходимо знать размер класса Matrix:
class Matrix;
Matrix obj1; // ошибка: класс Matrix не определен
class Matrix { ... };
Matrix obj2; // правильно
Таким образом, конкретизация происходит тогда, когда определяется объект класса, конкретизированного по этому шаблону. В следующем примере определение объекта qi приводит к конкретизации шаблона Queue<int>:
Queue<int> qi; // конкретизируется Queue<int>
Определение Queue<int> становится известно компилятору именно в этой точке, которая называется точкой конкретизации данного класса.
Если имеется указатель или ссылка на конкретизированный шаблон, то конкретизация также производится в момент обращения к объекту, на который они ссылаются. В определенной выше функции foo() класс Queue<int> конкретизируется в следующих случаях: когда разыменовывается указатель pqi, когда ссылка qi используется для получения значения именуемого объекта и когда pqi или qi употребляются для доступа к членам или функциям-членам этого класса:
void foo( Queue<int> &qi )
{
Queue<int> *pqi = &qi;
// Queue<int> конкретизируется в результате вызова функции-члена
pqi->add( 255 );
// ...
}
Определение Queue<int> становится известным компилятору еще до вызова функции-члена add() из foo().
Напомним, что в определении шаблона класса Queue есть также ссылка на шаблон QueueItem:
template <class Type>
class Queue {
public:
// ...
private:
QueueItem<Type> *front;
QueueItem<Type> *back;
};
При конкретизации Queue типом int члены front и back становятся указателями на QueueItem<int>. Следовательно, конкретизированный экземпляр Queue<int> ссылается на экземпляр QueueItem, конкретизированный типом int. Но поскольку соответствующие члены являются указателями, то QueueItem<int> конкретизируется лишь в момент их разыменования в функциях-членах класса Queue<int>.
Наш класс QueueItem служит вспомогательным средством для реализации класса Queue и не будет непосредственно употребляться в вызывающей программе. Поэтому пользовательская программа способна манипулировать только объектами Queue. Конкретизация шаблона QueueItem происходит лишь в момент конкретизации шаблона класса Queue или его членов. (В следующих разделах мы рассмотрим конкретизации членов шаблона класса.)
В зависимости от типов, которыми может конкретизироваться шаблон, при его определении надо учитывать некоторые нюансы. Почему, например, следующее определение конструктора класса QueueItem не подходит для конкретизации общего вида?
template <class Type>
class QueueItem {
public:
QueueItem( Type ); // неудачное проектное решение
// ...
};
В данном определении аргумент передается по значению. Это допустимо, если QueueItem конкретизируется встроенным типом (например, QueueItem<int>). Но если такая конкретизация производится для объемного типа (скажем, Matrix), то накладные расходы, вызванные неправильным выбором на этапе проектирования, становятся неприемлемыми. (В разделе 7.3 обсуждались вопросы производительности, связанные с передачей параметров по значению и по ссылке.) Поэтому аргумент конструктора объявляется как ссылка на константный тип:
QueueItem( const Type & );
Следующее определение приемлемо, если у типа, для которого конкретизируется QueueItem, нет ассоциированного конструктора:
template <class Type>
class QueueItem {
// ...
public:
// потенциально неэффективно
QueueItem( const Type &t ) {
item = t; next = 0;
}
};
Если аргументом шаблона является тип класса с конструктором (например, string), то item инициализируется дважды! Конструктор по умолчанию string вызывается для инициализации item перед выполнением тела конструктора QueueItem. Затем для созданного объекта item производится почленное присваивание. Избежать такого можно с помощью явной инициализации item в списке инициализации членов внутри определения конструктора QueueItem:
template <class Type>
class QueueItem {
// ...
public:
// item инициализируется в списке инициализации членов конструктора
QueueItem( const Type &t )
: item(t) { next = 0; }
};
(Списки инициализации членов и основания для их применения обсуждались в разделе 14.5.)
Конструирование базового и производного классов
Напомним, что объект производного класса состоит из одного или более подобъектов, соответствующих базовым классам, и части, относящейся к самому производному. Например, NameQuery состоит из подобъекта Query и объекта-члена string. Для иллюстрации поведения конструктора производного класса введем еще один член встроенного типа:
class NameQuery : public Query {
public:
// ...
protected:
bool _present;
string _name;
};
Если _present установлен в false, то слово _name в тексте отсутствует.
Рассмотрим случай, когда в NameQuery конструктор не определен. Тогда при определении объекта этого класса
NameQuery nq;
по очереди вызывается конструктор по умолчанию Query, а затем конструктор по умолчанию класса string (ассоциированный с объектом _name). Член _present остается неинициализированным, что потенциально может служить источником ошибок. Чтобы инициализировать его, можно так определить конструктор по умолчанию для класса NameQuery:
inline NameQuery::NameQuery() { _present = false; }
Теперь при определении nq вызываются три конструктора по умолчанию: для базового класса Query, для класса string при инициализации члена _name и для класса NameQuery.
А как передать аргумент конструктору базового класса Query? Ответить на этот вопрос можно, рассуждая по аналогии.
Для передачи одного или более аргументов конструктору объекта-члена мы используем список инициализации членов (здесь можно также задать начальные значения членам, не являющимся объектами классов; подробности см. в разделе 14.5):
inline NameQuery::
NameQuery( const string &name )
: _name( name ), _present( false )
{}
Для передачи одного или более аргументов конструктору базового класса также разрешается использовать список инициализации членов. В следующем примере мы передаем конструктору string аргумент name, а конструктору базового класса Query – объект, адресованный указателем ploc:
inline NameQuery::
NameQuery( const string &name,
vector<location> *ploc )
: _name( name ), Query( *ploc ), _present( true )
{}
Хотя Query помещен в список инициализации вторым, его конструктор всегда вызывается раньше конструктора для _name. Порядок их вызова следующий:
Конструктор базового класса. Если базовых классов несколько, то конструкторы вызываются в порядке их следования в списке базовых классов, а не в порядке появления в списке инициализации. (О множественном наследовании в этой связи мы поговорим в главе 18.)
Конструктор объекта-члена. Если в классе есть несколько таких членов, то конструкторы вызываются в порядке их объявления в классе, а не в порядке появления в списке инициализации (подробнее см. раздел 14.5).
Конструктор производного класса.
Конструктор производного класса должен стремиться передать значение члена базового класса подходящему конструктору того же класса, а не присваивать его напрямую. В противном случае реализации двух классов становятся сильно связанными и тогда изменить или расширить реализацию базового будет затруднительно. (Ответственность разработчика базового класса ограничивается предоставлением подходящего множества конструкторов.)
В оставшейся части этого раздела мы последовательно изучим конструктор базового класса и конструкторы четырех производных от него, а после этого рассмотрим альтернативный дизайн иерархии классов Query, чтобы познакомиться с иерархиями глубиной больше двух. В конце раздела речь пойдет о деструкторах классов.
Конструктор базового класса
В нашем базовом классе объявлено два нестатических члена: _solution и _loc:
class Query {
public:
// ...
protected:
set<short> *_solution;
vector<location> _loc;
// ...
};
Конструктор Query по умолчанию должен явно инициализировать только член _solution. Для инициализации _loc автоматически вызывается конструктор класса vector. Вот реализация нашего конструктора:
inline Query::Query(): _solution( 0 ) {}
В Query нам понадобится еще один конструктор, принимающий ссылку на вектор позиций:
inline
Query::
Query( const vector< locaton > &loc )
: _solution( 0 ), _loc( loc )
{}
Он вызывается только из конструктора NameQuery, когда объект этого класса используется для представления указанного в запросе слова. В таком случае передается предварительно подготовленный для него вектор позиций. Остальные три производных класса вычисляют свои векторы позиций в соответствующей функции-члене eval(). (В следующем подразделе мы покажем, как это делается. Реализации функций-членов eval() приведены в разделе 17.5.)
Какой уровень доступа обеспечить для конструкторов? Мы не хотим объявлять их открытыми, так как предполагается, что Query будет существовать в программе только в виде подобъекта в составе объектов производных от него классов. Поэтому мы объявим конструктор не открытым, а защищенным:
class Query {
public:
// ...
protected:
Query();
// ...
};
Ко второму конструктору класса Query предъявляются еще более жесткие требования: он не только должен конструировать Query в виде подобъекта производного класса, но этот производный класс должен к тому же быть NameQuery. Можно объявить конструктор закрытым, а NameQuery сделать другом класса Query. (В предыдущем разделе мы говорили, что производный класс может получить доступ только к открытым и защищенным членам базового. Поэтому любая попытка вызвать второй конструктор из классов AndQuery, OrQuery или NotQuery приведет к ошибке компиляции.)
class Query {
public:
// ...
protected:
Query();
// ...
private:
explicit Query( const vector<location>& );
};
(Необходимость второго конструктора спорна; вероятно, правильнее заполнить _loc в функции eval() класса NameQuery. Однако принятый подход в большей степени отвечает нашей цели проиллюстрировать использование конструктора базового класса.)
Конструктор как конвертер
Набор конструкторов класса, принимающих единственный параметр, например, SmallInt(int) класса SmallInt, определяет множество неявных преобразований в значения типа SmallInt. Так, конструктор SmallInt(int) преобразует значения типа int в значения типа SmallInt.
extern void calc( SmallInt );
int i;
// необходимо преобразовать i в значение типа SmallInt
// это достигается применением SmallInt(int)
calc( i );
При вызове calc(i) число i преобразуется в значение типа SmallInt с помощью конструктора SmallInt(int), вызванного компилятором для создания временного объекта нужного типа. Затем копия этого объекта передается в calc(), как если бы вызов функции был записан в форме:
// Псевдокод на C++
// создается временный объект типа SmallInt
{
SmallInt temp = SmallInt( i );
calc( temp );
}
Фигурные скобки в этом примере обозначают время жизни данного объекта: он уничтожается при выходе из функции.
Типом параметра конструктора может быть тип некоторого класса:
class Number {
public:
// создание значения типа Number из значения типа SmallInt
Number( const SmallInt & );
// ...
};
В таком случае значение типа SmallInt можно использовать всюду, где допустимо значение типа Number:
extern void func( Number );
SmallInt si(87);
int main()
{ // вызывается Number( const SmallInt & )
func( si );
// ...
}
Если конструктор используется для выполнения неявного преобразования, то должен ли тип его параметра точно соответствовать типу подлежащего преобразованию значения? Например, будет ли в следующем коде вызван SmallInt(int), определенный в классе SmallInt, для приведения dobj к типу SmallInt?
extern void calc( SmallInt );
double dobj;
// вызывается ли SmallInt(int)? Да
// dobj преобразуется приводится от double к int
// стандартным преобразованием
calc( dobj );
Если необходимо, к фактическому аргументу применяется последовательность стандартных преобразований до того, как вызвать конструктор, выполняющий определенное пользователем преобразование. При обращении к функции calc()употребляется стандартное преобразование dobj из типа double в тип int. Затем уже для приведения результата к типу SmallInt вызывается SmallInt(int).
Компилятор неявно использует конструктор с единственным параметром для преобразования его типа в тип класса, к которому принадлежит конструктор. Однако иногда удобнее, чтобы конструктор Number(const SmallInt&) можно было вызывать только для инициализации объекта типа Number значением типа SmallInt, но ни в коем случае не для выполнения неявных преобразований. Чтобы избежать такого употребления конструктора, объявим его явным (explicit):
class Number {
public:
// никогда не использовать для неявных преобразований
explicit Number( const SmallInt & );
// ...
};
Компилятор никогда не применяет явные конструкторы для выполнения неявных преобразований типов:
extern void func( Number );
SmallInt si(87);
int main()
{ // ошибка: не существует неявного преобразования из SmallInt в Number
func( si );
// ...
}
Однако такой конструктор все же можно использовать для преобразования типов, если оно запрошено явно в форме оператора приведения типа:
SmallInt si(87);
int main()
{ // ошибка: не существует неявного преобразования из SmallInt в Number
func( si );
func( Number( si ) ); // правильно: приведение типа
func( static_cast< Number >( si ) ); // правильно: приведение типа
}
Конструктор класса
Среди других функций-членов конструктор выделяется тем, что его имя совпадает с именем класса. Для объявления конструктора по умолчанию мы пишем2:
class Account {
public:
// конструктор по умолчанию ...
Account();
// ...
private:
char *_name;
unsigned int _acct_nmbr;
double _balance;
};
Единственное синтаксическое ограничение, налагаемое на конструктор, состоит в том, что он не должен иметь тип возвращаемого значения, даже void. Поэтому следующие объявления ошибочны:
// ошибки: у конструктора не может быть типа возвращаемого значения
void Account::Account() { ... }
Account* Account::Account( const char *pc ) { ... }
Количество конструкторов у одного класса может быть любым, лишь бы все они имели разные списки формальных параметров.
Откуда мы знаем, сколько и каких конструкторов определить? Как минимум, необходимо присвоить начальное значение каждому члену, который в этом нуждается. Например, номер счета либо задается явно, либо генерируется автоматически таким образом, чтобы гарантировать его уникальность. Предположим, что он будет создаваться автоматически. Тогда мы должны разрешить инициализировать оставшиеся два члена _name и _balance:
Account( const char *name, double open_balance );
Объект класса Account, инициализируемый конструктором, можно объявить следующим образом:
Account newAcct( "Mikey Matz", 0 );
Если же есть много счетов, для которых начальный баланс равен 0, то полезно иметь конструктор, задающий только имя владельца и автоматически инициализирующий _balance нулем. Один из способов сделать это– предоставить конструктор вида:
Account( const char *name );
Другой способ – включить в конструктор с двумя параметрами значение по умолчанию, равное нулю:
Account( const char *name, double open_balance = 0.0 );
Оба конструктора обладают необходимой пользователю функциональностью, поэтому оба решения приемлемы. Мы предпочитаем использовать аргумент по умолчанию, поскольку в такой ситуации общее число конструкторов класса сокращается.
Нужно ли поддерживать также задание одного лишь начального баланса без указания имени клиента? В данном случае спецификация класса явно запрещает это. Наш конструктор с двумя параметрами, из которых второй имеет значение по умолчанию, предоставляет полный интерфейс для указания начальных значений тех членов класса Account, которые могут быть инициализированы пользователем:
class Account {
public:
// конструктор по умолчанию ...
Account();
// имена параметров в объявлении указывать необязательно
Account( const char*, double=0.0 );
const char* name() { return name; }
// ...
private:
// ...
};
Ниже приведены два примера правильного определения объекта класса Account, где конструктору передается один или два аргумента:
int main()
{
// правильно: в обоих случаях вызывается конструктор
// с двумя параметрами
Account acct( "Ethan Stern" );
Account *pact = new Account( "Michael Lieberman", 5000 );
if ( strcmp( acct.name(), pact->name() ))
// ...
}
C++ требует, чтобы конструктор применялся к определенному объекту до его первого использования. Это означает, что как для acct, так и для объекта, на который указывает pact, конструктор будет вызван перед проверкой в инструкции if.
Компилятор перестраивает нашу программу, вставляя вызовы конструкторов. Вот как, по всей вероятности, будет модифицировано определение acct внутри main():
// псевдокод на C++,
// иллюстрирующий внутреннюю вставку конструктора
int main()
{
Account acct;
acct.Account::Account("Ethan Stern", 0.0);
// ...
}
Конечно, если конструктор определен как встроенный, то он подставляется в точке вызова.
Обработка оператора new несколько сложнее. Конструктор вызывается только тогда, когда он успешно выделил память. Модификация определения pact в несколько упрощенном виде выглядит так:
// псевдокод на C++,
// иллюстрирующий внутреннюю вставку конструктора при обработке new
int main()
{
// ...
Account *pact;
try {
pact = _new( sizeof( Account ));
pact->Acct.Account::Account(
"Michael Liebarman", 5000.0);
}
catch( std::bad_alloc ) {
// оператор new закончился неудачей:
// конструктор не вызывается
}
// ...
}
Существует три в общем случае эквивалентных формы задания аргументов конструктора:
// в общем случае эти формы эквивалентны
Account acct1( "Anna Press" );
Account acct2 = Account( "Anna Press" );
Account acct3 = "Anna Press";
Форма acct3 может использоваться только при задании единственного аргумента. Если аргументов два или более, мы рекомендуем пользоваться формой acct1, хотя допустима и acct2.
// рекомендуемая форма вызова конструктора
Account acct1( "Anna Press" );
Новички часто допускают ошибку при объявлении объекта, инициализированного конструктором по умолчанию:
// увы! работает не так, как ожидалось
Account newAccount();
Эта инструкция компилируется без ошибок. Однако при попытке использовать объект в таком контексте:
// ошибка компиляции ...
if ( ! newAccount.name() ) ...
компилятор не сможет применить к функции нотацию доступа к членам класса. Определение
// определяет функцию newAccount,
// а не объект класса
Account newAccount();
интерпретируется компилятором как определение функции без параметров, которая возвращает объект типа Account. Правильное объявление объекта класса, инициализируемого конструктором по умолчанию, не содержит пустых скобок:
// правильно: определяется объект класса ...
Account newAccount;
Определять объект класса, не указывая списка фактических аргументов, можно в том случае, если в нем либо объявлен конструктор по умолчанию, либо вообще нет объявлений конструкторов. Если в классе объявлен хотя бы один конструктор, то не разрешается определять объект класса, не вызывая ни одного из них. В частности, если в классе определен конструктор, принимающий один или более параметров, но не определен конструктор по умолчанию, то в каждом определении объекта такого класса должны присутствовать необходимые аргументы. Можно возразить, что не имеет смысла определять конструктор по умолчанию для класса Account, поскольку не бывает счетов без имени владельца. В пересмотренной версии класса Account такой конструктор исключен:
class Account {
public:
// имена параметров в объявлении указывать необязательно
Account( const char*, double=0.0 );
const char* name() { return name; }
// ...
private:
// ...
};
Теперь при объявлении каждого объекта Account в конструкторе обязательно
надо указать как минимум аргумент типа C-строки, но это скорее всего бессмысленно. Почему? Контейнерные классы (например, vector) требуют, чтобы для класса помещаемых в них элементов был либо задан конструктор по умолчанию, либо вообще никаких конструкторов. Аналогичная ситуация имеет место при выделении динамического массива объектов класса. Так, следующая инструкция вызвала бы ошибку компиляции для новой версии Account:
// ошибка: требуется конструктор по умолчанию для класса Account
Account *pact = new Account[ new_client_cnt ];
На практике часто требуется задавать конструктор по умолчанию, если имеются какие-либо другие конструкторы.
А если для класса нет разумных значений по умолчанию? Например, класс Account требует задавать для любого объекта фамилию владельца счета. В таком случае лучше всего установить состояние объекта так, чтобы было видно, что он еще не инициализирован корректными значениями:
// конструктор по умолчанию для класса Account
inline Account::
Account() {
_name = 0;
_balance = 0.0;
_acct_nmbr = 0;
}
Однако в функции-члены класса Account придется включить проверку целостности объекта перед его использованием.
Существует и альтернативный синтаксис: список инициализации членов, в котором через запятую указываются имена и начальные значения. Например, конструктор по умолчанию можно переписать следующим образом:
// конструктор по умолчанию класса Account с использованием
// списка инициализации членов
inline Account::
Account()
: _name(0),
_balance( 0.0 ), _acct_nmbr( 0 )
{}
Такой список допустим только в определении, но не в объявлении конструктора. Он помещается между списком параметров и телом конструктора и отделяется двоеточием. Вот как выглядит наш конструктор с двумя параметрами при частичном использовании списка инициализации членов:
inline Account::
Account( const char* name, double opening_bal )
: _balance( opening_bal )
{
_name = new char[ strlen(name)+1 ];
strcpy( _name, name );
_acct_nmbr = get_unique_acct_nmbr();
}
get_unique_acct_nmbr() – это не являющаяся открытой функция-член, которая возвращает гарантированно не использованный ранее номер счета.
Конструктор нельзя объявлять с ключевыми словами const или volatile (см. раздел 13.3.5), поэтому приведенные записи неверны:
class Account {
public:
Account() const; // ошибка
Account() volatile; // ошибка
// ...
};
Это не означает, что объекты класса с такими спецификаторами запрещено инициализировать конструктором. Просто к объекту применяется подходящий конструктор, причем без учета спецификаторов в объявлении объекта. Константность объекта класса устанавливается после того, как работа по его инициализации завершена, и пропадает в момент вызова деструктора. Таким образом, объект класса со спецификатором const считается константным с момента завершения работы конструктора до момента запуска деструктора. То же самое относится и к спецификатору volatile.
Рассмотрим следующий фрагмент программы:
// в каком-то заголовочном файле
extern void print( const Account &acct );
// ...
int main()
{
// преобразует строку "oops" в объект класса Account
// с помощью конструктора Account::Account( "oops", 0.0 )
print( "oops" );
// ...
}
По умолчанию конструктор с одним параметром (или с несколькими – при условии, что все параметры, кроме первого, имеют значения по умолчанию) играет роль оператора преобразования. В этом фрагменте программы конструктор Account неявно применяется компилятором для трансформации литеральной строки в объект класса Account при вызове print(), хотя в данной ситуации такое преобразование не нужно.
Непреднамеренные неявные преобразования классов, например трансформация "oops" в объект класса Account, оказались источником трудно обнаруживаемых ошибок. Поэтому в стандарт C++ было добавлено ключевое слово explicit, говорящее компилятору, что такие преобразования не нужны:
class Account {
public:
explicit Account( const char*, double=0.0 );
};
Данный модификатор применим только к конструктору. (Операторы преобразования и слово explicit обсуждаются в разделе 15.9.2.)
Конструктор по умолчанию
Конструктором по умолчанию называется конструктор, который можно вызывать, не задавая аргументов. Это не значит, что такой конструктор не может принимать аргументов; просто с каждым его формальным параметром ассоциировано значение по умолчанию:
// все это конструкторы по умолчанию
Account::Account() { ... }
iStack::iStack( int size = 0 ) { ... }
Complex::Complex(double re=0.0, double im=0.0) { ... }
Когда мы пишем:
int main()
{
Account acct;
// ...
}
то компилятор сначала проверяет, определен ли для класса Account конструктор по умолчанию. Возникает одна из следующих ситуаций:
1. Такой конструктор определен. Тогда он применяется к acct.
2. Конструктор определен, но не является открытым. В данном случае определение acct помечается компилятором как ошибка: у функции main() нет прав доступа.
3. Конструктор по умолчанию не определен, но есть один или несколько конструкторов, требующих задания аргументов. Определение acct помечается как ошибка: слишком мало аргументов у конструктора.
4. Нет ни конструктора по умолчанию, ни какого-либо другого. Определение считается корректным, acct не инициализируется, конструктор не вызывается.
Пункты 1 и 3 должны быть уже достаточно понятны (если это не так, перечитайте данную главу) Посмотрим более внимательно на пункты 2 и 4.
Допустим, что все члены класса Account объявлены открытыми и не объявлено никакого конструктора:
class Account {
public:
char *_name;
unsigned int _acct_nmbr;
double _balance;
};
В таком случае при определении объекта класса Account специальной инициализации не производится. Начальные значения всех трех членов зависят только от контекста, в котором встретилось определение. Например, для статических объектов гарантируется, что все их члены будут обнулены (как и для объектов, не являющихся экземплярами классов):
// статический класс хранения
// вся ассоциированная с объектом память обнуляется
Account global_scope_acct;
static Account file_scope_acct;
Account foo()
{
static Account local_static_acct;
// ...
}
Однако объекты, определенные локально или распределенные динамически, в начальный момент будут содержать случайный набор битов, оставшихся в стеке программы:
// локальные и распределенные из хипа объекты не инициализированы
// до момента явной инициализации или присваивания
Account bar()
{
Account local_acct;
Account *heap_acct = new Account;
// ...
}
Новички часто полагают, что компилятор автоматически генерирует конструктор, если он не задан, и применяет его для инициализации членов класса. Для Account в том виде, в каком мы его определили, это неверно. Никакой конструктор не генерируется и не вызывается. Для более сложных классов, имеющих члены, которые сами являются классами, или использующих наследование, это отчасти справедливо: конструктор по умолчанию может быть сгенерирован, но и он не присваивает начальных значений членам встроенных или составных типов, таким, как указатели или массивы.
Если мы хотим, чтобы подобные члены инициализировались, то должны сами позаботиться об этом, предоставив один или несколько конструкторов. В противном случае отличить корректное значение члена такого типа от неинициализированного, если объект создан локально или распределен из хипа,3 практически невозможно.
Конструктор производного класса
В классе NameQuery также определены два конструктора. Они объявлены открытыми, поскольку ожидается, что в приложении будут создаваться объекты этого класса:
class NameQuery : public Query {
public:
explicit NameQuery( const string& );
NameQuery( const string&, const vector<location>* );
// ...
protected:
// ...
};
Конструктор с одним параметром принимает в качестве аргумента строку. Она передается конструктору объекта типа string, который вызывается для инициализации члена _name. Конструктор по умолчанию базового класса Query вызывается неявно:
inline
NameQuery::
NameQuery( const string &name )
// Query::Query() вызывается неявно
: _name( name )
{}
Конструктор с двумя параметрами также принимает строку в качестве одного из них. Второй его параметр– это указатель на вектор позиций. Он передается закрытому конструктору базового класса Query. (Обратите внимание, что _present нам больше не нужен, и мы исключили его из числа членов NameQuery.)
inline
NameQuery::
NameQuery( const string &name, vector<location> *ploc )
: _name( name ), Query( *ploc )
{}
Конструкторы можно использовать так:
string title( "Alice" );
NameQuery *pname;
// проверим, встречается ли "Alice" в отображении слов
// если да, получить ассоциированный с ним вектор позиций
if ( vector<location> *ploc = retrieve_location( title ))
pname = new NameQuery( title, ploc );
else pname = new NameQuery( title );
В каждом из классов NotQuery, OrQuery и AndQuery определено по одному конструктору, каждый из которых вызывает конструктор базового класса неявно:
inline NotQuery::
NotQuery( Query *op = 0 ) : _op( op ) {}
inline OrQuery::
OrQuery( Query *lop = 0, Query *rop = 0 )
: _lop( lop ), _rop( rop )
{}
inline AndQuery::
AndQuery( Query *lop = 0, Query *rop = 0 )
: _lop( lop ), _rop( rop )
{}
(В разделе 17.7 мы построим объекты каждого из производных классов для представления различных запросов пользователя.)
Конструкторы и функциональные try-блоки
Можно объявить функцию так, что все ее тело будет заключено в try-блок. Такие try-блоки называются функциональными. (Мы упоминали их в разделе 11.2.) Например:
int main() {
try {
// тело функции main()
}
catch ( pushOnFull ) {
// ...
}
catch ( popOnEmpty ) {
// ...
}
Функциональный try-блок ассоциирует группу catch-обработчиков с телом функции. Если инструкция внутри тела возбуждает исключение, то поиск его обработчика ведется среди тех, что следуют за телом функции.
Функциональный try-блок необходим для конструкторов класса. Почему? Определение конструктора имеет следующий вид:
имя_класса( список_параметров )
// список инициализации членов:
: член1(выражение1 ) , // инициализация член1
член2(выражение2 ) , // инициализация член2
// тело функции:
{ /* ... */ }
выражение1 и выражение2 могут быть выражениями любого вида, в частности функциями, которые возбуждают исключения.
Рассмотрим еще раз класс Account, описанный в главе 14. Его конструктор можно переопределить так:
inline Account::
Account( const char* name, double opening_bal )
: _balance( opening_bal - ServiceCharge() )
{
_name = new char[ strlen(name) + 1 ];
strcpy( _name, name );
_acct_nmbr = get_unique_acct_nmbr();
}
Функция ServiceCharge(), вызываемая для инициализации члена _balance, может возбуждать исключение. Как нужно реализовать конструктор, если мы хотим обрабатывать все исключения, возбуждаемые функциями, которые вызываются при конструировании объекта типа Account?
Помещать try-блок в тело функции нельзя:
inline Account::
Account( const char* name, double opening_bal )
: _balance( opening_bal - ServiceCharge() )
{
try {
_name = new char[ strlen(name) + 1 ];
strcpy( _name, name );
_acct_nmbr = get_unique_acct_nmbr();
}
catch (...) {
// специальная обработка
// не перехватывает исключения,
// возбужденные в списке инициализации членов
}
}
Поскольку try-блок не охватывает список инициализации членов, то catch-обработчик, находящийся в конце конструктора, не рассматривается при поиске кандидатов, которые способны перехватить исключение, возбужденное в функции ServiceCharge().
Использование функционального try-блока – это единственное решение, гарантирующее, что все исключения, возбужденные при создании объекта, будут перехвачены в конструкторе. Для конструктора класса Account такой try-блок можно определить следующим образом:
inline Account::
Account( const char* name, double opening_bal )
try
: _balance( opening_bal - ServiceCharge() )
{
_name = new char[ strlen(name) + 1 ];
strcpy( _name, name );
_acct_nmbr = get_unique_acct_nmbr();
}
catch (...) {
// теперь специальная обработка
// перехватывает исключения,
// возбужденные в ServiceCharge()
}
Обратите внимание, что ключевое слово try находится перед списком инициализации членов, а составная инструкция, образующая try-блок, охватывает тело конструктора. Теперь предложение catch(...) принимается во внимание при поиске обработчика исключения, возбужденного как в списке инициализации членов, так и в теле конструктора.
Контейнеры multimap и multiset
Контейнеры map и set не допускают повторяющихся значений ключей, а multimap (мультиотображение) и multiset (мультимножество) позволяют сохранять ключи с дублирующимися значениями. Например, в телефонном справочнике может понадобиться отдельный список номеров для каждого абонента. В перечне книг одного автора может быть несколько названий, а в нашей программе с одним словом текста сопоставляется несколько позиций. Для использования multimap и multiset нужно включить соответствующий заголовочный файл – map или set:
#include <map>
multimap< key_type, value_type > multimapName;
// ключ - string, значение - list< string >
multimap< string, list< string > > synonyms;
#include <set>
multiset< type > multisetName;
Для прохода по мультиотображению или мультимножеству можно воспользоваться комбинацией итератора, который возвращает find() (он указывает на первый найденный элемент), и значения, которое возвращает count(). (Это работает, поскольку в данных контейнерах элементы с одинаковыми ключами обязательно являются соседними). Например:
#include <map>
#include <string>
void code_fragment()
{
multimap< string, string > authors;
string search_item( "Alain de Botton" );
// ...
int number = authors.count( search_item );
mu1timap< string,string >::iterator iter;
iter = authors.find( search_item );
for ( int cnt = 0; cnt < number; ++cnt, ++-iter )
do_something( *iter );
// ...
}
Более элегантный способ перебрать все значения с одинаковыми ключами использует специальную функцию-член equal_range(), которая возвращает пару итераторов. Один из них указывает на первое найденное значение, а второй – на следующее за последним найденным. Если последний из найденных элементов является последним в контейнере, второй итератор содержит величину, равную end():
#include <map>
#include <string>
#include <utility>
Конвертеры
Конвертер– это особый случай функции-члена класса, реализующий определенное пользователем преобразование объекта в некоторый другой тип. Конвертер объявляется в теле класса путем указания ключевого слова operator, за которым следует целевой тип преобразования.
Имя, находящееся за ключевым словом, не обязательно должно быть именем одного из встроенных типов. В показанном ниже классе Token определено несколько конвертеров. В одном из них для задания имени типа используется typedef tName, а в другом – тип класса SmallInt.
#include "SmallInt.h"
typedef char *tName;
class Token {
public:
Token( char *, int );
operator SmallInt() { return val; }
operator tName() { return name; }
operator int() { return val; }
// другие открытые члены
private:
SmallInt val;
char *name;
};
Обратите внимание, что определения конвертеров в типы SmallInt и int одинаковы. Конвертер Token::operator int() возвращает значение члена val. Поскольку val имеет тип SmallInt, то неявно применяется SmallInt::operator int() для преобразования val в тип int. Сам Token::operator int() неявно употребляется компилятором для преобразования объекта типа Token в значение типа int. Например, этот конвертер используется для неявного приведения фактических аргументов t1 и t2 типа Token к типу int формального параметра функции print():
#include "Token.h"
void print( int i )
{
cout << "print( int ) : " << i << endl;
}
Token t1( "integer constant", 127 );
Token t2( "friend", 255 );
int main()
{
print( t1 ); // t1.operator int()
print( t2 ); // t2.operator int()
return 0;
}
После компиляции и запуска программа выведет такие строки:
print( int ) : 127
print( int ) : 255
Общий вид конвертера следующий:
operator type();
где type может быть встроенным типом, типом класса или именем typedef. Конвертеры, в которых type – тип массива или функции, не допускаются. Конвертер должен быть функцией-членом. В его объявлении не должны задаваться ни тип возвращаемого значения, ни список параметров:
operator int( SmallInt & ); // ошибка: не член
class SmallInt {
public:
int operator int(); // ошибка: задан тип возвращаемого значения
operator int( int = 0 ); // ошибка: задан список параметров
// ...
};
Конвертер вызывается в результате явного преобразования типов. Если преобразуемое значение имеет тип класса, у которого есть конвертер, и в операции приведения указан тип этого конвертера, то он и вызывается:
#include "Token.h"
Token tok( "function", 78 );
// функциональная нотация: вызывается Token::operator SmallInt()
SmallInt tokVal = SmallInt( tok );
// static_cast: вызывается Token::operator tName()
char *tokName = static_cast< char * >( tok );
У конвертера Token::operator tName() может быть нежелательный побочный эффект. Попытка прямого обращения к закрытому члену Token::name помечается компилятором как ошибка:
char *tokName = tok.name; // ошибка: Token::name - закрытый член
Однако наш конвертер, разрешая пользователям непосредственно изменять Token::name, делает как раз то, от чего мы хотели защититься. Скорее всего, это не годится. Вот, например, как могла бы произойти такая модификация:
#include "Token.h"
Token tok( "function", 78 );
char *tokName = tok; // правильно: неявное преобразование
*tokname = 'P'; // но теперь в члене name находится Punction!
Мы намереваемся разрешить доступ к преобразованному объекту класса Token только для чтения. Следовательно, конвертер должен возвращать тип const char*:
typedef const char *cchar;
class Token {
public:
operator cchar() { return name; }
// ...
};
// ошибка: преобразование char* в const char* не допускается
char *pn = tok;
const char *pn2 = tok; // правильно
Другое решение – заменить в определении Token тип char* на тип string из стандартной библиотеки C++:
class Token {
public:
Token( string, int );
operator SmallInt() { return val; }
operator string() { return name; }
operator int() { return val; }
// другие открытые члены
private:
SmallInt val;
string name;
};
Семантика конвертера Token::operator string() состоит в возврате копии значения ( а не указателя на значение) строки, представляющей имя лексемы. Это предотвращает случайную модификацию закрытого члена name класса Token.
Должен ли целевой тип точно соответствовать типу конвертера? Например, будет ли в следующем коде вызван конвертер int(), определенный в классе Token?
extern void calc( double );
Token tok( "constant", 44 );
// Вызывается ли оператор int()? Да
// применяется стандартное преобразование int --> double
calc( tok );
Если целевой тип (в данном случае double) не точно соответствует типу конвертера (в нашем случае int), то конвертер все равно будет вызван при условии, что существует последовательность стандартных преобразований, приводящая к целевому типу из типа конвертера. (Эти последовательности описаны в разделе 9.3.) При обращении к функции calc() вызывается Token::operator int() для преобразования tok из типа Token в тип int. Затем для приведения результата от типа int к типу double применяется стандартное преобразование.
Вслед за определенным пользователем преобразованием допускаются только стандартные. Если для достижения целевого типа необходимо еще одно пользовательское преобразование, то компилятор не применяет никаких преобразований. Предположим, что в классе Token не определен operator int(), тогда следующий вызов будет ошибочным:
extern void calc( int );
Token tok( "pointer", 37 );
// если Token::operator int() не определен,
// то этот вызов приводит к ошибке компиляции
calc( tok );
Если конвертер Token::operator int() не определен, то приведение tok к типу int потребовало бы вызова двух определенных пользователем конвертеров. Сначала фактический аргумент tok надо было бы преобразовать из типа Token в тип SmallInt с помощью конвертера
Token::operator SmallInt()
а затем результат привести к типу int – тоже с помощью пользовательского конвертера
Token::operator int()
Вызов calc(tok) помечается компилятором как ошибка, так как не существует неявного преобразования из типа Token в тип int.
Если логического соответствия между типом конвертера и типом класса нет, назначение конвертера может оказаться непонятным читателю программы:
class Date {
public:
// попробуйте догадаться, какой именно член возвращается!
operator int();
private:
int month, day, year;
};
Какое значение должен вернуть конвертер int() класса Date? Сколь бы основательными ни были причины для того или иного решения, читатель останется в недоумении относительно того, как пользоваться объектами класса Date, поскольку между ними и целыми числами нет явного логического соответствия. В таких случаях лучше вообще не определять конвертер.
Копирующий конструктор
Инициализация объекта другим объектом того же класса называется почленной инициализацией по умолчанию. Копирование одного объекта в другой выполняется путем последовательного копирования каждого нестатического члена. Проектировщик класса может изменить такое поведение, предоставив специальный копирующий конструктор. Если он определен, то вызывается всякий раз, когда один объект инициализируется другим объектом того же класса.
Часто почленная инициализация не обеспечивает корректного поведения класса. Поэтому мы явно определяем копирующий конструктор. В нашем классе Account это необходимо, иначе два объекта будут иметь одинаковые номера счетов, что запрещено спецификацией класса.
Копирующий конструктор принимает в качестве формального параметра ссылку на объект класса (традиционно объявляемую со спецификатором const). Вот его реализация:
inline Account::
Account( const Account &rhs )
: _balance( rhs._balance )
{
_name = new char[ strlen(rhs._name) + 1 ];
strcpy( _name, rhs._name );
// копировать rhs._acct_nmbr нельзя
_acct_nmbr = get_unique_acct_nmbr();
}
Когда мы пишем:
Account acct2( acct1 );
компилятор определяет, объявлен ли явный копирующий конструктор для класса Account. Если он объявлен и доступен, то он и вызывается; а если недоступен, то определение acct2 считается ошибкой. В случае, когда копирующий конструктор не объявлен, выполняется почленная инициализация по умолчанию. Если впоследствии объявление копирующего конструктора будет добавлено или удалено, никаких изменений в программы пользователей вносить не придется. Однако перекомпилировать их все же необходимо. (Более подробно почленная инициализация рассматривается в разделе 14.6.)
Упражнение 14.1
Какие из следующих утверждений ложны? Почему?
1. У класса должен быть хотя бы один конструктор.
2. Конструктор по умолчанию – это конструктор с пустым списком параметров.
3. Если разумных начальных значений у членов класса нет, то не следует предоставлять конструктор по умолчанию.
4. Если в классе нет конструктора по умолчанию, то компилятор генерирует его автоматически и инициализирует каждый член значением по умолчанию для соответствующего типа.
Упражнение 14.2
Предложите один или несколько конструкторов для данного множества членов. Объясните свой выбор:
class NoName {
public:
// здесь должны быть конструкторы
// ...
protected:
char *pstring;
int ival;
double dval;
};
Упражнение 14.3
Выберите одну из следующих абстракций (или предложите свою собственную). Решите, какие данные (задаваемые пользователем) подходят для представляющего эту абстракцию класса. Напишите соответствующий набор конструкторов. Объясните свое решение.
Книга
Дата
Служащий
Транспортное средство
Объект
Дерево
Упражнение 14.4
Пользуясь приведенным определением класса:
class Account {
public:
Account();
explicit Account( const char*, double=0.0 );
// ...
};
объясните, что происходит в результате следующих определений:
(a) Account acct;
(b) Account acct2 = acct;
(c) Account acct3 = "Rena Stern";
(d) Account acct4( "Anna Engel", 400.00 );
(e) Account acct5 = Account( acct3 );
Упражнение 14.5
Параметр копирующего конструктора может и не быть константным, но обязан быть ссылкой. Почему ошибочна такая инструкция:
Account::Account( const Account rhs );
Краткий обзор
Реализация обобщенного алгоритма не зависит от типа контейнера, поэтому одна основанная на шаблонах реализация может работать со всеми контейнерами, а равно и со встроенным типом массива. Рассмотрим алгоритм find(). Если коллекция не отсортирована, то, чтобы найти элемент, требуются лишь следующие общие шаги:
По очереди исследовать каждый элемент.
Если элемент равен искомому значению, то вернуть его позицию в коллекции.
В противном случае анализировать следующий элемент Повторять шаг 2, пока значение не будет найдено либо пока не будет просмотрена вся коллекция.
Если мы достигли конца коллекции и не нашли искомого, то вернуть некоторое значение, показывающее, что нужного элемента нет.
Алгоритм, как мы и утверждали, не зависит ни от типа контейнера, к которому применяется, ни от типа искомого значения, однако для его использования необходимы:
способ обхода коллекции: переход к следующему элементу и распознавание того, что достигнут конец коллекции. При работе с встроенным типом массива мы решаем эту проблему, передавая два аргумента: указатель на первый элемент и число элементов, подлежащих обходу (в случае строк символов в стиле C передавать второй аргумент необязательно, так как конец строки обозначается двоичным нулем);
умение сравнивать каждый элемент контейнера с искомым значением. Обычно это делается с помощью оператора равенства, ассоциированного со значениями типа, или путем передачи указателя на функцию, осуществляющую сравнение;
некоторый обобщенный тип для представления позиции элемента внутри контейнера и специального признака на случай, если элемент не найден. Обычно мы возвращаем индекс элемента либо указатель на него. В ситуации, когда поиск неудачен, возвращается –1 вместо индекса или 0 вместо указателя.
Обобщенные алгоритмы решают первую проблему, обход контейнера, с помощью абстракции итератора – обобщенного указателя, поддерживающего оператор инкремента для доступа к следующему элементу, оператор разыменования для получения его значения и операторы равенства и неравенства для определения того, совпадают ли два итератора. Диапазон, к которому применяется алгоритм, помечается парой итераторов: first адресует первый элемент, а last – тот, который следует за последним. К самому элементу, адресованному итератором last, алгоритм не применяется; он служит стражем, прекращающим обход. Кроме того, last используется как возвращаемое значение с семантикой “отсутствует”. Если же значение получено, то возвращается итератор, помечающий позицию найденного элемента.
Имеется по две версии каждого обобщенного алгоритма: в одной для сравнения применяется оператор равенства, а в другой – объект-функция или указатель на функцию, реализующую сравнение. (Объекты-функции рассматриваются в разделе 12.3.) Вот, например, реализация обобщенного алгоритма find(), в котором используется оператор сравнения для типов хранимых в контейнере элементов:
template < class ForwardIterator, class Type >
ForwardIterator
find( ForwardIterator first, ForwardIterator last, Type value )
{
for ( ; first != last; ++first )
if ( value == *first )
return first;
return last;
}
ForwardIterator (однонаправленный итератор) – это один из пяти категорий итераторов, предопределенных в стандартной библиотеке. Он поддерживает чтение и запись адресуемого элемента. (Все пять категорий рассматриваются в разделе 12.4.)
Алгоритмы достигают независимости от типов за счет того, что никогда не обращаются к элементам контейнера непосредственно; доступ и обход элементов осуществляются только с помощью итераторов. Неизвестны ни фактический тип контейнера, ни даже то, является ли он контейнером или встроенным массивом. Для работы со встроенным типом массива обобщенному алгоритму можно передать не только обычные указатели, но и итераторы. Например, алгоритм find() для встроенного массива элементов типа int можно использовать так:
#include <algoritm>
#include <iostream>
int main()
{
int search_value;
int ia[ 6 ] = { 27, 210, 12, 47, 109, 83 };
cout << "enter search value: ";
cin >> search_value;
int *presult = find( &ia[0], &ia[6], search_value );
cout << "The value " << search_value
<< ( presult == &ia[6]
? " is not present" : " is present" )
<< endl;
}
Если возвращенный указатель равен адресу &ia[6] (который расположен за последним элементом массива), то поиск оказался безрезультатным, в противном случае значение найдено.
Вообще говоря, при передаче адресов элементов массива обобщенному алгоритму мы можем написать
int *presult = find( &ia[0], &ia[6], search_value );
или
int *presult = find( ia, ia+6, search_value );
Если бы мы хотели ограничиться лишь отрезком массива, то достаточно было бы модифицировать передаваемые алгоритму адреса. Так, при следующем обращении к find() просматриваются только второй и третий элементы (напомним, что элементы массива нумеруются с нуля):
// искать только среди элементов ia[1] и ia[2]
int *presult = find( &ia[1], &ia[3], search_value );
А вот пример использования контейнера типа vector с алгоритмом find():
#include <algorithm>
#include <vector>
#include <iostream>
int main()
{
int search_value;
int ia[ 6 ] = { 27, 210, 12, 47, 109, 83 };
vector<int> vec( ia, ia+6 );
cout << "enter search value: ";
cin >> search_value;
vector<int>::iterator presult;
presult = find( vec.begin(), vec.end(), search_value );
cout << "The value " << search_value
<< ( presult == vec.end()
? " is not present" : " is present" )
<< endl;
}
find() можно применить и к списку:
#include <algorithm>
#include <list>
#include <iostream>
int main()
{
int search_value;
int ia[ 6 ] = { 27, 210, 12, 47, 109, 83 };
list<int> ilist( ia, ia+6 );
cout << "enter search value: ";
cin >> search_value;
list<int>::iterator presult;
presult = find( ilist.begin(), ilist.end(), search_value );
cout << "The value " << search_value
<< ( presult == ilist.end()
? " is not present" : " is present" )
<< endl;
}
(В следующем разделе мы обсудим построение программы, в которой используются различные обобщенные алгоритмы, а затем рассмотрим объекты-функции. В разделе 12.4 мы подробнее расскажем об итераторах. Развернутое введение в обобщенные алгоритмы – предмет раздела 12.5, а их детальное обсуждение и иллюстрация применения вынесено в Приложение. В конце главы речь пойдет о случаях, когда применение обобщенных алгоритмов неуместно.)
Упражнение 12.1
Обобщенные алгоритмы критикуют за то, что при всей элегантности дизайна проверка корректности возлагается на программиста. Например, если передан неверный итератор или пара итераторов, помечающая неверный диапазон, то поведение программы не определено. Вы согласны с такой критикой? Следует ли оставить применение обобщенных алгоритмов только наиболее квалифицированным специалистам? Может быть, нужно запретить использование потенциально опасных конструкций, таких, как обобщенные алгоритмы, указатели и явные приведения типов?
Краткий обзор С++
Эту главу мы начнем с рассмотрения встроенного в язык С++ типа данных “массив”. Массив – это набор данных одного типа, например массив целых чисел или массив строк. Мы рассмотрим недостатки, присущие встроенному массиву, и напишем для его представления свой класс Array, где попытаемся избавиться от этих недостатков. Затем мы построим целую иерархию подклассов, основываясь на нашем базовом классе Array. В конце концов мы сравним наш класс Array с классом vector из стандартной библиотеки С++, реализующим аналогичную функциональность. В процессе создания этих классов мы коснемся таких свойств С++, как шаблоны, пространства имен и обработка ошибок.
Литералы
В С++ имеется набор встроенных типов данных для представления целых и вещественных чисел, символов, а также тип данных “символьный массив”, который служит для хранения символьных строк. Тип char служит для хранения отдельных символов и небольших целых чисел. Он занимает один машинный байт. Типы short, int и long предназначены для представления целых чисел. Эти типы различаются только диапазоном значений, которые могут принимать числа, а конкретные размеры перечисленных типов зависят от реализации. Обычно short занимает половину машинного слова, int– одно слово, long – одно или два слова. В 32-битных системах int и long, как правило, одного размера.
Типы float, double и long double предназначены для чисел с плавающей точкой и различаются точностью представления (количеством значащих разрядов) и диапазоном. Обычно float (одинарная точность) занимает одно машинное слово, double (двойная точность) – два, а long double (расширенная точность) – три.
char, short, int и long вместе составляют целые типы, которые, в свою очередь, могут быть знаковыми
(signed) и беззнаковыми (unsigned). В знаковых типах самый левый бит служит для хранения знака (0 – плюс, 1 – минус), а оставшиеся биты содержат значение. В беззнаковых типах все биты используются для значения. 8-битовый тип signed char может представлять значения от -128 до 127, а unsigned char – от 0 до 255.
Когда в программе встречается некоторое число, например 1, то это число называется литералом, или литеральной константой. Константой, потому что мы не можем изменить его значение, и литералом, потому что его значение фигурирует в тексте программы. Литерал является неадресуемой величиной: хотя реально он, конечно, хранится в памяти машины, нет никакого способа узнать его адрес. Каждый литерал имеет определенный тип. Так, 0 имеет тип int, 3.14159 – тип double.
Литералы целых типов можно записать в десятичном, восьмеричном и шестнадцатеричном виде. Вот как выглядит число 20, представленное десятичным, восьмеричным и шестнадцатеричным литералами:
20 // десятичный
024 // восьмеричный
0х14 // шестнадцатеричный
Если литерал начинается с 0, он трактуется как восьмеричный, если с 0х или 0Х, то как шестнадцатеричный. Привычная запись рассматривается как десятичное число.
По умолчанию все целые литералы имеют тип signed int. Можно явно определить целый литерал как имеющий тип long, приписав в конце числа букву L (используется как прописная L, так и строчная l, однако для удобства чтения не следует употреблять строчную: ее легко перепутать с 1). Буква U (или u) в конце определяет литерал как unsigned int, а две буквы – UL или LU – как тип unsigned long. Например:
128u 1024UL 1L 8Lu
Литералы, представляющие действительные числа, могут быть записаны как с десятичной точкой, так и в научной (экспоненциальной) нотации. По умолчанию они имеют тип double. Для явного указания типа float нужно использовать суффикс F или f, а для long double - L или l, но только в случае записи с десятичной точкой. Например:
3.14159F 0/1f 12.345L 0.0
3el 1.0E-3E 2. 1.0L
Слова true и false являются литералами типа bool.
Представимые литеральные символьные константы записываются как символы в одинарных кавычках. Например:
'a' '2' ',' ' ' (пробел)
Специальные символы (табуляция, возврат каретки) записываются как escape-последовательности . Определены следующие такие последовательности (они начинаются с символа обратной косой черты):
новая строка \n
горизонтальная табуляция \t
забой \b
вертикальная табуляция \v
возврат каретки \r
прогон листа \f
звонок \a
обратная косая черта \\
знак вопроса \?
одиночная кавычка \'
двойная кавычка \"
escape-последовательность общего вида имеет форму \ooo, где ooo – от одной до трех восьмеричных цифр. Это число является кодом символа. Используя ASCII-код, мы можем написать следующие литералы:
\7 (звонок) \14 (новая строка)
\0 (null) \062 ('2')
Символьный литерал может иметь префикс L (например, L'a'), что означает специальный тип wchar_t – двухбайтовый символьный тип, который применяется для хранения символов национальных алфавитов, если они не могут быть представлены обычным типом char, как, например, китайские или японские буквы.
Строковый литерал – строка символов, заключенная в двойные кавычки. Такой литерал может занимать и несколько строк, в этом случае в конце строки ставится обратная косая черта. Специальные символы могут быть представлены своими escape-последовательностями. Вот примеры строковых литералов:
"" (пустая строка)
"a"
"\nCC\toptions\tfile.[cC]\n"
"a multi-line \
string literal signals its \
continuation with a backslash"
Фактически строковый литерал представляет собой массив символьных констант, где по соглашению языков С и С++ последним элементом всегда является специальный символ с кодом 0 (\0).
Литерал 'A' задает единственный символ А, а строковый литерал "А" – массив из двух элементов: 'А' и \0 (пустого символа).
Раз существует тип wchar_t, существуют и литералы этого типа, обозначаемые, как и в случае с отдельными символами, префиксом L:
L"a wide string literal"
Строковый литерал типа wchar_t – это массив символов того же типа, завершенный нулем.
Если в тесте программы идут подряд два или несколько строковых литералов (типа char или wchar_t), компилятор соединяет их в одну строку. Например, следующий текст
"two" "some"
породит массив из восьми символов – twosome и завершающий нулевой символ. Результат конкатенации строк разного типа не определен. Если написать:
// this is not a good idea
"two" L"some"
то на каком-то компьютере результатом будет некоторая осмысленная строка, а на другом может оказаться нечто совсем иное. Программы, использующие особенности реализации того или иного компилятора или операционной системы, являются непереносимыми. Мы крайне не рекомендуем пользоваться такими конструкциями.
Упражнение 3.1
Объясните разницу в определениях следующих литералов:
(a) 'a', L'a', "a", L"a"
(b) 10, 10u, 10L, 10uL, 012, 0*C
(c) 3.14, 3.14f, 3.14L
Упражнение 3.2
Какие ошибки допущены в приведенных ниже примерах?
(a) "Who goes with F\144rgus?\014"
(b) 3.14e1L
(c) "two" L"some"
(d) 1024f
(e) 3.14UL
(f) "multiple line
comment"
Логические объекты-функции
Логические объекты-функции поддерживают операции “логическое И” (возвращает true, если оба операнда равны true, – применяет оператор &&, аcсоциированный с типом Type), “логическое ИЛИ” (возвращает true, если хотя бы один из операндов равен true, – применяет оператор ||, аcсоциированный с типом Type) и “логическое НЕ” (возвращает true, если операнд равен false, – применяет оператор !, аcсоциированный с типом Type)
Логическое И: logical_and<Type>
logical_and<int> intAnd;
ires = intLess( ival1, ival2 );
dres = BinaryFunc( logical_and<double>(), dval1, dval2 );
Логическое ИЛИ: logical_or<Type>
logical_or<int> intSub;
ires = intSub( ival1, ival2 );
dres = BinaryFunc( logical_or<double>(), dval1, dval2 );
Логическое НЕ: logical_not<Type>
logical_not<Int> IntNot;
ires = IntNot( Ival1, Ival2 );
dres = UnaryFunc( logical_or<double>(), dval1 );
Локальная область видимости
Локальная область видимости – это часть исходного текста программы, содержащаяся в определении функции (или блоке внутри тела функции). Все функции имеют свои локальные области видимости. Каждая составная инструкция (или блок) внутри функции также представляет собой отдельную локальную область. Такие области могут быть вложенными. Например, следующее определение функции содержит два их уровня (функция выполняет двоичный поиск в отсортированном векторе целых чисел):
const int notFound = -1; // глобальная область видимости
int binSearch( const vector<int> &vec, int val )
{ // локальная область видимости: уровень #1
int low = 0;
int high = vec.size() - 1;
while ( low <= high )
{ // локальная область видимости: уровень #2
int mid = ( low + high ) / 2;
if ( val < vec[ mid ] )
high = mid - 1;
else low = mid + 1;
}
return notFound; // локальная область видимости: уровень #1
}
Первая локальная область видимости – тело функции binSearch(). В ней объявлены параметры функции vec и val, а также переменные low и high. Цикл while внутри функции задает вложенную локальную область, в которой определена одна переменная mid. Параметры vec и val и переменные low и high видны во вложенной области. Глобальная область видимости включает в себя обе локальных. В ней определена одна целая константа notFound.
Имена параметров функции vec и val принадлежат к первой локальной области видимости тела функции, и в ней использовать те же имена для других сущностей нельзя. Например:
int binSearch( const vector<int> &vec, int val )
{ // локальная область видимости: уровень #1
int val; // ошибка: неверное переопределение val
// ...
Имена параметров употребляются как внутри тела функции binSearch(), так и внутри вложенной области видимости цикла while. Параметры vec и val недоступны вне тела функции binSearch().
Разрешение имени в локальной области видимости происходит следующим образом: просматривается та область, где оно встретилось. Если объявление найдено, имя разрешено. Если нет, просматривается область видимости, включающая текущую. Этот процесс продолжается до тех пор, пока объявление не будет найдено либо не будет достигнута глобальная область видимости. Если и там имени нет, оно будет считаться ошибочным.
Из- за порядка просмотра областей видимости в процессе разрешения имен объявление из внешней области может быть скрыто объявлением того же имени во вложенной области. Если бы в предыдущем примере переменная low была объявлена в глобальной области видимости перед определением функции binSearch(), то использование low в локальной области видимости цикла while все равно относилось бы к локальному объявлению, скрывающему глобальное:
int low;
int binSearch( const vector<int> &vec, int val )
{
// локальное объявление low
// скрывает глобальное объявление
int low = 0;
// ...
// low - локальная переменная
while ( low <= high )
{//...
}
// ...
}
Для некоторых инструкций языка C++ разрешено объявлять переменные внутри управляющей части. Например, в цикле for переменную можно определить внутри инструкции инициализации:
for ( int index = 0; index < vecSize; ++index )
{
// переменная index видна только здесь
if ( vec[ index ] == someValue )
break;
}
// ошибка: переменная index не видна
if ( index != vecSize ) // элемент найден
Подобные переменные видны только в локальной области самого цикла for и вложенных в него (это верно для стандарта С++, в предыдущих версиях языка поведение было иным). Компилятор рассматривает это объявление так же, как если бы оно было записано в виде:
// представление компилятора
{ // невидимый блок
int index = 0;
for ( ; index < vecSize; ++index )
{
// ...
}
}
Тем самым программисту запрещается применять управляющую переменную вне локальной области видимости цикла. Если нужно проверить index, чтобы определить, было ли найдено значение, то данный фрагмент кода следует переписать так:
int index = 0;
for ( ; index < vecSize; ++index )
{
// ...
}
// правильно: переменная index видна
if ( index != vecSize ) // элемент найден
Поскольку переменная, объявленная в инструкции инициализации цикла for, является локальной для цикла, то же самое имя допустимо использовать аналогичным образом и в других циклах, расположенных в данной локальной области видимости:
void fooBar( int *ia, int sz )
{
for (int i=0; i<sz; ++i) ... // правильно
for (int i=0; i<sz; ++i) ... // правильно, другое i
for (int i=0; i<sz; ++i) ... // правильно, другое i
}
Аналогично переменная может быть объявлена внутри условия инструкций if и switch, а также внутри условия циклов while и for. Например:
if ( int *pi = getValue() )
{
// pi != 0 -- *pi можно использовать здесь
int result = calc(*pi);
// ...
}
else
{
// здесь pi тоже видна
// pi == 0
cout << "ошибка: getValue() завершилась неудачно" << endl;
}
Переменные, определенные в условии инструкции if, как переменная pi, видны только внутри if и соответствующей части else, а также во вложенных областях. Значением условия является значение этой переменной, которое она получает в результате инициализации. Если pi равна 0 (нулевой указатель), условие ложно и выполняется ветвь else. Если pi инициализируется любым другим значением, условие истинно и выполняется ветвь if. (Инструкции if, switch, for и while рассматривались в главе 5.)
Упражнение 8.1
Найдите различные области видимости в следующем примере. Какие объявления ошибочны и почему?
int ix = 1024;
int ix() ;
void func( int ix, int iy ) {
int ix = 255;
if (int ix=0) {
int ix = 79;
{
int ix = 89;
}
}
else {
int ix = 99;
}
}
Упражнение 8.2
К каким объявлениям относятся различные использования переменных ix и iy в следующем примере:
int ix = 1024;
void func( int ix, int iy ) {
ix = 100;
for( int iy = 0; iy < 400; iy += 100 ) {
iy += 100;
ix = 300;
}
iy = 400;
}
Локальные классы *
Класс, определенный внутри тела функции, называется локальным. Он виден только в той локальной области, где определен. Не существует синтаксиса, позволяющего обратиться к члену такого класса, в отличие от вложенного, извне локальной области видимости, содержащей его определение. Поэтому функции-члены локального класса должны определяться внутри определения самого класса. На практике это ограничивает их сложность несколькими строками кода; помимо всего прочего, такой код становится трудно читать.
Поскольку невозможно определить член локального класса в области видимости пространства имен, то в таком классе не бывает статических членов.
Класс, вложенный в локальный, может быть определен вне определения объемлющего класса, но только в локальной области видимости, содержащей это определение. Имя вложенного класса в таком определении должно быть квалифицировано именем объемлющего класса. Объявление вложенного класса в объемлющем нельзя опускать:
void foo( int val )
{
class Bar {
public:
int barVal;
class nested; // объявление вложенного класса обязательно
};
// определение вложенного класса
class Bar::nexted {
// ...
};
}
У объемлющей функции нет никаких специальных прав доступа к закрытым членам локального класса. Разумеется, это можно обойти, объявив ее другом данного класса. Однако необходимость делать его члены закрытыми вообще сомнительна, поскольку часть программы, из которой разрешается обратиться к нему, весьма ограничена. Локальный класс инкапсулирован в своей локальной области видимости. Дальнейшая инкапсуляция путем сокрытия информации не требуется: вряд ли на практике найдется причина, по которой не все члены локального класса должны быть открыты.
У локального класса, как и у вложенного, ограничен доступ к именам из объемлющей области видимости. Он может обратиться только к именам типов, статических переменных и элементов перечислений, определенных в объемлющих локальных областях. Например:
int a, val;
void foo( int val )
{
static int si;
enum Loc { a = 1024, b };
class Bar {
public:
Loc locVal; // правильно
int barVal;
void fooBar ( Loc l = a ) { // правильно: Loc::a
barVal = val; // ошибка: локальный объект
barVal = ::val; // правильно: глобальный объект
barVal = si; // правильно: статический локальный объект
locVal = b; // правильно: элемент перечисления
}
};
// ...
}
Имена в теле локального класса разрешаются лексически путем поиска в объемлющих областях видимости объявлений, предшествующих определению такого класса. При разрешении имен, встречающихся в телах его функций-членов, сначала просматривается область видимости класса, а только потом – объемлющие области,
Как всегда, если первое найденное объявление таково, что употребление имени оказывается некорректным, поиск других объявлений не производится. Несмотря на то что использование val в fooBar() выше является ошибкой, глобальная переменная val не будет найдена, если только ее имени не предшествует оператор разрешения глобальной области видимости.
Локальные объекты
Объявление переменной в локальной области видимости вводит локальный объект. Существует три вида таких объектов: автоматические, регистровые и статические, различающиеся временем жизни и характеристиками занимаемой памяти. Автоматический объект существует с момента активизации функции, в которой он определен, до выхода из нее. Регистровый объект – это автоматический объект, для которого поддерживается быстрое считывание и запись его значения. Локальный статический объект располагается в области памяти, существующей на протяжении всего времени выполнения программы. В этом разделе мы рассмотрим свойства всех этих объектов.
Массивы и векторы объектов
Массив объектов класса определяется точно так же, как массив элементов встроенного типа. Например:
Account table[ 16 ];
определяет массив из 16 объектов Account. Каждый элемент по очереди инициализируется конструктором по умолчанию. Можно и явно передать конструкторам аргументы внутри заключенного в фигурные скобки списка инициализации массива. Строка:
Account pooh_pals[] = { "Piglet", "Eeyore", "Tigger" };
определяет массив из трех элементов, инициализируемых конструкторами:
Account( "Piglet", 0.0 ); // первый элемент (Пятачок)
Account( "Eeyore", 0.0 ); // второй элемент (Иа-Иа)
Account( "Tigger", 0.0 ); // третий элемент (Тигра)
Один аргумент можно задать явно, как в примере выше. Если же необходимо передать несколько аргументов, то придется воспользоваться явным вызовом конструктора:
Account pooh_pals[] = {
Account( "Piglet", 1000.0 ),
Account( "Eeyore", 1000.0 ),
Account( "Tigger", 1000.0 )
};
Чтобы включить в список инициализации массива конструктор по умолчанию, мы употребляем явный вызов с пустым списком параметров:
Account pooh_pals[] = {
Account( "Woozle", 10.0 ), // Бука
Account( "Heffalump", 10.0 ), // Слонопотам
Account();
};
Эквивалентный массив из трех элементов можно объявить и так:
Account pooh_pals[3] = {
Account( "Woozle", 10.0 ),
Account( "Heffalump", 10.0 )
};
Таким образом, члены списка инициализации последовательно используются для заполнения очередного элемента массива. Те элементы, для которых явные аргументы не заданы, инициализируются конструктором по умолчанию. Если его нет, то в списке должны быть заданы аргументы конструктора для каждого элемента массива.
Доступ к отдельным элементам массива объектов производится с помощью оператора взятия индекса, как и для массива элементов любого из встроенных типов. Например:
pooh_pals[0];
обращается к Piglet, а
pooh_pals[1];
к Eeyore и т.д. Для доступа к членам объекта, находящегося в некотором элементе массива, мы сочетаем операторы взятия индекса и доступа к членам:
pooh_pals[1]._name != pooh_pals[2]._name;
Не существует способа явно указать начальные значения элементов массива, память для которого выделена из хипа. Если класс поддерживает создание динамических массивов с помощью оператора new, он должен либо иметь конструктор по умолчанию, либо не иметь никаких конструкторов. На практике почти у всех классов есть такой конструктор.
Объявление
Account *pact = new Account[ 10 ];
создает в памяти, выделенной из хипа, массив из десяти объектов класса Account, причем каждый инициализируется конструктором по умолчанию.
Чтобы уничтожить массив, адресованный указателем pact, необходимо применить оператор delete. Однако написать
// увы! это не совсем правильно
delete pact;
недостаточно, так как pact при этом не идентифицируется как массив объектов. В результате деструктор класса Account применяется лишь к первому элементу массива. Чтобы применить его к каждому элементу, мы должны включить пустую пару скобок между оператором delete и адресом удаляемого объекта:
// правильно:
// показывает, что pact адресует массив
delete [] pact;
Пустая пара скобок говорит о том, что pact адресует именно массив. Компилятор определяет, сколько в нем элементов, и применяет деструктор к каждому из них.
Массивы указателей на функции
Можно объявить массив указателей на функции. Например:
int (*testCases[10])();
testCases – это массив из десяти элементов, каждый из которых является указателем на функцию, возвращающую значение типа int и не имеющую параметров.
Подобные объявления трудно читать, поскольку не сразу видно, с какой частью ассоциируется тип функции.
В этом случае помогает использование имен, определенных с помощью директивы typedef:
// typedef делает объявление более понятным
typedef int (*PFV)(); // typedef для указателя на функцию
PFV testCases[10];
Данное объявление эквивалентно предыдущему.
Вызов функций, адресуемых элементами массива testCases, выглядит следующим образом:
const int size = 10;
PFV testCases[size];
int testResults[size];
void runtests() {
for ( int i = 0; i < size; ++i )
// вызов через элемент массива
testResults[ i ] = testCases[ i ]();
}
Массив указателей на функции может быть инициализирован списком, каждый элемент которого является функцией. Например:
int lexicoCompare( const string &, const string & );
int sizeCompare( const string &, const string & );
typedef int ( *PFI2S )( const string &, const string & );
PFI2S compareFuncs[2] =
{
lexicoCompare,
sizeCompare
};
Можно объявить и указатель на compareFuncs, его типом будет “указатель на массив указателей на функции”:
PFI2S (*pfCompare)[2] = compareFuncs;
Это объявление раскладывается на составные части следующим образом:
(*pfCompare)
Оператор разыменования говорит, что pfCompare является указателем. [2] сообщает о количестве элементов массива:
(*pfCompare) [2]
PFI2S – имя, определенное с помощью директивы typedef, называет тип элементов. Это “указатель на функцию, возвращающую int и имеющую два параметра типа const string &”. Тип элемента массива тот же, что и выражения &lexicoCompare.
Такой тип имеет и первый элемент массива compareFuncs, который может быть получен с помощью любого из выражений:
compareFunc[ 0 ];
(*pfCompare)[ 0 ];
Чтобы вызвать функцию lexicoCompare через pfCompare, нужно написать одну из следующих инструкций:
// эквивалентные вызовы
pfCompare [ 0 ]( string1, string2 ); // сокращенная форма
((*pfCompare)[ 0 ])( string1, string2 ); // явная форма
Многомерные массивы
В С++ есть возможность использовать многомерные массивы, при объявлении которых необходимо указать правую границу каждого измерения в отдельных квадратных скобках. Вот определение двумерного массива:
int ia[ 4 ][ 3 ];
Первая величина (4) задает количество строк, вторая (3)– количество столбцов. Объект ia определен как массив из четырех строк по три элемента в каждой. Многомерные массивы тоже могут быть инициализированы:
int ia[ 4 ][ 3 ] = {
{ 0, 1, 2 },
{ 3, 4, 5 },
{ 6, 7, 8 },
{ 9, 10, 11 }
};
Внутренние фигурные скобки, разбивающие список значений на строки, необязательны и используются, как правило, для удобства чтения кода. Приведенная ниже инициализация в точности соответствует предыдущему примеру, хотя менее понятна:
int ia[4][3] = { 0,1,2,3,4,5,6,7,8,9,10,11 };
Следующее определение инициализирует только первые элементы каждой строки. Оставшиеся элементы будут равны нулю:
int ia[ 4 ][ 3 ] = { {0}, {3}, {6}, {9} };
Если же опустить внутренние фигурные скобки, результат окажется совершенно иным. Все три элемента первой строки и первый элемент второй получат указанное значение, а остальные будут неявно инициализированы 0.
int ia[ 4 ][ 3 ] = { 0, 3, 6, 9 };
При обращении к элементам многомерного массива необходимо использовать индексы для каждого измерения (они заключаются в квадратные скобки). Так выглядит инициализация двумерного массива с помощью вложенных циклов:
int main()
{
const int rowSize = 4;
const int colSize = 3;
int ia[ rowSize ][ colSize ];
for ( int = 0; i < rowSize; ++i )
for ( int j = 0; j < colSize; ++j )
ia[ i ][ j ] = i + j j;
}
Конструкция
ia[ 1, 2 ]
является допустимой с точки зрения синтаксиса С++, однако означает совсем не то, чего ждет неопытный программист. Это отнюдь не объявление двумерного массива 1 на 2. Агрегат в квадратных скобках – это список выражений через запятую, результатом которого будет последнее значение 2 (см. оператор “запятая” в разделе 4.2). Поэтому объявление ia[1,2] эквивалентно ia[2]. Это еще одна возможность допустить ошибку.
Многоточие
Иногда нельзя перечислить типы и количество всех возможных аргументов функции. В этих случаях список параметров представляется многоточием (...), которое отключает механизм проверки типов. Наличие многоточия говорит компилятору, что у функции может быть произвольное количество аргументов неизвестных заранее типов. Многоточие употребляется в двух форматах:
void foo( parm_list, ... );
void foo( ... );
Первый формат предоставляет объявления для части параметров. В этом случае проверка типов для объявленных параметров производится, а для оставшихся фактических аргументов – нет. Запятая после объявления известных параметров необязательна.
Примером вынужденного использования многоточия служит функция printf() стандартной библиотеки С. Ее первый параметр является C-строкой:
int printf( const char* ... );
Это гарантирует, что при любом вызове printf() ей будет передан первый аргумент типа const char*. Содержание такой строки, называемой форматной, определяет, необходимы ли дополнительные аргументы при вызове. При наличии в строке формата метасимволов, начинающихся с символа %, функция ждет присутствия этих аргументов. Например, вызов
printf( "hello, world\n" );
имеет один строковый аргумент. Но
printf( "hello, %s\n", userName );
имеет два аргумента. Символ % говорит о наличии второго аргумента, а буква s, следующая за ним, определяет его тип – в данном случае символьную строку.
Большинство функций с многоточием в объявлении получают информацию о типах и количестве фактических параметров по значению явно объявленного параметра. Следовательно, первый формат многоточия употребляется чаще.
Отметим, что следующие объявления неэквивалентны:
void f();
void f( ... );
В первом случае f() объявлена как функция без параметров, во втором – как имеющая ноль или более параметров. Вызовы
f( someValue );
f( cnt, a, b, с );
корректны только для второго объявления. Вызов
f();
применим к любой из двух функций.
Упражнение 7.4
Какие из следующих объявлений содержат ошибки? Объясните.
Множественное и виртуальное наследование
В большинстве реальных приложений на C++ используется открытое наследование от одного базового класса. Можно предположить, что и в наших программах оно в основном будет применяться именно так. Но иногда одиночного наследования не хватает, потому что с его помощью либо нельзя адекватно смоделировать абстракцию предметной области, либо получающаяся модель чересчур сложна и неинтуитивна. В таких случаях следует предпочесть множественное наследование или его частный случай – виртуальное наследование. Их поддержка, имеющаяся в C++, – основная тема настоящей главы.
Множественное наследование
Для поддержки множественного наследования синтаксис списка базовых классов
class Bear : public ZooAnimal { ... };
расширяется: допускается наличие нескольких базовых классов, разделенных запятыми:
class Panda : public Bear, public Endangered { ... };
Для каждого из перечисленных базовых классов должен быть указан уровень доступа: public, protected или private. Как и при одиночном наследовании, множественно наследовать можно только классу, определение которого уже встречалось ранее.
Язык не накладывает никаких ограничений на число базовых классов, которым может наследовать производный. На практике чаще всего встречается два класса, один из которых представляет открытый абстрактный интерфейс, а второй – закрытую реализацию (хотя ни один из рассмотренных выше примеров этой модели не следует). Производные классы, наследующие от трех или более базовых, – это пример такого стиля проектирования, когда каждый базовый класс представляет одну грань полного интерфейса производного.
В случае множественного наследования объект производного класса содержит по одному подобъекту каждого из своих базовых (см. раздел 17.3). Например, когда мы пишем
Panda ying_yang;
то объект ying_yang будет состоять из подобъекта класса Bear (который в свою очередь содержит подобъект ZooAnimal), подобъекта Endangered и нестатических членов, объявленных в самом классе Panda, если таковые есть (см. рис. 18.3).
ZooAnimal Endangered
Bear
Panda
Рис. 18.3. Иерархия множественного наследования класса Panda
Конструкторы базовых классов вызываются в порядке объявления в списке базовых классов. Например, для ying_yang эта последовательность такова: конструктор Bear (но поскольку класс Bear – производный от ZooAnimal, то сначала вызывается конструктор ZooAnimal), затем конструктор Endangered и в самом конце конструктор Panda.
Как отмечалось в разделе 17.4, на порядок вызова не влияет ни наличие базовых классов в списке инициализации членов, ни порядок их перечисления. Иными словами, если бы конструктор Bear вызывался неявно и потому не был бы упомянут в списке инициализации членов, как в следующем примере:
// конструктор по умолчанию класса Bear вызывается до
// конструктора класса Endangered с двумя аргументами ...
Panda::Panda()
: Endangered( Endangered::environment,
Endangered::critical )
{ ... }
то все равно конструктор по умолчанию Bear был бы вызван раньше, чем явно заданный в списке конструктор класса Endangered с двумя аргументами.
Порядок вызова деструкторов всегда противоположен порядку вызова конструкторов. В нашем примере деструкторы вызываются в такой последовательности: ~Panda(), ~Endangered(), ~Bear(), ~ZooAnimal().
В разделе 17.3 уже говорилось, что в случае одиночного наследования к открытым и защищенным членам базового класса можно обращаться напрямую (не квалифицируя имя члена именем его класса), как если бы они были членами производного класса. То же самое справедливо и для множественного наследования. Однако при этом можно унаследовать одноименные члены из двух или более базовых классов. В таком случае прямое обращение оказывается неоднозначным и приводит к ошибке компиляции.
Однако такую ошибку вызывает не потенциальная неоднозначность неквалифицированного доступа к одному из двух одноименных членов, а лишь попытка фактического обращения к нему (см. раздел 17.4). Например, если в обоих классах Bear и Endangered определена функция-член print(), то инструкция
ying_yang.print( cout );
приводит к ошибке компиляции, даже если у двух унаследованных функций-членов разные списки параметров.
Error: ying_yang.print( cout ) -- ambiguous, one of
Bear::print( ostream& )
Endangered::print( ostream&, int )
Ошибка: ying_yang.print( cout ) -- неоднозначно, одна из
Bear::print( ostream& )
Endangered::print( ostream&, int )
Причина в том, что унаследованные функции-члены не образуют множество перегруженных функций внутри производного класса (см. раздел 17.3). Поэтому print() разрешается только по имени, а не по типам фактических аргументов. (О том, как производится разрешение, мы поговорим в разделе 18.4.)
В случае одиночного наследования указатель, ссылка или объект производного класса при необходимости автоматически преобразуются в указатель, ссылку или объект базового класса, которому открыто наследует производный. Это остается верным и для множественного наследования. Так, указатель, ссылку или сам объект класса Panda можно преобразовать в указатель, ссылку или объект ZooAnimal, Bear или Endangered:
extern void display( const Bear& );
extern void highlight( const Endangered& );
Panda ying_yang;
display( ying_yang ); // правильно
highlight( ying_yang ); // правильно
extern ostream&
operator<<( ostream&, const ZooAnimal& );
cout << ying_yang << endl; // правильно
Однако вероятность неоднозначных преобразований при множественном наследовании намного выше. Рассмотрим, к примеру, две функции:
extern void display( const Bear& );
extern void display( const Endangered& );
Неквалифицированный вызов display() для объекта класса Panda
Panda ying_yang;
display( ying_yang ); // ошибка: неоднозначность
приводит к ошибке компиляции:
Error: display( ying_yang ) -- ambiguous, one of
display( const Bear& );
display( const Endangered& );
Ошибка: display( ying_yang ) -- неоднозначно, одна из
display( const Bear& );
display( const Endangered& );
Компилятор не может различить два непосредственных базовых класса с точки зрения преобразования производного. Равным образом применимы обе трансформации. (Мы покажем способ разрешения этого конфликта в разделе 18.4.)
Чтобы понять, какое влияние оказывает множественное наследование на механизм виртуальных функций, определим их набор в каждом из непосредственных базовых классов Panda. (Виртуальные функции введены в разделе 17.2 и подробно обсуждались в разделе 17.5.)
class Bear : public ZooAnimal {
public:
virtual ~Bear();
virtual ostream& print( ostream& ) const;
virtual string isA() const;
// ...
};
class Endangered {
public:
virtual ~Endangered();
virtual ostream& print( ostream& ) const;
virtual void highlight() const;
// ...
};
Теперь определим в классе Panda собственный экземпляр print(), собственный деструктор и еще одну виртуальную функцию cuddle():
class Panda : public Bear, public Endangered
{
public:
virtual ~Panda();
virtual ostream& print( ostream& ) const;
virtual void cuddle();
// ...
};
Множество виртуальных функций, которые можно напрямую вызывать для объекта Panda, представлено в табл. 18.1.
Таблица 18.1. Виртуальные функции для класса Panda
Имя виртуальной функции |
Активный экземпляр |
деструктор | Panda::~Panda() |
print(ostream&) const | Panda::print(ostream&) |
isA() const | Bear::isA() |
highlight() const | Endangered::highlight() |
cuddle() | Panda::cuddle() |
Bear *pb = new Panda;
pb->print( cout ); // правильно: Panda::print(ostream&)
pb->isA(); // правильно: Bear::isA()
pb->cuddle(); // ошибка: это не часть интерфейса Bear
pb->highlight(); // ошибка: это не часть интерфейса Bear
delete pb; // правильно: Panda::~Panda()
(Обратите внимание, что если бы объекту класса Panda был присвоен указатель на ZooAnimal, то все показанные выше вызовы разрешались бы так же.)
Аналогично, если ссылка или указатель на объект Endangered инициализируется адресом объекта Panda или ему присваивается такой адрес, то части интерфейса, связанные с классами Panda и Bear, становятся недоступными:
Endangered *pe = new Panda;
pe->print( cout ); // правильно: Panda::print(ostream&)
// ошибка: это не часть интерфейса Endangered
pe->cuddle();
pe->highlight(); // правильно: Endangered::highlight()
delete pe; // правильно: Panda::~Panda()
Обработка виртуального деструктора выполняется правильно независимо от типа указателя, через который мы уничтожаем объект. Например, во всех четырех инструкциях порядок вызова деструкторов один и тот же – обратный порядку вызова конструкторов:
// ZooAnimal *pz = new Panda;
delete pz;
// Bear *pb = new Panda;
delete pb;
// Panda *pp = new Panda;
delete pp;
// Endangered *pe = new Panda;
delete pe;
Деструктор класса Panda вызывается с помощью механизма виртуализации. После его выполнения по очереди статически вызываются деструкторы Endangered и Bear, а в самом конце – ZooAnimal.
Почленная инициализация и присваивание объекту производного класса, наследующего нескольким базовым, ведут себя точно так же, как и при одиночном наследовании (см. раздел 17.6). Например, для нашего объявления класса Panda
class Panda : public Bear, public Endangered
{ ... };
в результате почленной инициализации объекта ling_ling
Panda yin_yang;
Panda ling_ling = yin_yang;
вызывается копирующий конструктор класса Bear (но, так как Bear производный от ZooAnimal, сначала выполняется копирующий конструктор класса ZooAnimal), затем – класса Endangered и только потом – класса Panda. Почленное присваивание ведет себя аналогично.
Упражнение 18.1
Какие из следующих объявлений ошибочны? Почему?
(a) class CADVehicle : public CAD, Vehicle { ... };
(b) class DoublyLinkedList:
public List, public List { ... };
(c) class iostream:
private istream, private ostream { ... };
Упражнение 18.2
Дана иерархия, в каждом классе которой определен конструктор по умолчанию:
class A { ... };
class B : public A { ... };
class C : public B { ... };
class X { ... };
class Y { ... };
class Z : public X, public Y { ... };
class MI : public C, public Z { ... };
Каков порядок вызова конструкторов в таком определении:
MI mi;
Упражнение 18.3
Дана иерархия, в каждом классе которой определен конструктор по умолчанию:
class X { ... };
class A { ... };
class B : public A { ... };
class C : private B { ... };
class D : public X, public C { ... };
Какие из следующих преобразований недопустимы:
D *pd = new D;
(a) X *px = pd; (c) B *pb = pd;
(b) A *pa = pd; (d) C *pc = pd;
Упражнение 18.4
Дана иерархия классов, обладающая приведенным ниже набором виртуальных функций:
class Base {
public:
virtual ~Base();
virtual ostream& print();
virtual void debug();
virtual void readOn();
virtual void writeOn();
// ...
};
class Derived1 : virtual public Base {
public:
virtual ~Derived1();
virtual void writeOn();
// ...
};
class Derived2 : virtual public Base {
public:
virtual ~Derived2();
virtual void readOn();
// ...
};
class MI : public Derived1, public Derived2 {
public:
virtual ~MI();
virtual ostream& print();
virtual void debug();
// ...
};
Какой экземпляр виртуальной функции вызывается в каждом из следующих случаев:
Base *pb = new MI;
(a) pb->print(); (c) pb->readOn(); (e) pb->log();
(b) pb->debug(); (d) pb->writeOn(); (f) delete pb;
Упражнение 18.5
На примере иерархии классов из упражнения 18.4 определите, какие виртуальные функции активны при вызове через pd1 и pd2:
(a) Derived1 *pd1 new MI;
(b) MI obj;
Derived2 d2 = obj;
Модель компиляции с разделением
Согласно этой модели объявления шаблонов функций помещаются в заголовочный файл, а определения – в файл с исходным текстом программы, т.е. объявления и определения шаблонов организованы так же, как в случае с невстроенными (non-inline) функциями. Например:
// model2.h
// модель с разделением
// сюда помещается только объявление шаблона
template <typename Type> Type min( Type t1, Type t2 );
// model2.C
// определение шаблона
export template <typename Type>
Type min( Type t1, Type t2 ) { /* ... */ }
Программа, которая конкретизирует шаблон функции min(), должна предварительно включить этот заголовочный файл:
// user.C
#include "model2.h"
int i, j;
double d = min ( i, j ); // правильно: здесь производится конкретизация
Хотя определение шаблона функции min() не видно в файле user.c, конкретизацию min(int,int) произвести можно. Но для этого шаблон min() должен быть определен специальным образом. Вы уже заметили, как именно? Если вы внимательно посмотрите на файл model2.c, то увидите, что определению шаблона функции min() предшествует ключевое слово export. Таким образом, шаблон min() становится экспортируемым. Слово export говорит компилятору, что данное определение шаблона может понадобиться для конкретизации функций в других файлах. В таком случае компилятор должен гарантировать, что это определение будет доступно во время конкретизации.
Для объявления экспортируемого шаблона перед ключевым словом template в его определении надо поместить слово export. Если шаблон экспортируется, то его разрешается конкретизировать в любом исходном файле программы – для этого нужно лишь объявить его перед использованием. Если слово export перед определением опущено, то компилятор может и не конкретизировать экземпляр функции min() с целыми параметрами и нам не удастся связать программу.
Обратите внимание, что в некоторых реализациях это ключевое слово не нужно, поскольку поддерживается расширение языка, согласно которому неэкспортированный шаблон функции может встречаться только в одном исходном файле, при этом экземпляры такого шаблона в других файлах конкретизируются правильно. Однако подобное поведение не соответствует стандарту, который требует, чтобы пользователь всегда помечал определения шаблонов функций как экспортируемые, если объявление шаблона видно в исходном файле до его конкретизации.
Ключевое слово export в объявлении шаблона, находящемся в заголовочном файле, можно опустить. Так, в объявлении min() в файле model2.h этого слова нет.
Шаблон функции должен быть определен как экспортируемый только один раз во всей программе. К сожалению, поскольку компилятор обрабатывает файлы один за другим, он обычно не замечает, что шаблон определен как экспортируемый в нескольких исходных файлах. В результате подобного недосмотра может произойти следующее:
при редактировании связей возникает ошибка, показывающая, что шаблон функции определен более, чем в одном файле;
компилятор несколько раз конкретизирует шаблон функции с одним и тем же множеством аргументов, что приводит к ошибке повторного определения функции при связывании программы;
компилятор может конкретизировать шаблон с помощью одного из его экспортированных определений, игнорируя все остальные.
Нельзя с уверенностью утверждать, что наличие в программе нескольких экспортируемых определений шаблона функции обязательно вызовет ошибку. При организации программы надо быть внимательным и следить за тем, чтобы подобные определения размещались только в одном исходном файле.
Модель с разделением позволяет отделить интерфейс шаблонов функций от его реализации и организовать программу так, что интерфейсы всех шаблонов помещаются в заголовочные файлы, а реализации – в файлы с исходным текстом. Однако не все компиляторы поддерживают такую модель, а те, которые поддерживают, не всегда делают это правильно: модель с разделением требует более изощренной среды программирования, которая доступна не во всех реализациях C++. (В другой нашей книге, “Inside C++ Object Model”, описан механизм конкретизации шаблонов, поддержанный в одной из реализаций C++, а именно в компиляторе Edison Design Group.)
Поскольку приводимые нами примеры работы с шаблонами невелики и поскольку мы хотим, чтобы они компилировались максимально большим числом компиляторов, мы ограничились использованием модели с включением.
компилятор неоднократно конкретизирует некоторый член одним и тем же множеством аргументов шаблона, что приводит к ошибке повторного определения во время связывания программы;
компилятор конкретизирует член с помощью одного из экспортированных определений шаблона, игнорируя все остальные.
Следовательно, нельзя утверждать, что при наличии в программе нескольких определений экспортированного члена шаблона обязательно будет сгенерирована ошибка. Создавая программу, надо быть внимательным и следить за тем, чтобы определения членов находились только в одном исходном файле.
Модель с разделением позволяет отделить интерфейс шаблона класса от его реализации и организовать программу так, что эти интерфейсы помещаются в заголовочные файлы, а реализации – в файлы с исходным текстом. Однако не все компиляторы поддерживают данную модель, а те, которые поддерживают, не всегда делают это правильно: для этого требуется более изощренная среда программирования, которая доступна не во всех реализациях C++.
В нашей книге используется только модель с включением, так как примеры работы с шаблонами небольшие и хотелось, чтобы они компилировались максимально большим числом компиляторов.
Модель компиляции с разделением
В этой модели определение шаблона класса и определения встроенных функций-членов помещаются в заголовочный файл, а определения невстроенных функций-членов и статических данных-членов– в файл с исходным текстом программы. Иными словами, определения шаблона класса и его членов организованы так же, как определения обычных классов (не шаблонов) и их членов:
// ---- Queue.h ----
// объявляет Queue как экспортируемый шаблон класса
export template <class Type>
class Queue {
// ...
public:
Type& remove();
void add( const Type & );
// ...
};
// ---- Queue.C ----
// экспортированное определение шаблона класса Queue
// находится в Queue.h
#include "Queue.h"
template <class Type>
void Queue<Type>::add( const Type &val ) { ... }
template <class Type>
Type& Queue<Type>::remove() { ... }
Программа, в которой используется конкретизированная функция-член, должна перед конкретизацией включить заголовочный файл:
// ---- User.C ----
#include "Queue.h"
int main() {
// конкретизация Queue<int>
Queue<int> *p_qi = new Queue<int>;
int ival;
// ...
// правильно: конкретизация Queue<int>::add( const int & )
p_qi->add( ival );
// ...
}
Хотя определение шаблона для функции-члена add() не видно в файле User.C, конкретизированный экземпляр Queue<int>::add(const int &) вызывать оттуда можно. Но для этого шаблон класса необходимо объявить экспортируемым.
Если он экспортируется, то для использования конкретизированных функций-членов или статических данных-членов необходимо знать лишь определение самого шаблона. Определения членов могут отсутствовать в тех файлах, где они конкретизируются.
Чтобы объявить шаблон класса экспортируемым, перед словом template в его определении или объявлении нужно поставить ключевое слово export:
export template <class Type>
class Queue { ... };
В нашем примере слово export применено к шаблону класса Queue в файле Queue.h; этот файл включен в файл Queue.C, содержащий определения функций-членов add() и remove(), которые автоматически становятся экспортируемыми и не должны присутствовать в других файлах перед конкретизацией.
Отметим, что, хотя шаблон класса объявлен экспортируемым, его собственное определение должно присутствовать в файле User.C. Конкретизация Queue<int>::add() в User.C вводит определение класса, в котором объявлены функции-члены Queue<int>::add() и Queue<int>::remove(). Эти объявления обязаны предшествовать вызову указанных функций. Таким образом, слово export влияет лишь на обработку функций-членов и статических данных-членов.
экспортируемыми можно объявлять также отдельные члены шаблона. В этом случае ключевое слово export указывается не перед шаблоном класса, а только перед экспортируемыми членами. Например, если автор шаблона класса Queue хочет экспортировать лишь функцию-член Queue<Type>::add() (т.е. изъять из заголовочного файла Queue.h только ее определение), то слово export можно указать именно в определении функции-члена add():
// ---- Queue.h ----
template <class Type>
class Queue {
// ...
public:
Type& remove();
void add( const Type & );
// ...
};
// необходимо, так как remove() не экспортируется
template <class Type>
Type& Queue<Type>::remove() { ... }
// ---- Queue.C ----
#include "Queue.h"
// экспортируется только функция-член add()
export template <class Type>
void Queue<Type>::add( const Type &val ) { ... }
Обратите внимание, что определение шаблона для функции-члена remove() перенесено в заголовочный файл Queue.h. Это необходимо, поскольку remove() более не находится в экспортируемом шаблоне и, следовательно, ее определение должно быть видно во всех файлах, где вызываются конкретизированные экземпляры.
Определение функции-члена или статического члена шаблона объявляется экспортируемым только один раз во всей программе. Поскольку компилятор обрабатывает файлы последовательно, он обычно не в состоянии определить, что эти члены объявлены экспортируемыми в нескольких исходных файлах. В таком случае результаты могут быть следующими:
при редактировании связей возникает ошибка, показывающая, что один и тот же член шаблона класса определен несколько раз;
Модель компиляции с включением
Согласно этой модели мы включаем определение шаблона в каждый файл, где этот шаблон конкретизируется. Обычно оно помещается в заголовочный файл, как и для встроенных функций. Именно такой моделью мы пользуемся в нашей книге. Например:
// model1.h
// модель с включением:
// определения шаблонов помещаются в заголовочный файл
template <typename Type>
Type min( Type t1, Type t2 ) {
return t1 < t2 ? t1 : t2;
}
Этот заголовочный файл включается в каждый файл, где конкретизируется функция min():
// определения шаблонов включены раньше
// используется конкретизация шаблона
#include "model1.h"
int i, j;
double dobj = min( i, j );
Заголовочный файл можно включить в несколько файлов с исходными текстами программы. Означает ли это, что компилятор конкретизирует экземпляр функции min() с целыми параметрами в каждом файле, где имеется обращение к ней? Нет. Программа должна вести себя так, словно min() с целыми параметрами определена только один раз. Где и когда в действительности конкретизируется шаблон функции, оставляется на усмотрение разработчика компилятора. Нам достаточно знать, что где-то в программе нужная функция min() была конкретизирована. (Как мы покажем далее, с помощью явного объявления конкретизации можно указать, где и когда оно должно быть выполнено. Такие объявления желательно использовать на поздних стадиях разработки продукта для улучшения производительности.)
Решение включать определения шаблонов функций в заголовочные файлы не всегда удачно. Тело шаблона описывает детали реализации, которые пользователям не интересны или которые мы хотели бы от них скрыть. В действительности, если определение шаблона велико, то количество кода в заголовочном файле может превысить разумные пределы. Кроме того, многократная компиляция одного и того же определения при обработке разных файлов увеличивает общее время компиляции программы. Отделить объявления шаблонов функций от их определений позволяет модель компиляции с разделением. Посмотрим, как ее можно использовать.
Модель компиляции с включением
В этой модели мы включаем определения функций-членов и статических членов шаблонов классов в каждый файл, где они конкретизируются. Для встроенных функций-членов, определенных в теле шаблона, это происходит автоматически. В противном случае такое определение следует поместить в один заголовочный файл с определением шаблона класса. Именно этой моделью мы и пользуемся в настоящей книге. Например, определения шаблонов Queue и QueueItem, как и их функций-членов и статических членов, находятся в заголовочном файле Queue.h.
Подобное размещение не лишено недостатков: определения функций-членов могут быть довольно большими и содержать детали реализации, которые неинтересны пользователям или должны быть скрыты от них. Кроме того, многократная компиляция одного определения шаблона при обработке разных файлов увеличивает общее время компиляции программы. Описанная модель (если она доступна) позволяет отделить интерфейс шаблона от реализации (т.е. от определений функций-членов и статических данных-членов).
Модели компиляции шаблонов *
Шаблон функции задает алгоритм для построения определений множества экземпляров функций. Сам шаблон не определяет никакой функции. Например, когда компилятор видит шаблон:
template <typename Type>
Type min( Type t1, Type t2 )
{
return t1 < t2 ? t1 : t2;
}
он сохраняет внутреннее представление min(), но и только. Позже, когда встретится ее реальное использование, скажем:
int i, j;
double dobj = min( i, j );
компилятор строит определение min() по сохраненному внутреннему представлению.
Здесь возникает несколько вопросов. Чтобы компилятор мог конкретизировать шаблон функции, должно ли его определение быть видимо при вызове экземпляра этой функции? Например, нужно ли определению шаблона min() появиться до ее конкретизации c целыми параметрами при инициализации dobj? Следует ли помещать шаблоны в заголовочные файлы, как мы поступаем с определениями встроенных (inline) функций? Или в заголовочные файлы можно помещать только объявления шаблонов, оставляя определения в файлах исходных текстов?
Чтобы ответить на эти вопросы, нам придется объяснить принятую в C++ модель компиляции шаблонов, сформулировать требования к организации определений и объявлений шаблонов в программах. В C++ поддерживаются две таких модели: модель с включением и модель с разделением. В данном разделе описываются обе модели и объясняется их использование.
Начинаем
В этой главе представлены основные элементы языка: встроенные типы данных, определения именованных объектов, выражений и операторов, определение и использование именованных функций. Мы посмотрим на минимальную законченную С++ программу, вкратце коснемся процесса компиляции этой программы, узнаем, что такое препроцессор, и бросим самый первый взгляд на поддержку ввода и вывода. Мы увидим также ряд простых, но законченных С++ программ.
Наилучшая из устоявших функций
Наследование влияет и на третий шаг разрешения перегрузки – выбор наилучшей из устоявших функций. На этом шаге ранжируются преобразования типов, с помощью которых можно привести фактические аргументы функции к типам соответственных формальных параметров. Следующие неявные преобразования имеют тот же ранг, что и стандартные (стандартные преобразования рассматривались в разделе 9.3):
преобразование аргумента типа производного класса в параметр типа любого из его базовых;
преобразование указателя на тип производного класса в указатель на тип любого из его базовых;
инициализация ссылки на тип базового класса с помощью l-значения типа производного.
Они не являются пользовательскими, так как не зависят от конвертеров и конструкторов, имеющихся в классе:
extern void release( const ZooAnimal& );
Panda yinYang;
// стандартное преобразование: Panda -> ZooAnimal
release( yinYang );
Поскольку аргумент yinYang типа Panda инициализирует ссылку на тип базового класса, то преобразование имеет ранг стандартного.
В разделе 15.10 мы говорили, что стандартные преобразования имеют более высокий ранг, чем пользовательские:
class Panda : public Bear,
public Endangered
{
// наследует ZooAnimal::operator const char *()
};
Panda yinYang;
extern void release( const ZooAnimal& );
extern void release( const char * );
// стандартное преобразование: Panda -> ZooAnimal
// выбирается: release( const ZooAnimal& )
release( yinYang );
Как release(const char*), так и release(ZooAnimal&) являются устоявшими функциями: первая потому, что инициализация параметра-ссылки значением аргумента – стандартное преобразование, а вторая потому, что аргумент можно привести к типу const char* с помощью конвертера ZooAnimal::operator const char*(), который представляет собой пользовательское преобразование. Так как стандартное преобразование лучше пользовательского, то в качестве наилучшей из устоявших выбирается функция release(const ZooAnimal&).
При ранжировании различных стандартных преобразований из производного класса в базовые лучшим считается приведение к тому базовому классу, который ближе к производному. Так, показанный ниже вызов не будет неоднозначным, хотя в обоих случаях требуется стандартное преобразование. Приведение к базовому классу Bear лучше, чем к ZooAnimal, поскольку Bear ближе к классу Panda. Поэтому лучшей из устоявших будет функция release(const Bear&):
extern void release( const ZooAnimal& );
extern void release( const Bear& );
// правильно: release( const Bear& )
release( yinYang );
Аналогичное правило применимо и к указателям. При ранжировании стандартных преобразований из указателя на тип производного класса в указатели на типы различных базовых лучшим считается то, для которого базовый класс наименее удален от производного. Это правило распространяется и на тип void*.
Стандартное преобразование в указатель на тип любого базового класса всегда лучше, чем преобразование в void*. Например, если дана пара перегруженных функций:
void receive( void* );
void receive( ZooAnimal* );
то наилучшей из устоявших для вызова с аргументом типа Panda* будет receive(ZooAnimal*).
В случае множественного наследования два стандартных преобразования из типа производного класса в разные типы базовых могут иметь одинаковый ранг, если оба базовых класса равноудалены от производного. Например, Panda наследует классам Bear и Endangered. Поскольку они равноудалены от производного Panda, то преобразования объекта Panda в любой из этих классов одинаково хороши. Но тогда единственной наилучшей из устоявших функции для следующего вызова не существует, и он считается ошибочным:
extern void mumble( const Bear& );
extern void mumble( const Endangered& );
/* ошибка: неоднозначный вызов:
* может быть выбрана любая из двух функций
* void mumble( const Bear& );
* void mumble( const Endangered& );
*/
mumble( yinYang );
Для разрешения неоднозначности программист может применить явное приведение типа:
mumble( static_cast< Bear >( yinYang ) ); // правильно
Инициализация объекта производного класса или ссылки на него объектом типа базового, а также преобразование указателя на тип базового класса в указатель на тип производного никогда не выполняются компилятором неявно. (Однако их можно выполнить с помощью явного применения dynamic_cast, как мы видели в разделе 19.1.) Для данного вызова не существует наилучшей из устоявших функции, так как нет неявного преобразования аргумента типа ZooAnimal в тип производного класса:
extern void release( const Bear& );
extern void release( const Panda& );
ZooAnimal za;
// ошибка: нет соответствия
release( za );
В следующем примере наилучшей из устоявших будет release(const char*). Это может показаться удивительным, так как к аргументу применена последовательность пользовательских преобразований, в которой участвует конвертер const char*(). Но поскольку неявного приведения от типа базового класса к типу производного не существует, то release(const Bear&) не является устоявшей функцией, так что остается только release(const char*):
Class ZooAnimal {
public:
// преобразование: ZooAnimal ==> const char*
operator const char*();
// ...
};
extern void release( const char* );
extern void release( const Bear& );
ZooAnimal za;
// za ==> const char*
// правильно: release( const char* )
release( za );Ошибка! Закладка не определена.Ошибка! Закладка не определена.Ошибка! Закладка не определена.
Упражнение 19.9
Дана такая иерархия классов:
class Base1 {
public:
ostream& print();
void debug();
void writeOn();
void log( string );
void reset( void *);
// ...
};
class Base2 {
public:
void debug();
void readOn();
void log( double );
// ...
};
class MI : public Base1, public Base2 {
public:
ostream& print();
using Base1::reset;
void reset( char * );
using Base2::log;
using Base2::log;
// ...
};
Какие функции входят в множество кандидатов для каждого из следующих вызовов:
MI *pi = new MI;
(a) pi->print(); (c) pi->readOn(); (e) pi->log( num );
(b) pi->debug(); (d) pi->reset(0); (f) pi->writeOn();
Упражнение 19.10
Дана такая иерархия классов:
class Base {
public:
operator int();
operator const char *();
// ...
};
class Derived : public Base {
public:
operator double();
// ...
};
Удастся ли выбрать наилучшую из устоявших функций для каждого из следующих вызовов? Назовите кандидаты, устоявшие функции и преобразования типов аргументов для каждой из них, наилучшую из устоявших (если она есть):
(a) void operate( double );
void operate( string );
void operate( const Base & );
Derived *pd = new Derived;
operate( *pd );
(b) void calc( int );
void calc( double );
void calc( const Derived & );
Base *pb = new Derived;
operate( *pb );
Наилучшая из устоявших функция
Наилучшей считается та из устоявших функций, формальные параметры которой наиболее точно соответствуют типам фактических аргументов. Для любой такой функции преобразования типов, применяемые к каждому аргументу, ранжируются для определения степени его соответствия параметру. (В разделе 6.2 описаны поддерживаемые преобразования типов.) Наилучшей из устоявших называют функцию, для которой одновременно выполняются два условия:
преобразования, примененные к аргументам, не хуже преобразований, необходимых для вызова любой другой устоявшей функции;
хотя бы для одного аргумента примененное преобразование лучше, чем для того же аргумента в любой другой устоявшей функции.
Может оказаться так, что для приведения фактического аргумента к типу соответствующего формального параметра нужно выполнить несколько преобразований. Так, в следующем примере
int arr[3];
void putValues(const int *);
int main() {
putValues(arr); // необходимо 2 преобразования
// массив в указатель + преобразование спецификатора
return 0;
}
для приведения аргумента arr от типа “массив из трех int” к типу “указатель на const int” применяется последовательность преобразований:
1. Преобразование массива в указатель, которое трансформирует массив из трех int в указатель на int.
2. Преобразование спецификатора, которое трансформирует указатель на int в указатель на const int.
Поэтому было бы более правильно говорить, что для приведения фактического аргумента к типу формального параметра устоявшей функции требуется последовательность преобразований. Поскольку применяется не одна, а несколько трансформаций, то на третьем шаге процесса разрешения перегрузки функции на самом деле ранжируются последовательности преобразований.
Рангом такой последовательности считается ранг самой плохой из входящих в нее трансформаций. Как объяснялось в разделе 9.2, преобразования типов ранжируются следующим образом: точное соответствие лучше расширения типа, а расширение типа лучше стандартного преобразования. В предыдущем примере оба изменения имеют ранг точного соответствия. Поэтому и у всей последовательности такой же ранг.
Такая совокупность состоит из нескольких преобразований, применяемых в указанном порядке:
преобразование l-значения ->
расширение типа или стандартное преобразование ->
преобразование спецификаторов
Термин преобразование l-значения относится к первым трем трансформациям из категории точных соответствий, рассмотренных в разделе 9.2: преобразование l-значения в r-значение, преобразование массива в указатель и преобразование функции в указатель. Последовательность трансформаций состоит из нуля или одного преобразования l-значения, за которым следует нуль или одно расширение типа или стандартное преобразование, и наконец нуль или одно преобразование спецификаторов. Для приведения фактического аргумента к типу формального параметра может быть применено только одна трансформация каждого вида.
Описанная последовательность называется последовательностью стандартных
преобразований. Существует также последовательность определенных пользователем преобразований, которая связана с функцией-конвертером, являющейся членом класса. (Конвертеры и последовательности определенных
пользователем преобразований рассматриваются в главе 15.)
Каковы последовательности изменений фактических аргументов в следующем примере?
namespace libs_R_us {
int max( int, int );
double max( double, double );
}
// using-объявление
using libs_R_us::max;
void func()
{
char c1, c2;
max( c1, c2 ); // вызывается libs_R_us::max( int, int )
}
Аргументы в вызове функции max() имеют тип char. Последовательность преобразований аргументов при вызове функции libs_R_us::max(int,int) следующая:
1a. Так как аргументы передаются по значению, то с помощью преобразования l-значения в r-значение извлекаются значения аргументов c1 и c2.
2a. С помощью расширения типа аргументы трансформируются из char в int.
Последовательность преобразований аргументов при вызове функции libs_R_us::max(double,double) следующая:
1b. С помощью преобразования l-значения в r-значение извлекаются значения аргументов c1 и c2.
2b. Стандартное преобразование между целым и плавающим типом приводит аргументы от типа char к типу double.
Ранг первой последовательности – расширение типа (самое худшее из примененных изменений), тогда как ранг второй – стандартное преобразование. Так как расширение типа лучше, чем преобразование, то в качестве наилучшей из устоявших для данного вызова выбирается функция libs_R_us::max(int,int).
Если ранжирование последовательностей преобразований аргументов не может выявить единственной устоявшей функции, то вызов считается неоднозначным. В данном примере для обоих вызовов calc() требуется такая последовательность:
1. Преобразование l-значения в r-значение для извлечения значений аргументов i и j.
2. Стандартное преобразование для приведения типов фактических аргументов к типам соответствующих формальных параметров.
Поскольку нельзя сказать, какая из этих последовательностей лучше другой, вызов неоднозначен:
int i, j;
extern long calc( long, long );
extern double calc( double, double );
void jj() {
// ошибка: неоднозначность, нет наилучшего соответствия
calc( i, j );
}
Преобразование спецификаторов (добавление спецификатора const или volatile к типу, который адресует указатель) имеет ранг точного соответствия. Однако, если две последовательности трансформаций отличаются только тем, что в конце одной из них есть дополнительное преобразование спецификаторов, то последовательность без него считается лучше. Например:
void reset( int * );
void reset( const int * );
int* pi;
int main() {
reset( pi ); // без преобразования спецификаторов лучше:
// выбирается reset( int * )
return 0;
}
Последовательность стандартных преобразований, примененная к фактическому аргументу для первой функции-кандидата reset(int*), – это точное соответствие, требуется лишь переход от l-значения к r-значению, чтобы извлечь значение аргумента. Для второй функции-кандидата reset(const int *) также применяется трансформация l-значения в r-значение, но за ней следует еще и преобразование спецификаторов для приведения результирующего значения от типа “указатель на int” к типу “указатель на const int”. Обе последовательности представляют собой точное соответствие, но неоднозначности при этом не возникает. Так как вторая последовательность отличается от первой наличием трансформации спецификаторов в конце, то последовательность без такого преобразования считается лучшей. Поэтому наилучшей из устоявших функций будет reset(int*).
Вот еще пример, в котором приведение спецификаторов влияет на то, какая последовательность будет выбрана:
int extract( void * );
int extract( const void * );
int* pi;
int main() {
extract( pi ); // выбирается extract( void * )
return 0;
}
Здесь для вызова есть две устоявших функции: extract(void*) и extract(const void*). Последовательность преобразований для функции extract(void*) состоит из трансформации l-значения в r-значение для извлечения значения аргумента, сопровождаемого стандартным преобразованием указателя: из указателя на int в указатель на void. Для функции extract(const void*) такая последовательность отличается от первой дополнительным преобразованием спецификаторов для приведения типа результата от указателя на void к указателю на const void. Поскольку последовательности различаются лишь этой трансформацией, то первая выбирается как более подходящая и, следовательно, наилучшей из устоявших будет функция extract(const void*).
Спецификаторы const и volatile влияют также на ранжирование инициализации параметров-ссылок. Если две такие инициализации отличаются только добавлением спецификатора const и volatile, то инициализация без дополнительной спецификации считается лучшей при разрешении перегрузки:
#include <vector>
void manip( vector<int> & );
void manip( const vector<int> & );
vector<int> f();
extern vector<int> vec;
int main() {
manip( vec ); // выбирается manip( vector<int> & )
manip( f() ); // выбирается manip( const vector<int> & )
return 0;
}
В первом вызове инициализация ссылок для вызова любой функции является точным соответствием. Но этот вызов все же не будет неоднозначным. Так как обе инициализации одинаковы во всем, кроме наличия дополнительной спецификации const во втором случае, то инициализация без такой спецификации считается лучше, поэтому перегрузка будет разрешена в пользу устоявшей функции manip(vector<int>&).
Для второго вызова существует только одна устоявшая функция manip(const vector<int>&). Поскольку фактический аргумент является временной переменной, содержащей результат, возвращенный f(), то такой аргумент представляет собой r-значение, которое нельзя использовать для инициализации неконстантного формального параметра-ссылки функции manip(vector<int>&). Поэтому наилучшей является единственная устоявшая manip(const vector<int>&).
Разумеется, у функций может быть несколько фактических аргументов. Выбор наилучшей из устоявших должен производиться с учетом ранжирования последовательностей преобразований всех аргументов. Рассмотрим пример:
extern int ff( char*, int );
extern int ff( int, int );
int main() {
ff( 0, 'a' ); // ff( int, int )
return 0;
}
Функция ff(), принимающая два аргумента типа int, выбирается в качестве наилучшей из устоявших по следующим причинам:
1. ее первый аргумент лучше. 0 дает точное соответствие с формальным параметром типа int, тогда как для установления соответствия с параметром типа char * требуется стандартное преобразование указателя;
2. ее второй аргумент имеет тот же ранг. К аргументу 'a' типа char для установления соответствия со вторым формальным параметром любой из двух функций должна быть применена последовательность преобразований, имеющая ранг расширения типа.
Вот еще один пример:
int compute( const int&, short );
int compute( int&, double );
extern int iobj;
int main() {
compute( iobj, 'c' ); // compute( int&, double )
return 0;
}
Обе функции compute( const int&, short ) и compute( int&, double ) устояли. Вторая выбирается в качестве наилучшей по следующим причинам:
1. ее первый аргумент лучше. Инициализация ссылки для первой устоявшей функции хуже потому, что она требует добавления спецификатора const, не нужного для второй функции;
2. ее второй аргумент имеет тот же ранг. К аргументу 'c' типа char для установления соответствия со вторым формальным параметром любой из двух функций должна быть применена последовательность трансформаций, имеющая ранг стандартного преобразования.
Наследование и композиция
Реализация класса PeekbackStack с помощью закрытого наследования от IntArray работает, но необходимо ли это? Помогло ли нам наследование в данном случае? Нет.
Открытое наследование – это мощный механизм для поддержки отношения “ЯВЛЯЕТСЯ”. Однако реализация PeekbackStack по отношению к IntArray – пример отношения “СОДЕРЖИТ”. Класс PeekbackStack содержит класс IntArray как часть своей реализации. Отношение “СОДЕРЖИТ”, как правило, лучше поддерживается с помощью композиции, а не наследования. Для ее реализации надо один класс сделать членом другого. В нашем случае объект IntArray делается членом PeekbackStack. Вот реализация PeekbackStack на основе композиции:
class PeekbackStack {
private:
const int static bos = -1;
public:
explicit PeekbackStack( int size ) :
stack( size ), _top( bos ) {}
bool empty() const { return _top == bos; }
bool full() const { return _top == size()-1; }
int top() const { return _top; }
int pop() {
if ( empty() )
/* обработать ошибку */ ;
return stack[ _top-- ];
}
void push( int value ) {
if ( full() )
/* обработать ошибку */ ;
stack[ ++_top ] = value;
}
bool peekback( int index, int &value ) const;
private:
int _top;
IntArray stack;
};
inline bool
PeekbackStack::
peekback( int index, int &value ) const
{
if ( empty() )
/* обработать ошибку */ ;
if ( index < 0 || index > _top )
{
value = stack[ _top ];
return false;
}
value = stack[ index ];
return true;
}
Решая, следует ли использовать при проектировании класса с отношением “СОДЕРЖИТ” композицию или закрытое наследование, можно руководствоваться такими соображениями:
если мы хотим заместить какие-либо виртуальные функции базового класса, то должны закрыто наследовать ему;
если мы хотим разрешить нашему классу ссылаться на класс из иерархии типов, то должны использовать композицию по ссылке (мы подробно расскажем о ней в разделе 18.3.4);
если, как в случае с классом PeekbackStack, мы хотим воспользоваться готовой реализацией, то композиция по значению предпочтительнее наследования. Если требуется отложенное выделение памяти для объекта, то следует выбрать композицию по ссылке (с помощью указателя).
Наследование и подтипизация классов
В главе 6 для иллюстрации обсуждения абстрактных контейнерных типов мы частично реализовали систему текстового поиска и инкапсулировали ее в класс TextQuery. Однако мы не написали к ней никакой вызывающей программы, отложив реализацию поддержки формулирования запросов со стороны пользователя до рассмотрения объектно-ориентированного программирования. В этой главе язык запросов будет реализован в виде иерархии классов Query с одиночным наследованием. Кроме того, мы модифицируем и расширим класс TextQuery из главы 6 для получения полностью интегрированной системы текстового поиска.
Программа для запуска нашей системы текстового поиска будет выглядеть следующим образом:
#include "TextQuery.h"
int main()
{
TextQuery tq;
tq.build_up_text();
tq.query_text();
}
build_text_map() – это слегка видоизмененная функция-член doit() из главы 6. Ее основная задача – построить отображение для хранения позиций всех значимых слов текста. (Если помните, мы не храним семантически нейтральные слова типа союзов if, and, but и т.д. Кроме того, мы заменяем заглавные буквы на строчные и устраняем суффиксы, обозначающие множественное число: например, testifies преобразуется в testify, а marches в march.) С каждым словом ассоциируется вектор позиций, в котором хранятся номера строки и колонки каждого вхождения слова в текст.
query_text() принимает запросы пользователя и преобразует их во внутреннюю форму на основе иерархии классов Query с одиночным наследованием и динамическим связыванием. Внутреннее представление запроса применяется к отображению слов на вектор позиций, построенному в build_text_map(). Ответом на запрос будет множество строк текстового файла, удовлетворяющих заданному критерию:
Enter a query - please separate each item by a space.
Terminate query (or session) with a dot( . ).
==> fiery && ( bird || shyly )
fiery ( 1 ) lines match
bird ( 1 ) lines match
shyly ( 1 ) lines match
( bird || shyly ) ( 2 ) lines match
fiery && ( bird || shyly ) ( 1 ) lines match
Requested query: fiery && ( bird || shyly )
( 3 ) like a fiery bird in flight. A beautiful fiery bird, he tells her.
В нашей системе мы выбрали следующий язык запросов:
одиночное слово, например Alice или untamed. Выводятся все строки, в которых оно встречается, причем каждой строке предшествует ее номер, заключенный в скобки. (Строки печатаются в порядке возрастания номеров). Например:
==> daddy
daddy ( 3 ) lines match
Requested query: daddy
( 1 ) Alice Emma has long flowing red hair. Her Daddy says
( 4 ) magical but untamed. "Daddy, shush, there is no such thing,"
( 6 ) Shyly, she asks, "I mean, Daddy, is there?"
запрос “НЕ”, формулируемый с помощью оператора !. Выводятся все строки, где не встречается указанное слово. Например, так формулируется отрицание запроса 1:
==> ! daddy
daddy ( 3 ) lines match
! daddy ( 3 ) lines match
Requested query: ! daddy
( 2 ) when the wind blows through her hair, it looks almost alive,
( 3 ) like a fiery bird in flight. A beautiful fiery bird, he tells her,
( 5 ) she tells him, at the same time wanting him to tell her more.
запрос “ИЛИ”, формулируемый с помощью оператора ||. Выводятся все строки, в которых встречается хотя бы одно из двух указанных слов:
==> fiery || untamed
fiery ( 1 ) lines match
untamed ( 1 ) lines match
fiery || untamed ( 2 ) lines match
Requested query: fiery || untamed
( 3 ) like a fiery bird in flight. A beautiful fiery bird, he tells her,
( 4 ) magical but untamed. "Daddy, shush, there is no such thing,"
запрос “И”, формулируемый с помощью оператора &&. Выводятся все строки, где оба указанных слова встречаются, причем располагаются рядом. Сюда входит и случай, когда одно слово является последним в строке, а другое – первым в следующей:
==> untamed && Daddy
untamed ( 1 ) lines match
daddy ( 3 ) lines match
untamed && daddy ( 1 ) lines match
Requested query: untamed && daddy
( 4 ) magical but untamed. "Daddy, shush, there is no such thing,"
Эти элементы можно комбинировать:
fiery && bird || shyly
Однако обработка производится слева направо, и все элементы имеют одинаковые приоритеты. Поэтому наш составной запрос интерпретируется как fiery bird ИЛИ shyly, а не как fiery bird ИЛИ fiery shyly:
==> fiery && bird || shyly
fiery ( 1 ) lines match
bird ( 1 ) lines match
fiery && bird ( 1 ) lines match
shyly ( 1 ) lines match
fiery && bird || shyly ( 2 ) lines match
Requested query: fiery && bird || shyly
( 3 ) like a fiery bird in flight. A beautiful fiery bird, he tells her,
( 6 ) Shyly, she asks, "I mean, Daddy, is there?"
Чтобы можно было группировать части запроса, наша система должна поддерживать скобки. Например:
fiery && (bird || shyly)
выдает все вхождения fiery bird или fiery shyly1. Результат исполнения этого запроса приведен в начале данного раздела. Кроме того, система не должна многократно отображать одну и ту же строку.
Навигация по элементам отображения
После того как мы построили отображение, хотелось бы распечатать его содержимое. Мы можем сделать это, используя итератор, начальное и конечное значение которого получают с помощью функций-членов begin() и end(). Вот текст функции display_map_text():
void
display_map_text( map<string,loc*> *text_map )
{
typedef map<string,loc*> tmap;
tmap::iterator iter = text_map->begin(),
iter_end = text_map->end();
while ( iter != iter_end )
{
cout << "word: " << (*iter).first << " (";
int loc_cnt = 0;
loc *text_locs = (*iter).second;
loc::iterator liter = text_locs->begin(),
liter_end = text_locs->end();
while (liter != liter_end ) {
if ( loc_cnt )
cout << ',';
else ++loc_cnt;
cout << '(' << (*liter).first
<< ',' << (*liter).second << ')';
++liter;
}
cout << ")\n";
++iter;
}
cout << endl;
}
Если наше отображение не содержит элементов, данная функция не нужна. Проверить, пусто ли оно, можно с помощью функции-члена size():
if ( text_map->size() )
display_map_text( text_map );
Но более простым способом, без подсчета элементов, будет вызов функции-члена empty():
if ( ! text_map->empty() )
display_map_text( text_map );
Навигация по множеству
Для проверки наших кодов реализуем небольшую функцию, выполняющую поиск по одному слову (поддержка языка запросов будет добавлена в главе 17). Если слово найдено, мы будем показывать каждую строку, в которой оно содержится. Слово может повторяться в строке, например:
tomorrow and tomorrow and tomorrow
однако такая строка будет представлена только один раз.
Одним из способов не учитывать повторное вхождение слова в строку является использование множества, как показано в следующем фрагменте кода:
// получим указатель на вектор позиций
loc ploc = (*text_map)[ query_text ];
// переберем все позиции
// вставим все номера строк в множество
set< short > occurrence_lines;
loc::iterator liter = ploc->begin(),
liter_end = ploc->end();
while ( liter != liter_end ) {
occurrence_lines.insert( occurrence_lines.end(),
(*liter).first );
++liter;
}
Контейнер set не допускает дублирования ключей. Поэтому можно гарантировать, что occurrence_lines не содержит повторений. Теперь нам достаточно перебрать данное множество, чтобы показать все номера строк, где встретилось данное слово:
register int size = occurrence_lines.size();
cout << "\n" << query_text
<< " встречается " << size
<< " раз(а):")
<< "\n\n";
set< short >::iterator it=occurrence_lines.begin();
for ( ; it != occurrence_lines.end(); ++it ) {
int line = -it;
cout << "\t( строка "
<< line + 1 << " ) "
<< (*text_file)[line] << endl;
}
(Полная реализация query_text() представлена в следующем разделе.)
Класс set поддерживает операции size(), empty() и erase() точно таким же образом, как и класс map, описанный выше. Кроме того, обобщенные алгоритмы предоставляют набор специфических функций для множеств, например set_union() (объединение) и set_difference() (разность). (Они использованы при реализации языка запросов в главе 17.)
Упражнение 6.23
Добавьте в программу множество слов, в которых заключающее 's' не подчиняется общим правилам и не должно удаляться. Примерами таких слов могут быть Pythagoras, Brahms и Burne_Jones. Включите в функцию suffix_s() из раздела 6.10 проверку этого набора.
Упражнение 6.24
Определите вектор, содержащий названия книг, которые вы собираетесь прочесть в ближайшие шесть виртуальных месяцев, и множество, включающее названия уже прочитанных произведений. Напишите программу, которая выбирает для вас книгу из вектора при условии, что вы ее еще не прочитали. Выбранное название программа должна заносить в множество прочитанных. Однако вы могли отложить книгу; следовательно, нужно обеспечить возможность удалять ее название из множества прочитанных. По окончании шести виртуальных месяцев распечатайте список прочитанного и непрочитанного.
Неявное преобразование типов
Язык определяет набор стандартных преобразований между объектами встроенного типа, неявно выполняющихся компилятором в следующих случаях:
арифметическое выражение с операндами разных типов: все операнды приводятся к наибольшему типу из встретившихся. Это называется арифметическим преобразованием. Например:
int ival = 3;
double dva1 = 3.14159;
// ival преобразуется в double: 3.0
ival + dva1;
присваивание значения выражения одного типа объекту другого типа. В этом случае результирующим является тип объекта, которому значение присваивается. Так, в первом примере литерал 0 типа int присваивается указателю типа int*, значением которого будет 0. Во втором примере double преобразуется в int.
// 0 преобразуется в нулевой указатель типа int*
int *pi = 0;
// dva1 преобразуется в int: 3
ivat = dva1;
передача функции аргумента, тип которого отличается от типа соответствующего формального параметра. Тип фактического аргумента приводится к типу параметра:
extern double sqrt( double );
// 2 преобразуется в double: 2.0
cout << "Квадратный корень из 2: " << sqrt( 2 ) << endt;
возврат из функции значения, тип которого не совпадает с типом возвращаемого результата, заданным в объявлении функции. Тип фактически возвращаемого значения приводится к объявленному. Например:
double difference( int ivati, int iva12 )
{
// результат преобразуется в double
return ivati - iva12;
}
Неявный указатель this
У каждого объекта класса есть собственная копия данных-членов. Например:
int main() {
Screen myScreen( 3, 3 ), bufScreen;
myScreen.clear();
myScreen.move( 2, 2 );
myScreen.set( '*' );
myScreen.display();
bufScreen.resize( 5, 5 );
bufScreen.display();
}
У объекта myScreen есть свои члены _width, _height, _cursor и _screen, а у объекта bufScreen – свои. Однако каждая функция-член класса существует в единственном экземпляре. Их и вызывают myScreen и bufScreen.
В предыдущем разделе мы видели, что функция-член может обращаться к членам своего класса, не используя операторы доступа. Так, определение функции move() выглядит следующим образом:
inline void Screen::move( int r, int c )
{
if ( checkRange( r, c ) ) // позиция на экране задана корректно?
{
int row = (r-1) * _width; // смещение строки
_cursor = row + c - 1;
}
}
Если функция move() вызывается для объекта myScreen, то члены _width и _height, к которым внутри нее имеются обращения, – это члены объекта myScreen. Если же она вызывается для объекта bufScreen, то и обращения производятся к членам данного объекта. Каким же образом _cursor, которым манипулирует move(), оказывается членом то myScreen, то bufScreen? Дело в указателе this.
Каждой функции-члену передается указатель на объект, для которого она вызвана, – this. В неконстантной функции-члене это указатель на тип класса, в константной – константный указатель на тот же тип, а в функции со спецификатором volatile указатель с тем же спецификатором. Например, внутри функции-члена move() класса Screen указатель this имеет тип Screen*, а в неконстантной функции-члене List – тип List*.
Поскольку this адресует объект, для которого вызвана функция-член, то при вызове move() для myScreen он указывает на объект myScreen, а при вызове для bufScreen – на объект bufScreen. Таким образом, член _cursor, с которым работает функция move(), в первом случае принадлежит объекту myScreen, а во втором – bufScreen.
Понять все это можно, если представить себе, как компилятор реализует объект this. Для его поддержки необходимо две трансформации:
1. Изменить определение функции-члена класса, добавив дополнительный параметр:
// псевдокод, показывающий, как происходит расширение
// определения функции-члена
// ЭТО НЕ КОРРЕКТНЫЙ КОД C++
inline void Screen::move( Screen *this, int r, int c )
{
if ( checkRange( r, c ) )
{
int row = (r-1) * this->_width;
this->_cursor = row + c - 1;
}
}
В этом определении использование указателя this для доступа к членам _width и _cursor сделано явным.
2. Изменение каждого вызова функции-члена класса с целью передачи одного дополнительного аргумента – адреса объекта, для которого она вызвана:
myScreen.move( 2, 2 );
транслируется в
move( &myScreen, 2, 2 );
Программист может явно обращаться к указателю this внутри функции. Так, вполне корректно, хотя и излишне, определить функцию-член home() следующим образом:
inline void Screen::home()
{
this->_cursor = 0;
}
Однако бывают случаи, когда без такого обращения не обойтись, как мы видели на примере функции-члена copy() класса Screen. В следующем подразделе мы рассмотрим и другие примеры.
Немного о комментариях
Комментарии помогают человеку читать текст программы; писать их грамотно считается правилом хорошего тона. Комментарии могут характеризовать используемый алгоритм, пояснять назначение тех или иных переменных, разъяснять непонятные места. При компиляции комментарии выкидываются из текста программы поэтому размер получающегося исполняемого модуля не увеличивается.
В С++ есть два типа комментариев. Один– такой же, как и в С, использующий символы /* для обозначения начала и */ для обозначения конца комментария. Между этими парами символов может находиться любой текст, занимающий одну или несколько строк: вся последовательность между /* и */ считается комментарием. Например:
/*
* Это первое знакомство с определением класса в C++.
* Классы используются как в объектном, так и в
* объектно-ориентированном программировании. Реализация
* класса Screen представлена в главе 13.
*/
class Screen {
/* Это называется телом класса */
public:
void home(); /* переместить курсор в позицию 0,0 */
void refresh ();/* перерисовать экран */
private:
/* Классы поддерживают "сокрытие информации" */
/* Сокрытие информации ограничивает доступ из */
/* программы к внутреннему представлению класса */
/* (его данным). Для этого используется метка */
/* "private:" */
int height, width;
}
Слишком большое число комментариев, перемежающихся с кодом программы, может ухудшить читаемость текста. Например, объявления переменных width и height в данном тексте окружены комментариями и почти не заметны. Рекомендуется писать развернутое объяснение перед блоком текста. Как и любая программная документация, комментарии должны обновляться в процессе модификации кода. Увы, нередко случается, что они относятся к устаревшей версии.
Комментарии в стиле С не могут быть вложенными. Попробуйте откомпилировать нижеследующую программу в своей системе. Большинство компиляторов посчитают ее ошибочной:
#include <iostream>
/* комментарии /* */ не могут быть вложенными.
* Строку "не вкладываются" компилятор рассматривает,
* как часть программы. Это же относится к данной и следующей строкам
*/
int main() {
cout << "Здравствуй, мир\n";
}
Один из способов решить проблему вложенных комментариев – поставить пробел между звездочкой и косой чертой:
/* * /
Последовательность символов */ считается концом комментария только в том случае, если между ними нет пробела.
Второй тип комментариев – однострочный. Он начинается последовательностью символов // и ограничен концом строки. Часть строки вправо от двух косых черт игнорируется компилятором. Вот пример нашего класса Screen с использованием двух строчных комментариев:
/*
* Первое знакомство с определением класса в C++.
* Классы используются как в объектном, так и в
* объектно-ориентированном программировании. Реализация
* класса Screen представлена в главе 13.
*/
class Screen {
// Это называется телом класса
public:
void home(); // переместить курсор в позицию 0,0
void refresh (); // перерисовать экран
private:
/* Классы поддерживают "сокрытие информации". */
/* Сокрытие информации ограничивает доступ из */
/* программы к внутреннему представлению класса */
/* (его данным). Для этого используется метка */
/* "private:" */
int height, width;
}
Обычно в программе употребляют сразу оба типа комментариев. Строчные комментарии удобны для кратких пояснений – в одну или полстроки, а комментарии, ограниченные /* и */, лучше подходят для развернутых многострочных пояснений.
Неоднозначность
Наличие в одном и том же классе конвертеров, выполняющих неявные преобразования во встроенные типы, и перегруженных операторов может приводить к неоднозначности при выборе между ними. Например, есть следующее определение класса String с функцией сравнения:
class String {
// ...
public:
String( const char * = 0 );
bool operator== ( const String & ) const;
// нет оператора operator== ( const char * )
};
и такое использование оператора operator==:
String flower( "tulip" );
void foo( const char *pf ) {
// вызывается перегруженный оператор String::operator==()
if ( flower == pf )
cout << pf << " is a flower!\en";
// ...
}
Тогда при сравнении
flower == pf
вызывается оператор равенства класса String:
String::operator==( const String & ) const;
Для трансформации правого операнда pf из типа const char* в тип String параметра operator==() применяется определенное пользователем преобразование, которое вызывает конструктор:
String( const char * )
Если добавить в определение класса String конвертер в тип const char*:
class String {
// ...
public:
String( const char * = 0 );
bool operator== ( const String & ) const;
operator const char*(); // новый конвертер
};
то показанное использование operator==() становится неоднозначным:
// проверка на равенство больше не компилируется!
if (flower == pf)
Из-за добавления конвертера operator const char*() встроенный оператор сравнения
bool operator==( const char *, const char * )
тоже считается устоявшей функцией. С его помощью левый операнд flower типа String может быть преобразован в тип const char *.
Теперь для использования operator==() в foo() есть две устоявших операторных функции. Первая из них
String::operator==( const String & ) const;
требует применения определенного пользователем преобразования правого операнда pf из типа const char* в тип String. Вторая
bool operator==( const char *, const char * )
требует применения пользовательского преобразования левого операнда flower из типа String в тип const char*.
Таким образом, первая устоявшая функция лучше для левого операнда, а вторая– для правого. Поскольку наилучшей функции не существует, то вызов помечается компилятором как неоднозначный.
При проектировании интерфейса класса, включающего объявление перегруженных операторов, конструкторов и конвертеров, следует быть весьма аккуратным. Определенные пользователем преобразования применяются компилятором неявно. Это может привести к тому, что встроенные операторы окажутся устоявшими при разрешении перегрузки для операторов с операндами типа класса.
Упражнение 15.17
Назовите пять множеств функций-кандидатов, рассматриваемых при разрешении перегрузки оператора с операндами типа класса.
Упражнение 15.18
Какой из операторов operator+() будет выбран в качестве наилучшего из устоявших для оператора сложения в main()? Перечислите все функции-кандидаты, все устоявшие функции и преобразования типов, которые надо применить к аргументам для каждой устоявшей функции.
namespace NS {
class complex {
complex( double );
// ...
};
class LongDouble {
friend LongDouble operator+( LongDouble &, int ) { /* ... */ }
public:
LongDouble( int );
operator double();
LongDouble operator+( const complex & );
// ...
};
LongDouble operator+( const LongDouble &, double );
}
int main() {
NS::LongDouble ld(16.08);
double res = ld + 15.05; // какой operator+?
return 0;
}
Несколько слов о заголовочных файлах
Заголовочный файл предоставляет место для всех extern-объявлений объектов, объявлений функций и определений встроенных функций. Это называется локализацией объявлений. Те исходные файлы, где объект или функция определяется или используется, должны включать заголовочный файл.
Такие файлы позволяют добиться двух целей. Во-первых, гарантируется, что все исходные файлы содержат одно и то же объявление для глобального объекта или функции. Во-вторых, при необходимости изменить объявление это изменение делается в одном месте, что исключает возможность забыть внести правку в какой-то из исходных файлов.
Пример с addToken() имеет следующий заголовочный файл:
// ----- token.h -----
typedef unsigned char uchar;
const uchar INLINE = 128;
// ...
const uchar IT = ...;
const uchar GT = ...;
extern uchar lastTok;
extern int addToken( uchar );
inline bool is_relational( uchar tok )
{ return (tok >= LT && tok <= GT); }
// ----- lex.C -----
#include "token.h"
// ...
// ----- token.C -----
#include "token.h"
// ...
При проектировании заголовочных файлов нужно учитывать несколько моментов. Все объявления такого файла должны быть логически связанными. Если он слишком велик или содержит слишком много не связанных друг с другом элементов, программисты не станут включать его, экономя на времени компиляции. Для уменьшения временных затрат в некоторых реализациях С++ предусматривается использование предкомпилированных
заголовочных файлов. В руководстве к компилятору сказано, как создать такой файл из обычного. Если в вашей программе используются большие заголовочные файлы, применение предкомпиляции может значительно сократить время обработки.
Чтобы это стало возможным, заголовочный файл не должен содержать объявлений встроенных (inline) функций и объектов. Любая из следующих инструкций является определением и, следовательно, не может быть использована в заголовочном файле:
extern int ival = 10;
double fica_rate;
extern void dummy () {}
Хотя переменная i объявлена с ключевым словом extern, явная инициализация превращает ее объявление в определение. Точно так же и функция dummy(), несмотря на явное объявление как extern, определяется здесь же: пустые фигурные скобки содержат ее тело. Переменная fica_rate определяется и без явной инициализации: об этом говорит отсутствие ключевого слова extern. Включение такого заголовочного файла в два или более исходных файла одной программы вызовет ошибку связывания – повторные определения объектов.
В файле token.h, приведенном выше, константа INLINE и встроенная функция is_relational() кажутся нарушающими правило. Однако это не так.
Определения символических констант и встроенных функций являются специальными видами определений: те и другие могут появиться в программе несколько раз.
При возможности компилятор заменяет имя символической константы ее значением. Этот процесс называют подстановкой константы. Например, компилятор подставит 128 вместо INLINE везде, где это имя встретится в исходном файле. Для того чтобы компилятор произвел такую замену, определение константы (значение, которым она инициализирована) должно быть видимо в том месте, где она используется. Определение символической константы может появиться несколько раз в разных файлах, потому что в результирующем исполняемом файле благодаря подстановке оно будет только одно.
В некоторых случаях, однако, такая подстановка невозможна. Тогда лучше вынести инициализацию константы в отдельный исходный файл. Это делается с помощью явного объявления константы как extern. Например:
// ----- заголовочный файл -----
const int buf_chunk = 1024;
extern char *const bufp;
// ----- исходный файл -----
char *const bufp = new char[buf_chunk];
Хотя bufp объявлена как const, ее значение не может быть вычислено во время компиляции (она инициализируется с помощью оператора new, который требует вызова библиотечной функции). Такая конструкция в заголовочном файле означала бы, что константа определяется каждый раз, когда этот заголовочный файл включается. Символическая константа – это любой объект, объявленный со спецификатором const. Можете ли вы сказать, почему следующее объявление, помещенное в заголовочный файл, вызывает ошибку связывания, если такой файл включается в два различных исходных?
// ошибка: не должно быть в заголовочном файле
const char* msg = "?? oops: error: ";
Проблема вызвана тем, что msg не константа. Это неконстантный указатель, адресующий константу. Правильное объявление выглядит так (полное описание объявлений указателей см. в главе 3):
const char *const msg = "?? oops: error: ";
Такое определение может появиться в разных файлах.
Схожая ситуация наблюдается и со встроенными функциями. Для того чтобы компилятор мог подставить тело функции “по месту”, он должен видеть ее определение. (Встроенные функции были представлены в разделе 7.6.)
Следовательно, встроенная функция, необходимая в нескольких исходных файлах, должна быть определена в заголовочном файле. Однако спецификация inline – только “совет” компилятору. Будет ли функция встроенной везде или только в данном конкретном месте, зависит от множества обстоятельств. Если компилятор пренебрегает спецификацией inline, он генерирует определение функции в исполняемом файле. Если такое определение появится в данном файле больше одного раза, это будет означать ненужную трату памяти.
Большинство компиляторов выдают предупреждение в любом из следующих случаев (обычно это требует включения режима выдачи предупреждений):
само определение функции не позволяет встроить ее. Например, она слишком сложна. В таком случае попробуйте переписать функцию или уберите спецификацию inline и поместите определение функции в исходный файл;
конкретный вызов функции может не быть “подставлен по месту”. Например, в оригинальной реализации С++ компании AT&T (cfront) такая подстановка невозможна для второго вызова в пределах одного и того же выражения. В такой ситуации выражение следует переписать, разделив вызовы встроенных функций.
Перед тем как употребить спецификацию inline, изучите поведение функции во время выполнения. Убедитесь, что ее действительно можно встроить. Мы не рекомендуем объявлять функции встроенными и помещать их определения в заголовочный файл, если они не могут быть таковыми по своей природе.
Упражнение 8.3
Установите, какие из приведенных ниже инструкций являются объявлениями, а какие – определениями, и почему:
(a) extern int ix = 1024;
(b) int iy;
(c) extern void reset( void *p ) { /* ... */ }
(d) extern const int *pi;
(e) void print( const matrix & );
Упражнение 8.4
Какие из приведенных ниже объявлений и определений вы поместили бы в заголовочный файл? В исходный файл? Почему?
(a) int var;
(b) inline bool is_equal( const SmallInt &, const SmallInt & ){ }
(c) void putValues( int *arr, int size );
(d) const double pi = 3.1416;
(e) extern int total = 255;
Объединение – класс, экономящий память
Объединение – это специальный вид класса. Данные-члены хранятся в нем таким образом, что перекрывают друг друга. Все члены размещаются, начиная с одного и того же адреса. Для объединения отводится столько памяти, сколько необходимо для хранения самого большого его члена. В любой момент времени можно присвоить значение лишь одному такому члену.
Рассмотрим пример, иллюстрирующий использование объединения. Лексический анализатор, входящий в состав компилятора, разбивает программу на последовательность лексем. Так, инструкция
int i = 0;
преобразуется в последовательность из пяти лексем:
1. Ключевое слово int.
2. Идентификатор i.
3. Оператор =
4. Константа 0 типа int.
5. Точка с запятой.
Лексический анализатор передает эти лексемы синтаксическому анализатору, парсеру, который идентифицирует полученную последовательность. Полученная информация должна дать парсеру возможность распознать эту последовательность лексем как объявление. Для этого с каждой лексемой ассоциируется информация, позволяющая парсеру увидеть следующее:
Type ID Assign Constant Semicolon
(Тип ИД Присваивание Константа Точка с запятой)
Далее парсер анализирует значения каждой лексемы. В данном случае он видит:
Type <==> int
ID <==> i
Constant <==> 0
Для Assign и Semicolon дополнительной информации не нужно, так как у них может быть только одно значение: соответственно := и ;.
Таким образом, в представлении лексемы могло бы быть два члена – token и value. token – это уникальный код, показывающий, что лексема имеет тип Type, ID, Assign, Constant или Semicolon, например 85 для ID и 72 для Semicolon.value содержит конкретное значение лексемы. Так, для лексемы ID в предыдущем объявлении value будет содержать строку "i", а для лексемы Type – некоторое представление типа int.
Представление члена value несколько проблематично. Хотя для любой отдельной лексемы в нем хранится всего одно значение, их типы для разных лексем могут различаться. Для лексемы ID в value хранится строка символов, а для Constant – целое число.
Конечно, для хранения данных нескольких типов можно использовать класс. Разработчик компилятора может объявить, что value принадлежит к типу класса, в котором для каждого типа данных есть отдельный член.
Применение класса решает проблему представления value. Однако для любой данной лексемы value имеет лишь один из множества возможных типов и, следовательно, будет задействован только один член класса, хотя памяти выделяется столько, сколько нужно для хранения всех членов. Чтобы память резервировалась только для нужного в данный момент члена, применяется объединение. Вот как оно определяется:
union TokenValue {
char _cval;
int _ival;
char *_sval;
double _dval;
};
Если самым большим типом среди всех членов TokenValue является dval, то размер TokenValue будет равен размеру объекта типа double. По умолчанию члены объединения открыты. Имя объединения можно использовать в программе всюду, где допустимо имя класса:
// объект типа TokenValue
TokenValue last_token;
// указатель на объект типа TokenValue
TokenValue *pt = new TokenValue;
Обращение к членам объединения, как и к членам класса, производится с помощью операторов доступа:
last_token._ival = 97;
char ch = pt->_cval;
Члены объединения можно объявлять открытыми, закрытыми или защищенными:
union TokenValue {
public:
char _cval;
// ...
private:
int priv;
}
int main() {
TokenValue tp;
tp._cval = '\n'; // правильно
// ошибка: main() не может обращаться к закрытому члену
// TokenValue::priv
tp.priv = 1024;
}
У объединения не бывает статических членов или членов, являющихся ссылками. Его членом не может быть класс, имеющий конструктор, деструктор или копирующий оператор присваивания. Например:
union illegal_members {
Screen s; // ошибка: есть конструктор
Screen *ps; // правильно
static int is; // ошибка: статический член
int &rfi; // ошибка: член-ссылка
};
Для объединения разрешается определять функции-члены, включая конструкторы и деструкторы:
union TokenValue {
public:
TokenValue(int ix) : _ival(ix) { }
TokenValue(char ch) : _cval(ch) { }
// ...
int ival() { return _ival; }
char cval() { return _cval; }
private:
int _ival;
char _cval;
// ...
};
int main() {
TokenValue tp(10);
int ix = tp.ival();
//...
}
Вот пример работы объединения TokenValue:
enum TokenKind ( ID, Constant /* и другие типы лексем */ }
class Token {
public:
TokenKind tok;
TokenValue val;
};
Объект типа Token можно использовать так:
int lex() {
Token curToken;
char *curString;
int curIval;
// ...
case ID: // идентификатор
curToken.tok = ID;
curToken.val._sval = curString;
break;
case Constant: // целая константа
curToken.tok = Constant;
curToken.val._ival = curIval;
break;
// ... и т.д.
}
Опасность, связанная с применением объединения, заключается в том, что можно случайно извлечь хранящееся в нем значение, пользуясь не тем членом. Например, если в последний раз значение присваивалось _ival, то вряд ли понадобится значение, оказавшееся в _sval. Это, по всей вероятности, приведет к ошибке в программе.
Чтобы защититься от подобного рода ошибок, следует создать дополнительный объект, дискриминант объединения, определяющий тип значения, которое в данный момент хранится в объединении. В классе Token роль такого объекта играет член tok:
char *idVal;
// проверить значение дискриминанта перед тем, как обращаться к sval
if ( curToken.tok == ID )
idVal = curToken.val._sval;
При работе с объединением, являющимся членом класса, полезно иметь набор функций для каждого хранящегося в объединении типа данных:
#include <cassert>
// функции доступа к члену объединения sval
string Token::sval() {
assert( tok==ID );
return val._sval;
}
Имя в определении объединения задавать необязательно. Если оно не используется в программе как имя типа для объявления других объектов, его можно опустить. Например, следующее определение объединения Token эквивалентно приведенному выше, но без указания имени:
class Token {
public:
TokenKind tok;
// имя типа объединения опущено
union {
char _cval;
int _ival;
char *_sval;
double _dval;
} val;
};
Существует анонимное объединение – объединение без имени, за которым не следует определение объекта. Вот, например, определение класса Token, содержащее анонимное объединение:
class Token {
public:
TokenKind tok;
// анонимное объединение
union {
char _cval;
int _ival;
char *_sval;
double _dval;
};
};
К данным-членам анонимного объединения можно напрямую обращаться в той области видимости, в которой оно определено. Перепишем функцию lex(), используя предыдущее определение:
int lex() {
Token curToken;
char *curString;
int curIval;
// ... выяснить, что находится в лексеме
// ... затем установить curToken
case ID:
curToken.tok = ID;
curToken._sval = curString;
break;
case Constant: // целая константа
curToken.tok = Constant;
curToken._ival = curIval;
break;
// ... и т.д.
}
Анонимное объединение позволяет убрать один уровень доступа, поскольку обращение к его членам идет как к членам класса Token. У него не может быть закрытых или защищенных членов, а также функций-членов. Такое объединение, определенное в глобальной области видимости, должно быть объявлено в безымянном пространстве имен или иметь модификатор static.
Объектно-ориентированное проектирование
Из чего складывается объектно-ориентированное проектирование четырех рассмотренных выше видов запросов? Как решаются проблемы их внутреннего представления?
С помощью наследования
можно определить взаимосвязи между независимыми классами запросов. Для этого мы вводим в рассмотрение абстрактный класс Query, который будет служить для них базовым
(соответственно сами эти классы будут считаться производными). Абстрактный класс можно представить себе как неполный, который становится более или менее завершенным, когда из него порождаются производные классы, – в нашем случае AndQuery, OrQuery, NotQuery и NameQuery.
В нашем абстрактном классе Query определены данные и функции-члены, общие для всех четырех типов запроса. При порождении из Query производного класса, скажем AndQuery, мы выделяем уникальные характеристики каждого вида запроса. К примеру, NameQuery – это специальный вид Query, в котором операндом всегда является строка. Мы будем называть NameQuery производным и говорить, что Query является его базовым классом. (То же самое относится и к классам, представляющим другие типы запросов.) Производный класс наследует данные и функции-члены базового и может обращаться к ним непосредственно, как к собственным членам.
Основное преимущество иерархии наследования в том, что мы программируем открытый интерфейс абстрактного базового класса, а не отдельных производных от него специализированных типов, что позволяет защитить наш код от последующих изменений иерархии. Например, мы определяем eval() как открытую виртуальную функцию абстрактного базового класса Query. Пользовательский код, записанный в виде:
_rop->eval();
экранирован от любых изменений в языке запросов. Это не только позволяет добавлять, модифицировать и удалять типы, не изменяя программы пользователя, но и освобождает автора нового вида запроса от необходимости заново реализовывать поведение или действия, общие для всех типов в иерархии. Такая гибкость достигается за счет двух характеристик механизма наследования: полиморфизма
и динамического связывания.
Когда мы говорим о полиморфизме в языке C++, то имеем в виду главным образом способность указателя или ссылки на базовый класс адресовать любой из производных от него. Если определить обычную функцию eval() следующим образом:
// pquery может адресовать любой из классов, производных от Query
void eval( const Query *pquery )
{
pquery->eval();
}
то мы вправе вызывать ее, передавая адрес объекта любого из четырех типов запросов:
int main()
{
AndQuery aq;
NotQuery notq;
OrQuery *oq = new OrQuery;
NameQuery nq( "Botticelli" );
// правильно: любой производный от Query класс
// компилятор автоматически преобразует в базовый класс
eval( &aq );
eval( ¬q );
eval( oq );
eval( &nq );
}
В то же время попытка передать eval() адрес объекта класса, не являющегося производным от Query, вызовет ошибку компиляции:
int main()
{
string name( "Scooby-Doo" );
// ошибка: тип string не является производным от Query
eval( &name );
}
Внутри eval() выполнение инструкции вида
pquery->eval();
должно вызывать нужную виртуальную функцию-член eval() в зависимости от фактического класса объекта, адресуемого указателем pquery. В примере выше pquery последовательно адресует объекты AndQuery, NotQuery, OrQuery и NameQuery. В каждой точке вызова определяется фактический тип класса объекта и вызывается подходящий экземпляр eval().
Механизм, с помощью которого это достигается, называется динамическим связыванием. (Мы вернемся к проектированию и использованию виртуальных функций в разделе 17.5.)
В объектно-ориентированной парадигме программист манипулирует неизвестным экземпляром, принадлежащим к одному из ограниченного, но потенциально бесконечного множества различных типов. (Ограничено оно иерархией наследования. Теоретически, однако, ни на глубину, ни на ширину такой иерархии не накладывается никаких ограничений.) В C++ это достигается путем манипулирования объектами исключительно через указатели и ссылки на базовый класс. В объектной (не объектно-ориентированной) парадигме программист работает с экземпляром фиксированного типа, который полностью определен на этапе компиляции.
Хотя для полиморфной манипуляции объектом требуется, чтобы доступ к нему осуществлялся с помощью указателя или ссылки, сам по себе факт их использования не обязательно приводит к полиморфизму. Рассмотрим такие объявления:
// полиморфизма нет
int *pi;
// нет поддержанного языком полиморфизма
void *pvi;
// pquery может адресовать объект любого производного от Query класса
Query *pquery;
В C++ полиморфизм существует только в пределах отдельных иерархий классов. Указатели типа void* можно назвать полиморфными, но в языке их поддержка не предусмотрена. Такими указателями программист должен управлять самостоятельно, с помощью явных приведений типов и той или иной формы дискриминанта, показывающего, объект какого типа в данный момент адресуется. (Можно сказать, что это “второсортные” полиморфные объекты.)
Язык C++ обеспечивает поддержку полиморфизма следующими способами:
путем неявного преобразования указателя или ссылки на производный класс к указателю или ссылке на открытый базовый:
Query *pquery = new NameQuery( "Class" );
через механизм виртуальных функций:
pquery->eval();
с помощью операторов dynamic_cast и typeid (они подробно обсуждаются в разделе 19.1):
if ( NameQuery *pnq =
dynamic_cast< NameQuery* >( pquery )) ...
Проблему представления запроса мы решим, определив каждый операнд в классах AndQuery, NotQuery и OrQuery как указатель на тип Query*. Например:
class AndQuery {
public:
// ...
private:
Query *_lop;
Query *_rop;
};
Теперь оба операнда могут адресовать объект любого класса, производного от абстрактного базового класса Query, без учета того, определен он уже сейчас или появится в будущем. Благодаря механизму виртуальных функций, вычисление операнда, происходящее во время выполнения программы, не зависит от фактического типа:
_rop->eval();
На рис. 17.1 показана иерархия наследования, состоящая из абстрактного класса Query и четырех производных от него классов. Как этот рисунок транслируется в код программы на C++?
Query
AndQuery OrQuery NotQuery NameQuery
Рис. 17.1. Иерархия классов Query
В разделе 2. 4 мы рассматривали реализацию иерархии классов IntArray. Синтаксическая структура определения иерархии, изображенной на рис. 17.1, аналогична:
class Query { ... };
class AndQuery : public Query { ... };
class OrQuery : public Query { ... };
class NotQuery : public Query { ... };
class NameQuery : public Query { ... };
Наследование задается с помощью списка базовых классов. В случае одиночного наследования этот список имеет вид:
: уровень-доступа базовый-класс
где уровень-доступа – это одно из ключевых слов public, protected, private (смысл защищенного и закрытого наследования мы обсудим в разделе 18.3), а базовый-класс – имя ранее определенного класса. Например, Query является открытым базовым классом для любого из четырех классов запросов.
Класс, встречающийся в списке базовых, должен быть предварительно определен. Следующего опережающего объявления Query недостаточно для того, чтобы он мог выступать в роли базового:
// ошибка: Query должен быть определен
class Query;
class NameQuery : piblic Query { ... };
Опережающее объявление производного класса должно включать только его имя, но не список базовых классов. Поэтому следующее опережающее объявление класса NameQuery приводит к ошибке компиляции:
// ошибка: опережающее объявление не должно
// включать списка базовых классов
class NameQuery : public Query;
Правильный вариант в данном случае выглядит так:
// опережающее объявление как производного,
// так и обычного класса содержит только имя класса
class Query;
class NameQuery;
Главное различие между базовыми классами Query и IntArray (см. раздел 2.4) состоит в том, что Query не представляет никакого реального объекта в нашем приложении. Пользователи класса IntArray вполне могут определять и использовать объекты этого типа непосредственно. Что же касается Query, то разрешается определять лишь указатели и ссылки на него, используя их для косвенного манипулирования объектами производных классов. О Query говорят, что это абстрактный базовый класс. В противоположность этому IntArray является конкретным базовым классом. Преобладающей формой в объектно-ориентированном проектировании является определение абстрактного базового класса типа Query и одиночное открытое наследование ему.
Упражнение 17.1
Библиотека может выдавать на руки предметы, для каждого из которых определены специальные правила выдачи и возврата. Организуйте их в иерархию наследования:
книга аудио-книга
аудиокассета детская кукла
видеокассета видеоигра для приставки SEGA
книга с подневной оплатой видеоигра для приставки SONY
книга на компакт-диске видеоигра для приставки Nintendo
Упражнение 17.2
Выберите или придумайте собственную абстракцию, содержащую семейство типов. Организуйте типы в иерархию наследования:
(a) Форматы графических файлов (gif, tiff, jpeg, bmp и т.д.)
(b) Геометрические примитивы (прямоугольник, круг, сфера, конус и т.д.)
(c) Типы языка C++ (класс, функция, функция-член и т.д.)
Объектно-ориентированный подход
Вспомним спецификацию нашего массива в предыдущем разделе. Мы говорили о том, что некоторым пользователям может понадобиться упорядоченный массив, в то время как большинство, скорее всего, удовлетворится и неупорядоченным. Если представить себе, что наш массив IntArray упорядочен, то реализация таких функций, как min(), max(), find(), должна отличаться от их реализации для массива неупорядоченного большей эффективностью. Вместе с тем, для поддержания массива в упорядоченном состоянии все прочие функции должны быть сильно усложнены.
Мы выбрали наиболее общий случай – неупорядоченный массив. Но как же быть с теми немногочисленными пользователями, которым обязательно нужна функциональность массива упорядоченного? Мы должны специально для них создать другой вариант массива?
А вот и еще одна категория недовольных пользователей: их не удовлетворяют накладные расходы на проверку правильности индекса. Мы исходили из того, что корректность работы нашего класса превыше всего, и старались обезопасить себя от ошибочных ситуаций. Но возьмем, к примеру, разработчиков систем виртуальной реальности. Трехмерные изображения должны строиться с максимально возможной скоростью, быть может, за счет точности.
Да, мы можем удовлетворить и тех и других, создав для каждой группы пользователей свой, немного модернизированный, вариант IntArray. Более того, его даже не слишком трудно сделать, поскольку мы старались создать хорошую реализацию и необходимые изменения затронут совсем небольшие участки кода. Итак, копируем исходный текст, вносим необходимые изменения в нужные места и получаем три класса:
// неупорядоченный массив без проверки границ индекса
class IntArray { ... };
// неупорядоченный массив с проверкой границ индекса
class IntArrayRC { ... };
// упорядоченный массив без проверки границ индекса
class IntSortedArray { ... };
Подобное решение имеет следующие недостатки:
нам необходимо сопровождать три копии кода, различающиеся весьма незначительно. Хорошо бы выделить общие участки кода. Кроме упрощения сопровождения, это позволит использовать их впоследствии, если мы захотим создать еще один вариант массива, например упорядоченный с проверкой границ индекса;
если понадобится какая- то общая функция для обработки всех наших массивов, то нам придется написать три копии, поскольку типы ее параметров будут различаться:
void process_array (IntArray&);
void process_array (IntArrayRC&);
void process_array (IntSortedArray&);
хотя реализация этих функций может быть совершенно идентичной. Было бы лучше написать единственную функцию, которая могла бы работать не только со всеми нашими массивами, но и с теми их вариациями, какие мы, возможно, реализуем впоследствии.
Парадигма объектно-ориентированного программирования позволяет осуществить все эти пожелания. Механизм наследования обеспечивает пожелания из первого пункта. Если один класс является потомком другого (например, IntArrayRC потомок класса IntArray), то наследник имеет возможность пользоваться всеми данными и функциями-членами, определенными в классе-предке. То есть класс IntArrayRC может просто использовать всю основную функциональность, предоставляемую классом IntArray, и добавить только то, что нужно ему для обеспечения проверки границ индекса.
В С++ класс, свойства которого наследуются, называют также базовым классом, а класс-наследник – производным классом, или подклассом базового. Класс и подкласс имеют общий интерфейс, предоставляемый базовым классом (т.к. подкласс имеет все функции-члены базового класса). Значит, программу, использующую только функции из этого общего интерфейса, не должен интересовать фактический тип объекта, с которым она работает, – базового ли типа этот объект или производного. В этом смысле общий интерфейс скрывает специфичные для подкласса детали. Отношения между классами и подклассами называются иерархией наследования классов. Вот как может выглядеть реализация функции swap(), которая меняет местами два указанных элемента массива. Первым параметром функции является ссылка на базовый класс IntArray:
#include <IntArray.h>
void swap (IntArray &ia, int i, int j)
{
int temp ia[i];
ia[i] = ia[j];
ia[j] = temp;
}
// ниже идут обращения к функции swap:
IntArray ia;
IntArrayRC iarc;
IntSortedArray ias;
// правильно - ia имеет тип IntArray
swap (ia,0,10);
// правильно - iarc является подклассом IntArray
swap (iarc,0,10);
// правильно - ias является подклассом IntArray
swap (ias,0,10);
// ошибка - string не является подклассом IntArray
string str("Это не IntArray!");
swap (str,0,10);
Каждый из трех классов реализует операцию взятия индекса по-своему. Поэтому важно, чтобы внутри функции swap() вызывалась нужная операция взятия индекса. Так, если swap() вызвана для IntArrayRC:
swap (iarc,0,10);
то должна вызываться функция взятия индекса для объекта класса IntArrayRC, а для
swap (ias,0,10);
функция взятия индекса IntSortedArray. Именно это и обеспечивает механизм
виртуальных функций С++.
Давайте попробуем сделать наш класс IntArray базовым для иерархии подклассов. Что нужно изменить в его описании? Синтаксически – совсем немного. Возможно, придется открыть для производных классов доступ к скрытым членам класса. Кроме того, те функции, которые мы собираемся сделать виртуальными, необходимо явно пометить специальным ключевым словом virtual. Основная же трудность состоит в таком изменении реализации базового класса, которая позволит ей лучше отвечать своей новой цели – служить базой для целого семейства подклассов.
При простом объектном подходе можно выделить двух разработчиков конечной программы – разработчик класса и пользователь класса (тот, кто использует данный класс в конечной программе), причем последний обращается только к открытому интерфейсу. Для такого случая достаточно двух уровней доступа к членам класса – открытого
(public) и закрытого (private).
Если используется наследование, то к этим двум группам разработчиков добавляется третья, промежуточная. Производный класс может проектировать совсем не тот человек, который проектировал базовый, и для того чтобы реализовать класс-наследник, совсем не обязательно иметь доступ к реализации базового. И хотя такой доступ может потребоваться при проектировании подкласса, от конечного пользователя обоих классов эта часть по-прежнему должна быть закрыта. К двум уровням доступа добавляется третий, в некотором смысле промежуточный, – защищенный (protected). Члены класса, объявленные как защищенные, могут использоваться классами-потомками, но никем больше. (Закрытые члены класса недоступны даже для его потомков.)
Вот как выглядит модифицированное описание класса IntArray:
class IntArray {
public:
// конструкторы
explicit IntArray (int sz = DefaultArraySize);
IntArray (int *array, int array_size);
IntArray (const IntArray &rhs);
// виртуальный деструктор
virtual ~IntArray() { delete[] ia; }
// операции сравнения:
bool operator== (const IntArray&) const;
bool operator!= (const IntArray&) const;
// операция присваивания:
IntArray& operator= (const IntArray&);
int size() const { return _size; };
// мы убрали проверку индекса...
virtual int& operator[](int index)
{ return ia[index]; }
virtual void sort();
virtual int min() const;
virtual int max() const;
virtual int find (int value) const;
protected:
static const int DefaultArraySize = 12;
void init (int sz; int *array);
int _size;
int *ia;
}
Открытые функции-члены по-прежнему определяют интерфейс класса, как и в реализации из предыдущего раздела. Но теперь это интерфейс не только базового, но и всех производных от него подклассов.
Нужно решить, какие из членов, ранее объявленных как закрытые, сделать защищенными. Для нашего класса IntArray сделаем защищенными все оставшиеся члены.
Теперь нам необходимо определить, реализация каких функций-членов базового класса может меняться в подклассах. Такие функции мы объявим виртуальными. Как уже отмечалось выше, реализация операции взятия индекса будет отличаться по крайней мере для подкласса IntArrayRC. Реализация операторов сравнения и функции size() одинакова для всех подклассов, следовательно, они не будут виртуальными.
При вызове невиртуальной функции компилятор определяет все необходимое еще на этапе компиляции. Если же он встречает вызов виртуальной функции, то не пытается сделать этого. Выбор нужной из набора виртуальных функций (разрешение вызова) происходит во время выполнения программы и основывается на типе объекта, из которого она вызвана. Рассмотрим пример:
void init (IntArray &ia)
{
for (int ix=0; ix<ia.size(); ++ix)
ia[ix] = ix;
}
Формальный параметр функции ia может быть ссылкой на IntArray, IntArrayRC или на IntSortedArray. Функция-член size() не является виртуальной и разрешается на этапе компиляции. А вот виртуальный оператор взятия индекса не может быть разрешен на данном этапе, поскольку реальный тип объекта, на который ссылается ia, в этот момент неизвестен.
(В главе 17 мы будем говорить о виртуальных функциях более подробно. Там мы рассмотрим также и накладные расходы, которые влечет за собой их использование.)
Вот как выглядит определение производного класса IntArrayRC:
#ifndef IntArrayRC_H
#define IntArrayRC_H
#include "IntArray.h"
class IntArrayRC : public IntArray {
public:
IntArrayRC( int sz = DefaultArraySize );
IntArrayRC( const int *array, int array_size );
IntArrayRC( const IntArrayRC &rhs );
virtual int& operator[]( int ) const;
private:
void check_range( int ix );
};
#endif
Этот текст мы поместим в заголовочный файл IntArrayRC.h. Обратите внимание на то, что в наш файл включен заголовочный файл IntArray.h.
В классе IntArrayRC мы должны реализовать только те особенности, которые отличают его от IntArray: класс IntArrayRC должен иметь свою собственную реализацию операции взятия индекса; функцию для проверки индекса и собственный набор конструкторов.
Все данные и функции-члены класса IntArray можно использовать в классе IntArrayRC так, как будто это его собственные члены. В этом и заключается смысл наследования. Синтаксически наследование выражается строкой
class IntArrayRC : public IntArray
Эта строка показывает, что класс IntArrayRC произведен от класса IntArray, другими словами, наследует ему. Ключевое слово public в данном контексте говорит о том, что производный класс сохраняет открытый интерфейс базового класса, то есть что все открытые функции базового класса остаются открытыми и в производном. Объект типа IntArrayRC может использоваться вместо объекта типа IntArray, как, например, в приведенном выше примере с функцией swap(). Таким образом, подкласс IntArrayRC – это расширенная версия класса IntArray.
Вот как выглядит реализация операции взятия индекса:
IntArrayRC::operator[]( int index )
{
check_range( index );
return _ia[ index ];
}
А вот реализация встроенной функции check_range():
#include <cassert>
inline void IntArrayRC::check_range(int index)
{
assert (index>=0 && index < _size);
}
(Мы говорили о макросе assert() в разделе 1.3.)
Почему проверка индекса вынесена в отдельную функцию, а не выполняется прямо в теле оператора взятия индекса? Потому что, если мы когда-нибудь потом захотим изменить что-то в реализации проверки, например написать свою обработку ошибок, а не использовать assert(), это будет сделать проще.
В каком порядке активизируются конструкторы при создании производного класса? Первым вызывается конструктор базового класса, инициализирующий те члены, которые входят в базовый класс. Затем начинает работать конструктор производного класса, где мы должны проинициализировать только те члены, которые являются специфичными для подкласса, то есть отсутствуют в базовом классе.
Однако заметим, что в нашем производном классе IntArrayRC нет новых членов, представляющих данные. Значит ли это, что нам не нужно реализовывать конструкторы для него? Ведь вся работа по инициализации членов данных уже проделана конструкторами базового класса.
На самом деле конструкторы, как и деструкторы или операторы присваивания, не наследуются – это правило языка С++. Кроме того, конструктор производного класса обеспечивает механизм передачи параметров конструктору базового класса. Рассмотрим пример. Пусть мы хотим создать объект класса IntArrayRC следующим образом:
int ia[] = {0,1,1,2,3,5,8,13};
IntArrayRC iarc(ia,8);
Нам нужно передать параметры ia и 8 конструктору базового класса IntArray. Для этого служит специальная синтаксическая конструкция. Вот как выглядят реализации двух конструкторов IntArrayRC:
inline IntArrayRC::IntArrayRC( int sz )
: IntArray( sz ) {}
inline IntArrayRC::IntArrayRC( const int *iar, int sz )
: IntArray( iar, sz ) {}
(Мы будем подробно говорить о конструкторах в главах 14 и 17. Там же мы покажем, почему не нужно реализовывать конструктор копирования для IntArrayRC.)
Часть определения, следующая за двоеточием, называется списком инициализации членов. Именно здесь, указав конструктор базового класса, мы можем передать ему параметры. Тела обоих конструкторов пусты, поскольку их работа состоит исключительно в передаче параметров конструктору базового класса. Нам не нужно реализовывать деструктор для IntArrayRC, так как ему просто нечего делать. Точно так же, как при создании объекта производного типа вызывается сначала конструктор базового типа, а затем производного, при уничтожении автоматически вызываются деструкторы – естественно, в обратном порядке: сначала деструктор производного, затем базового. Таким образом, деструктор базового класса будет вызван для объекта типа IntArrayRC, хотя тот и не имеет собственной аналогичной функции.
Мы поместим все встроенные функции класса IntArrayRC в тот же заголовочный файл IntArrayRC.h. Поскольку у нас нет невстроенных функций, то создавать файл IntArrayRC.C не нужно.
Вот пример простой программы, использующей классы IntArray и IntArrayRC:
#include <iostream>
#include "IntArray.h"
#include "IntArrayRC.h"
void swap( IntArray &ia, int ix, int jx )
{
int tmp = ia[ ix ];
ia[ ix ] = ia[ jx ];
ia[ jx ] = tmp;
}
int main()
{
int array[ 4 ] = { 0, 1, 2, 3 };
IntArray ia1( array, 4 );
IntArrayRC ia2( array, 4 );
// ошибка: должно быть size-1
// не может быть выявлена объектом IntArray
cout << "swap() with IntArray ia1" << endl;
swap( ia1, 1, ia1.size() );
// правильно: объект IntArrayRC "поймает" ошибку
cout << "swap() with IntArrayRC ia2" << endl;
swap( ia2, 1, ia2.size() );
return 0;
}
При выполнении программа выдаст следующий результат:
swap() with IntArray ia1
swap() with IntArrayRC ia2
Assertion failed: ix >= 0 && ix < _size,
file IntArrayRC.h, line 19
Упражнение 2.8
Отношение наследования между типом и подтипом служит примером отношения является. Так, массив IntArrayRC является подвидом массива IntArray, книга является подвидом выдаваемых библиотекой предметов, аудиокнига является подвидом книги и т.д. Какие из следующих утверждений верны?
(a) функция-член является подвидом функции
(b) функция-член является подвидом класса
(c) конструктор является подвидом функции-члена
(d) самолет является подвидом транспортного средства
(e) машина является подвидом грузовика
(f) круг является подвидом геометрической фигуры
(g) квадрат является подвидом треугольника
(h) автомобиль является подвидом самолета
(i) читатель является подвидом библиотеки
Упражнение 2.9
Определите, какие из следующих функций могут различаться в реализации для производных классов и, таким образом, выступают кандидатами в виртуальные функции:
(a) rotate();
(b) print();
(c) size();
(d) DateBorrowed(); // дата выдачи книги
(e) rewind();
(f) borrower(); // читатель
(g) is_late(); // книга просрочена
(h) is_on_loan(); // книга выдана
Упражнение 2.10
Ходят споры о том, не нарушает ли принципа инкапсуляции введение защищенного уровня доступа. Есть мнение, что для соблюдения этого принципа следует отказаться от использования такого уровня и работать только с закрытыми членами. Противоположная точка зрения гласит, что без защищенных членов производные классы невозможно реализовывать достаточно эффективно и в конце концов пришлось бы везде задействовать открытый уровень доступа. А каково ваше мнение по этому поводу?
Упражнение 2.11
Еще одним спорным аспектом является необходимость явно указывать виртуальность функций в базовом классе. Есть мнение, что все функции должны быть виртуальными по умолчанию, тогда ошибка в разработке базового класса не повлечет таких серьезных последствий в разработке производного, когда из-за невозможности изменить реализацию функции, ошибочно не определенной в базовом классе как виртуальная, приходится сильно усложнять реализацию. С другой стороны, виртуальные функции невозможно объявить как встроенные, и использование только таких функций сильно снизит эффективность. Каково ваше мнение?
Упражнение 2.12
Каждая из приведенных ниже абстракций определяет целое семейство подвидов, как, например, абстракция “транспортное средство” может определять “самолет”, “автомобиль”, “велосипед”. Выберите одно из семейств и составьте для него иерархию подвидов. Приведите пример открытого интерфейса для этой иерархии, включая конструкторы. Определите виртуальные функции. Напишите псевдокод маленькой программы, использующей данный интерфейс.
(a) Точка
(b) Служащий
(c) Фигура
(d) Телефонный_номер
(e) Счет_в_банке
(f) Курс_продажи
Объектный подход
В этом разделе мы спроектируем и реализуем абстракцию массива, используя механизм классов С++. Первоначальный вариант будет поддерживать только массив элементов типа int. Впоследствии при помощи шаблонов мы расширим наш массив для поддержки любых типов данных.
Первый шаг состоит в том, чтобы определить, какие операции будет поддерживать наш массив. Конечно, было бы заманчиво реализовать все мыслимые и немыслимые операции, но невозможно сделать сразу все на свете. Поэтому для начала определим то, что должен уметь наш массив:
1. обладать некоторыми знаниями о самом себе. Пусть для начала это будет знание собственного размера;
2. поддерживать операцию присваивания и операцию сравнения на равенство;
3. отвечать на некоторые вопросы, например: какова величина минимального и максимального элемента; содержит ли массив элемент с определенным значением; если да, то каков индекс первого встречающегося элемента, имеющего это значение;
4. сортировать сам себя. Пусть такая операция покажется излишней, все-таки реализуем ее в качестве дополнительного упражнения: ведь кому-то это может пригодиться.
5. Конечно, мы должны реализовать и базовые операции работы с массивом, а именно:Возможность задать размер массива при его создании. (Речь не идет о том, чтобы знать эту величину на этапе компиляции.)
6. Возможность проинициализировать массив некоторым набором значений.
7. Возможность обращаться к элементу массива по индексу. Пусть эта возможность реализуется с помощью стандартной операции взятия индекса.
8. Возможность обнаруживать обращения к несуществующим элементам массива и сигнализировать об ошибке. Не будем обращать внимание на тех потенциальных пользователей нашего класса, которые привыкли работать со встроенными массивами С и не считают данную возможность полезной – мы хотим создать такой массив, который был бы удобен в использовании даже самым неискушенным программистам на С++.
Кажется, мы перечислили достаточно потенциальных достоинств нашего будущего массива, чтобы загореться желанием немедленно приступить к его реализации. Как же это будет выглядеть на С++? В самом общем случае объявление класса выглядит следующим образом:
class classname {
public:
// набор открытых операций
private:
// закрытые функции, обеспечивающие реализацию
};
class, public и private – это ключевые слова С++, а classname – имя, которое программист дал своему классу. Назовем наш проектируемый класс IntArray: на первом этапе этот массив будет содержать только целые числа. Когда мы научим его обращаться с данными любого типа, можно будет переименовать его в Array.
Определяя класс, мы создаем новый тип данных. На имя класса можно ссылаться точно так же, как на любой встроенный описатель типа. Можно создавать объекты этого нового типа аналогично тому, как мы создаем объекты встроенных типов:
// статический объект типа IntArray
IntArray myArray;
// указатель на динамический объект типа IntArray
IntArray *pArray = new IntArray;
Определение класса состоит из двух частей: заголовка (имя, предваренное ключевым словом class) и тела, заключенного в фигурные скобки. Заголовок без тела может служить объявлением класса.
// объявление класса IntArray
// без определения его
class IntArray;
Тело класса состоит из определений членов и спецификаторов доступа – ключевых слов public, private и protected. (Пока мы ничего не будем говорить об уровне доступа protected.) Членами класса могут являться функции, которые определяют набор действий, выполняемых классом, и переменные, содержащие некие внутренние данные, необходимые для реализации класса. Функции, принадлежащие классу, называют функциями-членами или, по-другому, методами
класса. Вот набор методов класса IntArray:
class IntArray {
public:
// операции сравнения: #2b
bool operator== (const IntArray&) const;
bool operator!= (const IntArray&) const;
// операция присваивания: #2a
IntArray& operator= (const IntArray&);
int size() const; // #1
void sort(); // #4
int min() const; // #3a
int max() const; // #3b
// функция find возвращает индекс первого
// найденного элемента массива
// или -1, если элементов не найдено
int find (int value) const; // #3c
private:
// дальше идут закрытые члены,
// обеспечивающие реализацию класса
...
}
Номера, указанные в комментариях при объявлениях методов, ссылаются на спецификацию класса, которую мы составили в начале данного раздела. Сейчас мы не будем объяснять смысл ключевого слова const, он не так уж важен для понимания того, что мы хотим продемонстрировать на данном примере. Будем считать, что это ключевое слово необходимо для правильной компиляции программы.
Именованная функция-член (например, min()) может быть вызвана с использованием одной из двух операций доступа к члену класса. Первая операция доступа, обозначаемая точкой (.), применяется к объектам класса, вторая – стрелка (->) – к указателям на объекты. Так, чтобы найти минимальный элемент в объекте, имеющем тип IntArray, мы должны написать:
// инициализация переменной min_val
// минимальным элементом myArray
int min_val = myArray.min();
Чтобы найти минимальный элемент в динамически созданном объекте типа IntArray, мы должны написать:
int min_val = pArray->min();
(Да, мы еще ничего не сказали о том, как же проинициализировать наш объект – задать его размер и наполнить элементами. Для этого служит специальная функция-член, называемая конструктором. Мы поговорим об этом чуть ниже.)
Операции применяются к объектам класса точно так же, как и к встроенным типам данных. Пусть мы имеем два объекта типа IntArray:
IntArray myАrray0, myArray1;
Инструкции присваивания и сравнения с этими объектами выглядят совершенно обычным образом:
// инструкция присваивания -
// вызывает функцию-член myArray0.operator=(myArray1)
myArray0 = myArray1;
// инструкция сравнения -
// вызывает функцию-член myArray0.operator==(myArray1)
if (myArray0 == myArray1)
cout << "Ура! Оператор присваивания сработал!\n";
Спецификаторы доступа public и private определяют уровень доступа к членам класса. К тем членам, которые перечислены после public, можно обращаться из любого места программы, а к тем, которые объявлены после private, могут обращаться только функции-члены данного класса. (Помимо функций-членов, существуют еще функции-друзья класса, но мы не будем говорить о них вплоть до раздела 15.2.)
В общем случае открытые члены класса составляют его открытый интерфейс, то есть набор операций, которые определяют поведение класса. Закрытые члены класса обеспечивают его скрытую реализацию.
Такое деление на открытый интерфейс и скрытую реализацию называют сокрытием информации, или инкапсуляцией. Это очень важная концепция программирования, мы еще поговорим о ней в следующих главах. В двух словах, эта концепция помогает решить следующие проблемы:
если мы меняем или расширяем реализацию класса, то изменения можно выполнить так, что большинство пользовательских программ, использующих наш класс, их “не заметят”: модификации коснутся лишь скрытых членов (мы поговорим об этом в разделе 6.18);
если в реализации класса обнаруживается ошибка, то обычно для ее исправления достаточно проверить код, составляющий именно скрытую реализацию, а не весь код программы, где данный класс используется.
Какие же внутренние данные потребуются для реализации класса IntArray? Необходимо где-то сохранить размер массива и сами его элементы. Мы будем хранить их в массиве встроенного типа, память для которого выделяется динамически. Так что нам потребуется указатель на этот массив. Вот как будут выглядеть определения этих данных-членов:
class IntArray {
public:
// ...
int size() const { return _size; }
private:
// внутренние данные-члены
int _size;
int *ia;
};
Поскольку мы поместили член _size в закрытую секцию, пользователь класса не имеет возможности обратиться к нему напрямую. Чтобы позволить внешней программе узнать размер массива, мы написали функцию-член size(), которая возвращает значение члена _size. Нам пришлось добавить символ подчеркивания к имени нашего скрытого члена _size, поскольку функция-член с именем size() уже определена. Члены класса – функции и данные – не могут иметь одинаковые имена.
Может показаться, что реализуя подобным образом доступ к скрытым данным класса, мы очень сильно проигрываем в эффективности. Сравним два выражения (предположим, что мы изменили спецификатор доступа члена _size на public):
IntArray array;
int array_size = array.size();
array_size = array._size;
Действительно, вызов функции гораздо менее эффективен, чем прямой доступ к памяти, как во втором операторе. Так что же, принцип сокрытия информации заставляет нас жертвовать эффективностью?
На самом деле, нет. С++ имеет механизм встроенных (inline) функций. Текст встроенной функции подставляется компилятором в то место, где записано обращение к ней. (Это напоминает механизм макросов, реализованный во многих языках, в том числе и в С++. Однако есть определенные отличия, о которых мы сейчас говорить не будем.) Вот пример. Если у нас есть следующий фрагмент кода:
for (int index=0; index<array.size(); ++index)
// ...
то функция size() не будет вызываться _size раз во время исполнения. Вместо вызова компилятор подставит ее текст, и результат компиляции предыдущего кода будет в точности таким же, как если бы мы написали:
for (int index=0; index<array._size; ++index)
// ...
Если функция определена внутри тела класса (как в нашем случае), она автоматически считается встроенной. Существует также ключевое слово inline, позволяющее объявить встроенной любую функцию[3].
Мы до сих пор ничего не сказали о том, как будем инициализировать наш массив.
Одна из самых распространенных ошибок при программировании (на любом языке) состоит в том, что объект используется без предварительной инициализации. Чтобы помочь избежать этой ошибки, С++ обеспечивает механизм автоматической инициализации для определяемых пользователем классов – конструктор класса.
Конструктор – это специальная функция-член, которая вызывается автоматически при создании объекта типа класса. Конструктор пишется разработчиком класса, причем у одного класса может быть несколько конструкторов.
Функция-член класса, носящее то же имя, что и сам класс, считается конструктором. (Нет никаких специальных ключевых слов, позволяющих определить конструктор как-то по-другому.) Мы уже сказали, что конструкторов может быть несколько. Как же так: разные функции с одинаковыми именами?
В С++ это возможно. Разные функции могут иметь одно и то же имя, если у этих функций различны количество и/или типы параметров. Это называется перегрузкой функции. Обрабатывая вызов перегруженной функции, компилятор смотрит не только на ее имя, но и на список параметров. По количеству и типам передаваемых параметров компилятор может определить, какую же из одноименных функций нужно вызывать в данном случае. Рассмотрим пример. Мы можем определить следующий набор перегруженных функций min(). (Перегружаться могут как обычные функции, так и функции-члены.)
// список перегруженных функций min()
// каждая функция отличается от других списком параметров
#include <string>
int min (const int *pia,int size);
int min (int, int);
int min (const char *str);
char min (string);
string min (string,string);
Поведение перегруженных функций во время выполнения ничем не отличается от поведения обычных. Компилятор определяет нужную функцию и помещает в объектный код именно ее вызов. (В главе 9 подробно обсуждается механизм перегрузки.)
Итак, вернемся к нашему классу IntArray. Давайте определим для него три конструктора:
class IntArray {
public:
explicit IntArray (int sz = DefaultArraySize);
IntArray (int *array, int array_size);
IntArray (const IntArray &rhs);
// ...
private:
static const int DefaultArraySize = 12;
}
Первый из перечисленных конструкторов
IntArray (int sz = DefaultArraySize);
называется конструктором по умолчанию, потому что он может быть вызван без параметров. (Пока не будем объяснять ключевое слово explicit.) Если при создании объекта ему задается параметр типа int, например
IntArray array1(1024);
то значение 1024 будет передано в конструктор. Если же размер не задан, допустим:
IntArray array2;
то в качестве значения отсутствующего параметра конструктор принимает величину DefaultArraySize. (Не будем пока обсуждать использование ключевого слова static в определении члена DefaultArraySize: об этом говорится в разделе 13.5. Скажем лишь, что такой член данных существует в единственном экземпляре и принадлежит одновременно всем объектам данного класса.)
Вот как может выглядеть определение нашего конструктора по умолчанию:
IntArray::IntArray (int sz)
{
// инициализация членов данных
_size = sz;
ia = new int[_size];
// инициализация элементов массива
for (int ix=0; ix<_size; ++ix)
ia[ix] = 0;
}
Это определение содержит несколько упрощенный вариант реализации. Мы не позаботились о том, чтобы попытаться избежать возможных ошибок во время выполнения. Какие ошибки возможны? Во-первых, оператор new может потерпеть неудачу при выделении нужной памяти: в реальной жизни память не бесконечна. (В разделе 2.6 мы увидим, как обрабатываются подобные ситуации.) А во-вторых, параметр sz из-за небрежности программиста может иметь некорректное значение, например нуль или отрицательное.
Что необычного мы видим в таком определении конструктора? Сразу бросается в глаза первая строчка, в которой использована операция разрешения области видимости
(::):
IntArray::IntArray(int sz);
Дело в том, что мы определяем нашу функцию-член (в данном случае конструктор) вне тела класса. Для того чтобы показать, что эта функция на самом деле является членом класса IntArray, мы должны явно предварить имя функции именем класса и двойным двоеточием. (Подробно области видимости разбираются в главе 8; области видимости применительно к классам рассматриваются в разделе 13.9.)
Второй конструктор класса IntArray инициализирует объект IntArray значениями элементов массива встроенного типа. Он требует двух параметров: массива встроенного типа со значениями для инициализации и размера этого массива. Вот как может выглядеть создание объекта IntArray с использованием данного конструктора:
int ia[10] = {0,1,2,3,4,5,6,7,8,9};
IntArray iA3(ia,10);
Реализация второго конструктора очень мало отличается от реализации конструктора по умолчанию. (Как и в первом случае, мы пока опустили обработку ошибочных ситуаций.)
IntArray::IntArray (int *array, int sz)
{
// инициализация членов данных
_size = sz;
ia = new int[_size];
// инициализация элементов массива
for (int ix=0; ix<_size; ++ix)
ia[ix] = array[ix];
}
Третий конструктор называется копирующим конструктором. Он инициализирует один объект типа IntArray значением другого объекта IntArray. Такой конструктор вызывается автоматически при выполнении следующих инструкций:
IntArray array;
// следующие два объявления совершенно эквивалентны:
IntArray ia1 = array;
IntArray ia2 (array);
Вот как выглядит реализация копирующего конструктора для IntArray, опять-таки без обработки ошибок:
IntArray::IntArray (const IntArray &rhs )
{
// инициализация членов данных
_size = rhs._size;
ia = new int[_size];
// инициализация элементов массива
for (int ix=0; ix<_size; ++ix)
ia[ix] = rhs.ia[ix];
}
В этом примере мы видим еще один составной тип данных – ссылку на объект, которая обозначается символом &. Ссылку можно рассматривать как разновидность указателя: она также позволяет косвенно обращаться к объекту. Однако синтаксис их использования различается: для доступа к члену объекта, на который у нас есть ссылка, следует использовать точку, а не стрелку; следовательно, мы пишем rhs._size, а не rhs->_size. (Ссылки рассматриваются в разделе 3.6.)
Заметим, что реализация всех трех конструкторов очень похожа. Если один и тот же код повторяется в разных местах, желательно вынести его в отдельную функцию. Это облегчает и дальнейшую модификацию кода, и чтение программы. Вот как можно модернизировать наши конструкторы, если выделить повторяющийся код в отдельную функцию init():
class IntArray {
public:
explicit IntArray (int sz = DefaultArraySize);
IntArray (int *array, int array_size);
IntArray (const IntArray &rhs);
// ...
private:
void init (int sz,int *array);
// ...
};
// функция, используемая всеми конструкторами
void IntArray::init (int sz,int *array)
{
_size = sz;
ia = new int[_size];
for (int ix=0; ix<_size; ++ix)
if ( !array )
ia[ix] = 0;
else
ix[ix] = array[ix];
}
// модифицированные конструкторы
IntArray::IntArray (int sz) { init(sz,0); }
IntArray::IntArray (int *array, int array_size)
{ init (array_size,array); }
IntArray::IntArray (const IntArray &rhs)
{ init (rhs._size,rhs.ia); }
Имеется еще одна специальная функция-член – деструктор, который автоматически вызывается в тот момент, когда объект прекращает существование. Имя деструктора совпадает с именем класса, только в начале идет символ тильды (~). Основное назначение данной функции – освободить ресурсы, отведенные объекту во время его создания и использования. Применение деструкторов помогает бороться с трудно обнаруживаемыми ошибками, ведущими к утечке памяти и других ресурсов. В случае класса IntArray эта функция-член должна освободить память, выделенную в момент создания объекта. (Подробно конструкторы и деструкторы описаны в главе 14.) Вот как выглядит деструктор для IntArray:
class IntArray {
public:
// конструкторы
explicit IntArray (int sz = DefaultArraySize);
IntArray (int *array, int array_size);
IntArray (const IntArray &rhs);
// деструктор
~IntArray() { delete[] ia; }
// ...
private:
// ...
};
Теперь нам нужно определить операции доступа к элементам массива IntArray. Мы хотим, чтобы обращение к элементам IntArray выглядело точно так же, как к элементам массива встроенного типа, с использованием оператора взятия индекса:
IntArray array;
int last_pos = array.size()-1;
int temp = array[0];
array[0] = array[last_pos];
array[last_pos] = temp;
Для реализации доступа мы используем возможность перегрузки операций. Вот как выглядит функция, реализующая операцию взятия индекса:
#include <cassert>
int& IntArray::operator[] (int index)
{
assert (index >= 0 && index < _size);
return ia[index];
}
Обычно для проектируемого класса перегружают операции присваивания, операцию сравнения на равенство, возможно, операции сравнения по величине и операции ввода/вывода. Как и перегруженных функций, перегруженных операторов, отличающихся типами операндов, может быть несколько. К примеру, можно создать несколько операций присваивания объекту значения другого объекта того же самого или иного типа. Конечно, эти объекты должны быть более или менее “похожи”. (Подробно о перегрузке операций мы расскажем в главе 15, а в разделе 3.15 приведем еще несколько примеров.)
Определения класса, различных относящихся к нему констант и, быть может, каких-то еще переменных и макросов по принятым соглашениям помещаются в заголовочный файл, имя которого совпадает с именем класса. Для класса IntArray мы должны создать заголовочный файл IntArray.h. Любая программа, в которой будет использоваться класс IntArray, должна включать этот заголовочный файл директивой препроцессора #include.
По тому же самому соглашению функции-члены класса, определенные вне его описания, помещаются в файл с именем класса и расширением, обозначающим исходный текст С++ программы. Мы будем использовать расширение .С (напомним, что в разных системах вы можете встретиться с разными расширениями исходных текстов С++ программ) и назовем наш файл IntArray.C.
Упражнение 2.5
Ключевой особенностью класса С++ является разделение интерфейса и реализации. Интерфейс представляет собой набор операций (функций), выполняемых объектом; он определяет имя функции, возвращаемое значение и список параметров. Обычно пользователь не должен знать об объекте ничего, кроме его интерфейса. Реализация скрывает алгоритмы и данные, нужные объекту, и может меняться при развитии объекта, никак не затрагивая интерфейс. Попробуйте определить интерфейсы для одного из следующих классов (выберите любой):
(a) матрица
(b) булевское значение
(c) паспортные данные человека
(d) дата
(e) указатель
(f) точка
Упражнение 2.6
Попробуйте определить набор конструкторов, необходимых для класса, выбранного вами в предыдущем упражнении. Нужен ли деструктор для вашего класса? Помните, что на самом деле конструктор не создает объект: память под объект отводится до начала работы данной функции, и конструктор только производит определенные действия по инициализации объекта. Аналогично деструктор уничтожает не сам объект, а только те дополнительные ресурсы, которые могли быть выделены в результате работы конструктора или других функций-членов класса.
Упражнение 2.7
В предыдущих упражнениях вы практически полностью определили интерфейс выбранного вами класса. Попробуйте теперь написать программу, использующую ваш класс. Удобно ли пользоваться вашим интерфейсом? Не хочется ли Вам пересмотреть спецификацию? Сможете ли вы сделать это и одновременно сохранить совместимость со старой версией?
Объекты-функции
Наша функция min() дает хороший пример как возможностей, так и ограничений механизма шаблонов:
template <typename Type>
const Type&
min( const Type *p, int size )
{
Type minval = p[ 0 ];
for ( int ix = 1; ix < size; ++ix )
if ( p[ ix ] < minval )
minval = p[ ix ];
return minval;
}
Достоинство этого механизма – возможность определить единственный шаблон min(), который конкретизируется для бесконечного множества типов. Ограничение же заключается в том, что даже при такой конкретизации min() будет работать не со всеми.
Это ограничение вызвано использованием оператора “меньше”: в некоторых случаях базовый тип его не поддерживает. Так, класс изображения Image может и не предоставлять реализации такого оператора, но мы об этом не знаем и пытаемся найти минимальный кадр анимации в данном массиве изображений. Однако попытка конкретизировать min() для такого массива приведет к ошибке компиляции:
error: invalid types applied to the < operator: Image < Image
(ошибка: оператор < применен к некорректным типам: Image < Image)
Возможна и другая ситуация: оператор “меньше” существует, но имеет неподходящую семантику. Например, если мы хотим найти наименьшую строку, но при этом принимать во внимание только буквы, не учитывая регистр, то такой реализованный в классе оператор не даст нужного результата.
Традиционное решение состоит в том, чтобы параметризовать оператор сравнения. В данном случае это можно сделать, объявив указатель на функцию, принимающую два аргумента и возвращающую значение типа bool:
template < typename Type,
bool (*Comp)(const Type&, const Type&)>
const Type&
min( const Type *p, int size, Comp comp )
{
Type minval = p[ 0 ];
for ( int ix = 1; ix < size; ++ix )
if ( Comp( p[ ix ] < minval ))
minval = p[ ix ];
return minval;
}
Такое решение вместе с нашей первой реализацией на основе встроенного оператора “меньше” обеспечивает универсальную поддержку для любого типа, включая и класс Image, если только мы придумаем подходящую семантику для сравнения двух изображений. Основной недостаток указателя на функцию связан с низкой эффективностью, так как косвенный вызов не дает воспользоваться преимуществами встроенных функций.
Альтернативная стратегия параметризации заключается в применении объекта-функции вместо указателя (примеры мы видели в предыдущем разделе). Объект-функция – это класс, перегружающий оператор вызова (operator()). Такой оператор инкапсулирует семантику обычного вызова функции. Объект-функция, как правило, передается обобщенному алгоритму в качестве аргумента, хотя можно определять и независимые объекты-функции. Например, если бы был определен объект-функция AddImages, который принимает два изображения, объединяет их некоторым образом и возвращает новое изображение, то мы могли бы объявить его следующим образом:
AddImages AI;
Чтобы объект-функция удовлетворял нашим требованиям, мы применяем оператор вызова, предоставляя необходимые операнды в виде объектов класса Image:
Image im1("foreground.tiff"), im2("background.tiff");
// ...
// вызывает Image AddImages::operator()(const Image1&, const Image2&);
Image new_image = AI (im1, im2 );
У объекта-функции есть два преимущества по сравнению с указателем на функцию. Во-первых, если перегруженный оператор вызова – это встроенная функция, то компилятор может выполнить ее подстановку, обеспечивая значительный выигрыш в производительности. Во-вторых, объект-функция способен содержать произвольное количество дополнительных данных, например кэш или информацию, полезную для выполнения текущей операции.
Ниже приведена измененная реализация шаблона min() (отметим, что это объявление допускает также и передачу указателя на функцию, но без проверки прототипа):
template < typename Type,
typename Comp >
const Type&
min( const Type *p, int size, Comp comp )
{
Type minval = p[ 0 ];
for ( int ix = 1; ix < size; ++ix )
if ( Comp( p[ ix ] < minval ))
minval = p[ ix ];
return minval;
}
Как правило, обобщенные алгоритмы поддерживают обе формы применения операции: как использование встроенного (или перегруженного) оператора, так и применение указателя на функцию либо объекта-функции.
Есть три источника появления объектов-функций:
из набора предопределенных арифметических, сравнительных и логических объектов-функций стандартной библиотеки;
из набора предопределенных адаптеров функций, позволяющих специализировать или расширять предопределенные (или любые другие) объекты-функции;
определенные нами собственные объекты-функции для передачи обобщенным алгоритмам. К ним можно применять и адаптеры функций.
В этом разделе мы рассмотрим все три источника объектов-функций.
Объекты-исключения
Объявлением исключения в catch-обработчике могут быть объявления типа или объекта. В каких случаях это следует делать? Тогда, когда необходимо получить значение или как-то манипулировать объектом, созданным в выражении throw. Если классы исключений спроектированы так, что в объектах-исключениях при возбуждении сохраняется некоторая информация и если в объявлении исключения фигурирует такой объект, то инструкции внутри catch-обработчика могут обращаться к информации, сохраненной в объекте выражением throw.
Изменим реализацию класса исключения pushOnFull, сохранив в объекте-исключении то значение, которое не удалось поместить в стек. Catch-обработчик, сообщая об ошибке, теперь будет выводить его в cerr. Для этого мы сначала модифицируем определение типа класса pushOnFull следующим образом:
// новый класс исключения:
// он сохраняет значение, которое не удалось поместить в стек
class pushOnFull {
public:
pushOnFull( int i ) : _value( i ) { }
int value { return _value; }
private:
int _value;
};
Новый закрытый член _value содержит число, которое не удалось поместить в стек. Конструктор принимает значение типа int и сохраняет его в члене _data. Вот как вызывается этот конструктор для сохранения значения из выражения throw:
void iStack::push( int value )
{
if ( full() )
// значение, сохраняемое в объекте-исключении
throw pushOnFull( value );
// ...
}
У класса pushOnFull появилась также новая функция-член value(), которую можно использовать в catch-обработчике для вывода хранящегося в объекте-исключении значения:
catch ( pushOnFull eObj ) {
cerr << "trying to push value " << eObj.value()
<< " on a full stack\n";
}
Обратите внимание, что в объявлении исключения в catch-обработчике фигурирует объект eObj, с помощью которого вызывается функция-член value() класса pushOnFull.
Объект-исключение всегда создается в точке возбуждения, даже если выражение throw – это не вызов конструктора и, на первый взгляд, не должно создавать объекта. Например:
enum EHstate { noErr, zeroOp, negativeOp, severeError };
enum EHstate state = noErr;
int mathFunc( int i ) {
if ( i == 0 ) {
state = zeroOp;
throw state; // создан объект-исключение
}
// иначе продолжается обычная обработка
}
В этом примере объект state не используется в качестве объекта-исключения. Вместо этого выражением throw создается объект-исключение типа EHstate, который инициализируется значением глобального объекта state. Как программа может различить их? Для ответа на этот вопрос мы должны присмотреться к объявлению исключения в catch-обработчике более внимательно.
Это объявление ведет себя почти так же, как объявление формального параметра. Если при входе в catch-обработчик исключения выясняется, что в нем объявлен объект, то он инициализируется копией объекта-исключения. Например, следующая функция calculate() вызывает определенную выше mathFunc(). При входе в catch-обработчик внутри calculate() объект eObj инициализируется копией объекта-исключения, созданного выражением throw.
void calculate( int op ) {
try {
mathFunc( op );
}
catch ( EHstate eObj ) {
// eObj - копия сгенерированного объекта-исключения
}
}
Объявление исключения в этом примере напоминает передачу параметра по значению. Объект eObj инициализируется значением объекта-исключения точно так же, как переданный по значению формальный параметр функции – значением соответствующего фактического аргумента. (Передача параметров по значению рассматривалась в разделе 7.3.)
Как и в случае параметров функции, в объявлении исключения может фигурировать ссылка. Тогда catch-обработчик будет напрямую ссылаться на объект-исключение, сгенерированный выражением throw, а не создавать его локальную копию:
void calculate( int op ) {
try {
mathFunc( op );
}
catch ( EHstate &eObj ) {
// eObj ссылается на сгенерированный объект-исключение
}
}
Для предотвращения ненужного копирования больших объектов применять ссылки следует не только в объявлениях параметров типа класса, но и в объявлениях исключений того же типа.
В последнем случае catch-обработчик сможет модифицировать объект-исключение. Однако переменные, определенные в выражении throw, остаются без изменения. Например, модификация eObj внутри catch-обработчика не затрагивает глобальную переменную state, установленную в выражении throw:
void calculate( int op ) {
try {
mathFunc( op );
}
catch ( EHstate &eObj ) {
// исправить ошибку, вызвавшую исключение
eObj = noErr; // глобальная переменная state не изменилась
}
}
Catch-обработчик переустанавливает eObj в noErr после исправления ошибки, вызвавшей исключение. Поскольку eObj – это ссылка, можно ожидать, что присваивание модифицирует глобальную переменную state. Однако изменяется лишь объект-исключение, созданный в выражении throw, поэтому модификация eObj не затрагивает state.
Объекты-исключения и виртуальные функции
Если сгенерированный объект-исключение имеет тип производного класса, а обрабатывается catch-обработчиком для базового, то этот обработчик не может использовать особенности производного класса. Например, к функции-члену value(), которая объявлена в классе pushOnFull, нельзя обращаться в catch-обработчике Excp:
catch ( const Excp &eObj ) {
// ошибка: в классе Excp нет функции-члена value()
cerr << "попытка поместить значение " << eObj.value()
<< " в полный стек\n";
}
Но мы можем перепроектировать иерархию классов исключений и определить виртуальные функции, которые можно вызывать из catch-обработчика для базового класса Excp с целью получения доступа к функциям-членам более специализированного производного:
// новые определения классов, включающие виртуальные функции
class Excp {
public:
virtual void print( string msg ) {
cerr << "Произошло исключение"
<< endl;
}
};
class stackExcp : public Excp { };
class pushOnFull : public stackExcp {
public:
virtual void print() {
cerr << "попытка поместить значение " << _value
<< " в полный стек\n";
}
// ...
};
Функцию print() теперь можно использовать в catch-обработчике следующим образом:
int main() {
try {
// iStack::push() возбуждает исключение pushOnFull
} catch ( Excp eObj ) {
eObj.print(); // хотим вызвать виртуальную функцию,
// но вызывается экземпляр из базового класса
}
}
Хотя возбужденное исключение имеет тип pushOnFull, а функция print() виртуальна, инструкция eObj.print() печатает такую строку:
Произошло исключение
Вызываемая print() является членом базового класса Excp, а не замещает ее в производном. Но почему?
Вспомните, что объявление исключения в catch-обработчике ведет себя почти так же, так объявление параметра. Когда управление попадает в catch-обработчик, то, поскольку в нем объявлен объект, а не ссылка, eObj инициализируется копией подобъекта Excp базового класса объекта исключения. Поэтому eObj – это объект типа Excp, а не pushOnFull. Чтобы вызвать виртуальные функции из производных классов, в объявлении исключения должен быть указатель или ссылка:
int main() {
try {
// iStack::push() возбуждает исключение pushOnFull
} catch ( const Excp &eObj ) {
eObj.print(); // вызывается виртуальная функция
// pushOnFull::print()
}
}
Объявление исключения в этом примере тоже относится к базовому классу Excp, но так как eObj – ссылка и при этом именует объект-исключение типа pushOnFull, то для нее можно вызывать виртуальные функции, определенные в классе pushOnFull. Когда catch-обработчик обращается к виртуальной функции print(), вызывается функция из производного класса, и программа печатает следующую строку:
попытка поместить значение 879 в полный стек
Таким образом, ссылка в объявлении исключения позволяет вызывать виртуальные функции, ассоциированные с классом объекта-исключения.