Правила программирования на Си и Си++

         

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


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

линию.

Если вы обнаружили у себя объект производного класса, от которого никогда не требуется использовать возможности базового класса, то, вероятно, в проектировании иерархии есть какая-то ошибка, хотя встречаются редкие случаи, когда такое поведение приемлемо; поэтому в языке есть закрытые базовые классы. Но все же включение (назначение объекта полем в классе, а не базовым классом) всегда лучше, чем наследование (при условии, конечно, что у вас есть выбор).

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

class derived : public base

{

};

вам почти всегда лучше делать так:

class derived

{

   base base_obj;

};

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

Удачный пример подобного неправильного использования наследования есть во многих иерархиях классов для Windows, которые наследуют классы типа "диалоговое окно" от "окна". Однако в реальной программе вы никогда не посылаете относящиеся к окну сообщения (типа "сдвинуться" или "изменить размер") в диалоговое окно. То есть диалоговое окно не является окном, по крайней мере, с точки зрения того, как диалоговое окно используется в программе. Скорее диалоговое окно использует окно, чтобы себя показать. Слово "является" подразумевает наследование, а "использует" —

включение, которое здесь лучше подходит.

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



Проектируйте структуры данных в последнюю очередь


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



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



Не пользуйтесь функциями типа get/set (чтения и присваивания значений)


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

struct xxx

{

   int x;

};

и:

class xxx {

private:

   int x;

public:

   void setx ( int ix ){ x = ix;      }

   int  getx ( void )  { return

x; }

}

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

· Сообщение реализует свойство. Открытая (public) функция реализует обработчик сообщения. Поля данных —

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

Заметьте, что вы будете изредка видеть обработчик сообщений, который ничего не делает, кроме возврата содержимого поля или помещает в поле значение, переданное в виде аргумента. Этот обработчик тем не менее не является функцией типа get/set. Вопрос в том, как возникает такая ситуация. Нет абсолютно ничего плохого в том, если вы начинаете с ряда сообщений и затем решаете, что самым простым способом реализации сообщения является помещение специального поля в определение класса. Другими словами, этот обработчик сообщений не является усложненным способом доступа к полю; скорее, это поле является простым способом реализовать сообщение. Хотя вы попали в то же место, вы попали туда совершенно другим путем.

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

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


который возвращается из сообщения get_date(). Проблема здесь в том, что проектирование выполнено выполнено наизнанку. Программист мыслил структурными категориями, а не объектно-ориентированными.
При выполнении должным образом единственным видимым в других частях программы объектом был бы объект "дата". "Дата" использовала бы объект "календарь" для реализации сообщения "инициализируй_себя"
(которое могло бы быть конструктором), но "календарь" бы содержался внутри "даты". Определение класса "календарь" можно было бы даже вложить в определение класса "дата". Объект "дата" также мог бы поддерживать другие инициализирующие сообщения, такие как "инициализируй_себя_от_редактируемого_ввода" или "инициализируй_себя_из_строки", но во всех случаях объект "дата" отвечает за нужное для инициализации взаимодействие с пользовательским интерфейсом. Остальная часть программы просто бы непосредственно использовала "дату"; никто, кроме "даты", даже бы не знал о существовании объекта "календарь". То есть вы бы объявили "дату" и приказали ей себя инициализировать. Затем вы можете передавать объект "дата" всюду, куда необходимо. Конечно, "дата" должна также уметь себя вывести, переслать в файл или из файла, сравнить себя с другими датами и так далее.

Никогда не допускайте открытого доступа к закрытым данным


Все данные в определении класса должны быть закрытыми. Точка. Никаких исключений. Проблема здесь заключается в тесном сцеплении между классом и его пользователями, если они имеют прямой доступ к полям данных. Я приведу вам несколько примеров. Скажем, у вас есть класс string, который использует массив типа char для хранения своих данных. Спустя год к вам обращается заказчик из Пакистана, поэтому вам нужно перевести все свои строки на урду, что вынуждает перейти на Unicode. Если ваш строковый класс позволяет какой-либо доступ к локальному буферу char*, или сделав это поле открытым (public), или определив функцию, возвращающую char*, то вы в большой беде.

Взглянем на код. Вот действительно плохой проект:

class string

{

public:

   char *buf;

   // ...

};

f()

{

   string s;

   // ...

printf("%s/n", s.buf );

}

Если вы попробуете изменить определение buf на wchar_t* для работы с Unicode (что предписывается ANSI Си), то все функции, которые имели прямой доступ к полю buf, перестают работать. И вы будете должны их все переписывать.

Другие родственные проблемы проявляются во внутренней согласованности. Если строковый объект содержит поле length, то вы могли бы модифицировать буфер без модификации length, тем самым разрушив эту строку. Аналогично, деструктор строки мог бы предположить, что, так как конструктор разместил этот буфер посредством new, то будет безопаснее передать указатель на buf оператору delete. Однако если у вас прямой доступ, то вы могли бы сделать что-нибудь типа:

string s;

char  array[128];

s.buf = array;

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

Простое закрытие при помощи модификатора private

поля buf не помогает, если вы продолжаете обеспечивать доступ посредством функции. Листинг 7 показывает фрагмент простого определения строки, которое будет использоваться мной несколько раз в оставшейся части этой главы. (Упрощение, сделанное мной, свелось к помещению всего в один листинг; обычно определение класса и встроенные функции будут в заголовочном файле, а остальной код — в файле .cpp).


{
    return strcmp(buf, r.buf) 0;
}
//------------------------------------------------–––––––––––-----
/* виртуальный */ int string::operator==( const
string r ) const
{
    return strcmp(buf, r.buf) == 0;
}
//--------------------------------------------------–––––––––––---
/* виртуальный */ void string::print( ostream output ) const
{
    cout buf;
}
//–------------------------------------------------–––––––––––----
inline ostream operator( ostream output, const string s )
{
// Эта функция не является функцией-членом класса string,
// но не должна быть дружественной, потому что мной тут
// реализован метод вывода строкой своего значения.

    s.print(output);
    return output;
}
Вы заметите, что я умышленно не реализовал следующую функцию в листинге 7:
string::operator const
char*() { return buf; }
Если бы реализовал, то мог бы сделать следующее:
void f( void
)
{
   string s;
   // ...
   printf("%s\n", (const char*)s );
}
но я не cмогу реализовать функцию operator
char*(), которая бы работала со строкой Unicode, использующей для символа 16-бит. Я должен бы был написать функцию operator
wchar_t*(), тем самым модифицировав код в функции f():
printf("%s/n", (const wchar_t*)s );
Тем не менее, одним из главных случаев, которых я стараюсь избежать при помощи объектно-ориентированного подхода, является необходимость модификации пользователя объекта при изменении внутреннего определения этого объекта, поэтому преобразование в char*
неприемлемо.
Также есть проблемы со стороны внутренней согласованности. Имея указатель на buf,
возвращенный функцией operator
const char*(), вы все же можете модифицировать строку при помощи указателя и испортить поле length, хотя для этого вам придется немного постараться:
string s;
// ...
char *p = (char *)(const char *)s;
gets( p );
В равной степени серьезная, но труднее обнаруживаемая проблема возникает в следующем коде:


const char
*g( void )
{
 string s;
// ...
return (const
char *)s;
}
Операция приведения вызывает функцию operator const char*(), возвращающую buf. Тем не менее, деструктор класса string
передает этот буфер оператору delete, когда строка покидает область действия. Следовательно, функция g()
возвращает указатель на освобожденную память. В отличие от предыдущего примера, при этой второй проблеме нет закрученного оператора приведения в два этапа, намекающего нам, что что-то не так.
Реализация в листинге 7 исправляет это, заменив преобразование char* на обработчиков сообщений типа метода самовывода (print()). Я бы вывел строку при помощи:
string s;
s.print( cout )
или:
cout s;
а не используя printf(). При этом совсем нет открытого доступа к внутреннему буферу. Функции окружения могут меньше беспокоиться о том, как хранятся символы, до тех пор, пока строковый объект правильно отвечает на сообщение о самовыводе. Вы можете менять свойства представления строки как хотите, не влияя на отправителя сообщения print(). Например, строковый объект мог бы содержать два буфера —
один для строк Unicode и другой для строк char* — и обеспечивать перевод одной строки в другую. Вы могли бы даже добавить для перевода на французский язык сообщение translate_to_French() и получить многоязыкую строку. Такая степень изоляции и является целью объектно-ориентированного программирования, но вы ее не добьетесь, если не будете непреклонно следовать этим правилам. Здесь нет места ковбоям от программирования.

Откажитесь от выражений языка Си, когда программируете на Си++


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

Хорошим примером этой проблемы в Си++ является char*. Большинство программистов на Си ни за что не соглашаются отказаться от использования строк в виде char*. Проблема заключается в том, что вы привыкли смотреть на char* и думать, что это строка. Это не строка. Это указатель. Убежденность в том, что указатель — это строка, обычно вызывает проблемы, некоторые из которых я уже рассматривал, а другие будут рассмотрены позднее.

Симптомами этой проблемы является появление char*

где-нибудь в программе, которая поддерживает класс string; вы должны делать все на языке string. Обобщим это: чтобы заставить объектно-ориентированную систему работать, все должно быть объектами. Основные типы Си не очень применимы, за исключением глубоких недр низкоуровневых функций-членов класса низкого уровня. Инкапсуляция вашего char* в классе string

решит множество проблем, и потратите массу времени, пытаясь поддерживать char*, при том, что существует вполне хороший класс string, который может делать ту же работу.

Определение класса не обязательно увеличивает накладные расходы, поэтому это не может быть оправданием. Если ваш класс string

имеет единственное поле char*, и если все из методов являются встроенными функциями, то ваши накладные расходы не превысят те, которые бы у вас были при прямом использовании char*, но зато вы получите все выгоды сопровождения, предоставляемые классами Си++. Более того, у вас будет возможность наследовать от string, что невозможно с char*.

Возьмем в качестве примера управляющий элемент-редактор Windows —

маленькое окно, в котором пользователь вводит данные. (Программисты для X-Window, для вас "управляющий элемент" Windows — это примерный эквивалент widget). Управляющий элемент-редактор имеет все свойства как окна, так и строки, и, следовательно, вам было бы желательно его реализовать, наследуя одновременно от класса window и от класса string.



Функция-член должна обычно использовать закрытые поля данных класса


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

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

Вы часто видите эту ошибку в архитектурах "документ/отображение" типа MacApp и MFC. С точки зрения архитектуры, "документ" содержит данные, а "отображение" реализует пользовательский интерфейс. Трудности возникают, когда вы хотите показать какие-нибудь данные в своем "отображении". Никогда не позволяйте "отображению" доступ к полям "документа" для их показа. Данные любого класса, включая "документ", должны быть тщательно охраняемым секретом. Лучшим подходом является передача "отображением" в "документ" сообщения "отобразить себя в этом окне".9



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


Никогда не надейтесь, что класс не будет использоваться в качестве базового класса. Сосредоточимся на случае с примером управляющего элемента-редактора из предыдущего правила. Я бы хотел реализовать такой элемент, наследуя одновременно от класса window и от класса string, потому что он обладает свойствами обоих. У меня ничего бы не получилось, если бы многие из функций string не были виртуальными. То есть, так как я могу делать со строкой следующее:

string str = "xxx"; // инициализировать строку значением "xxx"

str = "Абв";        // заменить предыдущее значение на "Абв"

str += "где";       // присоединяет "где" к имеющейся строке.

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

class edit_control : public string

   , public window

{/* ... */}

edit_control edit = "xxx";

edit = "Абв";

edit += "где";

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

Все это не возможно, если функции, подобные operator=() и operator+=(), не виртуальные в классе string и, тем самым, не позволяющие мне менять их поведение в производном классе edit_control. Например, так как функция operator=()

класса string из листинга 7 со страницы 155 является виртуальной, то я могу сделать следующее:

class edit_control : public string

   , public window

{

// ...

   virtual

string operator=( const string r );

}

virtual string edit_control::operator=( const string r )

{

   *(string *)this = r;

   window::caption() = r;  // операция разрешения видимости

                           // window:: просто для ясности

}

Следующей функции может быть передан или простой объект string, или объект edit_control; она не знает или ей все равно, какой конкретно:

f( string *s )

{

   // ...

   *s = "Новое значение" ;

}

В случае объекта string

внутренний буфер обновляется. В случае edit_control

буфер обновляется, но также модифицируется заголовок его окна.



Используйте константы


В программы на Си класс памяти const часто не включается. На самом деле это просто небрежность, но она мало влияет на возможности программы на Си. Так как Си++ гораздо разборчивее в отношении типов, чем Си, то в Си++ это гораздо более крупная проблема. Вы должны использовать модификатор const везде, где можно; это делает код более надежным, и часто компилятор не принимает код, который его не использует. Особенно важно:

·

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

puts( const char

*p )

сообщает компилятору, что функция puts() не намерена модифицировать символы в массиве, переданном при помощи p. Это является чрезвычайно полезной порцией информации для сопровождения.

· Все сообщения, не меняющие внутреннее состояние объекта, объявлять с модификатором const подобным образом:

class cls

{

   public: int operator==( const

cls p ) const ;

};

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

const current_class *this;

Если код в этой константной функции попытается модифицировать любое поле данных класса или предпримет вызов другой функции-члена, не помеченной const, то вы получите сообщение об ошибке компиляции такого примерно содержания "не могу преобразовать указатель на const current_class в указатель на current_class". Упомянутым указателем в данном случае является this, и никогда не будет дозволено преобразование указателя на константу в указатель на переменную (потому что вы тогда могли бы модифицировать константу при помощи указателя).

Константные ссылки тоже важны и рассматриваются позже.



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


Это правило является вариантом принципа "если это похоже на Си, то должно и действовать как Си". Используйте структуры, только если вы делаете что-то в стиле Си.

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

typedef struct

tagSIZE    // Существующее определение из

                          // заголовочного файла Си

{

   LONG  cx;

   LONG  cy;

}

SIZE;

class CSize : public SIZE // Определение в файле Си++

{

   // ...

}

Я видел определения классов, подобные следующему, где требуется доступ к полям cx и cy

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

высоты. Например:

CSize some_size;

   some_size.cy;                 // тьфу!

Вы должны иметь возможность написать:

some_size.height();

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



