Простой шаблон типа
Шаблон типа для класса задает способ построения отдельных классов, подобно тому, как описание класса задает способ построения его отдельных объектов. Можно определить стек, содержащий элементы произвольного типа:
template<class T>
class stack {
T* v;
T* p;
int sz;
public:
stack(int s) { v = p = new T[sz=s]; }
~stack() { delete[] v; }
void push(T a) { *p++ = a; }
T pop() { return *--p; }
int size() const { return p-v; }
};
Для простоты не учитывался контроль динамических ошибок. Не считая этого, пример полный и вполне правдоподобный. Префикс template<class T> указывает, что описывается шаблон типа с параметром T, обозначающим тип, и что это обозначение будет использоваться в последующем описании. После того, как идентификатор T указан в префиксе, его можно использовать как любое другое имя типа. Область видимости T продолжается до конца описания, начавшегося префиксом template<class T>. Отметим, что в префиксе T объявляется типом, и оно не обязано быть именем класса. Так, ниже в описании объекта sc тип T оказывается просто char.
Имя шаблонного класса, за которым следует тип, заключенный в угловые скобки <>, является именем класса (определяемым шаблоном типа), и его можно использовать как все имена класса. Например, ниже определяется объект sc класса stack<char>:
stack<char> sc(100); // стек символов
Если не считать особую форму записи имени, класс stack<char> полностью эквивалентен классу определенному так:
class stack_char {
char* v;
char* p;
int sz;
public:
stack_char(int s) { v = p = new char[sz=s]; }
~stack_char() { delete[] v; }
void push(char a) { *p++ = a; }
char pop() { return *--p; }
int size() const { return p-v; }
};
Можно подумать, что шаблон типа - это хитрое макроопределение, подчиняющееся правилам именования, типов и областей видимости, принятым в С++. Это, конечно, упрощение, но это такое упрощение, которое помогает избежать больших недоразумений. В частности, применение шаблона типа не предполагает каких-либо средств динамической поддержки помимо тех, которые используются для обычных "ручных" классов. Не следует так же думать, что оно приводит к сокращению программы.
Обычно имеет смысл вначале отладить конкретный класс, такой, например, как stack_char, прежде, чем строить на его основе шаблон типа stack<T>. С другой стороны, для понимания шаблона типа полезно представить себе его действие на конкретном типе, например int или shape*, прежде, чем пытаться представить его во всей общности.
Имея определение шаблонного класса stack, можно следующим образом определять и использовать различные стеки:
stack<shape*> ssp(200); // стек указателей на фигуры
stack<Point> sp(400); // стек структур Point
void f(stack<complex>& sc) // параметр типа `ссылка на
// complex'
{
sc.push(complex(1,2));
complex z = 2.5*sc.pop();
stack<int>*p = 0; // указатель на стек целых
p = new stack<int>(800); // стек целых размещается
// в свободной памяти
for ( int i = 0; i<400; i++) {
p->push(i);
sp.push(Point(i,i+400));
}
// ...
}
Поскольку все функции-члены класса stack являются подстановками, и в этом примере транслятор создает вызовы функций только для размещения в свободной памяти и освобождения.
Функции в шаблоне типа могут и не быть подстановками, шаблонный класс stack с полным правом можно определить и так:
template<class T> class stack {
T* v;
T* p;
int sz;
public:
stack(int);
~stack();
void push(T);
T pop();
int size() const;
};
В этом случае определение функции-члена stack должно быть дано где-то в другом месте, как это и было для функций- членов обычных, нешаблонных классов. Подобные функции так же параметризируются типом, служащим параметром для их шаблонного класса, поэтому определяются они с помощью шаблона типа для функции. Если это происходит вне шаблонного класса, это надо делать явно:
template<class T> void stack<T>::push(T a)
{
*p++ = a;
}
template<class T> stack<T>::stack(int s)
{
v = p = new T[sz=s];
}
Отметим, что в пределах области видимости имени stack<T> уточнение <T> является избыточным, и stack<T>::stack - имя конструктора.
Задача системы программирования, а вовсе не программиста, предоставлять версии шаблонных функций для каждого фактического параметра шаблона типа. Поэтому для приводившегося выше примера система программирования должна создать определения конструкторов для классов stack<shape*>, stack<Point> и stack<int>, деструкторов для stack<shape*> и stack<Point>, версии функций push() для stack<complex>, stack<int> и stack<Point> и версию функции pop() для stack<complex>. Такие создаваемые функции будут совершенно обычными функциями-членами, например:
void stack<complex>::push(complex a) { *p++ = a; }
Здесь отличие от обычной функции-члена только в форме имени класса. Точно так же, как в программе может быть только одно определение функции-члена класса, возможно только одно определение шаблона типа для функции-члена шаблонного класса. Если требуется определение функции-члена шаблонного класса для конкретного типа, то задача системы программирования найти шаблон типа для этой функции-члена и создать нужную версию функции. В общем случае система программирования может рассчитывать на указания от программиста, которые помогут найти нужный шаблон типа.
Важно составлять определение шаблона типа таким образом, чтобы его зависимость от глобальных данных была минимальной. Дело в том, шаблон типа будет использоваться для порождения функций и классов на основе заранее неизвестного типа и в неизвестных контекстах. Практически любая, даже слабая зависимость от контекста может проявиться как проблема при отладке программы пользователем, который, вероятнее всего, не был создателем шаблона типа. К совету избегать, насколько это возможно, использований глобальных имен, следует относиться особенно серьезно при разработке шаблона типа.
Простой шаблон типа для глобальной функции
Начнем с простейшего шаблона для sort():
template<class T> void sort(Vector<T>&);
void f(Vector<int>& vi,
Vector<String>& vc,
Vector<int>& vi2,
Vector<char*>& vs)
{
sort(vi); // sort(Vector<int>& v);
sort(vc); // sort(Vector<String>& v);
sort(vi2); // sort(Vector<int>& v);
sort(vs); // sort(Vector<char*>& v);
}
Какая именно функция sort() будет вызываться определяется фактическим параметром. Программист дает определение шаблона типа для функции, а задача системы программирования обеспечить создание правильных вариантов функции по шаблону и вызов соответствующего варианта. Например, простой шаблон с алгоритмом пузырьковой сортировки можно определить так:
template<class T> void sort(Vector<T>& v)
/*
Сортировка элементов в порядке возрастания
Используется сортировка по методу пузырька
*/
{
unsigned n = v.size();
for (int i=0; i<n-1; i++)
for (int j=n-1; i<j; j--)
if (v[j] < v[j-1]) { // меняем местами v[j] и v[j-1]
T temp = v[j];
v[j] = v[j-1];
v[j-1] = temp;
}
}
Советуем сравнить это определение с функцией сортировки с тем же алгоритмом из $$4.6.9. Существенное отличие этого варианта в том, что вся необходимая информация передается в единственном параметре v. Поскольку тип сортируемых элементов известен (из типа фактического параметра, можно непосредственно сравнивать элементы, а не передавать указатель на производящую сравнение функцию. Кроме того, нет нужды возиться с операцией sizeof. Такое решение кажется более красивым и к тому же оно более эффективно, чем обычное. Все же оно сталкивается с трудностью. Для некоторых типов операция < не определена, а для других, например char*, ее определение противоречит тому, что требуется в приведенном определении шаблонной функции. (Действительно, нам нужно сравнивать не указатели на строки, а сами строки). В первом случае попытка создать вариант sort() для таких типов закончится неудачей (на что и следует надеяться) , а во втором появиться функция, производящая неожиданный результат.
Чтобы правильно сортировать вектор из
Чтобы правильно сортировать вектор из элементов char* мы можем просто задать самостоятельно подходящее определение функции
sort(Vector<char*>&):
void sort(Vector<char*>& v)
{
unsigned n = v.size();
for (int i=0; i<n-1; i++)
for ( int j=n-1; i<j; j--)
if (strcmp(v[j],v[j-1])<0) {
// меняем местами v[j] и v[j-1]
char* temp = v[j];
v[j] = v[j-1];
v[j-1] = temp;
}
}
Поскольку для векторов из указателей на строки пользователь дал свое особое определение функции sort(), оно и будет использоваться, а создавать для нее определение по шаблону с параметром типа Vector<char*>& не нужно. Возможность дать для особо важных или "необычных" типов свое определение шаблонной функции дает ценное качество гибкости в программировании и может быть важным средством доведения программы до оптимальных характеристик.
Проверка типа
Необходимость контроля типа при обращениях к виртуальным функциям может оказаться определенным ограничением для разработчиков библиотек. Например, хорошо бы предоставить пользователю класс "стек чего-угодно". Непосредственно в С++ это сделать нельзя. Однако, используя шаблоны типа и наследование, можно приблизиться к той эффективности и простоте проектирования и использования библиотек, которые свойственны языкам с динамическим контролем типов. К таким языкам относится, например, язык Smalltalk, на котором можно описать "стек чего-угодно". Рассмотрим определение стека с помощью шаблона типа:
template < class T > class stack
{
T * p;
int sz;
public:
stack ( int );
~stack ();
void push ( T );
T & pop ();
};
Не ослабляя статического контроля типов, можно использовать такой стек
для хранения указателей на объекты типа plane (самолет):
stack < plane * > cs ( 200 );
void f ()
{
cs.push ( new Saab900 ); // Ошибка при трансляции :
// требуется plane*, а передан car*
cs.push ( new Saab37B );
// прекрасно: Saab 37B - на самом
// деле самолет, т.е. типа plane
cs.pop () -> takeoff ();
cs.pop () -> takeoff ();
}
Если статического контроля типов нет, приведенная выше ошибка обнаружится только при выполнении программы:
// пример динамическое контроля типа
// вместо статического; это не С++
Stack s; // стек может хранить указатели на объекты
// произвольного типа
void f ()
{
s.push ( new Saab900 );
s.push ( new Saab37B );
s.pop () -> takeoff (); // прекрасно: Saab 37B - самолет
cs.pop () -> takeoff (); // динамическая ошибка:
// машина не может взлететь
}
Для способа определения, допустима ли операция над объектом, обычно требуется больше дополнительных расходов, чем для механизма вызова виртуальных функций в С++.
Рассчитывая на статический контроль типов и вызов виртуальных функций, мы приходим к иному стилю программирования, чем надеясь только на динамический контроль типов. Класс в С++ задает строго определенный интерфейс для множества объектов этого и любого производного класса, тогда как в Smalltalk класс задает только минимально необходимое число операций, и пользователь вправе применять незаданные в классе операции. Иными словами, класс в С++ содержит точное описание операций, и пользователю гарантируется, что только эти операции транслятор сочтет допустимыми.
Расширенная динамическая информация о типе
В классе Type_info содержится только минимум информации, необходимой для идентификации типа и безопасных операций приведения. Но поскольку в самом классе Type_info есть функции-члены info() и get_info(), можно построить производные от него классы, чтобы в динамике определять, какие объекты Type_info возвращают эти функции. Таким образом, не меняя класса Type_info, пользователь может получать больше информации о типе с помощью объектов, возвращаемых функциями dynamic_type() и static_type(). Во многих случаях дополнительная информация должна содержать таблицу членов объекта:
struct Member_info {
char* name;
Type_info* tp;
int offset;
};
class Map_info : public Type_info {
Member_info** mi;
public:
static const Type_info info_obj;
virtual typeid get_info() const;
static typeid info();
// функции доступа
};
Класс Type_info вполне подходит для стандартной библиотеки. Это базовый класс с минимумом необходимой информации, из которого можно получать производные классы, предоставляющие больше информации. Эти производные классы могут определять или сами пользователи, или какие-то служебные программы, работающие с текстом на С++, или сами трансляторы языка.
Различение особых ситуаций
Естественно, в программе возможны несколько различных динамических ошибок. Эти ошибки можно сопоставить с особыми ситуациями, имеющими различные имена. Так, в классе Vector обычно приходится выявлять и сообщать об ошибках двух видов: ошибки диапазона и ошибки, вызванные неподходящим для конструктора параметром:
class Vector {
int* p;
int sz;
public:
enum { max = 32000 };
class Range { }; // особая ситуация индекса
class Size { }; // особая ситуация "неверный размер"
Vector(int sz);
int& operator[](int i);
// ...
};
Как было сказано, операция индексации запускает особую ситуацию Range, если ей задан выходящий из диапазона значений индекс. Конструктор запускает особую ситуацию Size, если ему задан недопустимый размер вектора:
Vector::Vector(int sz)
{
if (sz<0 || max<sz) throw Size();
// ...
}
Пользователь класса Vector может различить эти две особые ситуации,
если в проверяемом блоке (т.е. в блоке оператора try) укажет обработчики для обеих ситуаций:
void f()
{
try {
use_vectors();
}
catch (Vector::Range) {
// ...
}
catch (Vector::Size) {
// ...
}
}
В зависимости от особой ситуации будет выполняться соответствующий обработчик. Если управление дойдет до конца операторов обработчика, следующим будет выполняться оператор, который идет после списка обработчиков:
void f()
{
try {
use_vectors();
}
catch (Vector::Range) {
// исправить индекс и
// попробовать опять:
f();
}
catch (Vector::Size) {
cerr << "Ошибка в конструкторе Vector::Size";
exit(99);
}
// сюда мы попадем, если вообще не было особых ситуаций
// или после обработки особой ситуации Range
}
Список обработчиков напоминает переключатель, но здесь в теле обработчика операторы break не нужны. Синтаксис списка обработчиков отличен от синтаксиса вариантов case переключателя частично по этой причине, частично потому, чтобы показать, что каждый обработчик определяет свою область видимости (см. $$9.8).
Не обязательно все особые ситуации перехватывать в одной функции:
void f1()
{
try {
f2(v);
}
catch (Vector::Size) {
// ...
}
}
void f2(Vector& v)
{
try {
use_vectors();
}
catch (Vector::Range) {
// ...
}
}
Здесь f2() перехватит особую ситуацию Range, возникающую в use_vectors(), а особая ситуация Size будет оставлена для f1().
С точки зрения языка особая ситуация считается обработанной сразу при входе в тело ее обработчика. Поэтому все особые ситуации, запускаемые при выполнении этого обработчика, должны обрабатываться в функциях, вызвавших ту функцию, которая содержит проверяемый блок. Значит в следующем примере не возникнет бесконечного цикла:
try {
// ...
}
catch (input_overflow) {
// ...
throw input_overflow();
}
Здесь input_overflow (переполнение при вводе) - имя глобального класса.
Обработчики особых ситуаций могут быть вложенными:
try {
// ...
}
catch (xxii) {
try {
// сложная реакция
}
catch (xxii) {
// ошибка в процессе сложной реакции
}
}
Однако, такая вложенность редко бывает нужна в обычных программах, и чаще всего она является свидетельством плохого стиля.
Размер
Человек и организация склонны излишне радоваться тому, что они "действуют по правильной методе". В институтской среде это часто звучит как "развитие согласно строгим предписаниям". В обоих случаях здравый смысл становится первой жертвой страстного и часто искреннего желания внести улучшения. К несчастью, если здравого смысла не хватает, то ущерб, нанесенный неразумными действиями, может быть неограниченным.
Вернемся к этапам процесса развития, перечисленным в $$11.3, и к шагам проектирования, указанным в $$11.3.3. Относительно просто переработать эти этапы в точный метод проектирования, когда шаг точно определен, имеет хорошо определенные входные и выходные данные и полуформальную запись для задания входных и выходных данных. Можно составить протокол, которому должно подчиняться проектирование, создать средства, предоставляющие определенные удобства для записи и организации процесса. Далее, исследуя классификацию зависимостей, приведенную в $$12.2, можно постановить, что определенные зависимости являются хорошими, а другие следует считать плохими, и предоставить средства анализа, которые обеспечат проведение таких оценок во всех стадиях проекта. Чтобы завершить такую "стандартизацию" процесса создания программ, можно было бы ввести стандарты на документацию (в том числе правила на правописание и грамматику и соглашения о формате документации), а так же стандарты на общий вид программ (в том числе указания какие средства языка следует использовать, а какие нет, перечисление допустимых библиотек и тех, которые не нужно использовать, соглашения об именовании функций, типов, переменных, правила расположения текста программы и т.д.).
Все это может способствовать успеху проекта. По крайней мере, было бы явной глупостью, браться за проект системы, которая предположительно будет иметь порядка десяти миллионов строк текста, над которой будут работать сотни человек, и которую будут сопровождать тысячи человек в течении десятилетий, не имея достаточно хорошо определенного и строгого плана по всем перечисленным выше позициям.
К счастью, большинство систем не относится к этой категории. Тем не менее, если решено, что данный метод проектирования или следование указанным образцам в программировании и документации являются "правильными", то начинает оказываться давление, чтобы применять их повсеместно. В небольших проектах это приводит к нелепым ограничениям и большим накладным расходам. В частности, это может привести к тому, что мерой развития и успеха становится не продуктивная работа, а пересылка бумажек и заполнение различных бланков. Если это случится, то в таком проекте настоящих программистов и разработчиков вытеснят бюрократы.
Когда происходит такое нелепое злоупотребление методами проектирования (по всей видимости совершенно разумными), то неудача проекта становится оправданием отказа от практически всякой формализации процесса разработки программного обеспечения. Это, в свою очередь, ведет к такой путанице и таким провалам, которые как раз и должен был предотвратить надлежащий метод проектирования.
Основная проблема состоит в определении степени формализации, пригодной для процесса развития конкретного проекта. Не рассчитывайте легко найти ее решение. По сути для малого проекта каждый метод может сработать. Еще хуже то, что похоже практически каждый метод, даже если он плохо продуман и жесток по отношению к исполнителям, может сработать для большого проекта, если вы готовы затратить уйму времени и денег.
В процессе развития программного обеспечения главная задача - сохранить целостность проекта. Трудность этой задачи зависит нелинейно от размера проекта. Сформулировать и сохранить основные установки в большом проекте может только один человек или маленькая группа. Большинство людей тратит столько времени на решение подзадач, технические детали, повседневную административную работу, что общие цели проекта легко забывает или заменяет их на более локальные и близкие цели. Верный путь к неудаче, когда нет человека или группы с прямым заданием следить за целостностью проекта. Верный путь к неудаче, когда у такого человека или группы нет средств воздействовать на проект в целом.
Отсутствие согласованных дальних целей намного более опасно для проекта и организации, чем отсутствие какого-либо одного конкретного свойства. Небольшая группа людей должна сформулировать такие общие цели, постоянно держать их в уме, составить документы, содержащие самое общее описание проекта, составить пояснения к основным понятиям, и вообще, помогать всем остальным помнить о назначении проекта.
Разрешение неоднозначности
Естественно, у двух базовых классов могут быть функции-члены с одинаковыми именами:
class task {
// ...
virtual debug_info* get_debug();
};
class displayed {
// ...
virtual debug_info* get_debug();
};
При использовании класса satellite подобная неоднозначность функций должна быть разрешена:
void f(satellite* sp)
{
debug_info* dip = sp->get_debug(); //ошибка: неоднозначность
dip = sp->task::get_debug(); // нормально
dip = sp->displayed::get_debug(); // нормально
}
Однако, явное разрешение неоднозначности хлопотно, поэтому для ее устранения лучше всего определить новую функцию в производном классе:
class satellite : public task, public derived {
// ...
debug_info* get_debug()
{
debug_info* dip1 = task:get_debug();
debug_info* dip2 = displayed::get_debug();
return dip1->merge(dip2);
}
};
Тем самым локализуется информация из базовых для satellite классов. Поскольку satellite::get_debug() является переопределением функций get_debug() из обоих базовых классов, гарантируется, что именно она будет вызываться при всяком обращении к get_debug() для объекта типа satellite.
Транслятор выявляет коллизии имен, возникающие при определении одного и того же имени в более, чем одном базовом классе. Поэтому программисту не надо указывать какое именно имя используется, кроме случая, когда его использование действительно неоднозначно. Как правило использование базовых классов не приводит к коллизии имен. В большинстве случаев, даже если имена совпадают, коллизия не возникает, поскольку имена не используются непосредственно для объектов производного класса.
Аналогичная проблема, когда в двух классах есть функции с одним именем, но разным назначением, обсуждается в $$13.8 на примере функции draw() для классов Window и Cowboy.
Если неоднозначности не возникает, излишне указывать имя базового класса при явном обращении к его члену. В частности, если множественное наследование не используется, вполне достаточно использовать обозначение типа "где-то в базовом классе". Это позволяет программисту не запоминать имя прямого базового класса и спасает его от ошибок (впрочем, редких), возникающих при перестройке иерархии классов. Например, в функции из $$6.2.5
void manager::print()
{
employee::print();
// ...
}
предполагается, что employee - прямой базовый класс для manager. Результат этой функции не изменится, если employee окажется косвенным базовым классом для manager, а в прямом базовом классе функции print() нет. Однако, кто-то мог бы следующим образом перестроить классы:
class employee {
// ...
virtual void print();
};
class foreman : public employee {
// ...
void print();
};
class manager : public foreman {
// ...
void print();
};
Теперь функция foreman::print() не будет вызываться, хотя почти наверняка предполагался вызов именно этой функции. С помощью небольшой хитрости можно преодолеть эту трудность:
class foreman : public employee {
typedef employee inherited;
// ...
void print();
};
class manager : public foreman {
typedef foreman inherited;
// ...
void print();
};
void manager::print()
{
inherited::print();
// ...
}
Правила областей видимости, в частности те, которые относятся к вложенным типам, гарантируют, что возникшие несколько типов inherited не будут конфликтовать друг с другом. В общем-то дело вкуса, считать решение с типом inherited наглядным или нет.
Разрешение перегрузки для шаблонной функции
К параметрам шаблонной функции нельзя применять никаких преобразований
типа. Вместо этого при необходимости создаются новые варианты функции:
template<class T> T sqrt(t);
void f(int i, double d, complex z)
{
complex z1 = sqrt(i); // sqrt(int)
complex z2 = sqrt(d); // sqrt(double)
complex z3 = sqrt(z); // sqrt(complex)
// ...
}
Здесь для всех трех типов параметров будет создаваться по шаблону своя функция sqrt. Если пользователь захочет чего-нибудь иного, например вызвать sqrt(double), задавая параметр int, нужно использовать явное преобразование типа:
template<class T> T sqrt(T);
void f(int i, double d, complex z)
{
complex z1 = sqrt(double(i)); // sqrt(double)
complex z2 = sqrt(d); // sqrt(double)
complex z3 = sqrt(z); // sqrt(complex)
// ...
}
В этом примере по шаблону будут создаваться определения только для sqrt(double) и sqrt(complex).
Шаблонная функция может перегружаться как простой, так и шаблонной функцией того же имени. Разрешение перегрузки как шаблонных, так и обычных функций с одинаковыми именами происходит за три шага. Эти правила слишком строгие, и, по всей видимости будут ослаблены, чтобы разрешить преобразования ссылок и указателей, а, возможно, и другие стандартные преобразования. Как обычно, при таких преобразованиях будет действовать контроль однозначности.
[1] Найти функцию с точным сопоставлением параметров ($$R.13.2); если такая есть, вызвать ее.
[2] Найти шаблон типа, по которому можно создать вызываемую функцию с точным сопоставлением параметров; если такая есть, вызвать ее.
[3] Попробовать правила разрешения для обычных функций ($$r13.2); если функция найдена по этим правилам, вызвать ее, иначе вызов является ошибкой.
В любом случае, если на первом шаге найдено более одной функции, вызов считается неоднозначным и является ошибкой. Например:
template<class T>
T max(T a, T b) { return a>b?a:b; };
void f(int a, int b, char c, char d)
{
int m1 = max(a,b); // max(int,int)
char m2 = max(c,d); // max(char,char)
int m3 = max(a,c); // ошибка: невозможно
// создать max(int,char)
}
Поскольку до генерации функции по шаблону не применяется никаких преобразований типа (правило [2]), последний вызов в этом примере нельзя разрешить как max(a,int(c)). Это может сделать сам пользователь, явно описав функцию max(int,int). Тогда вступает в силу правило [3]:
template<class T>
T max(T a, T b) { return a>b?a:b; }
int max(int,int);
void f(int a, int b, char c, char d)
{
int m1 = max(a,b); // max(int,int)
char m2 = max(c,d); // max(char,char)
int m3 = max(a,c); // max(int,int)
}
Программисту не нужно давать определение функции max(int,int), оно по умолчанию будет создано по шаблону.
Можно определить шаблон max так, чтобы сработал первоначальный вариант нашего примера:
template<class T1, class T2>
T1 max(T1 a, T2 b) { return a>b?a:b; };
void f(int a, int b, char c, char d)
{
int m1 = max(a,b); // int max(int,int)
char m2 = max(c,d); // char max(char,char)
int m3 = max(a,c); // max(int,char)
}
Однако, в С и С++ правила для встроенных типов и операций над ними таковы, что использовать подобный шаблон с двумя параметрами может быть совсем непросто. Так, может оказаться неверно задавать тип результата функции как первый параметр (T1), или, по крайней мере, это может привести к неожиданному результату, например для вызова
max(c,i); // char max(char,int)
Если в шаблоне для функции, которая может иметь множество параметров с различными арифметическими типами, используются два параметра, то в результате по шаблону будет порождаться слишком большое число определений разных функций. Более разумно добиваться преобразования типа, явно описав функцию с нужными типами.
Реализация списка
Реализация функций slist_base очевидна. Единственная трудность связана с обработкой ошибок. Например, что делать если пользователь с помощью функции get() пытается взять элемент из пустого списка. Подобные ситуации разбираются в функции обработки ошибок slist_handler(). Более развитый метод, рассчитанный на особые ситуации, будет обсуждаться в главе 9.
Приведем полное описание класса slist_base:
class slist_base {
slink* last; // last->next является началом списка
public:
void insert(slink* a); // добавить в начало списка
void append(slink* a); // добавить в конец списка
slink* get(); // удалить и возвратить
// начало списка
void clear() { last = 0; }
slist_base() { last = 0; }
slist_base(slink* a) { last = a->next = a; }
friend class slist_base_iter;
};
Чтобы упростить реализацию обеих функций insert и append, хранится указатель на последний элемент замкнутого списка:
void slist_base_insert(slink* a) // добавить в начало списка
{
if (last)
a->next = last->next;
else
last = a;
last->next = a;
}
Заметьте, что last->next - первый элемент списка.
void slist_base::append(slink* a) // добавить в конец списка
{
if (last) {
a->next = last->next;
last = last->next = a;
}
else
last = a->next = a;
}
slist* slist_base::get() // удалить и возвратить начало списка
{
if (last == 0)
slist_handler("нельзя взять из пустого списка");
slink* f = last->next;
if (f== last)
last = 0;
else
last->next = f->next;
return f;
}
Возможно более гибкое решение, когда slist_handler - указатель на функцию, а не сама функция. Тогда вызов
slist_handler("нельзя взять из пустого списка");
будет задаваться так
(*slist_handler)(" нельзя взять из пустого списка");
Как мы уже делали для функции new_handler ($$3.2.6), полезно завести функцию, которая поможет пользователю создавать свои обработчики ошибок:
typedef void (*PFV)(const char*);
PFV set_slist_handler(PFV a)
{
PFV old = slist_handler;
slist_handler = a;
return old;
}
PFV slist_handler = &default_slist_handler;
Особые ситуации, которые обсуждаются в главе 9, не только дают альтернативный способ обработки ошибок, но и способ реализации slist_handler.
.Сборщик мусора
Сборку мусора можно рассматривать как моделирование бесконечной памяти на памяти ограниченного размера. Помня об этом, можно ответить на типичный вопрос: должен ли сборщик мусора вызывать деструктор для тех объектов, память которых он использует? Правильный ответ - нет, поскольку, если размещенный в свободной памяти объект не был удален, то он не будет и уничтожен. Исходя из этого, операцию delete можно рассматривать как запрос на вызов деструктора (и еще это - сообщение системе, что память объекта можно использовать). Но как быть, если действительно требуется уничтожить размещенный в свободной памяти объект, который не был удален? Заметим, что для статических и автоматических объектов такой вопрос не встает, - деструкторы для них неявно вызываются всегда. Далее, уничтожение объекта "во время сборки мусора" по сути является операцией с непредсказуемым результатом. Она может совершиться в любое время между последним использованием объекта и "концом программы", а значит, в каком состоянии будет программа в этот момент неизвестно. Здесь использованы кавычки, потому что трудно точно определить, что такое конец программы. (прим. перев.)
Трудно правильно запрограммировать такие операции и они не так полезны, как кажется.
Задачу уничтожения объектов, если время этой операции точно не задано, можно решить с помощью программы обслуживания заявок на уничтожение. Назовем ее сервером заявок. Если объект необходимо уничтожить в конце программы, то надо записать в глобальный ассоциативный массив его адрес и указатель на функцию "очистки". Если объект удален явной операцией, заявка аннулируется. При уничтожении самого сервера (в конце программы) вызываются функции очистки для всех оставшихся заявок. Это решение подходит и для сборки мусора, поскольку мы рассматриваем ее как моделирование бесконечной памяти. Для сборщика мусора нужно выбрать одно из двух решений: либо удалять объект, когда единственной оставшейся ссылкой на него будет ссылка, находящаяся в массиве самого сервера, либо (стандартное решение) не удалять объект до конца программы, поскольку все-таки ссылка на него есть.
Сервер заявок можно реализовать как ассоциативный массив ($$8.8):
class Register {
Map<void*, void (*) (void*)> m;
public:
insert(void* po, void(*pf)()) { m[po]=pf; }
remove(void* po) { m.remove(po); }
};
Register cleanup_register;
Класс, постоянно обращающийся к серверу, может выглядеть так:
class X {
// ...
static void cleanup(void*);
public:
X()
{
cleanup_register.insert(this,&cleanup);
// ...
}
~X() { cleanup(this); }
// ...
};
void X::cleanup(void* pv)
{
X* px = (X*)pv;
cleanup_register.remove(pv);
// очистка
}
Чтобы в классе Register не иметь дела с типами, мы использовали статическую функцию-член с указателем типа void*.
Шаблоны типа
Зачем программисту может понадобиться определить такой тип, как вектор целых чисел? Как правило, ему нужен вектор из элементов, тип которых неизвестен создателю класса Vector. Следовательно, надо суметь определить тип вектора так, чтобы тип элементов в этом определении участвовал как параметр, обозначающий "реальные" типы элементов:
template < class T > class Vector
{ // вектор элементов типа T
T * v;
int sz;
public:
Vector ( int s )
{
if ( s <= 0 )
error ( "недопустимый для Vector размер" );
v = new T [ sz = s ];
// выделить память для массива s типа T
}
T & operator [] ( int i );
int size () { return sz; }
// ...
};
Таково определение шаблона типа. Он задает способ получения семейства сходных классов. В нашем примере шаблон типа Vector показывает, как можно получить класс вектор для заданного типа его элементов. Это описание отличается от обычного описания класса наличием начальной конструкции template<class T>, которая и показывает, что описывается не класс, а шаблон типа с заданным параметром-типом (здесь он используется как тип элементов). Теперь можно определять и использовать вектора разных типов:
void f ()
{
Vector < int > v1 ( 100 ); // вектор из 100 целых
Vector < complex > v2 ( 200 ); // вектор из 200
// комплексных чисел
v2 [ i ] = complex ( v1 [ x ], v1 [ y ] );
// ...
}
Возможности, которые реализует шаблон типа, иногда называются параметрическими типами или генерическими объектами. Оно сходно с возможностями, имеющимися в языках Clu и Ада. Использование шаблона типа не влечет за собой каких-либо дополнительных расходов времени по сравнению с использованием класса, в котором все типы указаны непосредственно.
Шаблоны типа для функций
Использование шаблонных классов означает наличие шаблонных функций-членов. Помимо этого, можно определить глобальные шаблонные функции, т.е. шаблоны типа для функций, не являющихся членами класса. Шаблон типа для функций порождает семейство функций точно также, как шаблон типа для класса порождает семейство классов. Эту возможность мы обсудим на последовательности примеров, в которых приводятся варианты функции сортировки sort(). Каждый из вариантов в последующих разделах будет иллюстрировать общий метод.
Как обычно мы сосредоточимся на организации программы, а не на разработке ее алгоритма, поэтому использоваться будет тривиальный алгоритм. Все варианты шаблона типа для sort() нужны для того, чтобы показать возможности языка м полезные приемы программирования. Варианты не упорядочены в соответствии с тем, насколько они хороши. Кроме того, можно обсудить и традиционные варианты без шаблонов типа, в частности, передачу указателя на функцию, производящую сравнение.
Шаблоны типа для списка
На практике при разработке класса, служащего коллекцией объектов, часто приходится учитывать взаимоотношения использующихся в реализации классов, управление памятью и необходимость определить итератор по содержимому коллекции. Часто бывает так, что несколько родственных классов разрабатываются совместно ($$12.2). В качестве примера мы предложим семейство классов, представляющих односвязные списки и шаблоны типа для них.
Шаблоны типа и производные классы
Мы уже видели, что сочетание производных классов (наследование) и шаблонов типа может быть мощным средством. Шаблон типа выражает общность между всеми типами, которые используются как его параметры, а базовый класс выражает общность между всеми представлениями (объектами) и называется интерфейсом. Здесь возможны некоторые простые недоразумения, которых надо избегать.
Два созданных по одному шаблону типа будут различны и между ними невозможно отношение наследования кроме единственного случая, когда у этих типов идентичны параметры шаблона. Например:
template<class T>
class Vector { /* ... */ }
Vector<int> v1;
Vector<short> v2;
Vector<int> v3;
Здесь v1 и v3 одного типа, а v2 имеет совершенно другой тип. Из того факта, что short неявно преобразуется в int, не следует, что есть неявное преобразование Vector<short> в Vector<int>:
v2 = v3; // несоответствие типов
Но этого и следовало ожидать, поскольку нет встроенного преобразования int[] в short[].
Аналогичный пример:
class circle: public shape { /* ... */ };
Vector<circle*> v4;
Vector<shape*> v5;
Vector<circle*> v6;
Здесь v4 и v6 одного типа, а v5 имеет совершенно другой тип. Из того факта, что существует неявное преобразование circle в shape и circle* в shape*, не следует, что есть неявные преобразования Vector<circle*> в Vector<shape*> или Vector<circle*>* в Vector<shape*>* :
v5 = v6; // несоответствие типов
Дело в том, что в общем случае структура (представление) класса, созданного по шаблону типа, такова, что для нее не предполагаются отношения наследования. Так, созданный по шаблону класс может содержать объект типа, заданного в шаблоне как параметр, а не просто указатель на него. Кроме того, допущение подобных преобразований приводит к нарушению контроля типов:
void f(Vector<circle>* pc)
{
Vector<shape>* ps = pc; // ошибка: несоответствие типов
(*ps)[2] = new square; // круглую ножку суем в квадратное
// отверстие (память выделена для
// square, а используется для circle
}
На примерах шаблонов Islist, Tlink, Slist, Splist, Islist_iter, Slist_iter и SortableVector мы видели, что шаблоны типа дают удобное средство для создания целых семейств классов. Без шаблонов создание таких семейств только с помощью производных классов может быть утомительным занятием, а значит, ведущим к ошибкам. С другой стороны, если отказаться от производных классов и использовать только шаблоны, то появляется множество копий функций-членов шаблонных классов, множество копий описательной части шаблонных классов и во множестве повторяются функции, использующие шаблоны типа.
Шаг определение интерфейсов
Определите интерфейсы классов. На этой стадии проектирования не нужно рассматривать приватные функции. Вопросы реализации, возникающие на стадии проектирования, лучше всего обсуждать на шаге 3 при рассмотрении различных зависимостей. Более того, существует золотое правило: если класс не допускает по крайней мере двух существенно отличающихся реализаций, то что-то явно не в порядке с этим классом, это просто замаскированная реализация, а не представление абстрактного понятия. Во многих случаях для ответа на вопрос: "Достаточно ли интерфейс класса независим от реализации?"- надо указать, возможна ли для класса схема ленивых вычислений.
Отметим, что общие базовые классы и друзья (friend) являются частью общего интерфейса класса (см. $$5.4.1 и $$12.4). Полезным упражнением может быть определение раздельного интерфейса для классов-наследников и всех остальных классов с помощью разбиения интерфейса на общую и закрытые части.
Именно на этом шаге следует продумать и описать точные определения типов аргументов. В идеале желательно иметь максимальное число интерфейсов со статическими типами, относящимися к области приложения (см. $$12.1.3 и $$12.4).
При определении интерфейсов следует обратить внимание на те классы, где набор операций представлен более, чем на одном уровне абстракции. Например, в классе file у некоторых функций-членов аргументы имеют тип file_descriptor (дескриптор_файла), а у других аргументы - строка символов, которая обозначает имя файла. Операции с file_descriptor работают на другом уровне (меньшем) абстракции, чем операции с именем файла, так что даже странно, что они относятся к одному классу. Возможно, было бы лучше иметь два класса: один представляет понятие дескриптора файла, а другой - понятие имени файла. Обычно все операции класса должны представлять понятия одного уровня абстракции. Если это не так, то стоит подумать о реорганизации и его, и связанных с ним классов.
Шаг определение классов
Определите понятия/классы и установите основные связи между ними. Главное в хорошем проекте - прямо отразить какое-либо понятие "реальности", т.е. уловить понятие из области приложения классов, представить взаимосвязь между классами строго определенным способом, например, с помощью наследования, и повторить эти действия на разных уровнях абстракции. Но как мы можем уловить эти понятия? Как на практике решить, какие нам нужны классы?
Лучше поискать ответ в самой области приложения, чем рыться в программистском хранилище абстракций и понятий. Обратитесь к тому, кто стал экспертом по работе в некогда сделанной системе, а также к тому, кто стал критиком системы, пришедшей ей на смену. Запомните выражения того и другого.
Часто говорят, что существительные играют роль классов и объектов, используемых в программе, это действительно так. Но это только начало. Далее, глаголы могут представлять операции над объектами или обычные (глобальные) функции, вырабатывающие новые значения, исходя из своих параметров, или даже классы. В качестве примера можно рассматривать функциональные объекты, описанные в $$10.4.2. Такие глаголы, как "повторить" или "совершить" (commit) могут быть представлены итеративным объектом или объектом, представляющим операцию выполнения программы в базах данных. Даже прилагательные можно успешно представлять с помощью классов, например, такие, как "хранимый", "параллельный", "регистровый", "ограниченный". Это могут быть классы, которые помогут разработчику или программисту, задав виртуальные базовые классы, специфицировать и выбрать нужные свойства для классов, проектируемых позднее.
Лучшее средство для поиска этих понятий / классов – грифельная доска, а лучший метод первого уточнения - это беседа со специалистами в области приложения или просто с друзьями. Обсуждение необходимо, чтобы создать начальный жизнеспособный словарь терминов и понятийную структуру. Мало кто может сделать это в одиночку. Обратитесь к [1], чтобы узнать о методах подобных уточнений.
Не все классы соответствуют понятиям из области приложения. Некоторые могут представлять ресурсы системы или абстракции периода реализации (см. $$12.2.1).
Взаимоотношения, о которых мы говорим, естественно устанавливаются в области приложения или (в случае повторных проходов по шагам проектирования) возникают из последующей работы над структурой классов. Они отражают наше понимание основ области приложения. Часто они являются классификацией основных понятий. Пример такого отношения: машина с выдвижной лестницей есть грузовик, есть пожарная машина, есть движущееся средство.
В $$11.3.3.2 и $$11.3.3.5 предлагается некоторая точка зрения на классы и иерархию классов, если необходимо улучшить их структуру.
Шаг определение набора операций
Уточните определения классов, указав набор операций для каждого. В действительности нельзя разделить процессы определения классов и выяснения того, какие операции для них нужны. Однако, на практике они различаются, поскольку при определении классов внимание концентрируется на основных понятиях, не останавливаясь на программистских вопросах их реализации, тогда как при определении операций прежде всего сосредотачивается на том, чтобы задать полный и удобный набор операций. Часто бывает слишком трудно совместить оба подхода, в особенности, учитывая, что связанные классы надо проектировать одновременно.
Возможно несколько подходов к процессу определения набора операций.
Предлагаем следующую стратегию:
[1] Рассмотрите, каким образом объект класса будет создаваться, копироваться (если нужно) и уничтожаться.
[2] Определите минимальный набор операций, который необходим для понятия, представленного классом.
[3] Рассмотрите операции, которые могут быть добавлены для удобства записи, и включите только несколько действительно важных.
[4] Рассмотрите, какие операции можно считать тривиальными, т.е. такими, для которых класс выступает в роли интерфейса для реализации производного класса.
[5] Рассмотрите, какой общности именования и функциональности можно достигнуть для всех классов компонента.
Очевидно, что это - стратегия минимализма. Гораздо проще добавлять любую функцию, приносящую ощутимую пользу, и сделать все операции виртуальными. Но, чем больше функций, тем больше вероятность, что они не будут использоваться, наложат определенные ограничения на реализацию и затруднят эволюцию системы. Так, функции, которые могут непосредственно читать и писать в переменную состояния объекта из класса, вынуждают использовать единственный способ реализации и значительно сокращают возможности перепроектирования. Такие функции снижают уровень абстракции от понятия до его конкретной реализации. К тому же добавление функций добавляет работы программисту и даже разработчику, когда он вернется к проектированию. Гораздо легче включить в интерфейс еще одну функцию, как только установлена потребность в ней, чем удалить ее оттуда, когда уже она стала привычной.
Причина, по которой мы требуем явного принятия решения о виртуальности данной функции, не оставляя его на стадию реализации, в том, что, объявив функцию виртуальной, мы существенно повлияем на использование ее класса и на взаимоотношения этого класса с другими. Объекты из класса, имеющего хотя бы одну виртуальную функцию, требуют нетривиального распределения памяти, если сравнить их с объектами из таких языков как С или Фортран. Класс с хотя бы одной виртуальной функцией по сути выступает в роли интерфейса по отношению к классам, которые "еще могут быть определены", а виртуальная функция предполагает зависимость от классов, которые "еще могу быть определены" (см. $$12.2.3)
Отметим, что стратегия минимализма требует, пожалуй, больших усилий со стороны разработчика.
При определении набора операций больше внимания следует уделять тому, что надо сделать, а не тому, как это делать.
Иногда полезно классифицировать операции класса по тому, как они работают с внутренним состоянием объектов:
-
Базовые операции: конструкторы, деструкторы, операции копирования.
- Селекторы: операции, не изменяющие состояния объекта.
- Модификаторы: операции, изменяющие состояние объекта.
- Операции преобразований, т.е. операции порождающие объект другого типа, исходя из значения (состояния) объекта, к которому они применяются.
- Повторители: операции, которые открывают доступ к объектам класса или используют последовательность объектов.
Это не есть разбиение на ортогональные группы операций. Например, повторитель может быть спроектирован как селектор или модификатор. Выделение этих групп просто предназначено помочь в процессе проектирования интерфейса класса. Конечно, допустима и другая классификация. Проведение такой классификации особенно полезно для поддержания непротиворечивости между классами в рамках одного компонента.
В языке С++ есть конструкция, помогающая заданию селекторов и модификаторов в виде функции-члена со спецификацией const и без нее. Кроме того, есть средства, позволяющие явно задать конструкторы, деструкторы и функции преобразования. Операция копирования реализуется с помощью операций присваивания и конструкторов копирования.
Шаг указание зависимостей
Уточните определение классов, указав их зависимости от других классов. Различные виды зависимостей обсуждаются в $$12.2. Основными по отношению к проектированию следует считать отношения наследования и использования. Оба предполагают понимание того, что значит для класса отвечать за определенное свойство системы. Отвечать за что-либо не означает, что класс должен содержать в себе всю информацию, или, что его функции-члены должны сами проводить все необходимые операции. Как раз наоборот, каждый класс, имеющий определенный уровень ответственности, организует работу, перепоручая ее в виде подзадач другим классам, которые имеют меньший уровень ответственности. Но надо предостеречь, что злоупотребление этим приемом приводит к неэффективным и плохо понимаемым проектам, поскольку происходит размножение классов и объектов до такой степени, что вместо реальной работы производится только серия запросов на ее выполнение. То, что можно сделать в данном месте, следует сделать.
Необходимость учесть отношения наследования и использования на этапе проектирования (а не только в процессе реализации) прямо вытекает из того, что классы представляют определенные понятия. Отсюда также следует, что именно компонент (т.е. множество связанных классов), а не отдельный класс, являются единицей проектирования.
Шаги проектирования
Рассмотрим проектирование отдельного класса. Обычно это не лучший метод. Понятия не существуют изолированно, наоборот, понятие определяется в связи с другими понятиями. Аналогично и класс не существует изолированно, а определяется совместно с множеством связанных между собой классов. Это множество часто называют библиотекой классов или компонентом. Иногда все классы компонента образуют единую иерархию, иногда это не так (см. $$12.3).
Множество классов компонента бывают объединены некоторым логическим условием, иногда это - общий стиль программирования или описания, иногда - предоставляемый сервис. Компонент является единицей проектирования, документации, права собственности и, часто, повторного использования. Это не означает, что если вы используете один класс компонента, то должны разбираться во всех и уметь применять все классы компонента или должны подгружать к вашей программе модули всех классов компонента. В точности наоборот, обычно стремятся обеспечить, чтобы использование класса вело к минимуму накладных расходов: как машинных ресурсов, так и человеческих усилий. Но для использования любого класса компонента нужно понимать логическое условие, которое его определяет (можно надеяться, что оно предельно ясно изложено в документации), понимать соглашения и стиль, примененный в процессе проектирования и описания компонента, и доступный сервис (если он есть).
Итак, перейдем к способам проектирования компонента. Поскольку часто это непростая задача, имеет смысл разбить ее на шаги и, сконцентрировавшись на подзадачах, дать полное и последовательное описание. Обычно нет единственно правильного способа разбиения. Тем не менее, ниже приводится описание последовательности шагов, которая пригодилась в нескольких случаях:
[1] Определить понятие / класс и установить основные связи между ними.
[2] Уточнить определения классов, указав набор операций для каждого.
[a] Провести классификацию операций. В частности уточнить необходимость построения, копирования и уничтожения.
[b] Убедиться в минимальности, полноте и удобстве.
[3] Уточнить определения классов, указав их зависимость от других классов.
[a] Наследование.
[b] Использование зависимостей.
[4] Определить интерфейсы классов.
[a] Поделить функции на общие и защищенные.
[b] Определить точный тип операций класса.
Отметим, что это шаги итеративного процесса. Обычно для получения проекта, который можно уверенно использовать для первичной реализации или повторной реализации, нужно несколько раз проделать последовательность шагов. Одним из преимуществ глубокого анализа и предложенной здесь абстракции данных оказывается относительная легкость, с которой можно перестроить взаимоотношения классов даже после программирования каждого класса. Хотя это никогда не бывает просто.
Далее следует приступить к реализации классов, а затем вернуться, чтобы оценить проект, исходя из опыта реализации. Рассмотрим эти шаги в отдельности.
Символьные константы
Символьной константой является символ, заключенный в одиночные кавычки, например, 'a' или '0'. Символьные константы можно считать константами, которые дают имена целым значениям символов из набора, принятого на машине, на которой выполняется программа. Это необязательно тот же набор символов, который есть на машине, где программа транслировалась. Таким образом, если вы запускаете программу на машине, использующей набор символов ASCII, то значение '0' равно 48, а если машина использует код EBCDIC, то оно будет равно 240. Использование символьных констант вместо их десятичного целого эквивалента повышает переносимость программ. Некоторые специальные комбинации символов, начинающиеся с обратной дробной черты, имеют стандартные названия:
Конец строки NL(LF) \n
Горизонтальная табуляция HT \t
Вертикальная табуляция VT \v
Возврат BS \b
Возврат каретки CR \r
Перевод формата FF \f
Сигнал BEL \a
Обратная дробная черта \ \\
Знак вопроса ? \?
Одиночная кавычка ' \'
Двойная кавычка " \"
Нулевой символ NUL \0
Восьмеричное число ooo \ooo
Шестнадцатеричное число hhh \xhhh
Несмотря на их вид, все эти комбинации задают один символ. Тип символьной константы - char. Можно также задавать символ с помощью восьмеричного числа, представленного одной, двумя или тремя восьмеричными цифрами (перед цифрами идет \) или с помощью шестнадцатеричного числа (перед шестнадцатеричными цифрами идет \x). Число шестнадцатеричных цифр в такой последовательности неограничено. Последовательность восьмеричных или шестнадцатеричных цифр завершается первым символом, не являющимся такой цифрой. Приведем примеры:
'\6' '\x6' 6 ASCII ack
'\60' '\x30' 48 ASCII '0'
'\137' '\x05f' 95 ASCII '_'
Этим способом можно представить любой символ из набора символов машины. В частности, задаваемые таким образом символы можно включать в символьные строки (см. следующий раздел). Заметим, что если для символов используется числовая форма задания, то нарушается переносимость программы между машинами с различными наборами символов.
Скобки
Синтаксис языка С++ перегружен скобками, и разнообразие их применений способно сбить с толку. Они выделяют фактические параметры при вызове функций, имена типов, задающих функции, а также служат для разрешения конфликтов между операциями с одинаковым приоритетом. К счастью, последнее встречается не слишком часто, поскольку приоритеты и порядок применения операций определены так, чтобы выражения вычислялись "естественным образом" (т.е. наиболее распространенным образом). Например, выражение
if (i<=0 || max<i) // ...
означает следующее: "Если i меньше или равно нулю, или если max меньше i".
То есть, оно эквивалентно
if ( (i<=0) || (max<i) ) // ...
но не эквивалентно допустимому, хотя и бессмысленному выражению
if (i <= (0||max) < i) // ...
Тем не менее, если программист не уверен в указанных правилах, следует использовать скобки, причем некоторые предпочитают для надежности писать более длинные и менее элегантные выражения, как:
if ( (i<=0) || (max<i) ) // ...
При усложнении подвыражений скобки используются чаще. Не надо, однако,
забывать, что сложные выражения являются источником ошибок. Поэтому, если у вас появится ощущение, что в этом выражении нужны скобки, лучше разбейте его на части и введите дополнительную переменную. Бывают случаи, когда приоритеты операций не приводят к "естественному" порядку вычислений. Например, в выражении
if (i&mask == 0) // ловушка! & применяется после ==
не происходит маскирование i (i&mask), а затем проверка результата
на 0. Поскольку у == приоритет выше, чем у &, это выражение эквивалентно i&(mask==0). В этом случае скобки играют важную роль:
if ((i&mask) == 0) // ...
Имеет смысл привести еще одно выражение, которое вычисляется совсем не так, как мог бы ожидать неискушенный пользователь:
if (0 <= a <= 99) // ...
Оно допустимо, но интерпретируется как (0<=a)<=99, и результат первого
сравнения равен или 0, или 1, но не значению a (если, конечно, a не есть 1). Проверить, попадает ли a в диапазон 0...99, можно так:
if (0<=a && a<=99) // ...
Среди новичков распространена ошибка, когда в условии вместо == (равно) используют = (присвоить):
if (a = 7) // ошибка: присваивание константы в условии
// ...
Она вполне объяснима, поскольку в большинстве языков "=" означает "равно". Для транслятора не составит труда сообщать об ошибках подобного рода.
Сопровождение
"Сопровождение программного обеспечения" - неудачный термин. Слово "сопровождение" предлагает неверную аналогию с аппаратурой. Программы не требуют смазки, не имеют движущихся частей, которые изнашиваются так, что требуют замены, у них нет трещин, в которые попадает вода, вызывая ржавчину. Программы можно воспроизводить в точности и передавать в течении минуты на длинные расстояния. Короче, программы это совсем не то, что аппаратура. (В оригинале: "Software is not hardware").
Деятельность, которая обозначается, как сопровождение программ, на самом деле, состоит из перепроектирования и повторной реализации, а значит входит в обычный цикл развития программного обеспечения. Если в проекте учтены вопросы расширяемости, гибкости и переносимости, то обычные задачи сопровождения решаются естественным образом.
Подобно тестированию задачи сопровождения не должны решаться вне основного направления развития проекта и их не следует откладывать на потом.
Состояние формата
В классе ios содержится состояние формата, которое управляется функциями flags() и setf(). По сути эти функции нужны, чтобы установить или отменить следующие флаги:
class ios {
public:
// управляющие форматом флаги:
enum {
skipws=01, // пропуск обобщенных пробелов для input
// поле выравнивания:
left=02, // добавление перед значением
right=04, // добавление после значения
internal=010, // добавление между знаком и значением
// основание целого:
dec=020, // восьмеричное
oct=040, // десятичное
hex=0100, // шестнадцатеричное
showbase=0200, // показать основание целого
showpoint=0400, // выдать нули в конце
uppercase=01000, // 'E', 'X' , а не 'e', 'x'
showpos=02000, // '+' для положительных чисел
// запись числа типа float:
scientific=04000, // .dddddd Edd
fixed=010000, // dddd.dd
// сброс в выходной поток:
unitbuf=020000, // после каждой операции
stdio=040000 // после каждого символа
};
//...
};
Смысл флагов будет разъяснен в последующих разделах. Конкретные значения флагов зависят от реализации и даны здесь только для того, чтобы избежать синтаксически неверных конструкций.
Определение интерфейса как набора флагов и операций для их установки или отмены - это оцененный временем, хотя и несколько устаревший прием. Основное его достоинство в том, что пользователь может собрать воедино набор флагов, например, так:
const int my_io_options =
ios::left|ios::oct|ios::showpoint|ios::fixed;
Такое множество флагов можно задавать как параметр одной операции
cout.flags(my_io_options);
а также просто передавать между функциями одной программы:
void your_function(int ios_options);
void my_function()
{
// ...
your_function(my_io_options);
// ...
}
Множество флагов можно установить с помощью функции flags(), например:
void your_function(int ios_options)
{
int old_options = cout.flags(ios_options);
// ...
cout.flags(old_options); // reset options
}
Функция flags() возвращает старое значение множества флагов. Это позволяет переустановить значения всех флагов, как показано выше, а также задать значение отдельному флагу. Например вызов
myostream.flags(myostream.flags()|ios::showpos);
заставляет класс myostream выдавать положительные числа со знаком + и, в то же время, не меняет значения других флагов. Получается старое значение множества флагов, к которому добавляется с помощью операции | флаг showpos. Функция setf() делает то же самое, поэтому эквивалентная запись имеет вид
myostream.setf(ios::showpos);
После установки флаг сохраняет значение до явной отмены.
Все-таки управление вводом-выводом с помощью установки и отмены флагов - грубое и ведущее к ошибкам решение. Если только вы тщательно не изучите свое справочное руководство и не будете применять флаги только в простых случаях, как это делается в последующих разделах, то лучше использовать манипуляторы (описанные в 10.4.2.1). Приемы работы с состоянием потока лучше изучить на примере реализации класса, чем изучая интерфейс класса.
Состояния потока
С каждым потоком (istream или ostream) связано определенное состояние.
Нестандартные ситуации и ошибки обрабатываются с помощью проверки и установки состояния подходящим образом. Узнать состояние потока можно с помощью операций над классом ios:
class ios { //ios является базовым для ostream и istream
//...
public:
int eof() const; // дошли до конца файла
int fail() const; // следующая операция будет неудачна
int bad() const; // поток испорчен
int good() const; // следующая операция будет успешной
//...
};
Последняя операция ввода считается успешной, если состояние задается good() или eof(). Если состояние задается good(), то последующая операция ввода может быть успешной, в противном случае она будет неудачной. Применение операции ввода к потоку в состоянии, задаваемом не good(), считается пустой операцией. Если произошла неудача при попытке чтения в переменную v, то значение v не изменилось (оно не изменится, если v имеет тип, управляемый функциями члена из istream или ostream). Различие между состояниями, задаваемыми как fail() или как bad() уловить трудно, и оно имеет смысл только для разработчиков операций ввода. Если состояние есть fail(), то считается, что поток не поврежден, и никакие символы не пропали; о состоянии bad() ничего сказать нельзя.
Значения, обозначающие эти состояния, определены в классе ios:
class ios {
//...
public:
enum io_state {
goodbit=0,
eofbit=1,
filebit=2,
badbit=4,
};
//...
};
Истинные значения состояний зависят от реализации, и указанные значения приведены только, чтобы избежать синтаксически неправильных конструкций.
Проверять состояние потока можно следующим образом:
switch (cin.rdstate()) {
case ios::goodbit:
// последняя операция с cin была успешной
break;
case ios::eofbit:
// в конце файла
break;
case ios::filebit:
// некоторый анализ ошибки
// возможно неплохой
break;
case ios::badbit:
// cin возможно испорчен
break;
}
В более ранних реализациях для значений состояний использовались глобальные имена. Это приводило к нежелательному засорению пространства именования, поэтому новые имена доступны только в пределах класса ios. Если вам необходимо использовать старые имена в сочетании с новой библиотекой, можно воспользоваться следующими определениями:
const int _good = ios::goodbit;
const int _bad = ios::badbit;
const int _file = ios::filebit;
const int _eof = ios::eofbit;
typedef ios::io_state state_value ;
Разработчики библиотек должны заботится о том, чтобы не добавлять новых имен к глобальному пространству именования. Если элементы перечисления входят в общий интерфейс библиотеки, они всегда должны использоваться в классе с префиксами, например, как ios::goodbit и ios::io_state.
Для переменной любого типа, для которого определены операции << и >>, цикл копирования записывается следующим образом:
while (cin>>z) cout << z << '\n';
Если поток появляется в условии, то проверяется состояние потока, и условие выполняется (т.е. результат его не 0) только для состояния good(). Как раз в приведенном выше цикле проверяется состояние потока istream, что является результатом операции cin>>z. Чтобы узнать, почему произошла неудача в цикле или условии, надо проверить состояние. Такая проверка для потока реализуется с помощью операции приведения (7.3.2).
Так, если z является символьным вектором, то в приведенном цикле читается стандартный ввод и выдается для каждой строки стандартного вывода по одному слову (т.е. последовательности символов, не являющихся обобщенными пробелами). Если z имеет тип complex, то в этом цикле с помощью операций, определенных в 10.2.2 и 10.2.3, будут копироваться комплексные числа. Шаблонную функцию копирования для потоков со значениями произвольного типа можно написать следующим образом:
complex z;
iocopy(z,cin,cout); // копирование complex
double d;
iocopy(d,cin,cout); // копирование double
char c;
iocopy(c,cin,cout); // копирование char
Поскольку надоедает проверять на корректность каждую операцию ввода-вывода, то распространенным источником ошибок являются именно те места в программе, где такой контроль существенен. Обычно операции вывода не проверяют, но иногда они могут завершиться неудачно. Потоковый ввод-вывод разрабатывался из того принципа, чтобы сделать исключительные ситуации легкодоступными, и тем самым упростить обработку ошибок в процессе ввода-вывода.
Список без принудительной связи
После "экскурса" в вопросы построения и использования списка с принудительной связью перейдем к построению списков без принудительной связи. Это значит, что элементы списка не обязаны содержать дополнительную информацию, помогающую в реализации списочного класса. Поскольку мы больше не можем рассчитывать, что объект в списке имеет поле связи, такую связь надо предусмотреть в реализации:
template<class T>
struct Tlink : public slink {
T info;
Tlink(const T& a) : info(a) { }
};
Класс Tlink<T> хранит копию объектов типа T помимо поля связи, которое идет от его базового класса slink. Отметим, что используется инициализатор в виде info(a), а не присваивание info=a. Это существенно для эффективности операции в случае типов, имеющих нетривиальные конструкторы копирования и операции присваивания ($$7.11). Для таких типов (например, для String) определив конструктор как
Tlink(const T& a) { info = a; }
мы получим, что будет строиться стандартный объект String, а уже затем ему будет присваиваться значение. Имея класс, определяющий связь, и класс Islist, получить определение списка без принудительной связи совсем просто:
template<class T>
class Slist : private slist_base {
public:
void insert(const T& a)
{ slist_base::insert(new Tlink<T>(a)); }
void append(const T& a)
{ slist_base::append(new Tlink<T>(a)); }
T get();
// ...
};
template<class T>
T Slist<T>::get()
{
Tlink<T>* lnk = (Tlink<T>*) slist_base::get();
T i = lnk->info;
delete lnk;
return i;
}
Работать со списком Slist так же просто, как и со списком Ilist. Различие в том, что можно включать в Slist объект, класс которого не является производным от slink, а также можно включать один объект в два списка:
void f(int i)
{
Slist<int> lst1;
Slist<int> lst2;
lst1.insert(i);
lst2.insert(i);
// ...
int i1 = lst1.get();
int i2 = lst2.get();
// ...
}
Однако, список с принудительной связью, например Islist, позволял создавать существенно более эффективную программу и давал более компактное представление. Действительно, при каждом включении объекта в список Slist нужно разместить объект Tlink, а при каждом удалении объекта из Slist нужно удалить объект Tlink, причем каждый раз копируется объект типа T. Когда возникает такая проблема дополнительных расходов, могут помочь два приема. Во-первых, Tlink является прямым кандидатом для размещения с помощью практически оптимальной функции размещения специального назначения (см. $$5.5.6). Тогда дополнительные расходы при выполнении программы сократятся до обычно приемлемого уровня. Во-вторых, полезным оказывается такой прием, когда объекты хранятся в "первичном" списке, имеющим принудительную связь, а списки без принудительной связи используются только, когда требуется включение объекта в несколько списков:
void f(name* p)
{
Islist<name> lst1;
Slist<name*> lst2;
lst1.insert(p); // связь через объект `*p'
lst2.insert(p); // для хранения `p' используется
// отдельный объект типа список
// ...
}
Конечно, подобные трюки можно делать только в отдельном компоненте программы, чтобы не допустить путаницы списочных типов в интерфейсах различных компонент. Но это именно тот случай, когда ради эффективности и компактности программы на них стоит идти.
Поскольку конструктор Slist копирует параметр для insert(), список Slist пригоден только для таких небольших объектов, как целые, комплексные числа или указатели. Если для объектов копирование слишком накладно или неприемлемо по смысловым причинам, обычно выход бывает в том, чтобы вместо объектов помещать в список указатели на них. Это сделано в приведенной выше функции f() для lst2.
Отметим, что раз параметр для Slist::insert() копируется, передача объекта производного класса функции insert(), ожидающей объект базового класса, не пройдет гладко, как можно было (по наивности) подумать:
class smiley : public circle { /* ... */ };
void g1(Slist<circle>& olist, const smiley& grin)
{
olist.insert(grin); // ловушка!
}
В список будет включена только часть circle объекта типа smiley. Отметим, что эта неприятность будет обнаружена транслятором в том случае, который можно считать наиболее вероятным. Так, если бы рассматриваемый базовый класс был абстрактным, транслятор запретил бы "урезание" объекта производного класса:
void g2(Slist<shape>& olist, const circle& c)
{
olist.insert(c); // ошибка: попытка создать объект
// абстрактного класса
}
Чтобы избежать "урезания" объекта нужно использовать указатели:
void g3(Slist<shape*>& plist, const smiley& grin)
{
olist.insert(&grin); // прекрасно
}
Не нужно использовать параметр-ссылку для шаблонного класса:
void g4(Slist<shape&>& rlist, const smiley& grin)
{
rlist.insert(grin); // ошибка: будет созданы команды,
// содержащие ссылку на ссылку (shape&&)
}
При генерации по шаблону типа ссылки, используемые подобным образом, приведут ошибкам в типах. Генерация по шаблону типа для функции
Slist::insert(T&);
приведет к появлению недопустимой функции
Slist::insert(shape&&);
Ссылка не является объектом, поэтому нельзя иметь ссылку на ссылку.
Поскольку список указателей является полезной конструкцией, имеет смысл дать ему специальное имя:
template<class T>
class Splist : private Slist<void*> {
public:
void insert(T* p) { Slist<void*>::insert(p); }
void append(T* p) { Slist<void*>::append(p); }
T* get() { return (T*) Slist<void*>::get(); }
};
class Isplist : private slist_base {
public:
void insert(T* p) { slist_base::insert(p); }
void append(T* p) { slist_base::append(p); }
T* get() { return (T*) slist_base::get(); }
};
Эти определения к тому же улучшают контроль типов и еще больше сокращают необходимость дублировать функции.
Часто бывает полезно, чтобы тип элемента, указываемый в шаблоне типа, сам был шаблонным классом. Например, разреженную матрицу, содержащую даты, можно определить так:
typedef Slist< Slist<date> > dates;
Обратите внимание на наличие пробелов в этом определении. Если между первой и второй угловой скобкой > нет пробелов, возникнет синтаксическая ошибка, поскольку >> в определении
typedef Slist<Slist<date>> dates;
будет трактоваться как операция сдвига вправо. Как обычно, вводимое в typedef имя служит синонимом обозначаемого им типа, а не является новым типом. Конструкция typedef полезна для именования для длинных имен шаблонных классов также, как она полезна для любых других длинных имен типов.
Отметим, что параметр шаблона типа, который может по разному использоваться в его определении, должен все равно указываться среди списка параметров шаблона один раз. Поэтому шаблон типа, в котором используется объект T и список элементов T, надо определять так:
template<class T> class mytemplate {
T ob;
Slist<T> slst;
// ...
};
а вовсе не так:
template<class T, class Slist<t> > class mytemplate {
T obj;
Slist<T> slst;
// ...
};
В $$8.6 и $$R.14.2 даны правила, что может быть параметром шаблона типа.
Список литературы
В книге немного непосредственных ссылок на литературу. Здесь приведен
список книг и статей, на которые есть прямые ссылки, а также тех, которые только упоминаются.
[1] A.V.Aho, J.E.Hopcroft, and J.D.Ulman: Data Structures and Algoritms. Addison-Wesley, Reading, Massachusetts. 1983.
[2] O-J.Dahl, B.Myrhaug, and K.Nugaard: SIMULA Common Base Language. Norwegian Computing Ctnter S-22. Oslo, Norway. 1970
[3] O-J.Dahl and C.A.R.Hoare: Hierarhical Program Construction in Structured Programming. Academic Press, New York. 1972. pp. 174-220.
[4] Margaret A.Ellis and Bjarne Stroustrup: The Annotated C++ Reference Manual. Addison-Wesley, Reading, Massachusetts. 1990.
[5] A.Goldberg and D.Rodson: SMALLTALK-80 - The Language and Its Implementation. Addison-Wesley, Reading, Massachusetts. 1983.
[6] R.E.Griswold et.al.: The Snobol14 Programming Language. Prentice-Hall, Englewood Cliffs, New Jersy, 1970.
[7] R.E.Griswold and M.T.Griswold: The ICON Programming Language. Prentice-Hall, Englewood Cliffs, New Jersy. 1983.
[8] Brian W.Kernighan and Dennis M.Ritchie: The C Programming Language. Prentice-Hall, Englewood Cliffs, New Jersy. 1978. Second edition 1988.
[9] Andrew Koenig and Bjarne Stroustrup: C++: As Close to C as possible - but no closer. The C++ Report. Vol.1 No.7. July 1989.
[10] Andrew Koenig and Bjarne Stroustrup: Exception Handling for C++ (revised). Proc USENIX C++ Conference, April 1990. Also, Journal of Object Oriented Programming, Vol.3 No.2, July/August 1990. pp.16-33.
[11] Barbara Liskov et.al.: CLU Reference Manual. MIT/LCS/TR-225.
[12] George Orwell: 1984. Secker and Warburg, London. 1949.
[13] Martin Richards and Colin Whitby-Strevens: BCPL - The Language and Its Compiler. Cambridge University Press. 1980.
[14] L.Rosler: The Evolution of C - Past and Future. AT&T Bell Laboratories Technical Journal. Vol.63 No.8 Part 2. October 1984. pp.1685-1700.
[15] Ravi Sethi: Uniform Syntax for Type Expressions and Declarations. Software Practice & Experience, Vol.11. 1981. pp.623-628.
Список литературы с комментариями
В этой главе мы только поверхностно затронули вопросы проектирования
и управления программными проектами. По этой причине ниже предлагается список литературы с комментариями. Значительно более обширный список литературы с комментариями можно найти в [2].
[1] Bruce Anderson and Sanjiv Gossain: An Iterative Design Model for Reusable Object-Oriented Software. Proc. OOPSLA'90. Ottawa, Canada. pp. 12-27.
Описание модели итеративного проектирования и повторного проектирования с некоторыми примерами и обсуждением результатов.
[2] Grady Booch: Object Oriented Design. Benjamin Cummings. 1991.
В этой книге есть детальное описание проектирования, определенный метод проектирования с графической формой записи и несколько больших примеров проекта, записанных на различных языках. Это превосходная книга, которая во многом повлияла на эту главу. В ней более глубоко рассматриваются многие из затронутых здесь вопросов.
[3] Fred Brooks: The Mythical Man Month. Addison Wesley. 1982.
Каждый должен перечитывать эту книгу раз в пару лет. Предостережение от высокомерия. Она несколько устарела в технических вопросах, но совершенно не устарела во всем, что касается отдельного работника, организации и вопросов размера.
[4] Fred Brooks: No Silver Bullet. IEEE Computer, Vol.20 No.4. April 1987.
Сводка различных подходов к процессу развития больших программных систем с очень полезным предостережением от веры в магические рецепты ("золотая пуля").
[5] De Marco and Lister: Peopleware. Dorset House Publishing Co. 1987.
Одна из немногих книг, посвященных роли человеческого фактора в производстве программного обеспечения. Необходима для каждого менеджера. Достаточно успокаивающая для чтения перед сном. Лекарство от многих глупостей.
[6] Ron Kerr: A Materialistic View of the Software "Engineering" Analogy. in SIGPLAN Notices, March 1987. pp 123-125.
Использование аналогии в этой и следующей главах во многом обязано наблюдениям из указанной статьи, а так же беседам с Р. Керром, которые этому предшествовали.
[7] Barbara Liskov: Data Abstraction and Hierarchy. Proc. OOPSLA'87 (Addendum). Orlando, Florida. pp 17-34.
Исследуется, как использование наследования может повредить концепции абстрактных данных. Укажем, что в С++ есть специальные языковые средства, помогающие избежать большинство указанных проблем ($$12.2.5).
[8] C. N. Parkinson: Parkinson's Law and other Studies in Administration. Houghton-Mifflin. Boston. 1957.
Одно из забавных и самых язвительных описаний бед, к которым приводит процесс администрирования.
[9] Bertrand Meyer: Object Oriented Software Construction. Prentice Hall. 1988.
Страницы 1-64 и 323-334 содержат хорошее описание одного взгляда на объектно-ориентированное программирование и проектирование, а также много здравых, практических советов. В остальной части книги описывается язык Эйффель (Eiffel).
[10] Alan Snyder: Encapsulation and Inheritance in Object-Oriented Programming Languages. Proc. OOPSLA'86. Portland, Oregon. pp.38-45.
Возможно первое хорошее описание взаимодействия оболочки и наследования. В статье так же на хорошем уровне рассматриваются некоторые понятия, связанные с множественным наследованием.
[11] Rebecca Wirfs-Brock, Brian Wilkerson, and Lauren Wiener: Designing Object-Oriented Software. Prentice Hall. 1990.
Описывается антропоморфный метод проектирования основанный на специальных карточках CRC (Classes, Responsibilities, Collaboration) (т.е. Классы, Ответственность, Сотрудничество). Текст, а может быть и сам метод тяготеет к языку Smalltalk.
Список с принудительной связью
Вначале определим простой список, в котором предполагается, что в каждом заносимом в список объекте есть поле связи. Потом этот список будет использоваться как строительный материал для создания более общих списков, в которых объект не обязан иметь поле связи. Сперва в описаниях классов будет приведена только общая часть, а реализация будет дана в следующем разделе. Это делается за тем, чтобы вопросы проектирования классов не затемнялись деталями их реализации.
Начнем с типа slink, определяющего поле связи в односвязном списке:
struct slink {
slink* next;
slink() { next = 0; }
slink(slink* p) { next = p; }
};
Теперь можно определить класс, который может содержать объекты любого, производного от slink, класса:
class slist_base {
// ...
public:
int insert(slink*); // добавить в начало списка
int append(slink*); // добавить к концу списка
slink* get(); // удалить и возвратить начало списка
// ...
};
Такой класс можно назвать списком с принудительной связью, поскольку его можно использовать только в том случае, когда все элементы имеют поле slink, которое используется как указатель на slist_base. Само имя slist_base (базовый односвязный список) говорит, что этот класс будет использоваться как базовый для односвязных списочных классов. Как обычно, при разработке семейства родственных классов возникает вопрос, как выбирать имена для различных членов семейства. Поскольку имена классов не могут перегружаться, как это делается для имен функций, для обуздания размножения имен перегрузка нам не поможет.
Класс slist_base можно использовать так:
void f()
{
slist_base slb;
slb.insert(new slink);
// ...
slink* p = slb.get();
// ...
delete p;
}
Но поскольку структура slink не может содержать никакой информации помимо связи, этот пример не слишком интересен. Чтобы воспользоваться slist_base, надо определить полезный, производный от slink, класс. Например, в трансляторе используются узлы дерева программы name (имя), которые приходится связывать в список:
class name : public slink {
// ...
};
void f(const char* s)
{
slist_base slb;
slb.insert(new name(s));
// ...
name* p = (name*)slb.get();
// ...
delete p;
}
Здесь все нормально, но поскольку определение класса slist_base дано через структуру slink, приходится использовать явное приведение типа для преобразования значения типа slink*, возвращаемого функцией slist_base::get(), в name*. Это некрасиво. Для большой программы, в которой много списков и производных от slink классов, это к тому же чревато ошибками. Нам пригодилась бы надежная по типу версия класса slist_base:
template<class T>
class Islist : private slist_base {
public:
void insert(T* a) { slist_base::insert(a); }
T* get() { return (T*) slist_base::get(); }
// ...
};
Приведение в функции Islist::get() совершенно оправдано и надежно, поскольку в классе Islist гарантируется, что каждый объект в списке действительно имеет тип T или тип производного от T класса. Отметим, что slist_base является частным базовым классом Islist. Мы нет хотим, чтобы пользователь случайно натолкнулся на ненадежные детали реализации.
Имя Islist (intrusive singly linked list) обозначает односвязный список с принудительной связью. Этот шаблон типа можно использовать так:
void f(const char* s)
{
Islist<name> ilst;
ilst.insert(new name(s));
// ...
name* p = ilst.get();
// ...
delete p
}
Попытки некорректного использования будет выявлены на стадии трансляции:
class expr : public slink {
// ...
};
void g(expr* e)
{
Islist<name> ilst;
ilst.insert(e); // ошибка: Islist<name>::insert(),
// а нужно name*
// ...
}
Нужно отметить несколько важных моментов относительно нашего примера. Во-первых, решение надежно в смысле типов (преграда тривиальным ошибкам ставится в очень ограниченной части программы, а именно, в функциях доступа из Islist). Во-вторых, надежность типов достигается без увеличения затрат времени и памяти, поскольку функции доступа из Islist тривиальны и реализуются подстановкой. В-третьих, поскольку вся настоящая работа со списком делается в реализации класса slist_base (пока еще не представленной), никакого дублирования функций не происходит, а исходный текст реализации, т.е. функции slist_base, вообще не должен быть доступен пользователю. Это может быть существенно в коммерческом использовании служебных программ для списков. Кроме того, достигается разделение между интерфейсом и его реализацией, и становится возможной смена реализации без перетрансляции программ пользователя. Наконец, простой список с принудительной связью близок по использованию памяти и времени к оптимальному решению. Иными словами, такой подход близок к оптимальному по времени, памяти, упрятыванию данных и контролю типов и в тоже время он обеспечивает большую гибкость и компактность выражений.
К сожалению, объект может попасть в Islist только, если он является производным от slink. Значит нельзя иметь список Islist из значений типа int, нельзя составить список из значений какого-то ранее определенного типа, не являющегося производным от slink. Кроме того, придется постараться, чтобы включить объект в два списка Islist ($$6.5.1).
Сравнение языков С++ и С
Выбор С в качестве базового языка для С++ объясняется следующими его достоинствами:
(1) универсальность, краткость и относительно низкий уровень;
(2) адекватность большинству задач системного программирования;
(3) он идет в любой системе и на любой машине;
(4) полностью подходит для программной среды UNIX.
В С существуют свои проблемы, но в языке, разрабатываемом "с нуля" они
появились бы тоже, а проблемы С, по крайней мере, хорошо известны. Более важно то, что ориентация на С позволила использовать язык "С с классами" как полезный (хотя и не очень удобный) инструмент в течение первых месяцев раздумий о введении в С классов в стиле Симулы.
С++ стал использоваться шире, но по мере роста его возможностей, выходящих за пределы С, вновь и вновь возникала проблема совместимости. Ясно, что отказавшись от части наследства С, можно избежать некоторых проблем (см., например, [15]). Это не было сделано по следующим причинам:
(1) существуют миллионы строк программ на С, которые можно улучшить с помощью С++, но при условии, что полной переписи их на язык С++ не потребуется;
(2) существуют миллионы строк библиотечных функций и служебных программ на С, которые можно было бы использовать в С++ при условиях совместимости обоих языков на стадии связывания и их большого синтаксического сходства;
(3) существуют сотни тысяч программистов, знающих С; им достаточно овладеть только новыми средствами С++ и не надо изучать основ языка;
(4) поскольку С и С++ будут использоваться одними и теми же людьми на одних и тех же системах многие годы, различия между языками должны быть либо минимальными, либо максимальными, чтобы свести к минимуму количество ошибок и недоразумений. Описание С++ было переработано так, чтобы гарантировать, что любая допустимая в обоих языках конструкция означала в них одно и то же.
Язык С сам развивался в последние несколько лет, что отчасти было связано с разработкой С++ [14]. Стандарт ANSI для С [27] содержит, например, синтаксис описания функций, позаимствованный из языка "С с классами". Происходит взаимное заимствование, например, тип указателя void* был придуман для ANSI С, а впервые реализован в С++. Как было обещано в первом издании этой книги, описание С++ было доработано, чтобы исключить неоправданные расхождения. Теперь С++ более совместим с языком С, чем это было вначале ($$R.18). В идеале С++ должен максимально приближаться к ANSI C, но не более [9]. Стопроцентной совместимости никогда не было и не будет, поскольку это нарушит надежность типов и согласованность использования встроенных и пользовательских типов, а эти свойства всегда были одними из главных для С++.
Для изучения С++ не обязательно знать С. Программирование на С способствует усвоению приемов и даже трюков, которые при программировании на С++ становятся просто ненужными. Например, явное преобразование типа (приведение) , в С++ нужно гораздо реже, чем в С (см. "Замечания для программистов на С" ниже). Тем не менее, хорошие программы на языке С по сути являются программами на С++. Например, все программы из классического описания С [8] являются программами на С++. В процессе изучения С++ будет полезен опыт работы с любым языком со статическими типами.
Ссылка на себя
В функции-члене можно непосредственно использовать имена членов того объекта, для которого она была вызвана:
class X {
int m;
public:
int readm() { return m; }
};
void f(X aa, X bb)
{
int a = aa.readm();
int b = bb.readm();
// ...
}
При первом вызове readm() m обозначает aa.m, а при втором - bb.m.
У функции-члена есть дополнительный скрытый параметр, являющийся указателем на объект, для которого вызывалась функция. Можно явно использовать этот скрытый параметр под именем this. Считается, что в каждой функции-члене класса X указатель this описан неявно как
X *const this;
и инициализируется, чтобы указывать на объект, для которого функция-член вызывалась. Этот указатель нельзя изменять, поскольку он постоянный (*const). Явно описать его тоже нельзя, т.к. this - это служебное слово. Можно дать эквивалентное описание класса X:
class X {
int m;
public:
int readm() { return this->m; }
};
Для обращения к членам использовать this излишне. В основном this используется в функциях-членах, непосредственно работающих с указателями. Типичный пример - функция, которая вставляет элемент в список с двойной связью:
class dlink {
dlink* pre; // указатель на предыдущий элемент
dlink* suc; // указатель на следующий элемент
public:
void append(dlink*);
// ...
};
void dlink::append(dlink* p)
{
p->suc = suc; // т.е. p->suc = this->suc
p->pre = this; // явное использование "this"
suc->pre = p; // т.е. this->suc->pre = p
suc = p; // т.е. this->suc = p
}
dlink* list_head;
void f(dlink* a, dlink* b)
{
// ...
list_head->append(a);
list_head->append(b);
}
Списки с такой общей структурой служат фундаментом списочных классов, описываемых в главе 8. Чтобы присоединить звено к списку, нужно изменить объекты, на которые настроены указатели this, pre и suc. Все они имеют тип dlink, поэтому функция-член dlink::append() имеет к ним доступ. Защищаемой единицей в С++ является класс, а не отдельный объект класса.
Можно описать функцию- член таким образом, что объект, для которого она вызывается, будет доступен ей только по чтению. Тот факт, что функция не будет изменять объект, для которого она вызывается (т.е. this*), обозначается служебным словом const в конце списка параметров:
class X {
int m;
public:
readme() const { return m; }
writeme(int i) { m = i; }
};
Функцию-член со спецификацией const можно вызывать для постоянных объектов, а функцию-член без такой спецификации - нельзя:
void f(X& mutable, const X& constant)
{
mutable.readme(); // нормально
mutable.writeme(7); // нормально
constant.readme(); // нормально
constant.writeme(7); // ошибка
}
В этом примере разумный транслятор смог бы обнаружить, что функция X::writeme() пытается изменить постоянный объект. Однако, это непростая задача для транслятора. Из-за раздельной трансляции он в общем случае не может гарантировать "постоянство" объекта, если нет соответствующего описания со спецификацией const. Например, определения readme() и writeme() могли быть в другом файле:
class X {
int m;
public:
readme() const;
writeme(int i);
};
В таком случае описание readme() со спецификацией const существенно.
Тип указателя this в постоянной функции-члене класса X есть const X *const. Это значит, что без явного приведения с помощью this нельзя изменить значение объекта:
class X {
int m;
public:
// ...
void implicit_cheat() const { m++; } // ошибка
void explicit_cheat() const { ((X*)this)->m++; }
// нормально
};
Отбросить спецификацию const можно потому, что понятие "постоянства" объекта имеет два значения. Первое, называемое "физическим постоянством" состоит в том, что объект хранится в защищенной от записи памяти. Второе, называемое "логическим постоянством" заключается в том, что объект выступает как постоянный (неизменяемый) по отношению к пользователям. Операция над логически постоянным объектом может изменить часть данных объекта, если при этом не нарушается его постоянство с точки зрения пользователя. Операциями, ненарушающими логическое постоянство объекта, могут быть буферизация значений, ведение статистики, изменение переменных-счетчиков в постоянных функциях-членах.
Логического постоянства можно достигнуть приведением, удаляющим спецификацию const:
class calculator1 {
int cache_val;
int cache_arg;
// ...
public:
int compute(int i) const;
// ...
};
int calculator1::compute(int i) const
{
if (i == cache_arg) return cache_val;
// нелучший способ
((calculator1*)this)->cache_arg = i;
((calculator1*)this)->cache_val = val;
return val;
}
Этого же результата можно достичь, используя указатель на данные без const:
struct cache {
int val;
int arg;
};
class calculator2 {
cache* p;
// ...
public:
int compute(int i) const;
// ...
};
int calculator2::compute(int i) const
{
if (i == p->arg) return p->val;
// нелучший способ
p->arg = i;
p->val = val;
return val;
}
Отметим, что const нужно указывать как в описании, так и в определении
постоянной функции-члена. Физическое постоянство обеспечивается помещением объекта в защищенную по записи память, только если в классе нет конструктора ($$7.1.6).
Ссылки
Ссылку можно рассматривать как еще одно имя объекта. В основном ссылки используются для задания параметров и возвращаемых функциями значений , а также для перегрузки операций (см.$$7). Запись X& обозначает ссылку на X. Например:
int i = 1;
int& r = i; // r и i ссылаются на одно и то же целое
int x = r; // x = 1
r = 2; // i = 2;
Ссылка должна быть инициализирована, т.е. должно быть нечто, что она может обозначать. Следует помнить, что инициализация ссылки совершенно отличается от операции присваивания. Хотя можно указывать операции над ссылкой, ни одна из них на саму ссылку не действует, например,
int ii = 0;
int& rr = ii;
rr++; // ii увеличивается на 1
Здесь операция ++ допустима, но rr++ не увеличивает саму ссылку rr; вместо этого ++ применяется к целому, т.е. к переменной ii. Следовательно, после инициализации значение ссылки не может быть изменено: она всегда указывает на тот объект, к которому была привязана при ее инициализации. Чтобы получить указатель на объект, обозначаемый ссылкой rr, можно написать &rr. Очевидной реализацией ссылки может служить постоянный указатель, который используется только для косвенного обращения. Тогда инициализация ссылки будет тривиальной, если в качестве инициализатора указан адрес (т.е. объект, адрес которого можно получить; см. $$R.3.7). Инициализатор для типа T должен быть адресом. Однако, инициализатор для &T может быть и не адресом, и даже не типом T. В таких случаях делается следующее:
[1] во-первых, если необходимо, применяется преобразование типа (см.$$R.8.4.3);
[2] затем получившееся значение помещается во временную переменную;
[3] наконец, адрес этой переменной используется в качестве инициализатора ссылки.
Пусть имеются описания:
double& dr = 1; // ошибка: нужен адрес
const double& cdr = 1; // нормально
Это интерпретируется так:
double* cdrp; // ссылка, представленная как указатель
double temp;
temp = double(1);
cdrp = &temp;
Ссылки на переменные и ссылки на константы различаются по следующей причине: в первом случае создание временной переменной чревато ошибками, поскольку присваивание этой переменной означает присваивание временной переменной, которая могла к этому моменту исчезнуть. Естественно, что во втором случае подобных проблем не существует. и ссылки на константы часто используются как параметры функций (см.$$R.6.3). Ссылка может использоваться для функции, которая изменяет значение своего параметра. Например:
void incr(int& aa) { aa++; }
void f()
{
int x = 1;
incr(x); // x = 2
}
По определению передача параметров имеет ту же семантику, что и инициализация, поэтому при вызове функции incr ее параметр aa становится другим именем для x. Лучше, однако, избегать изменяющих свои параметры функций, чтобы не запутывать программу. В большинстве случаев предпочтительнее, чтобы функция возвращала результат явным образом, или чтобы использовался параметр типа указателя:
int next(int p) { return p+1; }
void inc(int* p) { (*p)++; }
void g()
{
int x = 1;
x = next(x); // x = 2
inc(&x); // x = 3
}
Кроме перечисленного, с помощью ссылок можно определить функции, используемые как в правой, так и в левой частях присваивания. Наиболее интересное применение это обычно находит при определении нетривиальных пользовательских типов. В качестве примера определим простой ассоциативный массив. Начнем с определения структуры
pair:
struct pair {
char* name; // строка
int val; // целое
};
Идея заключается в том, что со строкой связывается некоторое целое значение.
Нетрудно написать функцию поиска find(), которая работает со структурой данных, представляющей ассоциативный массив. В нем для каждой отличной от других строки содержится структура pair (пара: строка и значение ). В данном примере - это просто массив. Чтобы сократить пример, используется предельно простой, хотя и неэффективный алгоритм:
const int large = 1024;
static pair vec[large+1];
pair* find(const char* p)
/*
// работает со множеством пар "pair":
// ищет p, если находит, возвращает его "pair",
// в противном случае возвращает неиспользованную "pair"
*/
{
for (int i=0; vec[i].name; i++)
if (strcmp(p,vec[i].name)==0) return &vec[i];
if (i == large) return &vec[large-1];
return &vec[i];
}
Эту функцию использует функция value(), которая реализует массив целых,
индексируемый строками (хотя привычнее строки индексировать целыми):
int& value(const char* p)
{
pair* res = find(p);
if (res->name == 0) { // до сих пор строка не встречалась,
// значит надо инициализировать
res->name = new char[strlen(p)+1];
strcpy(res->name,p);
res->val = 0; // начальное значение равно 0
}
return res->val;
}
Для заданного параметра (строки) value() находит объект, представляющий целое (а не просто значение соответствующего целого) и возвращает ссылку на него. Эти функции можно использовать, например, так:
const int MAX = 256; // больше длины самого длинного слова
main() // подсчитывает частоту слов во входном потоке
{
char buf[MAX];
while (cin>>buf) value(buf)++;
for (int i=0; vec[i].name; i++)
cout << vec[i].name << ": " << vec [i].val<< '\n';
}
В цикле while из стандартного входного потока cin читается по одному слову и записывается в буфер buf (см. глава 10), при этом каждый раз значение счетчика, связанного со считываемой строкой, увеличивается. Счетчик отыскивается в ассоциативном массиве vec с помощью функции find(). В цикле for печатается получившаяся таблица различных слов из cin вместе с их частотой. Имея входной поток
aa bb bb aa aa bb aa aa
программа выдает:
aa: 5
bb: 3
С помощью шаблонного класса и перегруженной операции [] ($$8.8) достаточно просто довести массив из этого примера до настоящего ассоциативного массива.
Стандартные манипуляторы ввода-вывода
Это следующие манипуляторы:
// Simple manipulators:
ios& oct(ios&); // в восьмеричной записи
ios& dec(ios&); // в десятичной записи
ios& hex(ios&); // в шестнадцатеричной записи
ostream& endl(ostream&); // добавить '\n' и вывести
ostream& ends(ostream&); // добавить '\0' и вывести
ostream& flush(ostream&); // выдать поток
istream& ws(istream&); // удалить обобщенные пробелы
// Манипуляторы имеют параметры:
SMANIP<int> setbase(int b);
SMANIP<int> setfill(int f);
SMANIP<int> setprecision(int p);
SMANIP<int> setw(int w);
SMANIP<long> resetiosflags(long b);
SMANIP<long> setiosflags(long b);
Например,
cout << 1234 << ' '
<< hex << 1234 << ' '
<< oct << 1234 << endl;
напечатает
1234 4d2 2322
и
cout << setw(4) << setfill('#') << '(' << 12 << ")\n";
cout << '(' << 12 << ")\n";
напечатает
(##12)
(12)
Не забудьте включить файл <iomanip.h>, если используете манипуляторы с
параметрами.
Стандартные значения параметров
В общем случае у функции может быть больше параметров, чем в самых простых и наиболее часто используемых случаях. В частности, это свойственно функциям, строящим объекты (например, конструкторам, см. $$5.2.4). Для более гибкого использования этих функций иногда применяются необязательные параметры. Рассмотрим в качестве примера функцию печати целого числа. Вполне разумно применить в качестве необязательного параметра основание счисления печатаемого числа, хотя в большинстве случаев числа будут печататься как десятичные целые значения. Следующая функция
void print (int value, int base =10);
void F()
{
print(31);
print(31,10);
print(31,16);
print(31,2);
}
напечатает такие числа:
31 31 1f 11111
Вместо стандартного значения параметра можно было бы использовать перегрузку функции print:
void print(int value, int base);
inline void print(int value) { print(value,10); }
Однако в последнем варианте текст программы не столь явно демонстрирует желание иметь одну функцию print, но при этом обеспечить удобную и краткую форму записи.
Тип стандартного параметра сверяется с типом указанного значения при трансляции описания функции, а значение этого параметра вычисляется в момент вызова функции. Задавать стандартное значение можно только для завершающих подряд идущих параметров:
int f(int, int =0, char* =0); // нормально
int g(int =0, int =0, char*); // ошибка
int h(int =0, int, char* =0); // ошибка
Отметим, что в данном контексте наличие пробела между символами * и = весьма существенно, поскольку *= является операцией присваивания:
int nasty(char*=0); // синтаксическая ошибка
Статическая память
Рассмотрим такой пример:
table tbl(100);
void f(int i)
{
static table tbl2(i);
}
int main()
{
f(200);
// ...
}
Здесь конструктор, определенный в $$5.3.1, будет вызываться дважды: один раз для tbl и один раз для tbl2. Деструктор table::~table() также будет вызван дважды: для уничтожения tbl и tbl2 по выходе из main(). Конструкторы глобальных статических объектов в файле вызываются в том же порядке, в каком встречаются в файле описания объектов, а деструкторы для них вызываются в обратном порядке. Конструктор локального статического объекта вызывается, когда при выполнении программы первый раз встречается определение объекта.
Традиционно выполнение main() рассматривалось как выполнение всей программы. На самом деле, это не так даже для С. Уже размещение статического объекта класса с конструктором и (или) деструктором позволяет программисту задать действия, которые будут выполняться до вызова main() и (или) по выходе из main().
Вызов конструкторов и деструкторов для статических объектов играет в С++ чрезвычайно важную роль. С их помощью можно обеспечить соответствующую инициализацию и удаление структур данных, используемых в библиотеках. Рассмотрим <iostream.h>. Откуда берутся cin, cout и cerr? Когда они инициализируются? Более существенный вопрос: поскольку для выходных потоков используются внутренние буфера символов, то происходит выталкивание этих буферов, но когда? Есть простой и очевидный ответ: все действия выполняются соответствующими конструкторами и деструкторами до запуска main() и по выходе из нее (см. $$10.5.1). Существуют альтернативы использованию конструкторов и деструкторов для инициализации и уничтожения библиотечных структур данных, но все они или очень специализированы, или неуклюжи, или и то и другое вместе.
Если программа завершается обращение к функции exit(), то вызываются деструкторы для всех построенных статических объектов. Однако, если программа завершается обращением к abort(), этого не происходит. Заметим, что exit() не завершает программу немедленно. Вызов exit() в деструкторе может привести к бесконечной рекурсии. Если нужна гарантия, что будут уничтожены как статические, так и автоматические объекты, можно воспользоваться особыми ситуациями ($$9).
Иногда при разработке библиотеки бывает необходимо или просто удобно создать тип с конструктором и деструктором только для одной цели: инициализации и уничтожения объектов. Такой тип используется только один раз для размещения статического объекта, чтобы вызвать конструкторы и деструкторы.
Статические члены
Класс - это тип, а не некоторое данное, и для каждого объекта класса создается своя копия членов, представляющих данные. Однако, наиболее удачная реализация некоторых типов требует, чтобы все объекты этого типа имели некоторые общие данные. Лучше, если эти данные можно описать как часть класса. Например, в операционных системах или при моделировании управления задачами часто нужен список задач:
class task {
// ...
static task* chain;
// ...
};
Описав член chain как статический, мы получаем гарантию, что он будет создан в единственном числе, т.е. не будет создаваться для каждого объекта task. Но он находится в области видимости класса task, и может быть доступен вне этой области, если только описан в общей части. В этом случае имя члена должно уточняться именем класса:
if (task::chain == 0) // какие-то операторы
В функции-члене его можно обозначать просто chain. Использование статических членов класса может заметно сократить потребность в глобальных переменных.
Описывая член как статический, мы ограничиваем его область видимости и делаем его независимым от отдельных объектов его класса. Это свойство полезно как для функций-членов, так и для членов, представляющих данные:
class task {
// ...
static task* task_chain;
static void shedule(int);
// ...
};
Но описание статического члена - это только описание, и где-то в программе должно быть единственное определение для описываемого объекта или функции, например, такое:
task* task::task_chain = 0;
void task::shedule(int p) { /* ... */ }
Естественно, что и частные члены могут определяться подобным образом.
Отметим, что служебное слово static не нужно и даже нельзя использовать в определении статического члена класса. Если бы оно присутствовало, возникла бы неоднозначность: указывает ли оно на то, что член класса является статическим, или используется для описания глобального объекта или функции?
Слово static одно из самых перегруженных служебных слов в С и С++. К статическому члену, представляющему данные, относятся оба основных его значения: "статически размещаемый" , т.е. противоположный объектам, размещаемым в стеке или свободной памяти, и "статический" в смысле с ограниченной областью видимости, т.е. противоположный объектам, подлежащим внешнему связыванию. К функциям-членам относится только второе значение static.
Строки
Строка - это последовательность символов, заключенная в двойные кавычки:
"это строка"
Каждая строка содержит на один символ больше, чем явно задано: все строки оканчиваются нулевым символом ('\0'), имеющим значение 0. Поэтому
sizeof("asdf")==5;
Типом строки считается "массив из соответствующего числа символов",
поэтому тип "asdf" есть char[5]. Пустая строка записывается как "" и имеет тип char[1]. Отметим, что для любой строки s выполняется strlen(s)==sizeof(s)-1, поскольку функция strlen() не учитывает завершающий символ '\0'.
Внутри строки можно использовать для представления невидимых символов специальные комбинации с \. В частности, в строке можно задать сам символ двойной кавычки " или символ \. Чаще всего из таких символов оказывается нужным символ конца строки '\n', например:
cout << "звуковой сигнал в конце сообщения\007\n"
Здесь 7 - это значение в ASCII символа BEL (сигнал), который в переносимом виде обозначается как \a. Нет возможности задать в строке "настоящий" символ конца строки:
"это не строка,
а синтаксическая ошибка"
Для большей наглядности программы длинные строки можно разбивать пробелами, например:
char alpha[] = "abcdefghijklmnopqrstuvwxyz"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
Подобные, подряд идущие, строки будут объединяться в одну, поэтому массив alpha можно эквивалентным образом инициализировать с помощью одной строки:
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
В строке можно задавать символ '\0', но большинство программ не ожидает после него встречи с какими-либо еще символами. Например, строку "asdf\000hjkl" стандартные функции strcpy() и strlen() будут рассматривать как строку "asdf".
Если вы задаете в строке последовательностью восьмеричных цифр числовую константу, то разумно указать все три цифры. Запись этой строки и так не слишком проста, чтобы еще и раздумывать, относится ли цифра к числу или является отдельным символом. Для шестнадцатеричных констант используйте два разряда. Рассмотрим следующие примеры:
char v1[] = "a\x0fah\0129"; // 'a' '\xfa' 'h' '\12' '9'
char v2[] = "a\xfah\129"; // 'a' '\xfa' 'h' '\12' '9'
char v3[] = "a\xfad\127"; // 'a' '\xfad' '\127'
Строковые потоки
Как было показано, поток может быть привязан к файлу, т.е. массиву символов, хранящемуся не в основной памяти, а, например, на диске. Точно так же поток можно привязать к массиву символов в основной памяти. Например, можно воспользоваться выходным строковым потоком ostrstream для форматирования сообщений, не подлежащих немедленной печати:
char* p = new char[message_size];
ostrstream ost(p,message_size);
do_something(arguments,ost);
display(p);
С помощью стандартных операций вывода функция do_something может писать
в поток ost, передавать ost подчиняющимся ей функциям и т.п. Контроль переполнения не нужен, поскольку ost знает свой размер и при заполнении перейдет в состояние, определяемое fail(). Затем функция display может послать сообщение в "настоящий" выходной поток. Такой прием наиболее подходит в тех случаях, когда окончательная операция вывода предназначена для записи на более сложное устройство, чем традиционное, ориентированное на последовательность строк, выводное устройство. Например, текст из ost может быть помещен в фиксированную область на экране.
Аналогично, istrstream является вводным строковым потоком, читающим из последовательности символов, заканчивающейся нулем:
void word_per_line(char v[], int sz)
/*
печатать "v" размером "sz" по одному слову в строке
*/
{
istrstream ist(v,sz); // создать istream для v
char b2[MAX]; // длиннее самого длинного слова
while (ist>>b2) cout <<b2 << "\n";
}
Завершающий нуль считается концом файла.
Строковые потоки описаны в файле <strstream.h>.
Строковый класс
Теперь можно привести более осмысленный вариант класса string. В нем подсчитывается число ссылок на строку, чтобы минимизировать копирование, и используются как константы стандартные строки C++.
#include <iostream.h>
#include <string.h>
class string {
struct srep {
char* s; // указатель на строку
int n; // счетчик числа ссылок
srep() { n = 1; }
};
srep *p;
public:
string(const char *); // string x = "abc"
string(); // string x;
string(const string &); // string x = string ...
string& operator=(const char *);
string& operator=(const string &);
~string();
char& operator[](int i);
friend ostream& operator<<(ostream&, const string&);
friend istream& operator>>(istream&, string&);
friend int operator==(const string &x, const char *s)
{ return strcmp(x.p->s,s) == 0; }
friend int operator==(const string &x, const string &y)
{ return strcmp(x.p->s,y.p->s) == 0; }
friend int operator!=(const string &x, const char *s)
{ return strcmp(x.p->s,s) != 0; }
friend int operator!=(const string &x, const string &y)
{ return strcmp(x.p->s,y.p->s) != 0; }
};
Конструкторы и деструкторы тривиальны:
string::string()
{
p = new srep;
p->s = 0;
}
string::string(const string& x)
{
x.p->n++;
p = x.p;
}
string::string(const char* s)
{
p = new srep;
p->s = new char[ strlen(s)+1 ];
strcpy(p->s, s);
}
string::~string()
{
if (--p->n == 0) {
delete[] p->s;
delete p;
}
}
Как и всегда операции присваивания похожи на конструкторы. В них нужно позаботиться об удалении первого операнда, задающего левую часть присваивания:
string& string::operator=(const char* s)
{
if (p->n > 1) { // отсоединяемся от старой строки
p->n--;
p = new srep;
}
else // освобождаем строку со старым значением
delete[] p->s;
p->s = new char[ strlen(s)+1 ];
strcpy(p->s, s);
return *this;
}
string& string::operator=(const string& x)
{
x.p->n++; // защита от случая ``st = st''
if (--p->n == 0) {
delete[] p->s;
delete p
}
p = x.p;
return *this;
}
Операция вывода показывает как используется счетчик числа ссылок. Она сопровождает как эхо каждую введенную строку (ввод происходит с помощью операции << , приведенной ниже):
ostream& operator<<(ostream& s, const string& x)
{
return s << x.p->s << " [" << x.p->n << "]\n";
}
Операция ввода происходит с помощью стандартной функции ввода символьной строки ($$10.3.1):
istream& operator>>(istream& s, string& x)
{
char buf[256];
s >> buf; // ненадежно: возможно переполнение buf
// правильное решение см. в $$10.3.1
x = buf;
cout << "echo: " << x << '\n';
return s;
}
Операция индексации нужна для доступа к отдельным символам. Индекс контролируется:
void error(const char* p)
{
cerr << p << '\n';
exit(1);
}
char& string::operator[](int i)
{
if (i<0 || strlen(p->s)<i) error("недопустимое значение индекса");
return p->s[i];
}
В основной программе просто даны несколько примеров применения строковых операций. Слова из входного потока читаются в строки, а затем строки печатаются. Это продолжается до тех пор, пока не будет обнаружена строка done, или закончатся строки для записи слов, или закончится входной поток. Затем печатаются все строки в обратном порядке и программа завершается.
int main()
{
string x[100];
int n;
cout << " здесь начало \n";
for ( n = 0; cin>>x[n]; n++) {
if (n==100) {
error("слишком много слов");
return 99;
}
string y;
cout << (y = x[n]);
if (y == "done") break;
}
cout << "теперь мы идем по словам в обратном порядке \n";
for (int i=n-1; 0<=i; i--) cout << x[i];
return 0;
}
Структура книги
Книга состоит из трех частей. Главы с 1 по 10 являются учебником по языку. В главах с 11 по 13 обсуждаются вопросы проектирования и развития программного обеспечения с учетом возможностей С++. В конце книги приведено полное справочное руководство по языку. Исчерпывающее описание конструкций С++ содержится только там. Учебная часть книги содержит примеры, советы, предостережения и упражнения, для которых не нашлось места в руководстве.
Книга в основном посвящена вопросу, как с помощью языка C++ структурировать программу, а не вопросу, как записать на нем алгоритм. Следовательно, там, где можно было выбирать, предпочтение отдавалось не профессиональным, но сложным для понимания, а тривиальным алгоритмам. Так в одном из примеров используется пузырьковая сортировка, хотя алгоритм быстрой сортировки больше подходит для настоящей программы. Часто написать ту же программу, но с более эффективным алгоритмом, предлагается в виде упражнения.
Глава 1 содержит краткий обзор основных концепций и конструкций С++. Она позволяет познакомиться с языком в общих чертах. Подробные объяснения конструкций языка и способов их применения содержатся в последующих главах. Обсуждаются в первую очередь средства, обеспечивающие абстракцию данных и объектно-ориентированное программирование. Основные средства процедурного программирования упоминаются кратко.
В главах 2, 3 и 4 описываются средства С++, которые не используются для определения новых типов: основные типы, выражения и структуры управления. Другими словами, эти главы содержат описание той части языка, которая по сути представляет С. Изложение в указанных главах идет в углубленном виде.
Главы 5 - 8 посвящены средствам построения новых типов, которые не имеют аналогов в С. В главе 5 вводится основное понятие - класс. В ней показано, как можно определять пользовательские типы (классы), инициализировать их, обращаться к ним, и, наконец, как уничтожать их. Глава 6 посвящена понятию производных классов, которое позволяет строить из простых классов более сложные. Оно дает также возможность эффективной и безопасной (в смысле типа) работы в тех ситуациях, когда типы объектов на стадии трансляции неизвестны. В главе 7 объясняется, как можно определить унарные и бинарные операции для пользовательских типов, как задавать преобразования таких типов, и каким образом можно создавать, копировать и удалять объекты, представляющие пользовательские типы. Глава 8 посвящена шаблонам типа, т.е. такому средству С++, которое позволяет определить семейство типов и функций.
В главе 9 обсуждается обработка особых ситуаций, рассматриваются возможные реакции на ошибки и методы построения устойчивых к ошибкам систем. В главе 10 определяются классы ostream и istream, предоставляемые стандартной библиотекой для потокового ввода-вывода.
Главы 11 - 13 посвящены вопросам, связанным с применением С++ для проектирования и реализации больших программных систем. В главе 11 в основном рассматриваются вопросы проектирования и управления программными проектами. В главе 12 обсуждается взаимосвязь между языком С++ и проблемами проектирования. В главе 13 показаны способы создания библиотек.
Завершается книга справочным руководством по С++.
Ссылки на различные части книги даются в виде $$2.3.4, что означает раздел 3.4 главы 2. Для обозначения справочного руководства применяется буква R, например, $$R.8.5.5.
Структуры
Массив представляет собой совокупность элементов одного типа, а структура является совокупностью элементов произвольных (практически) типов. Например:
struct address {
char* name; // имя "Jim Dandy"
long number; // номер дома 61
char* street; // улица "South Street"
char* town; // город "New Providence"
char* state[2]; // штат 'N' 'J'
int zip; // индекс 7974
};
Здесь определяется новый тип, называемый address, который задает почтовый адрес. Определение не является достаточно общим, чтобы учесть все случаи адресов, но оно вполне пригодно для примера. Обратите внимание на точку с запятой в конце определения: это один из немногих в С++ случаев, когда после фигурной скобки требуется точка с запятой, поэтому про нее часто забывают.
Переменные типа address можно описывать точно так же, как и любые другие переменные, а с помощью операции . (точка) можно обращаться к отдельным членам структуры. Например:
address jd;
jd.name = "Jim Dandy";
jd.number = 61;
Инициализировать переменные типа struct можно так же, как массивы. Например:
address jd = {
"Jim Dandy",
61, "South Street",
"New Providence", {'N','J'}, 7974
};
Но лучше для этих целей использовать конструктор ($$5.2.4). Отметим,
что jd.state нельзя инициализировать строкой "NJ". Ведь строки оканчиваются нулевым символом '\0', значит в строке "NJ" три символа, а это на один больше, чем помещается в jd.state. К структурным объектам часто обращаются c помощью указателей, используя операцию ->. Например:
void print_addr(address* p)
{
cout << p->name << '\n'
<< p->number << ' ' << p->street << '\n'
<< p->town << '\n'
<< p->state[0] << p->state[1]
<< ' ' << p->zip << '\n';
}
Объекты структурного типа могут быть присвоены, переданы как фактические параметры функций и возвращены функциями в качестве результата. Например:
address current;
address set_current(address next)
{
address prev = current;
current = next;
return prev;
}
Другие допустимые операции, например, такие, как сравнение (== и !=), неопределены. Однако пользователь может сам определить эти операции (см. главу 7).
Размер объекта структурного типа не обязательно равен сумме размеров всех его членов. Это происходит по той причине, что на многих машинах требуется размещать объекты определенных типов, только выравнивая их по некоторой зависящей от системы адресации границе (или просто потому, что работа при таком выравнивании будет более эффективной ). Типичный пример - это выравнивание целого по словной границе. В результате выравнивания могут появиться "дырки" в структуре. Так, на уже упоминавшейся машине автора sizeof(address) равно 24, а не 22, как можно было ожидать.
Следует также упомянуть, что тип можно использовать сразу после его появления в описании, еще до того, как будет завершено все описание. Например:
struct link{
link* previous;
link* successor;
};
Однако новые объекты типа структуры нельзя описать до тех пор, пока не появится ее полное описание. Поэтому описание
struct no_good {
no_good member;
};
является ошибочным (транслятор не в состоянии установить размер no_good). Чтобы позволить двум (или более) структурным типам ссылаться друг на друга, можно просто описать имя одного из них как имя некоторого структурного типа. Например:
struct list; // будет определено позднее
struct link {
link* pre;
link* suc;
list* member_of;
};
struct list {
link* head;
};
Если бы не было первого описания list, описание члена link привело бы к
синтаксической ошибке. Можно также использовать имя структурного типа еще до того, как тип будет определен, если только это использование не предполагает знания размера структуры. Например:
class S; // 'S' - имя некоторого типа
extern S a;
S f();
void g(S);
Но приведенные описания можно использовать лишь после того, как тип S
будет определен:
void h()
{
S a; // ошибка: S - неописано
f(); // ошибка: S - неописано
g(a); // ошибка: S - неописано
}
Структуры и объединения
По определению структура - это класс, все члены которого общие, т.е. описание
struct s { ...
это просто краткая форма описания
class s { public: ...
Поименованное объединение определяется как структура, все члены которой имеют один и тот же адрес ($$R.9.5). Если известно, что в каждый момент времени используется значение только одного члена структуры, то объявив ее объединением, можно сэкономить память. Например, можно использовать объединение для хранения лексем транслятора С:
union tok_val {
char* p; // строка
char v[8]; // идентификатор (не более 8 символов)
long i; // значения целых
double d; // значения чисел с плавающей точкой
};
Проблема с объединениями в том, что транслятор в общем случае не знает, какой член используется в данный момент, и поэтому контроль типа невозможен. Например:
void strange(int i)
{
tok_val x;
if (i)
x.p = "2";
else
x.d = 2;
sqrt(x.d); // ошибка, если i != 0
}
Кроме того, определенное таким образом объединение нельзя инициализировать таким кажущимся вполне естественным способом:
tok_val val1 = 12; // ошибка: int присваивается tok_val
tok_val val2 = "12"; // ошибка: char* присваивается tok_val
Для правильной инициализации надо использовать конструкторы:
union tok_val {
char* p; // строка
char v[8]; // идентификатор (не более 8 символов)
long i; // значения целых
double d; // значения чисел с плавающей точкой
tok_val(const char*); // нужно выбирать между p и v
tok_val(int ii) { i = ii; }
tok_val(double dd) { d = dd; }
};
Эти описания позволяют разрешить с помощью типа членов неоднозначность при перегрузке имени функции (см. $$4.6.6 и $$7.3). Например:
void f()
{
tok_val a = 10; // a.i = 10
tok_val b = 10.0; // b.d = 10.0
}
Если это невозможно (например, для типов char* и char[8] или int и char и т.д.), то определить, какой член инициализируется, можно, изучив инициализатор при выполнении программы, или введя дополнительный параметр. Например:
tok_val::tok_val(const char* pp)
{
if (strlen(pp) <= 8)
strncpy(v,pp,8); // короткая строка
else
p = pp; // длинная строка
}
Но лучше подобной неоднозначности избегать.
Стандартная функция strncpy() подобно strcpy() копирует строки, но у нее есть дополнительный параметр, задающий максимальное число копируемых символов.
То, что для инициализации объединения используются конструкторы, еще не гарантирует от случайных ошибок при работе с объединением, когда присваивается значение одного типа, а выбирается значение другого типа. Такую гарантию можно получить, если заключить объединение в класс, в котором будет отслеживаться тип заносимого значения :
class tok_val {
public:
enum Tag { I, D, S, N };
private:
union {
const char* p;
char v[8];
long i;
double d;
};
Tag tag;
void check(Tag t) { if (tag != t) error(); }
public:
Tag get_tag() { return tag; }
tok_val(const char* pp);
tok_val(long ii) { i = ii; tag = I; }
tok_val(double dd) { d = dd; tag = D; }
long& ival() { check(I); return i; }
double& fval() { check(D); return d; }
const char*& sval() { check(S); return p; }
char* id() { check(N); return v; }
};
tok_val::tok_val(const char* pp)
{
if (strlen(pp) <= 8) { // короткая строка
tag = N;
strncpy(v,pp,8);
}
else { // длинная строка
tag = S;
p = pp; // записывается только указатель
}
}
Использовать класс tok_val можно так:
void f()
{
tok_val t1("короткая"); // присваивается v
tok_val t2("длинная строка"); // присваивается p
char s[8];
strncpy(s,t1.id(),8); // нормально
strncpy(s,t2.id(),8); // check() выдаст ошибку
}
Описав тип Tag и функцию get_tag() в общей части, мы гарантируем, что тип tok_val можно использовать как тип параметра. Таким образом, появляется надежная в смысле типов альтернатива описанию параметров с эллипсисом. Вот, например, описание функции обработки ошибок, которая может иметь один, два, или три параметра с типами char*, int или double:
extern tok_val no_arg;
void error(
const char* format,
tok_val a1 = no_arg,
tok_val a2 = no_arg,
tok_val a3 = no_arg);
Связывание
Если явно не определено иначе, то имя, не являющееся локальным для некоторой функции или класса, должно обозначать один и тот же тип, значение, функцию или объект во всех единицах трансляции данной программы. Иными словами, в программе может быть только один нелокальный тип, значение, функция или объект с данным именем. Рассмотрим для примера два файла:
// file1.c
int a = 1;
int f() { /* какие-то операторы */ }
// file2.c
extern int a;
int f();
void g() { a = f(); }
В функции g() используются те самые a и f(), которые определены в файле file1.c. Служебное слово extern показывает, что описание a в файле file2.c является только описанием, но не определением. Если бы присутствовала инициализация a, то extern просто проигнорировалось бы, поскольку описание с инициализацией всегда считается определением. Любой объект в программе может определяться только один раз. Описываться же он может неоднократно, но все описания должны быть согласованы по типу. Например:
// file1.c:
int a = 1;
int b = 1;
extern int c;
// file2.c:
int a;
extern double b;
extern int c;
Здесь содержится три ошибки: переменная a определена дважды ("int a;"
- это определение, означающее "int a=0;"); b описано дважды, причем с разными типами; c описано дважды, но неопределено. Такие ошибки (ошибки связывания) транслятор, который обрабатывает файлы по отдельности, обнаружить не может, но большая их часть обнаруживается редактором связей.
Следующая программа допустима в С, но не в С++:
// file1.c:
int a;
int f() { return a; }
// file2.c:
int a;
int g() { return f(); }
Во-первых, ошибкой является вызов f() в file2.c, поскольку в этом файле f() не описана. Во-вторых, файлы программы не могут быть правильно связаны, поскольку a определено дважды.
Если имя описано как static, оно становится локальном в этом файле. Например:
// file1.c:
static int a = 6;
static int f() { /* ... */ }
// file2.c:
static int a = 7;
static int f() { /* ... */ }
Приведенная программа правильна, поскольку a и f определены как статические. В каждом файле своя переменная a и функция f().
Если переменные и функции в данной части программы описаны как static, то в этой части программы проще разобраться, поскольку не нужно заглядывать в другие части. Описывать функции как статические полезно еще и по той причине, что транслятору предоставляется возможность создать более простой вариант операции вызова функции. Если имя объекта или функции локально в данном файле, то говорят, что объект подлежит внутреннему связыванию. Обратно, если имя объекта или функции нелокально в данном файле, то он подлежит внешнему связыванию.
Обычно говорят, что имена типов, т.е. классов и перечислений, не подлежат связыванию. Имена глобальных классов и перечислений должны быть уникальными во всей программе и иметь единственное определение. Поэтому, если есть два даже идентичных определения одного класса, это - все равно ошибка:
// file1.c:
struct S { int a; char b; };
extern void f(S*);
// file2.c:
struct S { int a; char b; };
void f(S* p) { /* ... */ }
Но будьте осторожны: опознать идентичность двух описаний класса не в состоянии большинство систем программирования С++. Такое дублирование может вызвать довольно тонкие ошибки (ведь классы в разных файлах будут считаться различными).
Глобальные функции-подстановки подлежат внутреннему связыванию, и то же по умолчанию справедливо для констант. Синонимы типов, т.е. имена typedef, локальны в своем файле, поэтому описания в двух данных ниже файлах не противоречат друг другу:
// file1.c:
typedef int T;
const int a = 7;
inline T f(int i) { return i+a; }
// file2.c:
typedef void T;
const int a = 8;
inline T f(double d) { cout<<d; }
Константа может получить внешнее связывание только с помощью явного описания:
// file3.c:
extern const int a;
const int a = 77;
// file4.c:
extern const int a;
void g() { cout<<a; }
В этом примере g() напечатает 77.
Связывание потоков
Функция tie() может установить и разорвать связь между ostream и istream. Рассмотрим пример:
main()
{
String s;
cout << "Password: ";
cin >> s;
// ...
}
Как можно гарантировать, что приглашение Password: появится на экране прежде, чем выполниться операция чтения? Вывод в cout и ввод из cin буферизуются, причем независимо, поэтому Password: появится только по завершении программы, когда закроется буфер вывода.
Решение состоит в том, чтобы связать cout и cin с помощью операции cin.tie(cout). Если ostream связан с потоком istream, то буфер вывода выдается при каждой операции ввода над istream. Тогда операции
cout << "Password: ";
cin >> s;
эквивалентны
cout << "Password: ";
cout.flush();
cin >> s;
Обращение is.tie(0) разрывает связь между потоком is и потоком, с которым он был связан, если такой был. Подобно другим потоковым функциям, устанавливающим определенное значение, tie(s) возвращает предыдущее значение, т.е. значение связанного потока перед обращением или 0. Вызов без параметра tie() возвращает текущее значение.
Связывание с программами на других языках
Программы на С++ часто содержат части, написанные на других языках, и наоборот, часто фрагмент на С++ используется в программах, написанных на других языках. Собрать в одну программу фрагменты, написанные на разных языках, или, написанные на одном языке, но в системах программирования с разными соглашениями о связывании, достаточно трудно. Например, разные языки или разные реализации одного языка могут различаться использованием регистров при передаче параметров, порядком размещения параметров в стеке, упаковкой таких встроенных типов, как целые или строки, форматом имен функций, которые транслятор передает редактору связей, объемом контроля типов, который требуется от редактора связей. Чтобы упростить задачу, можно в описании внешних указать условие связывания. Например, следующее описание объявляет strcpy внешней функцией и указывает, что она должна связываться согласно порядку связывания в С:
extern "C" char* strcpy(char*, const char*);
Результат этого описания отличается от результата обычного описания
extern char* strcpy(char*, const char*);
только порядком связывания для вызывающих strcpy() функций. Сама семантика вызова и, в частности, контроль фактических параметров будут одинаковы в обоих случаях. Описание extern "C" имеет смысл использовать еще и потому, что языки С и С++, как и их реализации, близки друг другу. Отметим, что в описании extern "C" упоминание С относится к порядку связывания, а не к языку, и часто такое описание используют для связи с Фортраном или ассемблером. Эти языки в определенной степени подчиняются порядку связывания для С.
Утомительно добавлять "C" ко многим описаниям внешних, и есть возможность указать такую спецификацию сразу для группы описаний. Например:
extern "C" {
char* strcpy(char*, const char);
int strcmp(const char*, const char*)
int strlen(const char*)
// ...
}
В такую конструкцию можно включить весь заголовочный файл С, чтобы указать, что он подчиняется связыванию для С++, например:
Свободная память
Именованный объект является либо статическим, либо автоматическим (см.$$2.1.3). Статический объект размещается в памяти в момент запуска программы и существует там до ее завершения. Автоматический объект размещается в памяти всякий раз, когда управление попадает в блок, содержащий определение объекта, и существует только до тех пор, пока управление остается в этом блоке. Тем не менее, часто бывает удобно создать новый объект, который существует до тех пор, пока он не станет ненужным. В частности, бывает удобно создать объект, который можно использовать после возврата из функции, где он был создан. Подобные объекты создает операция new, а операция delete используется для их уничтожения в дальнейшем. Про объекты, созданные операцией new, говорят, что они размещаются в свободной памяти. Примерами таких объектов являются узлы деревьев или элементы списка, которые входят в структуры данных, размер которых на этапе трансляции неизвестен. Давайте рассмотрим в качестве примера набросок транслятора, который строится аналогично программе калькулятора. Функции синтаксического анализа создают из представлений выражений дерево, которое будет в дальнейшем использоваться для генерации кода. Например:
struct enode {
token_value oper;
enode* left;
enode* right;
};
enode* expr()
{
enode* left = term();
for(;;)
switch(curr_tok) {
case PLUS:
case MINUS:
get_token();
enode* n = new enode;
n->oper = curr_tok;
n->left = left;
n->right = term();
left = n;
break;
default:
return left;
}
}
Генератор кода может использовать дерево выражений, например так:
void generate(enode* n)
{
switch (n->oper) {
case PLUS:
// соответствующая генерация
delete n;
}
}
Объект, созданный с помощью операции new, существует, до тех пор, пока он не будет явно уничтожен операцией delete. После этого память, которую он занимал, вновь может использоваться new. Обычно нет никакого "сборщика мусора", ищущего объекты, на которые никто не ссылается, и предоставляющего занимаемую ими память операции new для повторного использования. Операндом delete может быть только указатель, который возвращает операция new, или нуль. Применение delete к нулю не приводит ни к каким действиям.
Рассмотрим пример:
main()
{
table* p = new table(100);
table* q = new table(200);
delete p;
delete p; // вероятно, вызовет ошибку при выполнении
}
Конструктор table::table() будет вызываться дважды, как и деструктор table::~table(). Но это ничего не значит, т.к. в С++ не гарантируется, что деструктор будет вызываться только для объекта, созданного операцией new. В этом примере q не уничтожается вообще, зато p уничтожается дважды! В зависимости от типа p и q программист может считать или не считать это ошибкой. То, что объект не удаляется, обычно бывает не ошибкой, а просто потерей памяти. В то же время повторное удаление p - серьезная ошибка. Повторное применение delete к тому же самому указателю может привести к бесконечному циклу в подпрограмме, управляющей свободной памятью. Но в языке результат повторного удаления не определен, и он зависит от реализации.
Пользователь может определить свою реализацию операций new и delete (см. $$3.2.6 и $$6.7). Кроме того, можно установить взаимодействие конструктора или деструктора с операциями new и delete (см. $$5.5.6 и $$6.7.2). Размещение массивов в свободной памяти обсуждается в $$5.5.5.
Если определить функции operator new() и operator delete(), управление памятью для класса можно взять в свои руки. Это также можно, (а часто и более полезно), сделать для класса, служащего базовым для многих производных классов. Допустим, нам потребовались свои функции размещения и освобождения памяти для класса employee ($$6.2.5) и всех его производных классов:
class employee {
// ...
public:
void* operator new(size_t);
void operator delete(void*, size_t);
};
void* employee::operator new(size_t s)
{
// отвести память в `s' байтов
// и возвратить указатель на нее
}
void employee::operator delete(void* p, size_t s)
{
// `p' должно указывать на память в `s' байтов,
// отведенную функцией employee::operator new();
// освободить эту память для повторного использования
}
Назначение до сей поры загадочного параметра типа size_t становится очевидным. Это - размер освобождаемого объекта. При удалении простого служащего этот параметр получает значение sizeof(employee), а при удалении управляющего - sizeof(manager). Поэтому собственные функции классы для размещения могут не хранить размер каждого размещаемого объекта. Конечно, они могут хранить эти размеры (подобно функциям размещения общего назначения) и игнорировать параметр size_t в вызове operator delete(), но тогда вряд ли они будут лучше, чем функции размещения и освобождения общего назначения.
Как транслятор определяет нужный размер, который надо передать функции operator delete()? Пока тип, указанный в operator delete(), соответствует истинному типу объекта, все просто; но рассмотрим такой пример:
class manager : public employee {
int level;
// ...
};
void f()
{
employee* p = new manager; // проблема
delete p;
}
В этом случае транслятор не сможет правильно определить размер. Как и в случае удаления массива, нужна помощь программиста. Он должен определить виртуальный деструктор в базовом классе employee:
class employee {
// ...
public:
Свод правил
В этой главе мы затронули много тем, но как правило не давали настоятельных и конкретных рекомендаций по проектированию. Это соответствует моему убеждению, что нет "единственно верного решения". Принципы и приемы следует применять тем способом, который лучше подходит для конкретных задач. Для этого нужен вкус, опыт и разум. Все-таки можно указать некоторый свод правил, который разработчик может использовать в качестве ориентиров, пока не наберется достаточно опыта, чтобы выработать лучшие. Ниже приведен свод таких правил.
Эти правила можно использовать в качестве отправной точки в процессе выработки основных направлений для проекта или организации или в качестве проверочного списка. Подчеркну еще раз, что они не являются универсальными правилами и не могут заменить размышления.
·
Узнайте, что вам предстоит создать.
· Ставьте определенные и осязаемые цели.
· Не пытайтесь с помощью технических приемов решить социальные проблемы.
· Рассчитывайте на большой срок
- в проектировании, и
- управлении людьми.
· Используйте существующие системы в качестве моделей, источника вдохновения и отправной точки.
· Проектируйте в расчете на изменения:
- гибкость,
- расширяемость,
- переносимость, и
- повторное использование.
· Документируйте, предлагайте и поддерживайте повторно используемые компоненты.
· Поощряйте и вознаграждайте повторное использование
- проектов,
- библиотек, и
В этой главе мы коснулись многих тем, но, как правило, избегали давать настоятельные и конкретные рекомендации по рассматриваемым вопросам. Это отвечает моему убеждению, что нет "единственно верного решения". Принципы и приемы следует применять способом, наиболее подходящим для конкретной задачи. Здесь требуются вкус, опыт и разум. Тем не менее, можно предложить свод правил, которые разработчик может использовать в качестве ориентиров, пока не приобретет достаточно опыта, чтобы выработать лучшие. Этот свод правил приводится ниже.
Он может служить отправной точкой в процессе выработки основных направлений проекта конкретной задачи, или же он может использоваться организацией в качестве проверочного списка. Подчеркну еще раз, что эти правила не являются универсальными и не могут заменить собой размышления.
· Нацеливайте пользователя на применение абстракции данных и объектно-ориентированного программирования.
- Постепенно переходите на новые методы, не спешите.
- Используйте возможности С++ и методы объектно-ориентированного программирования только по мере надобности.
· Добейтесь соответствия стиля проекта и программы.
· Концентрируйте внимание на проектировании компонента.
· Используйте классы для представления понятий.
- Используйте общее наследование для представления отношений "есть".
- Используйте принадлежность для представления отношений "имеет".
- Убедитесь, что отношения использования понятны, не образуют циклов, и что число их минимально.
- Активно ищите общность среди понятий области приложения и реализации, и возникающие в результате более общие понятия представляйте как базовые классы.
· Определяйте интерфейс так, чтобы открывать минимальное количество требуемой информации:
- Используйте, всюду где это можно, частные данные и функции-члены.
- Используйте описания public или protected, чтобы отличить запросы разработчика производных классов от запросов обычных пользователей.
- Сведите к минимуму зависимости одного интерфейса от других.
- Поддерживайте строгую типизацию интерфейсов.
- Задавайте интерфейсы в терминах типов из области приложения.
Дополнительные правила можно найти $$11.5.
Сводка операций
Полное и подробное описание операций языка С++ дано в $$R.7. Советуем прочитать этот раздел. Здесь же приводится краткая сводка операций и несколько примеров. Каждая операция сопровождается одним или несколькими характерными для нее именами и примером ее использования. В этих примерах class_name обозначает имя класса, member - имя члена, object - выражение, задающее объект класса, pointer - выражение, задающее указатель, expr - просто выражение, а lvalue (адрес) - выражение, обозначающее не являющийся константой объект. Обозначение (type) задает имя типа в общем виде (с возможным добавлением *, () и т.д.). Если оно указано без скобок, существуют ограничения.
Порядок применения унарных операций и операций присваивания "справа налево", а всех остальных операций - "слева направо". То есть, a=b=c означает a=(b=c), a+b+c означает (a+b)+c, и *p++ означает *(p++), а не (*p)++.
Операции С++
:: | Разрешение области видимости | class_name :: member | |||
:: | Глобальное | :: name | |||
. | Выбор члена | object . member | |||
-> | Выбор члена | pointer -> member | |||
[] | Индексирование | pointer [ expr ] | |||
() | Вызов функции | expr ( expr_list ) | |||
() | Структурное значение | type ( expr_list ) | |||
sizeof | Размер объекта | sizeof expr | |||
sizeof | Размер типа | sizeof ( type ) | |||
++ | Постфиксный инкремент | lvalue ++ | |||
++ | Префиксный инкремент | ++ lvalue | |||
-- | Постфиксный декремент | lvalue -- | |||
-- | Префиксный декремент | -- lvalue | |||
~ | Дополнение | ~ expr | |||
! | Логическое НЕ | ! expr | |||
- | Унарный минус | - expr | |||
+ | Унарный плюс | + expr | |||
& | Взятие адреса | & lvalue | |||
* | Косвенность | * expr | |||
new | Создание (размещение) | new type | |||
delete | Уничтожение (освобождение) | delete pointer | |||
delete[] | Уничтожение массива | delete[] pointer | |||
() | Приведение(преобразование)типа | ( type ) expr | |||
. * | Выбор члена косвенный | object . pointer-to-member | |||
->* | Выбор члена косвенный | pointer -> pointer-to-member | |||
* | Умножение | expr * expr | |||
/ | Деление | expr / expr | |||
% | Остаток от деления | expr % expr | |||
+ | Сложение (плюс) | expr + expr | |||
- | Вычитание (минус) | expr - expr | |||
<< | Сложение (плюс) Сдвиг влево | expr << expr | |||
>> | Сдвиг вправо | expr >> expr | |||
< | Меньше | expr < expr | |||
<= | Меньше или равно | expr <= expr | |||
> | Больше | expr > expr | |||
>= | Больше или равно | expr >= expr | |||
== | Равно | expr == expr | |||
!= | Не равно | expr != expr | |||
& | Поразрядное И | expr & expr | |||
^ | Поразрядное исключающее ИЛИ | expr ^ expr | |||
| | Поразрядное включающее ИЛИ | expr | expr | |||
&& | Логическое И | expr && expr | |||
|| | Логическое ИЛИ | expr || expr | |||
? : | Операция условия | expr? expr : expr | |||
= | Простое присваивание | lvalue = expr | |||
*= | Присваивание с умножением | lvalue *= expr | |||
/= | Присваивание с делением | lvalue /= expr | |||
%= | Присваивание с взятием остатка от деления | lvalue %= expr | |||
+= | Присваивание со сложением | lvalue += expr | |||
-= | Присваивание с вычитанием | lvalue -= expr | |||
<<= | Присваивание со сдвигом влево | lvalue <<= expr | |||
>>= | Присваивание со сдвигом вправо | lvalue >>= expr | |||
&= | Присваивание с поразрядным И | lvalue &= expr | |||
|= | Присваивание с поразрядным включающим ИЛИ | lvalue |= expr | |||
^= | Присваивание с поразрядным исключающим ИЛИ | lvalue ^= expr | |||
, | Запятая (последовательность) | expr , expr |
Все операции таблицы, находящиеся между двумя ближайшими друг к другу горизонтальными чертами, имеют одинаковый приоритет. Приоритет операций уменьшается при движении "сверху вниз". Например, a+b*c означает a+(b*c), так как * имеет приоритет выше, чем +; а выражение a+b-c означает (a+b)-c, поскольку + и - имеют одинаковый приоритет, и операции + и - применяются "слева направо".
Сводка операторов
Полное и последовательное описание операторов С++ содержится в $$R.6. Советуем ознакомиться с этим разделом. Здесь же дается сводка операторов и несколько примеров.
Синтаксис операторов
оператор:
описание
{ список-операторов opt }
выражение opt ;
if ( выражение ) оператор
if ( выражение ) оператор else оператор
switch ( выражение ) оператор
while ( выражение ) оператор
do оператор while ( выражение )
for (начальный-оператор-for выражение opt; выражение opt) оператор
case выражение-константа : оператор
default : оператор
break ;
continue ;
return выражение opt ;
goto идентификатор ;
идентификатор : оператор
список-операторов:
оператор
список-операторов оператор
начальный-оператор-for:
описание
выражение opt ;
Обратите внимание, что описание является оператором, но нет операторов присваивания или вызова функции (они относятся к выражениям).