Не размещайте тела функций в определениях классов


Здесь есть несколько проблем. Если вы действительно поместите тело функции в определение класса таким образом:

class amanda

{

public:

   void

peekaboo( void ){ cout "ку-ку\n"; }

// функция игры

                                               // в прятки с

                                               // Амандой

}

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

class amanda

{

public:

   void peekaboo( void );

}

class amanda::peekaboo( void )

{

   cout "ку-ку\n";

}

Путаница —

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

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

Третья проблема более коварна и потребует нескольких часов на устранение, если вы не будете аккуратны. Рассмотрим фрагмент реализации связанного списка на листинге 8 (который не будет компилироваться). Классы linked_list и list_node

посылают сообщения друг другу. Компилятор должен увидеть определение класса до того, как он позволит вам послать сообщение объекту этого класса. (Вы можете объявить указатель на объект, лишь глядя на class xxx; но вы не можете ничего сделать при помощи этого указателя до завершения определения всего класса). Так как в листинге 8 используются встроенные функции, то невозможно устроить эти определения классов так, чтобы избежать предварительных ссылок. Вы можете решить эту проблему, поместив определения функций в конце того файла, где они объявлены. Я сделал это в листинге 9.




Листинг 8. Фрагмент реализации связанного списка
class list_node;
class linked_list
{
    int number_of_elements_in_list;
    list_node *root;
private:     // этот раздел содержит сообщения, получаемые
    friend class list_node; // только от объектов list_node
    void have_removed_an_element(void)
    {
       --number_of_elements_in_list;
    }

public:
    void remove_this_node( list_node *p )
    {
    // Следующая строка генерирует ошибку при компиляции,
    // так как компилятор не знает, что list_node
    // имеет сообщение remove_yourself_from_me( root ).

       p-remove_yourself_from_me( root );
    }

// ...
};

class list_node
{
    linked_list *owner;
private:                     // Этот раздел содержит
    friend class linked_list; // сообщения,получаемые только
                              // от объектов linked_list
    void remove_yourself_from_me( list_node *root )
    {
    // ... Выполнить удаление
       owner-have_removed_an_element();
    }
};
Листинг 9. Улучшенный вариант реализации связанного списка
class list_node;
class linked_list
{
    int number_of_elements_in_list;
    list_node *root;
private:
    friend class list_node;
    void have_removed_an_element( void );

public:
    void remove_this_node( list_node *p );

//...
};
//========================================================
class list_node
{
    linked_list *owner;
private:                // Этот раздел содержит сообщения,
    friend class linked_list; // получаемые только от
                              // объектов linked_list

    void remove_yourself_from_me( list_node *root );
};

//========================================================
// функции класса linked_list:
//========================================================
inline void
linked_list::remove_this_node( list_node *p )
{
    p-remove_yourself_from_me( root );
}
//--------------------------------------------------------
inline void
linked_list::have_removed_an_element( void )
{
    --number_of_elements_in_list;
}

//========================================================
// функции класса list_node:
//========================================================
void list_node::remove_yourself_from_me( list_node *root )
{
// ... Выполнить удаление
    owner-have_removed_an_element();
}

Избегайте перегрузки функций и аргументов, используемых по умолчанию


Это правило не применяется к конструкторам и функциям перегрузки операций.

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

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

f( int, long

);

f( long, int

);

f( 10, 10 );         // ОШИБКА: Какую из функций я вызываю?

Более коварно следующее:

f( int );

f( void* );

f( 0 );              // ОШИБКА: Вызов двусмысленный

Проблемой здесь является Си++, который считает, что 0

может быть как указателем, так и типом int. Если вы делаете так:

const void

*NULL = 0;

const int   ZERO = 0;

то вы можете записать f(NULL) для выбора варианта с указателем и f(ZERO) для доступа к целочисленному варианту, но это ведет к большой путанице. В такой ситуации вам бы лучше просто использовать функции с двумя разными именами.

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

f( int x = 0 );

и затем случайно вызвали f() без аргументов, компилятор успешно и без возражений вставит 0. Все, чего вы добились, — это устранили то, что в ином случае вызвало бы полезное сообщение об ошибке во время компиляции, и сдвинули ошибку на этап выполнения.

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

class string

{

public:

   string( char *s = ""     );

   string( const string r  );

   string( const CString r ); // преобразование из класса MFC.

   // ...

};

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


class string
{
 // ...
public:
   print( FILE     *fp  );
   print( iostream ios );
   print( window   win );
я бы рекомендовал:
class string
{
// ...
public:
   print_file   ( FILE     *fp  );
   print_stream ( iostream ios );
   print_window ( window   win );
Еще лучше, если бы у вас был класс устройства device, который бы мог представлять типы: файловый FILE, потоковый iostream и оконный window, в зависимости от того, как он инициализируется — тогда бы вы могли реализовать единственную функцию print(), принимающую в качестве аргумента device.
Я должен сказать, что сам порой нарушаю это правило, но делаю это, зная, что, переступив черту, могу навлечь на себя беду.
Часть 8б. Проблемы сцепления
Концепция сцепления описана ранее в общем виде. Я также указал наиболее важное правило Си++ для сокращения числа отношений сцепления: "Все данные должны быть закрытыми". Идея минимизации связей на самом деле центральная для Си++. Вы можете возразить, что главной целью объектно-ориентированного проектирования является минимизация отношений связи посредством инкапсуляции. Этот раздел содержит специфические для Си++ правила, касающиеся связывания.

Избегайте дружественных классов


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

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

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

тогда сможет получить доступ к каждому закрытому члену list. На самом деле я хочу следующего: "Функции-члены этого дружественного класса могут вызвать вот эти три закрытые функции-члена, но не могут получить доступ к чему-либо еще закрытому". К сожалению, язык Си++ не располагает методом ограничения доступа к заданному подмножеству обработчиков сообщений:

доступно все или ничего.

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

class granting

{

   // ...

private: friend

class grantee

   // Функции, определенные в этом разделе, будут доступны

   // членам класса grantee, но не доступны для открытого

   // использования извне.

   message_sent _from_grantee();

   another_message_sent_from_grantee();

private:

   // Настоящие закрытые функции располагаются здесь. Хотя

   // grantee мог бы получить доступ к этим функциям, но не

   // получает.

   // ...

};

Помните, что мы на самом деле не ограничиваем дружбы; это просто соглашение о записи, чтобы помочь читателю нашего определения класса угадать наше намерение. Надеемся, что кто-бы ни писал класс grantee, он будет достаточно взрослым, чтобы не обмануть нашего дружелюбия нежелательными улучшениями.



Наследование — это форма сцепления


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




Не портьте область глобальных имен: проблемы Си++


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

отличается от y.f(), если x и y

являются объектами разных классов. Аналогично, x::f()

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

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

class tree

{

   enum { max_nodes = 128 };

public:

   enum traversal_mechanism { inorder, preorder, postorder };

   print( traversal_mechanism how = inorder );

   // ...

}

// ...

f()

{

   tree t;

   // ...

   t.print( tree::postorder );

}

Константа tree::postorder, переданная в функцию print(), определенно не в глобальной области имен, потому что для доступа к ней требуется префикс tree::. При этом не возникает конфликта имен, так как если другой класс имеет член с именем postorder, то он вне класса будет именоваться other_class::postorder. Более того, константа max_nodes

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

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

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


const int
default_size = get_default_size_from_ini_file();
Ее значение считывается из файла во время загрузки программы, и оно не может быть изменено во время выполнения.
Вышеупомянутое также применимо к константам-членам класса, которые могут быть инициализированы через аргумент конструктора, но не могут меняться функциями-членами. Так как объект типа const не может стоять слева от знака равенства, константы-члены должны инициализироваться посредством списка инициализации членов следующим образом:
class fixed_size_window
{
   const size height;
   const size width;
   fixed_size_window( size the_height, size the_width )
                                 : height( the_height )
                                 , width ( the_width )
   {}
}
Вложенные классы также полезны. Вам часто будет нужно создать "вспомогательный" класс, о котором ваш пользователь даже не будет знать. Например, текст программы из Листинга 10 реализует класс int_array —
динамический двухмерный массив, размер которого может быть неизвестен до времени выполнения. Вы можете получить доступ к его элементам, используя стандартный для Си/Си++ синтаксис массива (a[row][col]). Класс int_array
делает это, используя вспомогательный класс, о котором пользователь int_array
ничего не знает. Я использовал вложенное определение для удаления определения этого вспомогательного класса из области видимости глобальных имен. Вот как это работает: Выражение a[row][col]
оценивается как (a[row])[col]. a[row]
вызывает int_array::operator[](),
который возвращает объект int_array::row, ссылающийся на целую строку. [col]
применяется к этому объекту int_array::row, приводя к вызову int_array::row::operator[](). Эта вторая версия operator[]()
возвращает ссылку на индивидуальную ячейку. Заметьте, что конструктор класса int_array::row
является закрытым, потому что я не хочу, чтобы любой пользователь имел возможность создать строку row. Строка должна предоставить дружественный статус массиву int_array с тем, чтобы int_array мог ее создать.


Листинг 10. Вспомогательные классы
#include iostream.h
class int_array
{
    class row
    {
       friend class
int_array;
       int *first_cell_in_row;
       row( int *p ) : first_cell_in_row(p) {}
    public:
       int operator[] ( int index );
    };

    int nrows;
    int ncols;
    int *the_array;

public:
    virtual
       ~int_array( void           );
        int_array( int rows, int
cols );

        row operator[] (int index);
};
//========================================================
// функции-члены класса int_array
//========================================================
int_array::int_array( int rows, int cols )
                         : nrows ( rows )
                         , ncols ( cols )
                         , the_array ( new int[rows * cols])
{}
//--------------------------------------------------------
int_array::~int_array( void )
{
    delete [] the_array;
}
//--------------------------------------------------------
inline int_array::row int_array::operator[]( int index )
{
    return row( the_array + (ncols * index) );
}
//========================================================
// функции-члены класса int_array::row
//========================================================
inline int
int_array::row::operator[]( int index )
{
    return first_cell_in_row[ index ];
}

//========================================================
void main ( void )      // ..§
{
    int_array ar(10,20); //
то же самое, что и ar[10][20], но
                         // размерность
во время компиляции
    ar[1][2] = 100;      // может быть не определена.
    cout ar[1][2];
}

Часть 8в. Ссылки

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


Использование ссылочных аргументов в языке программирования вызвано четырьмя причинами:

·

Они нужны вам для определения конструктора копии.

· Они нужны вам для определения перегруженных операций. Если вы определили:

some_class *operator+( some_class *left, some_class *right );

то вы должны сделать такое дополнение:

some_class x, y;

x = *(x + y)

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

x = x + 1;

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

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

Ссылки в языке не предназначены для имитации Паскаля и не должны использоваться так, как используются в программе на Паскале.

Проблема ссылочных аргументов — сопровождение. В прошлом году один из наших сотрудников написал следующую подпрограмму:


void copy_word( char *target, char

*src ) // src является

                                           // ссылкой на char*

{

   while( isspace(*src) )

      ++src;                    // Инкрементировать указатель,

                                // на который ссылается src.

   while( *src !isspace(*src) )

   *target++ = *src++;         // Передвинуть указатель,

                               // на который ссылается src,

                               // за

 

текущее слово.

}

Автор полагал, что вы будете вызывать copy_word()

многократно. Каждый раз подпрограмма копировала бы следующее слово в буфер target и продвигала бы указатель в источнике.


Вчера вы написали следующий код:
f( const char
*p )
{
   char *p = new char[1024];
   load( p );
   char word[64];
   copy_word( word, p );
   delete( p ); // Сюрприз!
p был модифицирован, поэтому весь
}               // этот участок памяти обращается в кучу мусора!
Главная проблема состоит в том, что, глядя на вызов copy_word( word, p ), вы не получаете подсказки о возможном изменении p в подпрограмме. Чтобы добраться до этой информации, вы должны взглянуть на прототип этой функции (который, вероятно, скрыт на 6-ом уровне вложенности в заголовочном файле). Огромные проблемы при сопровождении.
Если что-то похоже на обычный вызов функции Си, то оно должно и действовать как вызов обычной функции Си. Если бы автор copy_word()
использовал указатель для второго аргумента, то вызов выглядел бы подобным образом:
copy_word( word, p );
Этот дополнительный знак
является решающим. Средний сопровождающий программист полагает, что единственная причина передачи адреса локальной переменной в другую функцию состоит в том, чтобы разрешить функции модифицировать эту локальную переменную. Другими словами, вариант с указателем является самодокументирующимся; вы сообщаете своему читателю, что этот объект изменяется функцией. Ссылочный аргумент не дает вам такой информации.
Это не значит, что вы должны избегать ссылок. Четвертая причина в начале этого раздела вполне законна: ссылки являются замечательным способом избегать ненужных затрат на копирование, неявных при передаче по значению. Тем не менее, для обеспечения безопасности ссылочные аргументы должны всегда ссылаться на константные объекты. Для данного прототипа:
f( const some_class obj );
этот код вполне законен:
some_class an_object;
f( an_object );
Он похож на вызов по значению и при этом, что более важно, действует подобно вызову по значению —
модификатор const
предотвращает модификацию an_object в функции f(). Вы получили эффективность вызова по ссылке без его проблем.
Подведем итог: Я решаю, нужно или нет использовать ссылку, вначале игнорируя факт существования ссылок. Входные аргументы функций передаются по значению, а выходные —


используют указатели на то место, где будут храниться результаты. Я затем преобразую те аргументы, которые передаются по значению, в ссылки на константные объекты, если эти аргументы:
· являются объектами какого-то класса (в отличие от основных типов, подобных int);
· не модифицируются где-то внутри функции.
Объекты, которые передаются по значению и затем модифицируются внутри функции, конечно должны по-прежнему передаваться по значению.
В заключение этого обсуждения рассмотрим пример из реальной жизни того, как не надо использовать ссылки. Объект CDocument
содержит список объектов CView. Вы можете получить доступ к элементам этого списка следующим образом:
CDocument  *doc;
CView      *view;
POSITION pos = doc-GetFirstViewPosition();
while(  view = GetNextView(pos) )
   view-Invalidate();
Здесь есть две проблемы. Во-первых, у функции GetNextView()
неудачное имя. Она должна быть названа GetCurrentViewAndAdvancePosition(), потому что она на самом деле возвращает текущий элемент и затем продвигает указатель положения (который является ссылочным аргументом результата) на следующий элемент. Что приводит нас ко второй проблеме: средний читатель смотрит на предыдущий код и задумывается над тем, как завершается этот цикл. Другими словами, здесь скрывается сюрприз. Операция итерации цикла скрыта в GetNextView(pos), поэтому неясно, где она происходит. Ситуация могла быть хуже, если бы цикл был больше и содержал бы несколько функций, использующих pos в качестве аргумента — вы бы не имели никакого представления о том, какая из них вызывает перемещение.
Есть множество лучших способов решения этой проблемы. Простейший заключается в использовании в качестве аргумента GetNextView()
указателя вместо ссылки:
POSITION pos = doc-GetFirstViewPosition();
while( p = GetNextView( pos ) )
   p-Invalidate();
Таким способом pos
сообщает вам, что pos
будет модифицироваться; иначе зачем передавать указатель? Тем не менее, существуют и лучшие решения. Вот первое:


for( CView *p = doc-GetFirstView(); p ; p = p-NextView() )
   p-Invalidate();
Вот второе:
POSITION pos = doc-GetFirstViewPosition();
for( ; pos ; pos = doc-GetNextView(pos) )
   (pos-current())-Invalidate();
Вот третье:
CPosition pos = doc-GetFirstViewPosition();
for( ; pos; pos.Advance() )
   ( pos-CurrentView() )-Invalidate();
Вот четвертое:
ViewListIterator cur_view = doc-View_list(); // Просмотреть
                                              // весь
список
                                              // отображений
                                              // этого
                                              // документа.
for( ; cur_view ; ++cur_view ) // ++ переходит к следующему
                               // отображению.
cur_view-Invalidate();    // - возвращает указатель View*.
Вероятно, есть еще дюжина других возможностей. Все предыдущее варианты обладают требуемым свойством — в них нет скрытых операций и ясно, как происходит переход к "текущему положению".

Не возвращайте ссылки (или указатели) на локальные переменные


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

some_class f()

{

   some_class x;

   // ...

   return x;

}

Действительной проблемой здесь является синтаксис Си++. Оператор return может располагаться на отдалении от определения возвращаемой величины. Единственный способ узнать, что на самом деле делает return x, —

это взглянуть на заголовок функции и посмотреть, возвращает она ссылку,

или нет.



Не возвращайте ссылки на память, выделенную оператором new


Каждый вызов new должен сопровождаться delete — подобно malloc() и free(). Я иногда видел людей, старающихся избежать накладных расходов от конструкторов копии перегруженной бинарной операции подобным образом:

const some_class some_class::operator+( const some_class r )

const

{

   some_class *p = new some_class;

   // ...

   return *p;

}

Этот код не работает, потому что вы не можете вернуться к этой памяти, чтобы освободить ее. Когда вы пишите:

some_class a, b, c;

c = a + b;

то a + b возвращает объект, а не указатель. Единственным способом получить указатель, который вы можете передать в оператор delete, является:

some_class *p;

c = *(p = (a + b));

Это даже страшно выговорить. Функция operator+() не может прямо возвратить указатель. Если она выглядит подобным образом:

const some_class *some_class::operator+( const some_class r )

const

{

   some_class *p = new some_class;

   // ...

   return p;

}

то вы должны записать:

c = *(p = a + b);

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

const some_class some_class::operator+( const some_class r )

const

{

   some_class obj;

   // ...

   return obj;

}

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

Часть 8г. Конструкторы, деструкторы и operator=( )

Функции конструкторов, деструкторов и операций operator=()

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

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


some_class x;             // конструктор по умолчанию
some_class y = x;      // конструктор копии
но кроме этого он используется и неявно в двух ситуациях. Первой является вызов по значению:
some_class x;
f( some_class x ) // передается по значению, а не по ссылке.
f( x );       // вызывается конструктор копии для передачи x
              // по значению. Оно должно скопироваться в стек.
Второй является возврат по значению:
some_class g() // Помните, что x
- локальная, автоматическая
               // переменная.Она исчезает после возвращения
               // функцией значения.
{
   some_class x; // Оператор return после этого должен
   return x;     // скопировать x куда-нибудь в надежное место
}                // (обычно в стек после аргументов).Он
                 // использует для этой цели конструктор копии.
Генерируемая компилятором функция-операция operator=()
нужна лишь для поддержки копирования структур в стиле Си там, где не определена операция присваивания.

Присваивание самому себе должно работать


Определение operator=( )

должно всегда иметь следующую форму:

class class_name

{

   const class_name operator=( const class_name r );

};

const class_name class_name::operator=( const class_name r )

{

   if( this

!= r )

   {

      // здесь скопировать

   }

   return *this;

}

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

Эта функция возвращает ссылку, потому что она может это сделать. То есть вы могли бы удалить из объявления возвращаемой величины, и все бы работало прекрасно, но вы бы получили ненужный вызов конструктора копии, вынужденный возвратом по значению. Так как у нас уже есть объект, инициализированный по типу правой части (*this), то мы просто можем его вернуть. Даже если возврат объекта вместо ссылки в действительности является ошибкой для функции operator=(), компилятор просто выполнит то, что вы ему приказали. Здесь не будет сообщения об ошибке; и на самом деле все будет работать. Код просто будет выполняться более медленно, чем нужно.

Наконец, operator=()

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

(x =y) = z;

Причина состоит в том, что (x=y)

расценивается как возвращаемое значение функции operator=(), т.е. константная ссылка. Получателем сообщения =z

является объект, только что возвращенный от x=y. Тем не менее, вы не можете послать сообщение operator=()

константному объекту, потому что его объявление не имеет в конце const:

                                // НЕ ДЕЛАЙТЕ ЭТОГО В ФУНКЦИИ

                                // С ИСПОЛЬЗОВАНИЕМ operator=().

                                //                  |

                                //                  V

const class_name operator=( const class_name r ) const;

Компилятор должен выдать вам ошибку типа "не могу преобразовать ссылку на переменную в ссылку на константу", если вы попробуете (x=y)=z.

Другим спорным моментом в предыдущем коде является сравнение:

if( this

!= r )

в функции operator=(). Выражение:

class_name x;

// ...

x = x;

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

class_name array[10];

class_name *p = array;

// ...

*p = array[0];



Классы, имеющие члены-указатели


Если класс не определяет методы копирования — конструктор копии и функцию operator=(), то это делает компилятор. Созданный компилятором конструктор должен выполнять "почленное" копирование, которое  осуществляется

 

таким  образом,  как

 

будто

 

вы  написали  this-field = src.field для каждого члена. Это означает, что теоретически должны вызываться конструкторы копий и функции operator=()

вложенных объектов и базовых классов. Даже если все работает правильно, все же указатели копируются как указатели. То есть, строка string, представленная как char*, —

не строка, а указатель, и будет скопирован лишь указатель. Представьте, что определение string на листинге 7 со страницы 155 не имеет конструктора копии или функции operator=(). Если вы запишите

string s1 = "фу", s2;

// ...

s2 = s1;

то это присваивание вместо поля указателя s2

запишет указатель от s1. Та память, которая была адресована посредством s1-buf, теперь потеряна, то есть у вас утечка памяти. Хуже того, если вы меняете s1, то

s2

меняется также, потому что они указывают на один и тот же буфер. Наконец, когда строки выходят из области действия, они обе передают buf для освобождения, по сути, очищая его область памяти дважды, и, вероятно, разрушают структуру динамической памяти. Решайте эту проблему путем добавления конструктора копии и функции operator=(), как было сделано на листинге 7 со страницы 155. Теперь копия будет иметь свой собственный буфер с тем же содержанием, что и у буфера строки-источника.

Последнее замечание: я выше написал "должен выполнять" и "теоретически" в первом абзаце, потому что встречал компиляторы, которые фактически выполняли функцию memcpy() в качестве операции копирования по умолчанию, просто как это бы сделал компилятор Си. В этом случае конструктор копии и функция operator=()

вложенных объектов не будут вызваны, и вы всегда

будете должны обеспечивать конструктор копии и функцию operator=() для копирования вложенных объектов. Если вы желаете достигнуть при этом абсолютной надежности, вы будете должны проделать это для всех классов, чьи члены не являются основными числовыми типами Си++.



Исходите из того, что члены и базовые классы инициализируются в случайном порядке


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

class base

{

   string s;

public:

   base( const  char *init_value );

}

//------------------------------

base::base( const  char *init_value )

{

   s = init_value;

}

Основной принцип такой: если у вас есть доступ к объекту, то он должен быть инициализирован. Так как поле s

видимо для конструктора base, то Си++ гарантирует, что оно инициализировано до окончания выполнения тела конструктора. Список инициализации членов является механизмом выбора выполняемого конструктора. Если вы его опускаете, то получите конструктор по умолчанию, у которого нет аргументов, или, как в случае рассматриваемого нами класса string, такой, аргументы которого получают значения по умолчанию. Следовательно, компилятор вначале проинициализирует s

пустой строкой, разместив односимвольную строку при помощи new и поместив в нее \0. Затем выполняется тело конструктора и вызывается функция string::operator=(). Эта функция освобождает только что размещенный буфер, размещает буфер большей длины и инициализирует его значением init_value. Ужасно много работы. Лучше сразу проинициализировать объект корректным начальным значением. Используйте:

base( const  char *init_value ) : s(init_value)

{}

Теперь строка s

будет инициализирована правильно, и не нужен вызов operator=() для ее повторной инициализации.

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


·
Базовые классы в порядке объявления.
· Поля данных в порядке объявления.
Лишь затем выполняется конструктор производного класса. Одно последнее предостережение. Заметьте, что порядок объявления управляет порядком инициализации. Порядок, в котором элементы появляются в списке инициализации членов, является несущественным. Более того, порядок объявления не должен рассматриваться как неизменный. Например, вы можете изменить порядок, в котором объявлены поля данных. Рассмотрим следующее определение класса где-нибудь в заголовочном файле:
class wilma
{
   int y;
   int x;
public:
   wilma( int ix );
};
Вот определение конструктора в файле .c:
wilma::wilma( int ix ) : y(ix * 10), x(y + 1)
{}
Теперь допустим, что какой-то сопровождающий программист переставит поля данных в алфавитном порядке, поменяв местами x и y. Этот конструктор больше не работает: поле x
инициализируется первым, потому что оно первое в определении класса, и инициализируется значением y+1, но поле y еще не инициализировалось.
Исправьте код, исключив расчет на определенный порядок инициализации:
wilma::wilma( int ix ) : y(ix * 10), x((ix *10) + 1)
{}

Конструкторы копий должны использовать списки инициализации членов


У наследования тоже есть свои проблемы с копированием. Конструктор копии все же остается конструктором, поэтому здесь также применимы результаты обсуждения предыдущего правила. Если у конструктора копии нет списка инициализации членов, то для базовых классов и вложенных объектов используется конструктор по умолчанию. Так как список инициализации членов отсутствует в следующем определении конструктора копии, то компонент базового класса в объекте производного класса инициализируется с использованием base(void), а поле s инициализируется с использованием string::string(void):

class base

{

public:

   base( void );                  // конструктор по умолчанию

   base( const base r );         //

конструктор копии

   const base operator=( const base r );

};

class derived

{

   string s;              // класс имеет конструктор копии

public:

   derived( const derived r )

};

derived::derived( const derived r )

{}

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

derived::derived( const derived r ) : base(r), s(r.s) {}



Производные классы должны обычно определять конструктор копии и функцию operator=( )


При наследовании есть и другая связанная с копированием проблема. В одном месте руководства10

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

Я наблюдал два полностью несовместимых поведения компиляторов, столкнувшихся с этой дилеммой. Некоторые компиляторы считали правильным, чтобы сгенерированные компилятором конструкторы копий и функции operator=()

вызывались автоматически после конструкторов и функций operator=()

базового класса (и вложенного объекта).11  Это как раз тот способ, который, по мнению большинства, реализуется языком программирования. Другими словами, со следующим кодом проблем не будет:

class base

{

public:

   base( const base r );

   const base operator=( const base r );

};

class derived : public base

{

   string s;

   // нет операции operator=() или конструктора копии

};

derived x;

derived y = x; // вызывает конструктор копии базового класса

               // для копирования базового класса. Также

               // вызывает

конструктор копии строки для

               // копирования поля s.

x = y; // вызывает функцию базового класса operator=() для

       // копирования базового класса. Также вызывает

       // строковую

функцию operator=() для копирования поля s.

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


operator=()
производного класса действуют так, как будто бы их эквиваленты в базовом классе (и вложенном объекте) просто не существуют. Другими словами, конструктор по умолчанию — без аргументов —
вызывается для копирования компонента базового класса, а почленное копирование — которое может выполняться просто функцией memcpy()
— используется для поля. Мое понимание пересмотренного проекта стандарта Си++ ISO/ANSI позволяет сделать вывод, что такое поведение некорректно, но в течение некоторого времени вам придется рассчитывать на худшее, чтобы обеспечивать переносимость. Следовательно, это, вероятно, хорошая мысль — всегда помещать в производный класс конструктор копии и функцию operator=(), которые явно вызывают своих двойников из базового класса. Вот реализация предыдущего производного класса для самого худшего случая:
class derived : public base
{
   string s;
public:
   derived( const derived r );
   const derived operator=( const derived r );
};
//-----------------------------------------------------------
derived::derived( const derived r ) : base(r), s(r.s)
{}
//-----------------------------------------------------------
const derived derived::operator=( const derived r )
{
   (* (base*)this) = r;
   s = r.s;
}
Список инициализации членов в конструкторе копии описан ранее. Следующий отрывок из функции operator=()
нуждается в некотором пояснении:
(* (base*)this) = r;
Указатель this указывает на весь текущий объект; добавление оператора приведения преобразует его в указатель на компонент базового класса в текущем объекте — (base*)this. (* (base*)this)
является самим объектом, а выражение (* (base*)this) = r передает этому объекту сообщение, вызывая функцию operator=()
базового класса для перезаписи информации из правого операнда в текущий объект. Вы могли бы заменить этот код таким образом:
base::operator=( r );
но я видел компиляторы, которые бракуют этот оператор, если в базовом классе не объявлена явно функция operator=(). Первая форма работает независимо от того, объявлена явно operator=(), или нет. (Если не объявлена, то у вас будет по умолчанию реализовано почленное копирование).

Конструкторы, не предназначенные


Си++ использует конструкторы для преобразования типов. Например, конструктор char* в 9-ой строке листинга 7 на странице 155

также обрабатывает следующую операцию приведения:

char *pchar = "абвг";

(string) pchar;

Запомните, что приведение является операцией времени выполнения, которая создает временную переменную нужного типа и инициализирует ее из аргумента. Если приводится класс, то для инициализации используется конструктор. Следующий код работает прекрасно, потому что строковая константа char*

беспрепятственно преобразуется в string для передачи в функцию f():

f( const string s );

// ...

f( "белиберда" );

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

class array

{

   // ...

public:

   array( int

initial_size );

};

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

f( const array a );

// ...

f( isupper(*str) );

(Этот вызов передает f()

пустой одноэлементный массив, если *str

состоит из заглавных букв, или массив без элементов, если *str

— из строчных букв).

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

class array

{

   // ...

public:

   enum bogus { set_size_to };

   array( bogus, int

initial_size );

};

array ar( array::set_size_to, 128 );

Это по настоящему уродливо, но у нас нет выбора. Заметьте, что я не дал аргументу bogus

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



Используйте счетчики экземпляров объектов для инициализации на уровне класса


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

class window

{

   static int

num_windows;

public:

   window();

   ~window();

};

int window::num_windows = 0;

window::window()

{

   if( ++num_windows == 1 )  // только что создано первое окно

      initialize_video_system();

}

window::~window()

{

   if( --num_windows == 0 )      // только что уничтожено

      shut_down_video_system();  // последнее

окно

}

Наконец, счетчик экземпляров объектов может быть также использован в качестве счетчика числа вызовов для обеспечения инициализации на уровне подпрограммы:

f()

{

   static int

have_been_called = 0;

   if( !have_been_called )

   {

      have_been_called = 1;

      do_one_time_initializations();

   }

}



Суперобложки на Си++ для существующих интерфейсов редко хорошо работают


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

f( CWnd *win ) // CWnd - это окно

{

   // Следующая строка загружает "буфер" с шапкой окна

   // (текстом в строке заголовка)

   char buf[80];      /* = */

   win-GetWindowText(buf, sizeof(buf));

   // ...

}

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

вместить объявление и инициализацию в одной и той же строке.

Здесь имеется несколько проблем, первая из которых заключается в плохом проектировании класса CWnd

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

CString caption = win-caption();

и вы должны иметь возможность модифицировать этот атрибут так:

win-caption() = "новое содержание";

но вы не можете сделать этого в текущей реализации. Главная проблема состоит в том, библиотека MFC не была спроектирована в объектно-ориентированном духе — т.е. начать с объектов, затем выбрать, какие сообщения передавать между ними и какими атрибутами их наделить. Вместо этого проектировщики Microsoft начали от существующего процедурного интерфейса (API Си —

интерфейса прикладного программирования для Windows на Си) и добавили к нему суперобложку на Си++, тем самым увековечив все проблемы существующего интерфейса. Так как в API Си была функция с именем GetWindowText(), то проектировщики беззаботно сымитировали такой вызов при помощи функции-члена в своей оболочке CWnd. Они поставили заплату на интерфейс при помощи следующего вызова:

CString str;

win-GetWindowText( str );

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


Главный урок состоит в том, что проекты, основанные на процедурном подходе, радикально отличаются от объектно-ориентированных проектов. Обычно невозможно использовать код из одного проекта в другом без большой переработки. Простая оболочка из классов Си++ вокруг процедурного проекта не сделает его объектно-ориентированным.
Поучительно, я думаю, пошарить вокруг в поисках решения текущей проблемы с помощью Си++, но предупреждаю вас — здесь нет хорошего решения (кроме перепроектирования библиотеки классов). Моя первая попытка сделать оболочку вокруг CWnd
показана на листинге 11.
Для обеспечения возможности win-text() =
"Новый заголовок" необходим вспомогательный класс (window::caption). Вызов text()
возвращает объект заголовка, которому затем передается сообщение присваиванием.
Главная проблема на листинге 11 заключается в том, что библиотека MFC имеет много классов, унаследованных от CWnd, и интерфейс, реализованный в классе window, не будет отражен в других потомках CWnd. Си++ является компилируемым языком, поэтому нет возможности вставлять класс в середину иерархии классов без изменения исходного кода.
Листинг 12 определяет другое решение для смеси Си++ с MFC. Я выделил класс window::caption в отдельный класс, который присоединяется к окну, когда оно инициализируется. Используется подобным образом:
f(CWnd *win)
{
   caption cap( win )
   CString s = cap; // поддерживается преобразование в CString.
   cap =
"Новый заголовок";  // использует операцию
                             // operator=(CString)
}
Мне не нравится то, что изменение заголовка caption
меняет также окно, к которому этот заголовок присоединен в этом последнем примере. Скрытая связь между двумя объектами может сама по себе быть источником недоразумений, будучи слишком похожей на побочный эффект макроса. Как бы то ни было, листинг 12 решает проблему инициализации.
Листинг 11. Обертка для CWnd: первая попытка
class window : public CWnd
{
public:


    class caption
    {
       CWnd *target_window;
    private: friend class
window;
       caption( CWnd *p ) : target_window(p) {}

     public:
        operator CString ( void ) const;
        const caption operator=( const CString s );
     };

     caption text( void );
  };
  //–-------------------------------------------------------
  caption window::text( void )
  {
     return caption( this );
  }
  //--------------------------------------------------------
  window::caption::operator CString( void ) const
  {
     CString output;
     target_window-GetWindowText( output );
     return output;                 // возвращает копию
  }
  //--------------------------------------------------------
  const caption window::caption::operator=(const
CString s)
  {
     target_window-SetWindowText( s );
     return *this;
  }

Листинг 12. Заголовочный объект
class caption
{
    CWnd target_window;
public:
    window_text( CWnd *win ) : target_window( win ) {};
    operator const CString( void );
    const CString operator=( const CString r );
};

  inline caption::operator CString( void
);
  {
     CString output;
     target_window-GetWindowText( output );
         return output;
  }

  inline const
CString caption::operator=(const
CString s )
  {
    // возвращает тип CString (вместо типа заголовка
     // "caption"), поэтому будет срабатывать a = b = "абв"

     target_window-SetWindowText( s );
     return s;
  }
Часть 8д. Виртуальные функции
Виртуальные функции придают объекту производного класса способность модифицировать поведение, определенное на уровне базового класса (или предоставить какие-то возможности, в которых базовый класс испытывал потребность, но не мог их реализовать обычно из-за того, что информация, нужная для этой реализации, объявляется на уровне производного класса). Виртуальные функции являются центральными для объектно-ориентированного проектирования, потому что они позволяют вам определить базовый класс общего назначения, не требуя знания особенностей, которые могут быть предусмотрены лишь производным классом. Вы можете писать программу, которая думает, что манипулирует объектами базового класса, но на самом деле во время выполнения воздействует на объекты производного класса. Например, вы можете написать код, помещающий объект в обобщенную структуру данных data_structure, но на самом деле во время выполнения он вставляет его в tree или linked_list (классы, производные от data_structure). Это настолько фундаментальная объектно-ориентированная операция, что программа на Си++, которая не использует виртуальные функции, вероятно, просто плохо спроектирована.

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


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

должны быть способны себя распечатать. Вы не можете написать функцию print() на уровне базового класса, потому что геометрическая информация хранится в производных классах (круге circle, линии line, многоугольнике polygon и т.д.). Поэтому вы делаете print()

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

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

add( storable *insert )

{

   storable *object_already_in_database;

   // ...

   if( object_already_in_database-cmp(insert) 0 )

   // вставить объект в базу данных

}

Объект storable

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



Виртуальная функция не является виртуальной, если вызывается из конструктора или деструктора


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

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

class storable

{

   int stuff;

public:

   storable( void );

   virtual void print( void              );

   virtual void virtf( void              );

   virtual int  cmp  ( const storable r ) = 0;

   int nonvirtual( void );

};

storable::storable  ( void

) { stuff = 0;                       }

void

storable::print( void ) { /* материал для отладки print */ }

void

storable::virtf( void ) { /* делай что-нибудь */           }

int  storable::nonvirtual( void ) {                             }

Лежащее в основе определение класса (сгенерированное компилятором) может выглядеть подобно этому:

int _storable__print          ( storable *this ) { /* ... */ }

int _storable__virtf          ( storable *this ) { /* ... */ }

int _storable__nonvirtual     ( storable *this ) { /* ... */ }

typedef void

(*_vtab[])(...); // массив указателей на функции

_vtab _storable__vtab

{

   _storable__print,

   _storable__virtf,

   NULL             // метка-заполнитель для функции сравнения

};

typedef struct

storable

{

   _storable__vtab *_vtable;

   int stuff;

}

storable;

_storable__ctor( void )      // конструктор

{

   _vtable = _storable__vtable; // Эту строку добавляет


                                //
компилятор.
   stuff = 0;                   // Эта строка из исходного кода.
}
Когда вы вызываете невиртуальную функцию, используя такой код,
как:
storable *p;
p-nonvirtual();
то компилятор в действительности генерирует:
_storable__nonvirtual( p )
Если вы вызываете виртуальную функцию, подобную этой:
p-print();
то получаете нечто совершенно отличное:
( p-_vtable[0] )( p );
Вот таким-то окольным путем, посредством этой таблицы и работают виртуальные функции. Когда вы вызываете функцию производного класса при помощи указателя базового класса, то компилятор даже не знает, что он обращается к функции производного класса. Например, вот определение производного класса на уровне исходного кода:
class employee : public storable
{
   int
derived_stuff;
   // ...
public:
   virtual
int cmp( const storable r );
};
/* виртуальный */ int
employee::print( const storable r ) { }
/* виртуальный */ int employee::cmp  ( const storable r ) { }
А вот что сделает с ним компилятор:
int _employee__print( employee *this          ) { /* ... */ }
int _employee__cmp  ( employee *this, const
storable *ref_r )
{ /* ... */ }
_vtab _employee_vtable =
{
   _employee__print,
   _storable_virtf,  // Тут нет замещения в производном классе,
                     // поэтому
используется указатель на
                     // функцию базового класса.
   _employee_cmp
};
typedef struct
employee
{
   _vtab *_vtable;    // Генерируемое компилятором поле данных.
   int stuff;         // Поле базового класса.
   int
derived_stuff; // Поле, добавленное в объявлении
                      // производного класса.
}
employee;
_employee__ctor( employee *this ) // Конструктор по умолчанию,
{                                 // генерируемый
компилятором.
   _storable_ctor();        // Базовые классы
инициализируются
                            // в первую очередь.


_vtable = _employee_vtable; // Создается таблица виртуальных
}                           // функций.
Компилятор переписал те ячейки в таблице виртуальных функций, которые содержат замещенные в производном классе виртуальные функции. Виртуальная функция (virtf), которая не была замещена в производном классе, остается инициализированной функцией базового класса.
Когда вы создаете во время выполнения объект таким образом:
storable *p = new employee();
то компилятор на самом деле генерирует:
storable *p;
p = (storable *)malloc( sizeof(employee) );
_employee_ctor( p );
Вызов _employee_ctor()
сначала инициализирует компонент базового класса посредством вызова _sortable_ctor(), которая добавляет таблицу этой виртуальной функции к своей таблице и выполняется. Затем управление передается обратно к _employee_ctor() и указатель в таблице виртуальной функции переписывается так, чтобы он указывал на таблицу производного класса.
Отметьте, что, хотя p теперь указывает на employee, код p-print()
генерирует точно такой же код, как и раньше:
( p-_vtable[0] )( p );
Несмотря на это, теперь p
указывает на объект производного класса, поэтому вызывается версия print()
из производного класса (так как _vtable в объекте производного класса указывает на таблицу производного класса). Крайне необходимо, чтобы эти две функции print()
располагались в одной и той же ячейке своих таблиц смешений, но это обеспечивается компилятором.
Возвращаясь к основному смыслу данного правила, отметим, что при рассмотрении того, как работает конструктор, важен порядок инициализации. Конструктор производного класса перед тем, как он что-либо сделает, вызывает конструктор базового класса. Так как _vtable в конструкторе базового класса указывает на таблицу виртуальных функций базового класса, то вы лишаетесь доступа к виртуальным функциям базового класса после того, как вызвали их. Вызов print в конструкторе базового класса все так же дает:
( this-_vtable[0] )( p );
но _vtable
указывает на таблицу базового класса и _vtable[0]
указывает на функцию базового класса. Тот же самый вызов в конструкторе производного класса даст версию print()
производного класса, потому что _vtable
будет перекрыта указателем на таблицу производного класса к тому времени, когда была вызвана print().
Хотя я и не показывал этого прежде, то же самое происходит в деструкторе. Первое, что делает деструктор, — это помещает в _vtable
указатель на таблицу своего собственного класса. Только после этого он выполняет написанный вами код. Деструктор производного класса вызывает деструктор базового класса на выходе (в самом конце — после того, как выполнен написанный пользователем код).

Не вызывайте чисто виртуальные функции из конструкторов


Это правило вытекает из только что рассмотренной картины. Определение "чисто" виртуальной функции (у которой =0

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

вместо обычного указателя на функцию. (В случае "чисто" виртуальной функции нет функции, на которую необходимо указывать). Если вы вызываете чисто виртуальную функцию из конструктора, то используете таблицу базового класса и на самом деле вызываете функцию при помощи указателя NULL. Вы получите дамп оперативной памяти на машине с UNIX и "Общая ошибка защиты" в системе Windows, но MS-DOS просто исполнит то, что вы просили, и попытается выполнить код по адресу 0, считая его правильным.



Деструкторы всегда должны быть виртуальными


Рассмотрим такой код:

class base

{

   char *p;

   ~base() { p = new char[SOME_SIZE]; }

   base() { delete p; }

};

class derived : public base

{

   char *dp;

   ~derived() { dp = new char[[SOME_SIZE]; }

   derived() { delete dp; }

};

Теперь рассмотрим этот вызов:

base *p = new derived;

// ...

delete p;

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

p

указывает на объявленный тип base. Следовательно, delete

p

в действительности превращается в:

_base__destructor(p);

free(p);

Деструктор производного класса никогда не вызывается. Если вы переопределите эти классы, сделав деструктор виртуальным:

virtual ~base() { /* ... */ }

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

превращается в:

( p-_vtable[DESTRUCTOR_SLOT] ) (p);

Так как p

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

вызывает деструктор базового.



Функции базового класса


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

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




Не делайте функцию виртуальной


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



Защищенные функции обычно должны быть виртуальными


Одним из смягчающих факторов в ранее описанной ситуации со сцеплением базового и производного классов является то, что объекту производного класса Си++ едва когда-либо нужно посылать сообщение компоненту своего базового класса. Производный класс наследует назначение (и члены) от базового класса и обычно добавляет к нему назначение (и члены), но производный класс часто не вызывает функции базового класса. (Естественно, производный класс никогда не должен получать доступ к данным базового класса). Единственным иисключением являются виртуальные функции, которые можно рассматривать как средство изменения поведения базового класса. Сообщения часто передаются замещающей функцией производного класса в эквивалентную функцию базового класса. То есть, виртуальное замещение производного класса часто образует цепь с функцией базового класса, которую оно заместило. Например, класс CDialog из MFC реализует диалоговое окно Windows (тип окна для ввода данных). Этот класс располагает виртуальной функцией OnOk(), которая закрывает диалоговое окно, если пользователь щелкнул по кнопке с меткой "OK". Вы определяете свое собственное диалоговое окно путем наследования от CDialog и можете создать замещение OnOk(), которое будет выполнять проверку правильности данных перед тем, как позволить закрыть это диалоговое окно. Ваше замещение образует цепь с функцией базового класса для действительного выполнения закрытия:

class mydialog : public CDialog

{

   // ...

private:

   virtual OnOk( void );

};

/* виртуальный */ mydialog::OnOk( void )

{

   if( data_is_valid() )

      CDialog::OnOk();   // Послать сообщение базовому классу

   else

      beep();            // Обычно содержательное сообщение

                         // Windows об ошибке

}

Функция OnOk()

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

объекту mydialog. OnOk()

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

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

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

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



Опасайтесь приведения типов (спорные вопросы Си++)


Приведение типов в Си рассмотрено ранее, но и в Си++ приведение вызывает проблемы. В Си++ у вас также существует проблема нисходящего приведения —

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

class base

{

public:

   virtual int

operator==( const base r ) = 0;

};

class derived

{

   char *key;

public:

   virtual int

operator==( const base r )

   {

      return strcmp(key, ((const derived )r).key ) == 0;

   }

};

К несчастью, здесь нет гарантии, что передаваемый аргумент r

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

base, но не являющегося классом derived. С учетом предыдущего определения следующий код не работает:

class other_derived : public base

{

   int key;

   // ...

};

f()

{

   derived       dobj;

   other_derived other;

   if( derived == other_derived )

      id_be_shocked();

}

Комитет ISO/ANSI по Си++ рекомендовал механизм преобразования типов во время выполнения, который решает эту проблему, но на момент написания этой книги многие компиляторы его не поддерживают. Предложенный синтаксис выглядит подобным образом:

class derived : public base

{

   char *key;

public:

   virtual int operator==( const base r )

   {

      derived *p = dynamic_castderived *( r );

      return !p ? 0 : strcmp(key, ((const derived )r).key )==0;

   }

};

Шаблон функции dynamic_castt

возвращает 0, если операнд не может быть безопасно преобразован в тип t, иначе он выполняет преобразование.

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

непосредственно, потому что сам по себе класс object

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



Не вызывайте конструкторов из операции operator=( )


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

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

class some_class

{

public:

   virtual

     ~some_class( void                               );

      some_class( void                               );

      some_class( const some_class r );

   const some_class operator=( const some_class r );

};

const some_class operator=( const some_class r )

{

   if( this

!= r )

   {

      this-~some_class();

      new(this) some_class(r);

   }

   return *this;

}

Этот вариант оператора new инициализирует указываемый this объект как объект some_class, в данном случае из-за аргумента r

используя конструктор копии.12

Есть серьезные причины не делать показанное выше. Во-первых, это не будет работать после наследования. Если вы определяете:

class derived : public some_class

{

public:

   ~derived();

   // Предположим, что генерированная компилятором операция

   // operator=() выполнится за операцией operator=() базового

   // класса.

}

Вследствие того, что деструктор базового класса определен (правильно) как виртуальный, обращение предыдущего базового класса к:

this-~some_class()

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

this-some_class::~some_class();

Явное упоминание имени класса — some_class:: в этом примере —

подавляет механизм виртуальной функции. Функция вызывается, как если бы она не была виртуальной.

Деструктор не является единственной проблемой. Рассмотрим простое присваивание объектов производного класса:

derived d1, d2;

d1 = d2;

Операция производного класса operator=()

(вне зависимости от того, генерируется она компилятором или нет) образует цепочку с operator=()

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


указывает на таблицу производного класса. После присваивания указатель vtable
указывает на таблицу базового класса; он был переинициализирован неявным вызовом конструктора при вызове new в перегруженной операции operator=().
Таким образом, вызовы конструкторов в операции operator=()
просто не будут работать, если есть таблица виртуальных функций. Так как вы можете знать или не знать, на что похожи определения вашего базового класса, то вы должны исходить из того, что таблица виртуальных функций имеется, и поэтому не вызывайте конструкторов.
Лучшим способом устранения дублирования кода в операции присваивания operator=()
является использование простой вспомогательной функции:
class some_class
{
   void create  ( void                );
   void create  ( const some_class r );
   void destroy ( void                );
public:
   virtual
      ~some_class( void
) { destroy(); }
       some_class( void
) { create();  }
   const some_class operator=( const some_class r );
};
inline const some_class some_class::operator=( const
some_class r )
{
   destroy();
   create( r );
}
inline some_class::some_class( void )
{
   create();
}
~some_class::some_class( void )
{
   destroy();
}
Часть 8е. Перегрузка операций

Операция — это сокращение (без сюрпризов)


Операция — это не произвольный значок, означающий все, что вы ни пожелаете. Это аббревиатура англоязычного слова. Например, символ +

значит "прибавить", поэтому вы не должны заставлять перегруженный operator+()

делать что-нибудь еще. Хотя здесь все ясно (вы можете определить a + b для вычитания b из a, но не должны делать этого), я на самом деле веду речь о проблемах более творческого характера.

Вы можете благоразумно доказывать, что, когда выполняете конкатенацию, то "прибавляете" одну строку к концу другой, поэтому перегрузка + для конкатенации может быть приемлема. Вы также можете доказывать, что разумно использовать операции сравнения для лексикографического упорядочивания в классе string, поэтому перегрузка операций , == и т.д. также вероятно пойдет. Вы не сможете аргументировано доказать, что – или * имеют какой-нибудь смысл по отношению к строкам.

Другим хорошим примером того, как нельзя действовать, является интерфейс Си++ iostream. Использование сдвига () для обозначения "вывод" является нелепым. Ваши функции вывода в Си назывались printf(), а не shiftf(). Я понимаю, что Страуструп выбрал сдвиг, потому что он сходен с механизмом перенаправления ввода/вывода различных оболочек UNIX, но этот довод на самом деле не выдерживает проверки. Страуструп исходил из того, что все программисты на Си++ понимают перенаправление в стиле UNIX, но эта концепция отсутствует в некоторых операционных системах — например, в Microsoft Windows. К тому же, для того, чтобы аналогия была полной, операция

должна быть перегружена для выполнения операции затирания, а

добавления в конец. Тем не менее, тот факт, что и

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

x += 1 не будет работать так, как вы ожидаете, потому что у

более высокий приоритет, чем у +=, поэтому оператор интерпретируется как (cout x) += 1, что неверно. Си++ нуждается в расширяемости, обеспечиваемой системой iostream, но он вынужден добиваться ее за счет введения операторов "ввода" и "вывода", имеющих низший приоритет по отношению к любому оператору языка.


Аналогия проблеме " сдвиг как вывод" может быть найдена в проектировании компьютерных систем. Большинство проектировщиков аппаратуры были бы счастливы использовать +
вместо OR, а *
вместо AND, потому что такая запись используется во многих системах проектирования электронных компонентов. Несмотря на это, перегрузка операции operator+() в качестве OR
явно не нужна в Си++. К тому же, лексема означает "сдвиг" в Си и Си++; она не означает "вывод".
Как завершающий пример этой проблемы — я иногда видел реализации класса "множество", определяющие | и со значениями "объединение" и "пересечение". Это может иметь смысл для математика, знакомого с таким стилем записи, но при этом не является выражением ни Си, ни Си++, поэтому будет незнакомо для вашего среднего программиста на Си++ (и вследствие этого с трудом сопровождаться). Амперсанд является сокращением для AND; вы не должны назначать ему произвольное значение. Нет абсолютно ничего плохого в a.Union(b) или a.intersect(b). (Вы не можете использовать a.union(b) со строчной буквой u,
потому что union
является ключевым словом).

Используйте перегрузку операций


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

арифметических типов.

Тем не менее, также разумно использовать перегруженные операции и там, где аналогии с Си незаметны. Например, большинство классов будет перегружать присваивание. Перегрузка operator==() и operator!=()

также разумна в большинстве классов.

Менее ясным (и более противоречивым) примером является класс "итератор". Итератор является средством просмотра каждого члена структуры данных, и он используется почти точно так же, как если бы он был указателем на массив. Например, вы можете в Си итерировать массив, просматривая каждый элемент, следующим образом:

string array[ size ];

string *p = array;

for( int

i = size; --i = 0 ; )

   visit( *p++ );      // функции visit() передается строка.

Аналог в Си++ может выглядеть вот так (keys

является деревом, чьи узлы имеют строковые ключи; здесь могут быть любые другие структуры данных):

treestring keys;     // двоичное дерево с узлами, имеющими

                       // строковые ключи

iterator p = keys;

// ...

for( int

i = keys.size(); --i = 0 ; )

   visit( *p++ );      // функции visit() передается строка.

Другими словами, вы обращаетесь с деревом как с массивом, и можете итерировать его при помощи итератора, действующего как указатель на элемент. И так как iterator(p)

ведет себя точно как указатель в Си, то правило "без сюрпризов" не нарушается.



Перегрузив одну операцию, вы должны перегрузить все сходные с ней операции


Это правило является продолжением предыдущего. После того, как вы сказали, что "итератор работает во всем подобно указателю", он на самом деле должен так работать. Пример в предыдущем правиле использовал лишь перегруженные * и ++, но моя настоящая реализация итератора делает аналогию полной, поддерживая все операции с указателями. Таблица 4 показывает различные возможности (t

является деревом, а ti —

итератором для дерева). Обе операции *++p и *p++

должны работать и т.д. В предыдущем примере я бы должен был также перегрузить в классе tree операции operator[] и (унарная) operator*() для того, чтобы аналогия дерева с массивом выдерживалась везде. Вы уловили эту мысль?

Таблица 4. Перегрузка операторов в итераторе

Операция

Описание

ti = t;

Возврат к началу последовательности

--ti;

Возврат к предыдущему элементу

ti += i;

Переместить вперед на i элементов

ti -= i;

Переместить назад на i элементов

ti + i;

ti - i;

Присваивает итератору другой временной переменной значение с указанным смещением от ti

ti[i];

Элемент со смещением i от текущей позиции

ti[-i];

Элемент со смещением -i от текущей позиции

t2 = ti;

Скопировать позицию из одного итератора в другой

t2 - ti;

Расстояние между двумя элементами, адресуемыми различными итераторами

ti-msg();

Послать сообщение этому элементу

(*ti).msg();

Послать сообщение этому элементу

Одна из проблем здесь связана с операциями operator==() и operator!=(), которые при первом взгляде кажутся имеющими смысл в ситуациях, где другие операции сравнения бессмысленны. Например, вы можете использовать == для проверки двух окружностей на равенство, но означает ли равенство "одинаковые координаты и одинаковый радиус", или просто "одинаковый радиус"? Перегрузка других операций сравнения типа или = еще более сомнительна, потому что их значение не совсем очевидно. Лучше полностью избегать перегрузки операций, если есть какая-либо неясность в их значении.



Перегруженные операции должны работать точно так же, как они работают в Си


Главной новой проблемой здесь являются адресные типы lvalue и rvalue. Выражения типа lvalue

легко описываются в терминах Си++: они являются просто ссылками. Компилятор Си, вычисляя выражение, выполняет операции по одной за раз в порядке, определяемом правилами сочетательности и старшинства операций. Каждый этап в вычислениях использует временную переменную, полученную при предыдущей операции. Некоторые операции генерируют "rvalue" —

действительные объекты, на самом деле содержащие значение. Другие операции создают "lvalue" —

ссылки на объекты. (Кстати, "l" и "r" используются потому, что в выражении l=r

слева от =

генерируется тип lvalue. Справа образуется тип rvalue).

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

·

Операции присваивания (=, +=, -= и т.д.) и операции автоинкремента и автодекремента (++, --) требуют операндов типа lvalue для адресата — части, которая изменяется. Представьте ++ как эквивалент для +=1, чтобы понять, почему эта операция в той же категории, что и присваивание.

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

· Все другие операции могут иметь операнды как типа lvalue,

так и rvalue.

Используйте ссылку на объект типа const для всех операндов. (Вы могли бы передавать операторы по значению, но обычно это менее эффективно).

· Имена переменных составного типа (массивов) создают типы rvalue —

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


· Имена переменных несоставного типа дают lvalue.
· Операции *, - и [] генерируют lvalue, когда относятся к несоставной переменной, иначе они работают подобно именам составных переменных. Если y не является массивом, то x-y создает тип lvalue, который ссылается на этого поле данных. Если y —
массив, то x-y генерирует тип rvalue, который ссылается на первую ячейку этого массива.
В Си++ перегруженные * и [] должны возвращать ссылки на указанный объект. Операция operator- таинственна. Правила по существу заставляют вас использовать ее таким же образом, как вы делали бы это в Си. Операция - рассматривается как унарная с операндом слева от нее. Перегруженная функция должна возвращать указатель на что-нибудь, имеющее поля — структуру,
класс или объединение. Компилятор будет затем использовать такое поле для получения lvalue или rvalue. Вы не можете перегрузить .(точку).
· Все другие операнды генерируют тип rvalue.
Эквивалентные перегруженные операции должны возвращать объекты, а не ссылки или указатели.

Перегруженной бинарной операции


Это правило относится к числу тех, которые будут изменены с улучшением качества компиляторов. Рассмотрим следующее, простое для понимания дополнение к классу string из листинга 7 на странице 155:

class string

{

   enum special_ { special };

   string( special_ ) {};              // ничего не делает.

   // ...

public:

   const string operator+( const

string r ) const;

   // ...

};

//------------------------------------------------------------

const string::operator+( const string r ) const

{

   string tmp( special );          // создать пустой объект

   tmp.buf = new char[ strlen(buf) + strlen(r.buf) + 1 ];

   strcpy( tmp.buf, buf );

   strcat( tmp.buf, r.buf );

   return tmp;

}

Многие компиляторы, получив вышеуказанное, генерируют довольно неэффективный код. Объект tmp

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

Иногда вы можете улучшить такое поведение путем перегрузки встроенного псевдонима для операции приведения типа:

class string

{

   string(const char *left, const char *right );

public:

   const string string::operator+( const string r ) const

;

};

//-----------------------------------------------------------

string::string(const char *left, const char *right )

{

   buf = new char[ strlen(left) + strlen(right) + 1 ];

   strcpy( buf, left );

   strcat( buf, right );

}

//-----------------------------------------------------------

inline const

string::operator+( const string r ) const

{

   return string(buf, r.buf);

}

Более эффективные компиляторы здесь на самом деле рассматривают следующее:

string s1, s2;

s1 + s2;

как если бы вы сказали следующее (вы не можете сделать этого сами, потому что buf

является закрытым):

string(s1.buf, s2.buf)

Полезный результат заключается в устранении неявного вызова конструктора копии в операторе return в первом варианте реализации.



Если можно, то делайте все преобразования типов с помощью конструкторов


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

class riches                        //  богачи

{

public:

    riches( const rags r );

};

class rags                         //  оборванцы

{

public:

    operator riches( void );

};

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

конструктор в классе riches, или перегруженную операцию в классе rags; конструктор и перегруженная операция утверждают, что выполнят эту работу:

rags horatio_alger;     //   Гораций Алгер

riches bill_gates = (riches) horatio_alger;  // Бил Гейтс

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

class some_class

{

public:

   operator int          (void);

   operator const char * (void);

};

то простой оператор, подобный:

some_class x;

cout x;

не сработает. Проблема в том, что класс stream

определяет те же два преобразования:

ostream ostream::operator( int   x       );

ostream ostream::operator( const char *s );

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

Лучше выполнять все преобразования типов при помощи конструкторов и определять минимально необходимый их набор. Например, если у вас есть преобразование из типа double, то вам не нужны int, long и так далее, потому что нормальные правила преобразования типов Си применяются компилятором при вызове вашего конструктора.

Часть 8ж. Управление памятью



Используйте new/delete вместо malloc()/free()


Нет гарантии, что оператор new()

вызывает malloc() при запросе памяти для себя. Он может реализовывать свою собственную функцию управления памятью. Следовательно, возникает трудно обнаруживаемая ошибка при передаче функцией free()

памяти, полученной при помощи new (и наоборот).

Избегайте неприятностей, используя всегда при работе с Си++ new и delete. Наряду с прочим, это означает, что вы не должны пользоваться strdup() или любой другой функцией, скрывающей вызов malloc().



Вся память, выделенная в конструкторе, должна быть освобождена в деструкторе


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

значит легко отделаться.

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



Локальные перегрузки операторов new и delete опасны


Здесь основной проблемой является то, что операторы new и delete, определенные в виде членов класса, следуют другим правилам, чем перегруженные на глобальном уровне. Локальная перегрузка используется лишь тогда, когда вы размещаете единственный объект. Глобальная перегрузка используется вами всегда при размещении массива. Следовательно, этот код,

скорее всего, не будет работать:

some_class *p = new some_class[1];  // вызывает глобальный

                                    // оператор new()

//...

delete p;          // вызывает some_class::operator delete()

Помните, что эти две строки могут быть в различных файлах.

Часть 8з. Шаблоны

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



Используйте встроенные шаблоны функций вместо параметризированных макросов


Приведенный ранее пример:

#define SQUARE(x) ((x) * (x))

где:

SQUARE(++x)

расширяется до:

((++x)*(++x))

инкрементируя x

дважды. Вы не можете решить эту проблему в Си, а в Си++ можете. Простая встроенная функция работает вполне удовлетворительно, в таком виде:

inline int square( int x ){ return x * x; }

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

template class type

inline type square( type x ){ return x * x; }

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

template class type

inline type max( type x, type y ){ return (x y) ? x : y; }

Для обработки max(10, 10L) вы должны использовать прототип, чтобы принудить к расширению по тому варианту max(), который может выполнить данную работу:

long max( long, long );

Прототип вызывает расширение шаблона. Компилятор с легкостью преобразует аргумент типа int в long, даже если ему не нужно делать это преобразование для расширения шаблона.

Заметьте, что я здесь рекомендую использование шаблонов только потому, что square

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



Всегда знайте размер шаблона после его расширения


Большинство книг демонстрирует шаблоны типа простого контейнера массива, подобного показаному на листинге 13. Вы не можете использовать здесь наследование (скажем, с базовым классом array, от которого наследуется int_array). Проблема заключается в перегрузке операции operator[](). Вы бы хотели, чтобы она была виртуальной функцией в базовом классе, замещенная затем в производном классе, но сигнатура версии производного класса должна отличаться от сигнатуры базового класса, чтобы все это заработало. Здесь определения функций должны отличаться лишь возвращаемыми типами: int_array::operator[]()

должна возвращать ссылку на тип int, а

long_array::operator[]()

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

Листинг 13. Простой контейнер массива

template class type, int

size

class array

{

    type array[size];

public:

    class out_of_bounds {}; // возбуждается исключение, если

                            // индекс за пределами массива

    type operator[](int index);

};

  template class type, int

size

  inline type arraytype, size::operator[](int index)

  {

     if( 0 = index index size )

        return array[ index ]

     throw out_of_bounds;

  }

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


arrayint,10  ten_element_array;
arrayint,11  eleven_element_array;
arrayint,12  twelve_element_array;
arrayint,13  thirteen_element_array;
(то
есть
arrayint,10::operator[](), arrayint,11::operator []() и так далее).
Вопрос состоит в том, как сократить до минимума дублирование кода. Что, если мы уберем размер за пределы шаблона, как на листинге 14? Предыдущие объявления теперь выглядят так:
arrayint  ten_element_array (10);
arrayint  eleven_element_array (11);
arrayint  twelve_element_array (12);
arrayint  thirteen_element_array (13);
Теперь у нас есть только одно определение класса (и один вариант operator[]()) с четырьмя объектами этого класса.
Листинг 14. Шаблон массива (второй проход)
template class type
class array
{
    type *array;
    int size;
public:
    virtual ~array( void );
    array( int size = 128 );
    class out_of_bounds {};  //
возбуждается исключение, если
                             // индекс за пределами массива
    type operator[](int index);
  };

  template class type
  arraytype::array( int sz /*= 128*/ ): size(sz)
                                  , array( new type[ sz ] )
  {}

  template class type
  arraytype::~array( void )
  {
     delete [] array;
  }

  template class type
  inline type arraytype::operator[](int index)
  {
     if( 0 = index index size )
        return array[ index ]
     throw out_of_bounds;
  }
Главным недостатком этой второй реализации является то, что вы не можете объявить двухмерный массив. Определение на листинге 13 разрешает следующее:
array arrayint, 10, 20 ar;
(20-элементный массив из 10-элементных массивов). Определение на листинге 14 устанавливает размер массива, используя конструктор, поэтому лучшее, что вы можете получить, это:


array arrayint ar2(20);
Внутренний arrayint
создан с использованием конструктора по умолчанию, поэтому это 128-элементный массив; мы объявили 20-элементный массив из 128-элементных массивов.
Вы можете решить эту последнюю проблему при помощи наследования. Рассмотрим следующее определение производного класса:
template class type, int
size
class sized_array : public arraytype
{
public:
   sized_array() : arraytype(size) {}
};
Здесь ничего нет, кроме единственной встроенной функции, поэтому это определение очень маленького класса. Оно совсем не будет увеличивать размер программы, вне зависимости от того, сколько раз будет расширен шаблон. Вы теперь можете записать:
sized_array sized_arrayint,10, 20 ar3;
для того, чтобы получить 20-элементный массив из 10-элементных массивов.

Шаблоны не заменяют наследование; они его автоматизируют


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

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

class collection

{

   storable *head;

public:

// ...

storable *find( const storable a_match_of_this ) const;

};

storable *collection::find( const storable a_match_of_this ) const

{

// Послать сообщение объекту начала списка, указывающее, что спи–

// сок просматривается на совпадение со значением a_match_of_this;

   return  head ? head-find( a_match_of_this )

                : NULL

                ;

}

Механизм поиска нужных объектов скрыт внутри класса storable. Вы можете изменить лежащую в основе структуру данных, поменяв определение storable, и эти изменения совсем не затронут реализацию класса collection.

Затем давайте реализуем класс storable, использующий простой связанный список в качестве лежащей в основе структуры данных:

class storable

{

   storable *next, *prev;

public:

   storable *find ( const storable match_of_this ) const;

   storable *successor ( void ) const;

   virtual int

operator== ( const storable r ) const;

};

storable *storable::find( const storable match_of_this ) const

{

// Возвращает указатель на первый элемент в списке (начиная с

// себя), имеющий тот же ключ, что и match_of_this. Обычно,

// объект-коллекция должен послать это сообщение объекту начала

// списка, указатель на который хранится в классе коллекции.

   storable *current = this;

   for( ; current; current = current-next )

      if( *current == match_of_this )   // найдено совпадение

         return current;

}

storable *storable::successor( void ) const


{
// Возвращает следующее значение в последовательности.
   return next;
}
Функция operator==()
должна быть чисто виртуальной, потому что отсутствует возможность ее реализации на уровне класса storable. Реализация должна быть выполнена в производном классе13
:
class storable_string : public storable
{
  string s;
public:
  virtual int
operator==( const storable r ) const;
// ...
};
virtual int
operator==( const storable r ) const
{
  storable_string *right = dynamic_caststorable_string *( r );
  return right ? (s == r.s) : NULL;
}
Я здесь использовал предложенный в ISO/ANSI Cи++ безопасный механизм нисходящего приведения типов. right
инициализируется значением NULL,
если передаваемый объект (r) не относится к типу storable_string. Например, он может принадлежать к некоторому другому классу, также являющемуся наследником storable.
Пока все идет хорошо. Теперь к проблемам, связанным с шаблонами. Кто-нибудь, не понимающий того, что делает, говорит: "Ребята, я могу исключить наследование и потребность в виртуальных функциях, используя шаблоны", а делает, вероятно, нечто подобное:
template class t_key
class storable
{
   storable *next, *prev;
   t_key key;
public:
// ...
   storable *find      ( const
storable match_me ) const;
   storable *successor ( void                     ) const;
   int       operator==( const
storable r        ) const;
};
template class t_key
int
storablet_key::operator==( const storablet_key r ) const
{
   return key == r.key ;
}
template class t_key
storablet_key *storablet_key::successor( void
) const
{
   return next;
}
template class t_key
storable *storablet_key::find( const storablet_key
                                       match_me ) const
{
   storablet_key *current = this;


   for( ; current; current = current-next )
      if( *current == match_me )       // найдено совпадение
         return current;
}
Проблема здесь в непроизводительных затратах. Функции- члены шаблона класса сами являются шаблонами функций. Когда компилятор расширяет шаблон storable, он также расширяет варианты всех
функций-членов этого шаблона§.
Хотя я их не показал, вероятно, в классе storable
определено множество функций. Многие из этих функций будут похожи в том, что они не используют информацию о типе, передаваемую в шаблон. Это означает, что каждое расширение такой функции будет идентично по содержанию любому другому ее расширению. Из функций, которые не похожи на функцию successor(), большинство будут подобны find(), использующей информацию о типе, но которую легко изменить так, чтобы ее не использовать.
Вы можете решить эту проблему, используя механизм шаблонов для создания производного класса. Основываясь на предыдущей реализации, не использующей шаблоны, вы можете сделать следующее:
template class t_key
class storable_tem : public storable
{
   t_key key;
public:
   // Замещение базового класса
   virtual int
operator==( const storable r ) const;
   // ...
};
template class t_key
/* виртуальный */ int storable_temt_key::operator==( const storable r ) const
{
   t_key *right = dynamic_castt_key *( r );
   return right ? (s == r.s) : NULL;
}
Выбрав другой путь, я сосредоточил в базовом классе все функции, которые не зависят от типа key. Затем я использовал механизм шаблонов для создания определения производного класса, реализующего только те функции, которым нужно знать тип key.
Полезным результатом является существенное сокращение размера кода. Механизм шаблонов может рассматриваться как средство автоматизации производства шаблонных производных классов.
Часть 8и. Исключения

Назначение исключений — не быть пойманными


Как правило, исключение должно быть возбуждено, если:

· Нет другого способа сообщить об ошибке (например, конструкторов, перегруженных операций и т.д.).

· Ошибка неисправимая (например, нехватка памяти).

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

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

some_obj x;

if( x.is_invalid() )

// конструктор не выполнился.

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

x = a + b;

функция operator+()

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

if( x == INVALID )

// ...

или нечто подобное. Снова весьма неаккуратно. Исключения также полезны для обработки ошибок, которые обычно являются фатальными. Например, большинство программ просто вызовут exit(), если функция malloc() не выполнится. Все проверки типа:

if( !(p = malloc(size)) )

   fatal_error( E_NO_MEMORY );

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

Также имеется и другая проблема. Одной из причин того, что комитет ISO/ANSI по Си++ требует, чтобы оператор new

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


значение NULL. По причинам, обсуждаемым позже, я не думаю, что исключение должно быть использовано вместо возврата ошибки просто для защиты программистов от себя самих, но оно срабатывает с new, потому что эта ошибка обычно в любом случае неисправима. Лучшим примером может быть функция printf(). Большинство программистов на Си даже не знают, что printf()
возвращает код ошибки. (Она возвращает количество выведенных символов, которое может быть равно 0, если на диске нет места). Программисты, которые не знают о возврате ошибки, склонны ее игнорировать. А это не очень хорошо для программы, которая осуществляет запись в перенаправленный стандартный вывод, продолжать, как будто все в порядке, поэтому можно считать хорошей идеей возбудить здесь исключение.
Итак, что же плохого в исключениях? На самом деле существует две проблемы. Первой является читаемость. Вам будет тяжело меня убедить, что:
some_class obj;
try
{
   obj.f();
}
catch( some_class::error r )
{
   // выполнить действие в случае ошибки
}
лучше читается, чем:
if( obj.f() == ERROR )
// выполнить действие в случае ошибки
В любом случае, если try-блок содержит более одного вызова функций, вы не сможете просто исправить ошибку, потому что вы не сможете узнать, где возникла ошибка.
Следующий пример демонстрирует вторую проблему. Класс CFile, реализующий основной ввод/вывод двоичных файлов, возбуждает исключение в случае переполнения диска при записи, чего легко добиться на дискете. Более того, функция write() не возвращает никакого кода ошибки. Перехват исключения является единственным способом обнаружения ошибки. Вот пример того, как вы должны обнаруживать ошибку чтения:
char  data[128];
Cfile f( "some_file", CFile::modeWrite );
try
{
   f.Write( data, sizeof(data) );
}
catch( CFileException r )
{
   if( r.m_cause == CfileException::diskFull )
   // что-то сделать
}
Имеется две проблемы. Первая явно связана с уродливостью этого кода. Я бы гораздо охотнее написал:


bytes_written = f.Write( data, sizeof(data));
if( bytes_written != sizeof(data) )
   // разобраться с этим
Вторая проблема одновременно более тонкая и более серьезная. Вы не сможете исправить эту ошибку. Во-первых, вы не знаете, сколько байтов было записано перед тем, как диск переполнился. Если Write()
возвратила это число, то вы можете предложить пользователю сменить диск, удалить несколько ненужных файлов или сделать еще что-нибудь для освобождения места на диске. Вы не можете тут сделать это, потому что не знаете, какая часть буфера уже записана, поэтому вы не знаете, откуда начинать запись на новый диск.
Даже когда Write()
возвратила количество записанных байтов, то вы все еще не можете исправить ошибку. Например, даже если функцию CFile
переписать, как показано ниже, то она все равно не будет работать:
char  data[128];
CFile f( "some_file", CFile::modeWrite );
int bytes_written;
try
{
   bytes_written = f.Write( data, sizeof(data) );
}
catch( CFileException r )
{
   if( r.m_cause == CFileException::diskFull )
   // что-то выполнить.
   // при этом переменная bytes_written содержит мусор.
}
Управление передается прямо откуда-то изнутри Write() в обработчик catch при возбуждении исключения, перескакивая через все операторы return внутри Write(), а также через оператор присваивания в вызывающейся функции; переменная bytes_written
остается неинициализированной. Я думаю, что вы могли бы передать Write()
указатель на переменную, которую она могла использовать для хранения числа записанных байтов перед тем, как выбросить исключение, но это не будет значительным улучшением. Лучшим решением будет отказ от возбуждения исключения и возврат или числа записанных байтов, или какого-то эквивалента индикатора ошибки.
Последней проблемой являются непроизводительные затраты. Обработка исключения вызывает очень большие непроизводительные затраты, выражающиеся в возрастании в несколько раз размера кода и времени выполнения. Это происходит даже в операционных системах типа Microsoft Windows NT, которые поддерживают обработку исключений на уровне операционной системы. Вы можете рассчитывать на 10-20% увеличение размера кода и падение скорости выполнения на несколько процентов при интенсивном использовании исключений.14
Следовательно, исключения должны использоваться лишь тогда, когда непроизводительные затраты не берутся в расчет;
обычно, при наличии возможности, предпочесть возврат ошибки.

По возможности возбуждайте объекты типа error


Листинг 15 показывает простую систему определений класса для возбуждения исключений. Я могу перехватить ошибки чтения или записи подобным образом:

try

{

    file f("name", "rw");

    buffer b;

    b = f.read();

    f.write( b );

}

catch( file::open_error r )

{

    // Файл не существует или не может быть открыт.

}

catch( file::io_error r )

{

    // Какая-то из неисправимых ошибок ввода/вывода.

}

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

file f;

try

{

    buffer b;

    b = f.read()

    f.write( b );

}

catch( file::error r )

{

    // ...

}

Листинг 15. Классы исключений

class file

{

public:

     class error {};

     class open_error : public error {};

     class io_error : public error {};

/p>

     // ...

}

Этот код работает, потому что объект file::read_error

является объектом типа file::error

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

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

class long_double

{

public:

   class error {};

   class didvide_by_zero : public error {};

   // ...

};

Так как классы error являются вложенными определениями, то именами на самом деле являются file::error и long_double::error, поэтому здесь нет конфликта имен.

Для упрощения сопровождения я всегда использую error в качестве своего базового класса для исключений. (Я не мог использовать производный класс, даже если здесь был бы возможен всего один вид ошибки). Таким образом, я знаю, что, имея возбуждающий исключение класс some_class, можно перехватить это исключение при помощи:

catch(some_class::error r)

Эту ошибку искать не придется. Если применяется наследование, то я использую базовый класс error

таким образом:

class employee

{

public:

   class error                                {};

   class database_access_error : public error {};

};

class peon : public employee

{

   class error : public employee::error {};

   class aagh  : public error           {};

};

Этим способом исключение aagh

может быть перехвачено как peon::aagh, peon::error

или employee::error.

Нет смысла создавать класс глобального уровня error, от которого наследуются все локальные классы error, потому что для обработки этой ситуации вы можете использовать обработчик catch(…).



Возбуждение исключений из конструктора ненадежно


Я начну этот раздел с замечания о том, что компиляторы, которые соответствуют рабочим документам комитета ISO/ANSI по Си++, не имеют большей части из рассматриваемых здесь проблем. Тем не менее, многие компиляторы (один из которых компилятор Microsoft) им не соответствуют.

Ошибки в конструкторах являются действительной проблемой Си++. Так как они не вызываются явно, то и не могут возвратить коды ошибок обычным путем. Задание для конструируемого объекта "неверного" значения в лучшем случае громоздко и иногда невозможно. Возбуждение исключения может быть здесь решением, но при этом нужно учесть множество вопросов. Рассмотрим следующий код:

class c

{

    class error {};

    int *pi;

public:

    c() { throw error(); }

    // ...

};

void f( void

)

{

    try

    {

        c *cp = new c; // cp не инициализируется, если не

                       // выполняется

конструктор

        // ...

        delete

cp;  // эта строка в любом случае не выполнится.

    }

    catch( c::error err )

    {

        printf ("Сбой конструктора\n");

        delete cp;     // Дефект:

cp теперь содержит мусор

    }

}

Проблема состоит в том, что память, выделенная оператором new, никогда не освобождается. То есть, компилятор сначала выделяет память, затем вызывает конструктор, который возбуждает объект error. Затем управление передается прямо из конструктора в catch-блок. Код, которым возвращаемое значение оператора new

присваивается cp, никогда не выполняется — управление просто перескакивает через него. Следовательно, отсутствует возможность освобождения памяти, потому что у вас нет соответствующего указателя. Чтение мной рабочих документов комитета ISO/ANSI по Си++ показало, что такое поведение некорректно — память должна освобождаться неявно. Тем не менее, многие компиляторы делают это неправильно.

Вот простой способ исправить эту сложную ситуацию (я поместил тело функции в определение класса лишь для того, чтобы сделать пример покороче):


class с
{
    int *pi;
public:
    c() { /*...*/ throw this; }
};
void f( void
)
{
    try
    {
        c  *cp = NULL;
        cp = new c;
        c a_c_object();
    }
    catch( c *points_at_unconstructed_object )
    {
        if( !cp ) // если конструктор, вызванный посредством
                  // new, не выполняется
            delete
points_at_unconstructed_object;
    }
}
Ситуация усложняется, когда некоторые объекты размещаются при помощи new, а другие — из динамической памяти. Вы должны сделать что-то похожее на следующее, чтобы понять, в чем дело:
void f( void
)
{
   c *cp = NULL;  // cp должен быть объявлен снаружи try-блока,
                  // потому что
try-блок образует область
                  // действия, поэтому cp не может быть
                  // доступным в catch-блоке будучи объявлен в
                  // try-блоке.
   try
   {
      c a_c_object;
      cp = new c;
   }
   catch( c *points_at_unconstructed_object )
   {
      if( !cp )   // если конструктор, вызванный посредством
                  // new, не выполняется
         delete
points_at_unconstructed_object;
   }
}
Вы не можете решить эту проблему внутри конструктора, потому что для конструктора нет возможности узнать, получена ли инициализируемая им память от new, или из стека.
Во всех предыдущих примерах деструктор для сбойных объектов вызывается, даже если конструктор не выполнился и возбудил исключение. (Он вызывается или косвенно посредством оператора delete, или неявно при выходе объекта из области действия, даже если он покидает ее из-за возбуждения исключения).
Аналогично, вызов delete
косвенно вызывает деструктор для этого объекта. Я сейчас вернусь к этой ситуации. Перед выходом из этого деструктора незавершенный конструктор должен привести объект в исходное состояние перед тем, как сможет возбудить ошибку. С учетом предшествующего определения класса c
следующий код будет работать при условии, что отсутствует ошибка до оператора new int[128] и new выполнен успешно:


c::c( )
{
   if( some_error() )
      throw error(this); // ДЕФЕКТ: pi не
инициализирован.
   // ...
   pi = new int[128];    // ДЕФЕКТ: pi не
инициализирован,
                         // если оператор new возбуждает
                         // исключение.
   // ...
   if( some_other_error() )
   {
      delete [] pi;      // Не забудьте сделать это.
      throw error(this); // Это возбуждение безопасно
   }
}
c::~c( )
{
   delete pi;
}
Запомните, что pi
содержит мусор до своей инициализации оператором new. Если возбуждается исключение до вызова new или сам оператор new
возбудит исключение, то тогда pi
никогда не инициализируется. (Вероятно, оно не будет содержать NULL, а будет просто не инициализированно). Когда вызывается деструктор, то оператору delete
передается это неопределенное значение. Решим проблему, инициализировав этот указатель безопасным значением до того, как что-либо испортится:
c::c( ) : pi(NULL) // инициализируется на случай, если оператор
                   // new даст сбой
{
   if( some_error() )
      throw error(this); // Это возбуждение теперь безопасно.
      // ...
   pi = new int[128];    // Сбой оператора new теперь безопасен.
   // ...
   if( some_other_error() )
   {
      delete [] pi;     // Не забудьте высвободить динамическую
                        // память.
      throw error(this); // Это возбуждение безопасно.
   }
}
c::~c( )
{
   if( pi )
      delete pi;
}
Следует помнить, что нужно освобождать успешно выделенную память, если исключение возбуждено после операции выделения, так, как было сделано ранее.
У вас есть возможность почистить предложенный выше код при его использовании с учетом моего совета из предыдущего правила о возбуждении исключения объекта error и скрытия всех сложностей в этом объекте. Однако определение этого класса получается значительно более сложным. Реализация в листинге 16 опирается на тот факт, что деструктор явно объявленного объекта должен вызываться при выходе из try-блока, перед выполнением catch-блока. Деструктор для объекта, полученного при помощи new, не будет вызван до тех пор, пока память не будет передана оператору delete, что происходит в сообщении destroy(), посланном из оператора catch. Следовательно, переменная has_been_destroyed


будет содержать истину, если объект получен не при помощи new, и исключение возбуждено из конструктора, и ложь —
если объект получен посредством new, потому что деструктор еще не вызван.
Конечно, вы можете вполне резонно заметить, что у меня нет причин проверять содержимое объекта, который по теории должен быть уничтожен. Здесь уже другая проблема. Некоторые компиляторы (в том числе компилятор Microsoft Visual C++ 2.2) вызывают деструктор после выполнения оператора catch, даже если объекты, определенные в try-блоке, недоступны из catch-блока. Следовательно, код из листинга 16 не будет работать после этих компиляторов. Вероятно, лучшие решение состояло бы в написании варианта operator new(), который мог бы надежно указывать, получена память из кучи или из стека.

Листинг 16. except.cpp — возбуждение исключения из конструктора
class с
{
public:
    class error
    {
       c *p;   // NULL при успешном выполнении конструктора
    public:
       error( c *p_this );
       void destroy( void );
    };

private:

    unsigned has_been_destroyed : 1;
    int *pi;

private: friend
class error;
    int been_destroyed( void );

public:
    c() ;
    ~c();

};
//========================================================
c::error::error( c *p_this ) : p( p_this ) {}
//--------------------------------------------------------
void
c::error::destroy( void )
{
    if( p !p-been_destroyed() )
       delete
p;
}
//========================================================
c::c() : has_been_destroyed( 0 )
{
    // ...
    throw
error(this);
       // ...
}
//--------------------------------------------------------
c::~c()
{
    // ...
    has_beeb_destroyed = 1;
}
//--------------------------------------------------------
int
c::been_destroyed( void )
{
    return has_been_destroyed;


}
//========================================================
void
main( void )
{
    try
    {
       c *cp = new
c;
       c a_c_object;

       delete cp;
    }
    catch( c::error err )
    {
       err.destroy(); // деструктор вызывается, только если
    }                 // объект создан оператором new
}
Заключение
Вот так-то. Множество правил, которые я считаю полезными и которые, надеюсь, будут полезны и для вас. Конечно, многие из представленных здесь правил дискуссионны. Пожалуйста, я готов с вами о них поспорить. Несомненно, я не считаю себя каким-то законодателем в стиле Си++ и сам нарушаю многие из этих правил при случае; но я искренне верю, что следование этим правилам сделает меня лучшим программистом, и надеюсь, что вы их тоже оцените.
Я закончу вопросом. Сколько времени потребуется программисту на Си++ для того, чтобы заменить электрическую лампочку? Ответ — нисколько, а вы, кажется, все еще мыслите процедурно.
Правильно спроектированный класс электрическая_лампа
должен наследовать метод замены от базового класса лампа. Просто создайте объект производного класса и пошлите ему сообщение заменить_себя().
Об авторе
Ален Голуб —
программист, консультант и преподаватель, специализирующийся на Си++, объектно-ориентированном проектировании и операционных системах Microsoft. Он проводит семинары по приглашению частных фирм повсюду на территории США и преподает в филиалах Калифорнийского университета, расположенных в Беркли и Санта-Круз. Он также работает программистом и консультантом по объектно-ориентированному проектированию, используя Си и Си++ в операционных средах Microsoft Windows, Windows-95, Windows NT и UNIX.
М-р Голуб регулярно пишет для различных компьютерных журналов, включая "Microsoft Systems Journal", "Windows Tech Journal" и изредка "BYTE". Его популярная колонка "Сундучок с Си", публиковавшаяся в "Dr.Dobb's Journal" с 1983 по 1987 годы, стала для многих людей первым введением в Си. В число его книг входят "Compiler Design in C", "C+C++" и "The C Companion". М-р Голуб сочиняет музыку и имеет лицензию частного пилота.


Вы можете связаться с ним через Интернет по адресу allen@holub.com или через его фирму Software Engineering Consultants, P.O.Box 5679, Berkeley, CA 94705 (телефон и факс: (510) 540-7954).
§
Буч Г. Объектно–ориентированный анализ и проектирование с примерами приложений на С++, 2–е изд./Пер. с англ.—М.; СПб.: "Издательство БИНОМ" — "Невский диалект", 1998.—560 с.—Прим. перев.
§
Уже не редкость емкость дисковой памяти, превышающая спустя 5 лет указанные автором значения на два порядка, а оперативной — на порядок. — Прим.перев.
[1]
Web описана в книге Дональда Кнута ""The WEB System of Structured Documentation" (Palo Alto: Stanford University Dept. of Computer Science, Report No.STAN-CS-83-980, 1983). Система CWeb описана в книге Дональда Е. Кнута и Сильвио Ливая "The CWeb System of Structured Documentation" (Reading: Addison Wesley, 1994). Обе публикации не только описывают как работают эти системы, но хорошо демонстрируют это. В этих книгах документируются реальные тексты программ, реализующих указанные системы.
TEX является редакционно-издательской системой Кнута. Она имеется в нескольких коммерческих версиях.
§
"До каких же пор ты, Катилина, будешь испытывать наше терпение..." — начало известной речи Цицерона. — Прим. перев.
[2] Я подозреваю, что венгерская запись так интенсивно используется вследствие того, что большая часть Microsoft Windows написана на языке ассемблера.
[3] По крайней мере, оно должно быть. Я подозреваю, что некоторые энтузиасты венгерской записи так плохо организуют свои программы, что просто не могут найти нужные объявления. Включая тип в имя, они избавляются от многих часов поисков в неудачно спроектированных листингах. Программы на языке ассемблера, которые по необходимости включают в себя множество глобальных переменных, являются очевидным исключением.
§
В августе 1998 г. стандарт ратифицирован в виде "ISO/IEC 14882, Standard for the C++ Programming Language". Популярно


изложен в книге: Страуструп Б. Язык программирования С++, 3–е изд. /Пер. с англ.—СПб.; М.: "Невский диалект" — "Издательство БИНОМ", 1999.–991 с. — Прим. перев.
§
В стандарте ISO/IEC 14882 существует тип ‘bool’. Имеет смысл заменить тип переменной is_left_child
на bool. — Ред.
§
Решение о переводе некоторых из идентификаторов, по меньшей мере, спорное. Однако, если вы не знаете английского, то будете лишены возможности оценить юмор автора, которым он оживил большую часть своих примеров. — Ред.
§
Переменная, объявленная в операторе for, не выживает после этого оператора. — Ред.
§
Кроме того, стандарт пересмотрел подход к жизни переменных, объявленных в операторе for. — Ред.
§
С этим утверждением автора, так и следующим за ним примером инкрементирования аргумента макроса нельзя согласиться. — Ред.
§
Комментарий в языке Си должен быть заключен в /*  */. — Ред.
Чтобы быть строго корректным, по крайней мере на языке выражений Си++, я должен называть поле "компонентом данных-членов". Однако довольно неудобно говорить "компонент данных-членов name",
поэтому буду использовать просто "поле", когда его значение ясно из контекста.
Они не передаются. Даже в Smalltalk есть только один объект, который или получает сообщение, или нет. Несмотря на это, интерпретаторы Smalltalk склоняются к реализации обработки сообщений при помощи нескольких таблиц указателей на функции, по одной на каждый класс. Если интерпретатор не может найти обработчик сообщения в таблице диспетчеризации производного класса, то он просматривает таблицу базового класса. Этот механизм не используется в Си++, который является компилируемым языком и поэтому не использует многоуровневый просмотр таблиц в время выполнения. Например, даже если бы все функции в базовом классе были виртуальными, то таблица виртуальных функций производного класса имела бы по ячейке для каждой виртуальной функции базового класса. Среда времени выполнения Си++ не просматривает иерархию таблиц, а просто использует таблицу для текущего объекта. Подробнее об этом позднее.


Эта цитата является отрывком из статьи, размещенной Страуструпом в телеконференции BIX в декабре 1992 г. Полностью статья опубликована в книге Мартина Хеллера "Advanced Win32 Programming"(New York: Wiley,1993), pp.72-78.
Не путайте этот процесс с объединением. У mother нет поля parent, скорее та часть mother, которая определена на уровне базового класса, изображается как "компонент parent".
На самом деле правильнее сказать, что во время компиляции компилятор не знает, от какого из базовых классов parent
объект child
наследует обработчик сообщения go_to_sleep(), хотя эта правильность и может сбить с толку. Вы можете спросить, почему неопределенность имеет значение, ведь эта функция одна и та же в обоих классах. Компилятор не может создать ветвление времени выполнения, так как не знает, какое значение присвоить указателю this, когда он вызывает функцию-член базового класса.
Пользователи MFC могут обратиться за более глубоким обсуждением этого вопроса к моей статье "Rewriting the MFC Scribble Program Using an Object-Oriented Design Approach" в августовском номере журнала "Microsoft Systems Journal" за 1995 г.
§
Утверждение автора не соответствует стандарту языка. — Ред.
§ В соответствии со стандартом должно быть int main ( void ). — Ред.

Книга Эллис и Страуструпа "The Annotated C++ Reference Manual" (Reading: Addison Wesley, 1990), использованная в качестве базового документа комитетом ISO/ANSI по Си++§.
§Имеется перевод на русский язык под редакцией А.Гутмана "Справочное руководство по языку программирования Си++ с комментариями"
(М.: Мир, 1992). —Прим.перев.

Конечно, конструкторы копий и функции operator=(), создаваемые вами (в отличие от компилятора), никогда не вызывают своих двойников из базового класса автоматически.
§
Стандартом языка для этого предусмотрено ключевое слово explicit. — Ред.

Некоторые компиляторы в действительности позволяют выполнить явный вызов конструктора, поэтому вы, вероятно, сможете сделать точно так же:
const some_class operator=( const some_class r )
{
   if( this != r )
   {
      this-~some_class();
      this-some_class::some_class( r );
   }
}
Тем не менее, такое поведение является нестандартным.
§
Функции из шаблонов генерируется, только если они используются в программе (по крайней мере, так должен поступать хороший компилятор). — Ред.
  В действительности я бы использовал множественное наследование с участием класса string. Использованный здесь код имеет цель немного упростить пример.
§
См. предыдущее примечание к правилу 156. — Ред.
  Я определил это для 32-разрядного компилятора Visual C++ Microsoft; другие компиляторы показывают или сравнимые результаты, или худшие.

Форматирование и документация


/b>

Форматирование и документация

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

Я объединил правила форматирования и документации в одной части, потому что форматирование является одним из лучших инструментов документирования, имеющихся в вашем распоряжении. Под "документацией", рассматривающейся в этой части книги, понимается документирование программ (т.е. комментарии, а не документация уровня пользователя). Его зря не относят к программированию, ведь хороший комментарий для вас — в буквальном смысле нить Ариадны.

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




Имена и идентификаторы


/b>

Имена и идентификаторы

Имена играют важную роль. Правильно выбранные имена могут сделать программу поистине самодокументированной, не требуя совсем или требуя мало дополнительного набора в виде явных комментариев. Плохо выбранные имена (например, state —

состояние или штат ?) могут добавить ненужную сложность в вашу программу. Эта часть книги содержит правила выбора имен.




Общие проблемы разработки программ


/b>

Общие проблемы разработки программ

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




Правила обычного программирования


/b>

Правила обычного программирования

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




Правила, относящиеся к языку Си


/b>

Правила, относящиеся к языку Си

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




Правила программирования на Си++


/b>

Правила программирования на Си++

Эта часть книги содержит правила, уникальные для программирования на Си++. Как мной было сказано во "Введении", эта книга не является учебником по Си++, так что следующие правила предполагают, что вы по крайней мере знакомы с синтаксисом этого языка. Я не буду тратить слова попусту, описывая, как работает Си++. Имеется множество хороших книг, которые познакомят вас с Си++, включая и мою собственную "С+С++". Вы должны также ознакомиться с принципами объектно-ориентированного проектирования. Я рекомендую 2-е издание книги Гради Буча "Object-Oriented Analysis and Design with Applications" (Redwood City: Benjamin Cummings, 1994).

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


Часть 8а.

Вопросы проектирования и реализации



Препроцессор


/b>

Препроцессор

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

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




Процесс проектирования


/b>

Процесс проектирования

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

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




Работа над этой книгой затянулась,



Благодарности
Работа над этой книгой затянулась, и я весьма обязан трем редакторам издательства McGraw-Hill, которые по очереди мирились с постоянными задержками с моей стороны: Нэйлу Ливайну, Дэну Гонно и Дженифер Холт-Диджованна. Я особенно признателен Бобу Дюшарму, который защитил меня от самого себя, сделав очень тщательный просмотр первоначального наброска. Его советы значительно улучшили книгу в ее нынешнем виде.
Введение
Название этой книги отражает то, что я считаю основной трудностью при работе как с Си++, так и с Си: эти языки дают вам столько гибкости, что если у вас нет желания и способности призвать себя к порядку, то в итоге вы можете получить гигантский модуль не поддающейся сопровождению тарабарщины, притворяющейся к тому же компьютерной программой. Вы можете поистине делать все при помощи этих языков, даже если вы этого не хотите. В этой книге делается попытка дать средство для преодоления этой трудности в виде собрания практических правил программирования на Си++ и Си —
правил, которые, надеюсь, уберегут вас от неприятностей, если вы будете их использовать с самого начала. Хотя большинство из приводимых здесь правил применимы равно при программировании как на Си, так и на Си++, я включил много материала, относящегося лишь к миру Си++ и сконцентрированного по мере возможности в заключительном разделе. Если вы программируете лишь на Си, то просто игнорируйте материал по Си++, встречающийся вам в более ранних разделах.
Я профессионально занимаюсь программированием примерно с 1979 года и ежедневно пользуюсь правилами из этой книги. Я не утверждаю, что эти правила безусловны, или даже "верны". Однако я могу сказать, что они отлично мне служили все это время. Хотя эта книга не относится к категории путеводителей по "ловушкам и рытвинам", многие из этих правил предохранят вас от неприятностей того сорта, который обсуждается в путеводителях по "ловушкам и рытвинах".
Практические правила по своей сути гибки. Они постепенно меняются с ростом опыта, и ни одно правило не действует постоянно. Тем не менее я предупреждаю вас с самого начала, что мое мнение относительно этого материла самое наилучшее и что я не очень симпатизирую неряшливым мыслям или небрежному программированию. Я не извиняюсь за усиленное подчеркивание тех вещей, в которые я сильно верю. Мои мнения всегда могут измениться, если, конечно, вы сможете убедить меня в том, что я не прав, но имейте в виду, что эта книга основана на опыте, а не на теории. Я сознаю, что большая часть этой книги подходит опасно близко к чьему-то культу и многие вещи, произносимые мной, дискуссионны, но думаю, что всегда имеется возможность разумного разговора двух людей, объединенных целью совершенствования своего мастерства.


Я часто читаю курсы по Си++ и объектно-ориентированному проектированию как по приглашению частных фирм, так и в Калифорнийском университете в Беркли. Эта книга появилась в ответ на просьбы моих студентов, большинство из которых увлеченные профессионалы с настоящим желанием изучить этот материал. Я вижу множество программ в процессе проверки домашних заданий, и эти программы достаточно репрезентативны в качестве произведений сообщества профессиональных программистов из района залива Сан-Франциско. К несчастью, каждый семестр я также вижу, что одни и те же проблемы повторяются снова и снова. Поэтому эта книга является некоторым образом и списком распространенных проблем, найденных мной в созданных настоящими программистами реальных программах, сопровождаемым моими советами по их решению.
Обсуждаемые здесь проблемы программирования и проектирования не ограничиваются, к несчастью, лишь ученическими программами. Многие из примеров того, что не следует делать, взяты из коммерческого продукта: библиотеки классов Microsoft Foundation Classes (MFC) корпорации Microsoft. Я могу сказать, что эта библиотека была спроектирована без заботы о удобстве сопровождения людьми, не подозревающими о существовании даже элементарных принципов объектно-ориентированного проектирования. Я не выделял явно большинство примеров этого в тексте, так как это не книга с названием "Что неправильно в MFC"; пользователи библиотеки MFC узнают ее код, когда натолкнутся на него. Я выбрал примеры из MFC просто потому, что мне пришлось много с ней работать и очень близко познакомиться с ее недостатками. Во многих других коммерческих библиотеках классов имеются сходные проблемы.
Наконец, эта книга не является введением в Си++. Обсуждение, сопровождающее относящиеся к Си++ правила, предполагает, что вы знаете этот язык. Я не расходую место на описание того, как работает Си++. Имеется множество хороших книг, которые учат вас языку Си++, включая мою собственную "C+C++"
(New York: McGraw-Hill,1993). Вы должны также ознакомиться с принципами объектно-ориентированного проектирования. Я рекомендую второе издание книги Гради Буча "Object-Oriented Analysis and Design with Applications" (Redwood City: Benjamin Cummings,1994)§.
О нумерации правил: иногда я группировал некоторые правила вместе, потому что удобно описывать их все одновременно. В этом случае все эти правила (имеющие различные номера) располагаются в начале раздела. Я использовал запись номера правила вида "1.2" в случаях, когда оно является особым случаем другого правила.