Правила программирования на С и С++. Главы 7-8

ОГЛАВЛЕНИЕ

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

Ален И. Голуб


 

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

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


 

85. Подавляйте демонов запутанности (Часть 2).

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

85.1. Устраняйте беспорядок.

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

Таблица 2. Как ничего не делать в С.

Плохо Хорошо Комментарии
type *end = array; 

end += len-1;

type *end 

= array+(len-1)

Инициализируйте при объявлении. 
while (*p++ != '\0') while ( *p++)  
while (gets(buf) != NULL) while ( gets() )  
if ( p != NULL ) if ( p ) !=0 ничего не делает в выражении
if ( p == NULL ) if ( !p ) отношения
if (условие != 0) if ( условие )  
if (условие == 0) if ( !условие )  
if( условие ) 

return TRUE; 

else 

return FALSE;

return условие; (или return условие != 0). Если оно не было верным, то вы не сможете выполнить return TRUE
return условие?0:1; 

return условие?1:0;

return !условие; return условие!=0; Используйте соответствующий оператор. Операторы отношения типа ! и != выполняют по определению сравнение с 1 или 0. 
++x; 

f(x); 

--x;

f( x-1 ); Не модифицируйте значение, если вам после этого не нужно его использовать более одного раза. 
return ++x; return x+1; Смотрите предыдущее правило. 
int x; 

f( (int)x );

f(x); Переменная x и так имеет тип int
(void)printf("все в порядке"); printf("все в порядке"); Попросту опускайте возвращаемый тип, если он вам не нужен. 
if (x > y) 

else if (x ? y) 

else if (x ==y)

if ( x > y ) 

else if ( x ? y ) 

else

Если первое значение не больше и не меньше второго, то они должны быть равны. 
*(p+i) p[i]; Это по сути единственное исключение из приводимого ниже в данной главе правила об использовании указателей. При реализации действительно случайного доступа к элементам массива запись со скобками легче читается, чем вариант с указателем, в равной степени неэффективный при случайном доступе. 
Раз мы уже заговорили о ничегонеделаньи, то имейте в виду, что С с удовольствием допускает выражения, которые ничего не делают. Например, следующий оператор, показываемый полностью, совершенно законен и даже не вызовет предупреждающего сообщения компилятора:

a + b;Конечно, если вы хотели записать: a += b;

то вы, должно быть, попали в беду.


 

85.2. Избегайте битовых масок; используйте битовые поля.

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

struct fred

{

int status;

// ...

};

#define CONDITION_A 0x01

#define CONDITION_B 0x02

#define CONDITION_C 0x03

#define SET_CONDITION_A(p) ((p)->status |= CONDITION_A)

#define SET_CONDITION_B(p) ((p)->status |= CONDITION_B)

#define SET_CONDITION_C(p) ((p)->status |= CONDITION_C)

#define CLEAR_CONDITION_A(p) ((p)->status ?= ~CONDITION_A)

#define CLEAR_CONDITION_B(p) ((p)->status ?= ~CONDITION_B)

#define CLEAR_CONDITION_C(p) ((p)->status ?= ~CONDITION_C)

#define IN_CONDITION_A(p) ((p)->status ? CONDITION_A)

#define IN_CONDITION_B(p) ((p)->status ? CONDITION_B)

#define IN_CONDITION_C(p) ((p)->status ? CONDITION_C)

#define POSSIBILITIES(x) ((x) ? 0x0030)

#define POSSIBILITY_A 0x0000

#define POSSIBILITY_B 0x0010

#define POSSIBILITY_C 0x0020

#define POSSIBILITY_D 0x0030

Это означает необходимость в дополнение к полю из структуры данных сопровождать 17 макросов, которые к тому же будут, вероятно, спрятаны где-то в заголовочном файле, а не в том, где они используются. Ситуация еще более ухудшится, если вы не включите эти макросы и организуете проверки прямо в программе. Что-нибудь типа: if ( struct.status ?= ~CONDITION_A )

// ...

по меньшей мере с трудом читается. Еще хуже нечто, подобное следующему: struct.status = POSSIBILITY_A;

if ( POSSIBILITIES(struct.status) == POSSIBILITY_A )

// ...

 

Лучшее решение использует битовые поля; они не требуют дополнительного места и заведомо эффективно реализуются на большинстве машин. (Некоторые люди утверждают, что второй пример лучше, чем битовое поле, потому что здесь нет неявного сдвига, но многие машины поддерживают команду проверки бита, которая устраняет какую-либо потребность в сдвиге, который в случае своего использования вызывает очень незначительные накладные расходы. Необходимость в устранении ненужной путаницы обычно перевешивает подобные соображения о снижении эффективности). enum { possibility_a, possibility_b, possibility_b, possibility_d };

struct fred

{

unsigned in_condition_a : 1;

unsigned in_condition_b : 1;

unsigned in_condition_c : 1;

unsigned possibilities : 2;

};Вам теперь вообще не нужны макросы, потому что код, подобный следующему, превосходно читается без них: struct fred flintstone;

flintstone.in_condition_a = 1;

if ( flintstone.in_condition_a )

// ...flintstone.possibilities = possibility_b;

if ( flintstone.possibilities == possibility_a )

// ...Единственным очевидным исключением из этого правила является взаимодействие с архитектурами со страничной организацией памяти; битовые поля не гарантируют какого-то упорядочивания в типе int, из которого выделяются биты.

85.3. Не используйте флагов завершения.

Флаг завершения типа "готов" едва ли нужен в С или С++. Его использование просто добавляет одну лишнюю переменную в процедуру. Не делайте так:

BOOL готов = FALSE;

while ( !готов )

{

if ( некоторое_условие() )

готов = 1;

}Поступайте следующим образом: while ( 1 )

{

if ( некоторое_условие() ) break;}Многие программисты привыкли использовать флаги завершения, когда они учились программированию, в основном потому, что языки программирования типа Паскаля не поддерживают богатый набор управляющих операторов, имеющийся в С.

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

85.4. Рассчитывайте, что ваш читатель знает С.

Не делайте чего-то подобного этому:

#define SHIFT_LEFT(x, bits) ((x) ?? (bits))Программисты на С знают, что ?? означает "сдвиг влево". Аналогично, не делайте таких вещей: x++; // инкрементировать xПроблема в том, что комментарии, подобные вышеуказанному, часто встречаются в учебниках по языку программирования, ибо их читатель незнаком с С. Поэтому вы не должны делать вывод, что раз вы видите их в таком учебнике, то это является хорошей повсеместной практикой.

85.5. Не делайте вид, что С поддерживает булевый тип (#define TRUE).

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

#define TRUE 1

#define FALSE 0

Любая отличная от нуля величина в С означает истину, поэтому в следующем фрагменте f() может вернуть совершенно законное значение "истина", которое не совпало с 1, и проверка даст отрицательный результат: if( f() == TRUE ) // Вызов не выполняется, если f() возвращает значение "истина",

// отличное от 1.

// ...

Следующий вариант надежен, но довольно неудобен. Я не думаю, что можно рекомендовать что-либо из подобной практики: #define FALSE 0

if( f() != FALSE )

// ...

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

Часто необходимость в явном сравнении на истину или ложь можно устранить при помощи переименования:

if( я_сонливый(p) )значительно лучше, чем: if( сонливый(p) != FALSE )Так как определения TRUE и FALSE спрятаны в макросах, то хороший сопровождающий программист не может делать каких-либо предположений о их действительных значениях. Например, FALSE может быть -1, а TRUE - 0. И следственно, если функция возвращает в явном виде TRUE или FALSE, то наш прилежный сопровождающий программист должен будет потратить несколько дней, чтобы убедиться, что при проверке возвращаемого значения для каждого вызова используется явная проверка на равенство TRUE или FALSE (сравните для примера с простым логическим отрицанием ! перед вызовом). Следующий фрагмент: if( я_сердитый() ) ;более не может удовлетворять, так как компилятор ожидает, что ложь обозначается 0.

И напоследок - имейте в виду, что следующий вариант не будет работать:

#define FALSE 0

#define TRUE !FALSE

Операция !, подобно всем операторам отношений, преобразует операнд в 1, если он имеет значение "истина" (отличен от нуля), и 0, если наоборот. Предыдущий вариант идентичен следующему: #define FALSE 0

#define TRUE 1

Вот более надежный, но нелепый вариант: #define IS_TRUE(x) ((x) == 0)

#define IS_FALSE(x) ((x) != 0)

 

86. Для битового поля размером 1 бит должен быть определен тип unsigned.

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

struct fred

{

int i : 1;}

a_fred;

Возможными значениями являются 0 и -1. Оператор типа: #define TRUE 1

// ...

if( a_fred.i == TRUE )

// ...

не будет работать, потому что поле a_fred.i может иметь значение 0 или -1, но оно никогда не будет равняться 1. Следовательно, оператор if всегда не выполняется.


 

87. Указатели должны указывать на адрес, больший, чем базовый для массива.

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

int array[ SIZE ];

int *p = array + SIZE; // Здесь все в порядке; вы можете двигаться дальше.

while ( --p >= array ) // Это не работает - возможен бесконечный цикл.

//...Проблема состоит в том, что при сегментной архитектуре есть возможность того, что массив попадет на начало сегмента и получит исполнительный адрес 0x0000. (В архитектуре 8086 это будет смещением - частью адреса любого байта, состоящего из адреса сегмента и смещения). Если p установлен на начало массива (0x0000), то операция --p вызывает его перемещение на адрес 0xfffe (если у типа int размер 2 байта), который считается большим, чем p. Другими словами, предыдущий цикл никогда не закончится. Исправьте эту ситуацию следующим образом: while ( --p >= array )

{

// ...

if ( p == array )

break;}Вы можете выйти из положения так: int *p = array + (SIZE - 1);

do

{

// ...} while ( p-- > array );

но позаботьтесь, чтобы p был внутри массива перед началом цикла. (Указатель должен быть инициализирован значением p+(SIZE-1), а не p+SIZE).


 

88. Используйте указатели вместо индексов массива.

Вообще, инкрементирование указателя - лучший способ перемещения по массиву, чем индекс массива. Например, простой цикл, подобный следующему, страшно неэффективен:

struct thing

{

int field;

int another_field;

int another_field;

};

thing array[ nrows ][ ncols ];

int row, col;

for ( row = 0; row ? nrows ; ++nrows )

for ( col = 0; col ? ncols; ++cols ) array[row][col].field = 0;Выражение array[row][col] требует двух умножений и одного сложения во время выполнения. Вот что происходит на самом деле: array + (row * size_of_one_row) + (col * size_of_a_thing)Каждая структура имеет размер 12 байтов, и 12 не является степенью 2, поэтому вместо умножения нельзя использовать более эффективный сдвиг.

Вы можете сделать то же самое посредством указателей следующим образом:

thing *p = (thing *)array;

int n_cells = nrows * ncols;

while ( --n_cells >= 0 )

(p++)->field = 0;При этом здесь вообще нет умножения во время выполнения. Оператор инкрементирования p++ просто прибавляет 12 к p.

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

Аналогично, если внутренняя часть цикла в принципе неэффективна - скажем, например, мы сделали следующее:

for ( row = 0; row ? nrows ; ++nrows ) for ( col = 0; col ? ncols ; ++cols ) f( array[row][col] );и f() требует для выполнения две секунды - тогда относительный выигрыш от использования указателей будет существенно перевешен накладными расходами на вызов функции, и, естественно, вы можете утверждать, что квадратные скобки легче читаются. Конечно, если f() является встроенной функцией С++, то накладные расходы на вызов функции могут быть минимальными и есть смысл использовать указатель, поэтому вы можете возразить, что вариант с указателем лучше, ибо накладные расходы тяжело определить.

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


89. Избегайте goto, за исключением...

Правила в этом разделе применяйте только к программам на С. Оператор goto не должен никогда использоваться в С++ по причинам, рассмотренным в правиле 54 - существует вероятность того, что конструкторы и деструкторы будет невозможно вызвать.

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

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

while ( 1 )

{

while ( условие )

{

// ...

while ( другое_условие )

{

метка1:

// ...

goto метка2;

}// ...

}

if ( третье_условие )

{

// ...

if ( другое_условие )

goto метка1;

else

{

метка2:

// ... }}}Но самое интересное, что после того, как я разобрался с этим, стало легко переписать его, исключив переходы goto.

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

switch( некоторое_условие )

{

case A: if ( некоторое_другое_условие )

// ...else

{

case b: // ... }}Оператор goto полезен в некоторых случаях. Вот два из них:
  • Множество переходов goto к единственной метке, стоящей перед оператором return, лучше, чем множество операторов return. Такую процедуру легче отлаживать, так как для перехвата выхода из нее вы можете установить единственную точку прерывания. Имейте в виду, что goto должен предшествовать оператору; он не может стоять перед закрывающей фигурной скобкой. При необходимости пользуйтесь следующим приемом:
// ...

exit:

return ;

}

  • Переходы goto вниз по программе, обеспечивающие выход из системы вложенных циклов, лучше, чем флаг завершения типа "готов", который должен проверяться в каждом операторе управления циклом. Если каждый из операторов while в следующем примере выполнить по 100 раз, то флаг "готов" нужно проверить 1000000 раз, хотя он установлен всего лишь на случай ошибки:
int готов = 0;

int условие1, условие2, условие3;

// ...

while ( !готов ?? условие1 )

{

while ( !готов ?? условие2 ) {

while ( !готов ?? условие3 )

{

if ( нечто_ужасное )

готов = 1;}}}Исключите миллионы ненужных проверок при помощи goto следующим образом: while ( условие1 )

{

while ( условие2 )

{

while ( условие3 )

{

if ( нечто_ужасное )

goto выход;}}}

выход:

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


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

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

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



 

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

90. Не смешивайте объектно-ориентированное и "структурное" проектирование.

 

90.1. Если проект не ориетирован на объекты, то используйте С.

 

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

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

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

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

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

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

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

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


 

91. Рассчитывайте потратить больше времени на проектирование и меньше на разработку.

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


 

92. Библиотеки классов С++ обычно не могут быть использованы неискушенными пользователями.

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

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

Программная индустрия сталкивалась с этой проблемой и раньше, когда коллективы разработчиков были вынуждены переходить с языка КОБОЛ на С, но при этом не была обеспечена необходимая тренировка программистов, позволяющая им использовать С правильно. После этого в качестве урока осталась масса несопровождаемого, переполненного ошибками кода на С. С++ показывает все признаки еще более серьезной проблемы, так как руководители часто делают ставку на популярность С++, в действительности не зная, во что они впутываются. Масса кишащего ошибками кода на С++ пишется ежедневно людьми, которые даже не знают язык в степени, достаточной, чтобы понять, что они делают что-то неправильно.


 

93. Пользуйтесь контрольными таблицами.

Одной из причин того, что С++ имеет такую крутую кривую обучения, заключается в том, что вы должны отслеживать большое количество маленьких деталей, чтобы выполнить даже простые задачи. Просто забыть что-то, даже если вы это сделаете не надолго. Я решаю эту проблему, применяя повсюду несколько образцовых шаблонных файлов - по одному для каждой распространенной ситуации. (У меня есть один для определения базового класса, один - для определения производного класса, и т.д.). Я начинаю с копирования соответствующего шаблона в свой текущий рабочий файл и затем использую возможности своего редактора по поиску и замене для заполнения пустот. Я также перемещаю подходящие функции в файлы .cpp, когда нужно, и т.п.. Листинги 5 и 6 показывают простые шаблонные (в смысле естественного языка, а не языка С++) файлы для базового и производного классов (где кое-что опущено по сравнению с теми, которыми я пользуюсь на самом деле, но идею вы поняли).

Листинг 5. base.tem - контрольная таблица для определения базового класса.

  1. class base
  2. {
  3. cls obj;
  4. public:
  5. virtual
  6. ~base ( void );
  7. base ( void );
  8. base ( const base ?r );
  9. const base ?operator=( const base ?r );
  10. private:
  11. };
  12. //-------------------------------------------------------------------------------------------------------------------
  13. /* виртуальный */ ~base::base( void )
  14. {
  15. }
  16. //-------------------------------------------------------------------------------------------------------------------
  17. inline base::base( void ) : obj( value )
  18. {
  19. }
  20. //-------------------------------------------------------------------------------------------------------------------
  21. inline base::base( const base ?r ) : obj( r.obj )
  22. {}
  23. //-------------------------------------------------------------------------------------------------------------------
  24. inline const ?base::operator=( const base ?r )
  25. {
  26. if( this != ?r )
  27. {
  28. obj = r.obj;
  29. }
  30. return *this;
  31. }

     

Листинг 6. derived.tem - контрольная таблица для определения производного класса.
  1. class derived : public base
  2. {
  3. cls obj;
  4. public:
  5. virtual
  6. ~derived ( void );
  7. derived ( void );
  8. derived ( const base ?r );
  9. const derived ?operator=( const derived ?r );
  10. private:
  11. };
  12. //-------------------------------------------------------------------------------------------------------------------
  13. /* виртуальный */ ~derived::derived( void )
  14. {
  15. }
  16. //-------------------------------------------------------------------------------------------------------------------
  17. inline derived::derived( void ) : base( value ) ,
  18. obj( value )
  19. {
  20. }
  21. //-------------------------------------------------------------------------------------------------------------------
  22. inline derived::derived( const derived ?r ) : base ( r )
  23. , obj( r.obj )
  24. {}
  25. //-------------------------------------------------------------------------------------------------------------------
  26. inline const ?derived::operator=( const derived ?r )
  27. {
  28. if( this != ?r )
  29. {
  30. *((base *)this) = r;
  31. obj = r.obj;
  32. }
  33. return *this;
  34. }


94. Сообщения должны выражать возможности, а не запрашивать (предлагать ?) информацию.

Объектно-ориентированные и структурные системы склонны подходить к проблемам с диаметрально противоположных направлений. Возьмите в качестве примера скромную запись employee. В структурных системах вы бы использовали тип struct и имели бы доступ к полям этого типа повсюду из своей программы. Например, код для печати записи мог бы свободно повторяться в нескольких сотнях мест программы. Если вы меняете что-то в основе, вроде изменения типа поля name с массива char на 16-битные символы Unicode, то вы должны разыскать каждую ссылку на name и модифицировать ее для работы с новым типом.

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

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


 

95. Вам обычно не удастся переделать имеющуюся структурную программу в объектно-ориентированную.

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

В структурной системе код, который выполняет воспроизведение, является внешним. Некая функция получает откуда-то объект, после чего делает различные системные вызовы для вывода его на экран. Если вы говорите о printf(), то вызовы не очень сложные, но если речь заходит о Windows или Motif - у вас появляется проблема. Объектно-ориентированный проект фактически является вывернутым наизнанку в сравнении со структурным проектом.

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

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

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

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

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


 

96. Объект производного класса является объектом базового класса.

97. Наследование - это процесс добавления полей данных и методов-членов.

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

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

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

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

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

class string // строка

{

// ...public: const string ?operator=( const string ?r );};

class numeric_string // строка, содержащая число

{

string str;

// ...

public: const string ?operator=( const string ?r );}

const string ?numeric_string::operator=( const string ?r )

{

if( r.all_characters_are_digits() ) // все символы - цифры str = r;else throw invalid_assignment();return *this;}Это на самом деле довольно слабый пример объединения, потому что, если бы функция operator=() была виртуальной в базовом классе, то объект numeric_string мог бы наследовать от string и заместить оператор присваивания для проверки на верное числовое значение. С другой стороны, если перегруженная операция сложения + в классе string выполняет конкатенацию, то вам может понадобиться перегрузить + в классе numeric_string для выполнения арифметического сложения (т.е. преобразовывать строки в числа, которые складывать и затем присваивать результат строке). Объединение в последнем случае решило бы немного проблем.

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

Таблица 3. Два определения класса, одинаково представляемые на уровне машинного кода. 
Объединение Наследование
class container  

some_cls contained;  

// ...  

}; 

class base : public some_cls  

{  

// ...  

};


 

98. Сначала проектируйте объекты.

Первым пунктом повестки дня всегда должно быть проектирование системы обмена сообщениями, обычно посредством диаграмм объектов типа описанных Бучем. Начиная с иерархии классов, вы проявляете склонность к избыточному проектированию, реализуя возможности, которые не нужны. Кроме того, не зная, как нужно связать объекты друг с другом, обычно трудно сказать заранее, какие возможности потребуются в каждом классе. Тяжело обобщать, когда у вас нет деталей.


 

99. Затем проектируйте иерархию снизу вверх.

После того, как вы спроектировали систему объектов и сообщений, вы можете приступать к иерархии. Откиньтесь на спинку кресла и взгляните на различные объекты, и вы увидите, что многие из них получают похожие сообщения. Если два сообщения, посылаемые к разным объектам, похожи, но не одинаковы, то вам может подойти слегка более общее компромиссное, которое сможет работать и в том, и в другом месте. Обработчики для всех общих сообщений должны быть сконцентрированы в единый базовый класс. Например, имея один объект, получающий сообщения A, B и C, и второй объект, получающий A, B, D и E, вы должны остановиться на маленькой иерархии классов, в которой базовый класс реализует обработчики сообщений для A и B, один производный класс реализует обработчик для C, а второй производный класс - обработчики для D и E. Вы продолжаете этот процесс соединения общих элементов в общие базовые классы до тех пор, пока нечего будет соединять. Теперь у вас есть иерархия базовых классов.

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

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

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

99.1. Базовые классы должны иметь более одного производного объекта.

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


 

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

101. С++ - это не Smalltalk: избегайте общего класса object.

Процесс разработки иерархии снизу вверх обычно дает вам лес из маленьких деревьев, скорее широких, чем высоких. Построение иерархии снизу вверх поможет вам избежать общей проблемы для иерархий классов С++: класса object, от которого наследуется все в системе, как в Smalltalk. Такой проект хорош для Smalltalk, но, как правило, не работает в С++. Какое свойство мог бы реализовывать этот общий object? То есть какое свойство должен иметь каждый объект каждого класса в вашей программе? Единственное, что приходит на ум, это - управление памятью, способность объекта себя создать. Это делается в С++ посредством оператора new, который в действительности является функцией глобального уровня. Фактически вы можете смотреть на глобальный уровень С++, как на функциональный эквивалент object в Smalltalk. Хорошая иерархия классов С++ представляет собой обычно коллекцию иерархий меньшего размера. Процитируем такого авторитета, как самого Бьярна Страуструпа - создателя С++ - по этому поводу6 :

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

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

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

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

class employee

{

// содержит всю информацию, общую для всех служащих:

// фамилия, адрес и т.д.

};

class manager : public employee

{

// добавляет информацию, специфичную для управляющего, такую, как

// список подчиненных служащих. Управляющий тоже является служащим,

// поэтому применимо наследование

database list_of_managed_emploees;

}

class peon : public employee

{

// добавляет информацию, специфичную для поденщика

manager *this_boss;

}

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

class storable; // сохраняемый

class employee : public storable { /* ... */ };

class manager : public employee { /* ... */ };

class peon : public employee { /* ... */ };

Например, метод add() класса database мог бы получать указатель на объект storable в качестве своего аргумента. Таким способом любой объект storable (или объект, производный от storable) может быть добавлен в database без необходимости модифицировать что-либо в программе, в состав которой входит класс database.

Все кажется правильным до тех пор, пока мы реально не взглянем на то, как используются классы. Давайте скажем, что это средняя фирма, где число управляющих относится к числу поденщиков как 100 к 1. Однако списка управляющих нет, есть лишь список поденщиков. Тем не менее, каждый manager несет на себе излишнюю возможность сохраняемости, хотя она никогда не используется. Решим эту проблему при помощи множественного наследования.

class storable;

class employee { /* ... */ };

class manager : public employee { /* ... */ };

class peon : public employee, public storable { /* ... */ };

Проблема здесь в том, что эта "сохраняемость" является атрибутом объекта. Это не является базовым классом в стандартном смысле типа "круг является фигурой", а скорее - "поденщик является сохраняемым". Здесь важна замена существительного на прилагательное. Базовый класс, который реализует "свойство" типа сохраняемости, называется классом-смешением, потому что вы можете примешивать это свойство к тем классам, которым оно нужно, и только к этим классам. Хороший метод распознавания этих двух употреблений наследования состоит в том, что имя класса-смешения обычно выражено прилагательным (сохраняемый, сортируемый, устойчивый, динамический и т.д.). Именем настоящего базового класса обычно является существительное.

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

class parent {}; // родитель

class mother : public parent {}; // мать

class father : public parent {}; // отец

class child : public mother, public father {} // потомок

Здесь имеется две трудности. Если у parent есть метод для укладывания спать с названием go_to_sleep(), то вы получите ошибку, попытавшись послать такое сообщение: child philip; // Филипп - потомок

philip.go_to_sleep(); // Филипп, иди спать!

Проблема состоит в том, что в объекте child на самом деле два объекта parent. Запомните, что наследование просто добавляет поля (данные-члены) и обработчики сообщений (функции-члены). Объект mother имеет компонент parent: он содержит дополнительно к своим собственным все поля parent.7 То же самое относится и к father. Затем, у child есть mother и father, у каждого из которых есть parent. Проблема с philip.go_to_sleep() состоит в том, что компилятор не знает, какой из объектов parent должен получить это сообщение: тот, который в mother, или тот, который в father.8

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

class parent { public: go_to_sleep(); };

class mother : public parent {};

class father : public parent {};

class child : public mother, public father

{

public:

go_to_sleep()

{

mother::go_to_sleep();

father::go_to_sleep();

}}Другим решением является виртуальный базовый класс: class parent {};

class mother : virtual public parent {};

class father : virtual public parent {};

class child : public mother, public father {}

который заставляет компилятор помещать в объект child лишь один объект parent, совместно используемый объектами mother и father. Двусмысленность исчезает, но появляются другие проблемы. Во-первых, нет возможность показать на уровне потомка, хотите вы или нет виртуальный базовый класс. Например, в следующем коде tree_list_node может быть членом как дерева, так и списка одновременно: class node;

class list_node : public node {};

class tree_node : public node {};

class tree_list_node : public list_node, public tree_node {};

В следующем варианте tree_list_node может быть членом или дерева, или списка, но не обоих одновременно: class node;

class list_node : virtual public node {};

class tree_node : virtual public node {};

class tree_list_node : public list_node, public tree_node {};

Вам бы хотелось делать этот выбор при создании tree_list_node, но такой возможности нет. 

Второй проблемой является инициализация. Конструкторы в list_node и tree_node, вероятно, инициализируют базовый класс node, но разными значениями. Если имеется всего один node, то какой из конструкторов выполнит эту инициализацию? Ответ неприятный. Инициализировать node должен наследуемый последним производный класс (tree_list_node). Хотя это действительно плохая мысль - требовать, чтобы класс знал о чем-либо в иерархии, кроме своих непосредственных родителей - иначе было бы слишком сильное внутреннее связывание. 

Обратная сторона той же самой проблемы проявляется, если у вас есть виртуальные функции как в следующем коде:

class persistent

{

public:

virtual flush() = 0;};

class doc1: virtual public persistent

{

public:

virtual flush() { /* сохранить данные doc1 на диске */ }};

class doc2: virtual public persistent

{

public:

virtual flush() { /* сохранить данные doc2 на диске */ }};

class superdoc : public doc1, public doc2 {};

persistent *p = new superdoc();

p->flush(); // ОШИБКА: какая из функций flush() вызвана?


 

102. Смешения не должны наследоваться от чего попало.

103. Смешения должны быть виртуальными базовыми классами.

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

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

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


 

105. Наследование не подходит, если вы никогда не посылаете сообщения базового класса объекту производного класса.

106. Везде, где можно, предпочитайте включение наследованию.

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

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

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

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

class derived : public base

{

};

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

{

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

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

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


 

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

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

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


 

109. Все данные в определении класса должны быть закрытыми.

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

Все данные в определении класса должны быть закрытыми. Точка. Никаких исключений. Проблема здесь заключается в тесном сцеплении между классом и его пользователями, если они имеют прямой доступ к полям данных. Я приведу вам несколько примеров. Скажем, у вас есть класс 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).

Вы заметите, что я умышленно не реализовал следующую функцию в листинге 7:

string::operator const char*() { return buf; }

  Если бы реализовал, то мог бы сделать следующее:

Листинг 7. Простой строковый класс.

  1. class string
  2. {
  3. char *buf;
  4. int length; // длина буфера (не строки);
  5. public:
  6. virtual
  7. ~string( void );
  8. string( const char *input_str = "" );
  9. string( const string ?r );
  10. virtual const string ?operator=( const string ?r );
  11. virtual int operator? ( const string ?r ) const;
  12. virtual int operator> ( const string ?r ) const;
  13. virtual int operator==( const string ?r ) const;
  14. virtual void print( ostream ?output ) const;
  15. // ...
  16. };
  17. //--------------------------------------------------------------------------------------------------------------------
  18. inline string::string( const char *input_str /*= ""*/ )
  19. {
  20. length = strlen(input_str) + 1;
  21. buf = new char[ length ];
  22. strcpy( buf, input_str );
  23. }
  24. //--------------------------------------------------------------------------------------------------------------------
  25. inline string::string( const string ?r )
  26. {
  27. length = r.length;
  28. buf = new char[ length ];
  29. strcpy( buf, r.buf );
  30. }
  31. //--------------------------------------------------------------------------------------------------------------------
  32. /* виртуальный */ ~string::string( void )
  33. {
  34. delete buf;
  35. }
  36. //--------------------------------------------------------------------------------------------------------------------
  37. /* виртуальный */ const string ?string::operator=( const string ?r )
  38. {
  39. if( this != ?r )
  40. {
  41. if( length != r.length )
  42. {
  43. free( buf );
  44. length = r.length;
  45. buf = new char[ length ];
  46. }
  47. strcpy( buf, r.buf );
  48. }
  49. return *this;
  50. }
  51. //--------------------------------------------------------------------------------------------------------------------
  52. /* виртуальный */ int string::operator? ( const string ?r ) const
  53. {
  54. return strcmp(buf, r.buf) ? 0;
  55. }
  56. //--------------------------------------------------------------------------------------------------------------------
  57. /* виртуальный */ int string::operator> ( const string ?r ) const
  58. {
  59. return strcmp(buf, r.buf) > 0;
  60. }
  61. //--------------------------------------------------------------------------------------------------------------------
  62. /* виртуальный */ int string::operator==( const string ?r ) const
  63. {
  64. return strcmp(buf, r.buf) == 0;
  65. }
  66. //--------------------------------------------------------------------------------------------------------------------
  67. /* виртуальный */ void string::print( ostream ?output ) const
  68. {
  69. cout ?? buf;
  70. }
  71. //--------------------------------------------------------------------------------------------------------------------
  72. inline ostream ?operator??( ostream ?output, const string ?s )
  73. {
  74. // Эта функция не является функцией-членом класса string,
  75. // но не должна быть дружественной, потому что мной тут
  76. // реализован метод вывода строкой своего значения.
  77. s.print(output);
  78. return output;
  79. }
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() и получить многоязыкую строку. Такая степень изоляции и является целью объектно-ориентированного программирования, но вы ее не добьетесь, если не будете непреклонно следовать этим правилам. Здесь нет места ковбоям от программирования.


 

110.1. Не пользуйтесь функциями типа 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(). Проблема здесь в том, что проектирование выполнено выполнено наизнанку. Программист мыслил структурными категориями, а не объектно-ориентированными.

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


 

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

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

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

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

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

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


 

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

Никогда не надейтесь, что класс не будет использоваться в качестве базового класса. Сосредоточимся на случае с примером управляющего элемента-редактора из предыдущего правила. Я бы хотел реализовать такой элемент, наследуя одновременно от класса 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 со страницы 111 является виртуальной, то я могу сделать следующее:

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 буфер обновляется, но также модифицируется заголовок его окна.


 

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

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

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

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


 

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

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

  • Всегда передавать указатели на константные объекты, если вы не модифицируете эти объекты. Объявление:
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 текущий_класс в указатель на текущий_класс". Упомянутым указателем в данном случае является this, и никогда не будет дозволено преобразование указателя на константу в указатель на переменную (потому что вы тогда могли бы модифицировать константу при помощи указателя).

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


 

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

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

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

typedef struct tagSIZE // Существующее определение из заголовочного файла С

{

LONG cx;

LONG cy;

}

SIZE;

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

{

// ...

}

Я видел определения классов, подобные следующему, где требуется доступ к полям cx и cy базового класса через указатель производного класса для того, чтобы определить соответствующее им значение третьей координаты - высоты. Например: CSize some_size;

some_size.cy; // тьфу!

Вы должны иметь возможность написать: some_size.height();

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


 

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

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

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. Фрагмент реализации связанного списка

  1. class list_node;
  2. class linked_list
  3. {
  4. int number_of_elements_in_list;
  5. list_node *root;
  6. private: // этот раздел содержит сообщения, получаемые
  7. friend class list_node; // только от объектов list_node
  8. void have_removed_an_element(void)
  9. {
  10. --number_of_elements_in_list;
  11. }
  12. public
  13. void remove_this_node( list_node *p )
  14. {
  15. // Следующая строка генерирует ошибку при компиляции,
  16. // так как компилятор не знает, что list_node
  17. // имеет сообщение remove_yourself_from_me( ?root ).
  18. p->remove_yourself_from_me( ?root );
  19. }
  20. // ...
  21. };
  22. class list_node
  23. {
  24. linked_list *owner;
  25. private: // Этот раздел содержит сообщения,
  26. friend class linked_list: // получаемые только от объектов linked_list
  27. void remove_yourself_from_me( list_node *root )
  28. {
  29. // ... Выполнить удаление
  30. owner->have_removed_an_element();
  31. }
  32. };
Листинг 9. Улучшенный вариант реализации связанного списка
  1. class list_node;
  2. class linked_list
  3. {
  4. int number_of_elements_in_list;
  5. list_node *root;
  6. private:
  7. friend class list_node;
  8. void have_removed_an_element( void );
  9. public
  10. void remove_this_node( list_node *p );
  11. //...
  12. };
  13. //===============================================================
  14. class list_node
  15. {
  16. linked_list *owner;
  17. private: // Этот раздел содержит сообщения,
  18. friend class linked_list: // получаемые только от объектов
  19. // linked_list
  20. void remove_yourself_from_me( list_node *root );
  21. };
  22. //===============================================================
  23. // функции класса linked_list:
  24. //===============================================================
  25. inline void linked_list::remove_this_node( list_node *p )
  26. {
  27. p->remove_yourself_from_me( ?root );
  28. }
  29. //--------------------------------------------------------------------------------------------------------------------
  30. inline void linked_list::have_removed_an_element( void )
  31. {
  32. --number_of_elements_in_list;
  33. }
  34. //===============================================================
  35. // функции класса list_node:
  36. //===============================================================
  37. void list_node::remove_yourself_from_me( list_node *root )
  38. {
  39. // ... Выполнить удаление
  40. owner->have_removed_an_element();
  41. }

     


 

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

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

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

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

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.Б. Проблемы сцепления

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


 

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

Сцепление происходит лишь до определенной степени. Тесное сцепление между классами происходит, когда вы используете ключевое слово 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, он будет достаточно взрослым, чтобы не обмануть нашего дружелюбия нежелательными улучшениями.

 
 

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

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


 

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

Определение класса обеспечивает отличный способ вывода идентификатора из области глобальных имен, потому что эти идентификаторы должны быть доступны или через объект, или посредством явного имени класса. Функция 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. Вспомогательные классы.

  1. #include ?iostream.h>
  2. class int_array
  3. {
  4. class row
  5. {
  6. friend class int_array;
  7. int *first_cell_in_row;
  8. row( int *p ) : first_cell_in_row(p) {}
  9. public:
  10. int ?operator[] ( int index );
  11. };
  12. int nrows;
  13. int ncols;
  14. int *the_array;
  15. public:
  16. virtual
  17. ~int_array( void );
  18. int_array( int rows, int cols );
  19. row operator[] (int index);
  20. };
  21. //===============================================================
  22. // функции-члены класса int_array
  23. //===============================================================
  24. int_array::int_array( int rows, int cols )
  25. : nrows ( rows )
  26. , ncols ( cols )
  27. , the_array ( new int[rows * cols] )
  28. {}
  29. //-------------------------------------------------------------------------------------------------------------------
  30. int_array::~int_array( void )
  31. {
  32. delete [] the_array;
  33. }
  34. //-------------------------------------------------------------------------------------------------------------------
  35. inline int_array::row int_array::operator[]( int index )
  36. {
  37. return row( the_array + (ncols * index) );
  38. }
  39. //===============================================================
  40. // функции-члены класса int_array::row
  41. //===============================================================
  42. inline int ?int_array::row::operator[]( int index )
  43. {
  44. return first_cell_in_row[ index ];
  45. }
  46. //===============================================================
  47. void main ( void )
  48. {
  49. int_array ar(10,20); // то же самое, что и ar[10][20], но размерность
  50. // во время компиляции может быть не определена.
  51. ar[1][2] = 100;
  52. cout ?? ar[1][2];
  53. }

     

 

Глава 8.В. Ссылки

120. Ссылочные аргументы всегда должны быть константами.

 

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

 

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

  • Они нужны вам для определения конструктора копии.
  • Они нужны вам для определения перегруженных операций. Если вы определили:
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 ) )

view->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*.

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


 

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

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

some_class ?f()

{

some_class x;

// ...

return x;

}

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


 

123. Не возвращайте ссылки на память, выделенную оператором 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 ); // передается по значению, а не по ссылке.

// ... // вызывается конструктор копии для передачи x

f( x ); // по значению. Оно должно скопироваться в стек.

Второй является возврат по значению: g() // Помните, что x - локальная, автоматическая переменная.

{ // Она исчезает после возвращения функцией значения.

some_class x; // Оператор return после этого должен скопировать x куда-нибудь

return x; // в надежное место (обычно в стек после аргументов).

} // Он использует для этой цели конструктор копии.

Генерируемая компилятором функция-операция operator=() нужна лишь для поддержки копирования структур в стиле С там, где не определена операция присваивания.


 

124. Операция operator=( ) должна возвращать ссылку на константу.

 

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

 

Определение 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];


 

126. Классы, имеющие члены-указатели, должны всегда определять конструктор копии и функцию operator=().

Если класс не определяет методы копирования - конструктор копии и функцию operator=(), то это делает компилятор. Созданный компилятором конструктор должен выполнять "почленное" копирование, которое осуществляется таким образом, как будто вы написали this->field = src.field для каждого члена. Это означает, что теоретически должны вызываться конструкторы копий и функции operator=() вложенных объектов и базовых классов. Даже если все работает правильно, все же указатели копируются как указатели. То есть, строка string, представленная как char*, - не строка, а указатель, и будет скопирован лишь указатель. Представьте, что определение string на листинге 7 со страницы 111 не имеет конструктора копии или функции operator=(). Если вы запишите:

string s1 = "фу", s2;

// ...

s2 = s1;

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

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


 

127. Если у вас есть доступ к объекту, то он должен быть инициализирован.

128. Используйте списки инициализации членов.

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

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

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)

{}

 

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

У наследования тоже есть свои проблемы с копированием. Конструктор копии все же остается конструктором, поэтому здесь также применимы результаты обсуждения предыдущего правила. Если у конструктора копии нет списка инициализации членов, то для базовых классов и вложенных объектов используется конструктор по умолчанию. Так как список инициализации членов отсутствует в следующем определении конструктора копии, то компонент базового класса в объекте производного класса инициализируется с использованием 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) {}

 

131. Производные классы должны обычно определять конструктор копии и функцию 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=(), или нет. (Если не объявлена, то у вас будет по умолчанию реализовано почленное копирование).


 

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

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

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 имени, потому что он используется только для выбора функции.


 

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

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

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();

}}

 

 

134. Избегайте инициализации в два приема.

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

Как правило, переменная должна инициализироваться во время объявления. Разделение инициализации и объявления иногда обусловливается плохим проектированием в программе, которая написана не вами, как в следующем фрагменте, написанном для выполнения совместно с библиотекой 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: первая попытка.

  1. class window : public CWnd
  2. {
  3. public:
  4. class caption
  5. {
  6. CWnd *target_window;
  7. private: friend class window;
  8. caption( CWnd *p ) : target_window(p) {}
  9. public:
  10. operator CString ( void ) const;
  11. const caption ?operator=( const CString ?s );
  12. };
  13. caption text( void );
  14. };
  15. //--------------------------------------------------------------
  16. caption window::text( void )
  17. {
  18. return caption( this );
  19. }
  20. //--------------------------------------------------------------
  21. window::caption::operator CString( void ) const
  22. {
  23. CString output;
  24. target_window->GetWindowText( output );
  25. return output; // возвращает копию
  26. }
  27. //--------------------------------------------------------------
  28. const caption ?window::caption::operation=( const CString ?s )
  29. {
  30. target_window->SetWindowText( s );
  31. return *this;
  32. }
Листинг 12. Заголовочный объект
  1. class caption
  2. {
  3. CWnd target_window;
  4. public:
  5. window_text( CWnd *win ) : target_window( win ) {};
  6. operator const CString( void );
  7. const CString ?operator=( const CString ?r );
  8. };
  9. inline caption::operator CString( void );
  10. {
  11. CString output;
  12. target_window->GetWindowText( output );
  13. return output;
  14. }
  15. inline const CString ?caption::operator= ( const CString ?s )
  16. {
  17. // возвращает тип CString (вместо типа заголовка "caption"),
  18. // поэтому будет срабатывать
  19. // a = b = "абв"
  20. target_window->SetWindowText( s );
  21. return s;
  22. }


 

Глава 8.Д. Виртуальные функции

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

 

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

Виртуальные функции существуют ради двух целей. Во-первых, виртуальные функции определяют возможности, которые должны иметь все производные классы, но которые не могут быть реализованы на уровне базового класса. Например, вы можете сказать, что все объекты-фигуры 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).


 

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

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

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

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 указатель на таблицу своего собственного класса. Только после этого он выполняет написанный вами код. Деструктор производного класса вызывает деструктор базового класса на выходе (в самом конце - после того, как выполнен написанный пользователем код).

 

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

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


 

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

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

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 указывает на объект производного класса, то вы получаете деструктор производного класса, который вызывается за деструктором базового класса, когда выполнены компоненты производного класса.


 

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

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

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


 

141. Не делайте функцию виртуальной, если вы не желаете, чтобы производный класс получил контроль над ней.

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

 

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

Одним из смягчающих факторов в ранее описанной ситуации со сцеплением базового и производного классов является то, что объекту производного класса С++ едва когда-либо нужно посылать сообщение компоненту своего базового класса. Производный класс наследует назначение (и члены) от базового класса и обычно добавляет к нему назначение (и члены), но производный класс часто не вызывает функции базового класса. (Естественно, производный класс никогда не должен получать доступ к данным базового класса). Одним исключением являются виртуальные функции, которые можно рассматривать как средство изменения поведения базового класса. Сообщения часто передаются замещающей функцией производного класса в эквивалентную функцию базового класса. То есть, виртуальное замещение производного класса часто образует цепь с функцией базового класса, которую оно заместило. Например, класс 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(), но эта функция не доступна извне.

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

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


 

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

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

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_cast?derived *>( ?r );

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

}

};Шаблон функции dynamic_cast?t> возвращает 0, если операнд не может быть безопасно преобразован в тип t, иначе он выполняет преобразование.

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

 

144. Не вызывайте конструкторов из операции 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.Е. Перегрузка операций

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

Операция - это не произвольный значок, означающий все, что вы ни пожелаете. Это аббревиатура англоязычного слова. Например, символ + значит "прибавить", поэтому вы не должны заставлять перегруженный 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 является ключевым словом).


 

146. Используйте перегрузку операций только для определения операций, имеющих аналог в С (без сюрпризов).

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

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

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

string array[ size ];

string *p = array;

for( int i = size; --i >= 0 ; )

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

Аналог в С++ может выглядеть вот так (keys является деревом, чьи узлы имеют строковые ключи; здесь могут быть любые другие структуры данных): tree?string> keys; // двоичное дерево с узлами, имеющими строковые ключи

iterator p = keys;

// ...

for( int i = keys.size(); --i >= 0 ; )

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

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


 

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

Это правило является продолжением предыдущего. После того, как вы сказали, что "итератор работает совсем подобно указателю", он на самом деле должен так работать. Пример в предыдущем правиле использовал лишь перегруженные * и ++, но моя настоящая реализация итератора делает аналогию полной, поддерживая все операции с указателями. Таблица 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!=(), которые при первом взгляде кажутся имеющими смысл в ситуациях, где другие операции сравнения бессмысленны. Например, вы можете использовать == для проверки двух окружностей на равенство, но означает ли равенство "одинаковые координаты и одинаковый радиус", или просто "одинаковый радиус"? Перегрузка других операций сравнения типа ? или ?= еще более сомнительна, потому что их значение не совсем очевидно. Лучше полностью избегать перегрузки операций, если есть какая-либо неясность в их значении.


 

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

Главной новой проблемой здесь являются адресные типы 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.
Эквивалентные перегруженные операции должны возвращать объекты, а не ссылки или указатели.


 

149. Перегруженной бинарной операции лучше всего быть встроенным (inline) псевдонимом операции приведения типа.

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

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 в первом варианте реализации.


 

150. Не сходите с ума с операторами преобразования типов.

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

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

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 );

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

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

 

Глава 8.Ж. Управление памятью

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

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

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

 

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

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

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


 

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

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

some_class *p = new some_class[1]; // вызывает глобальный оператор new() //...delete p; // вызывает some_class::operator delete()

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


 

Глава 8.З. Шаблоны

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


 

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

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

#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 является встроенной функцией. Если бы этого не было, то для того, чтобы такой механизм был жизнеспособным, пришлось бы генерировать слишком много кода.


 

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

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

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

  1. template ?class type, int size >
  2. class array
  3. {
  4. type array[size];
  5. public:
  6. class out_of_bounds {}; // возбуждается исключение, если вы используете
  7. // индекс за пределами массива
  8. type ?operator[](int index);
  9. };
  10. template ?class type, int size >
  11. inline type ?array?type, size>::operator[](int index)
  12. {
  13. if( 0 ?= index ?? index ? size )
  14. return array[ index ]
  15. throw out_of_bounds;
  16. }
Единственная причина осуществимости этого определения заключается в том, что функция-член является встроенной. Если бы этого не было, то вы могли бы получить значительное количество повторяющегося кода. Запомните, что везде далее происходит полное расширение шаблона, включая все функции-члены. Вследствие того, что каждое из следующих определений на самом деле создает разный тип, то вы должны расширить этот шаблон четыре раза, генерируя четыре идентичные функции operator[](), по одной для каждого расширения шаблона: array?int,10> ten_element_array;

array?int,11> eleven_element_array;

array?int,12> twelve_element_array;

array?int,13> thirteen_element_array;

( то есть array?int,10>::operator[](),array?int,11>::operator[]() и так далее).

Вопрос состоит в том, как сократить до минимума дублирование кода. Что, если мы уберем размер за пределы шаблона как на листинге 14? Предыдущие объявления теперь выглядят так:

array?int> ten_element_array (10);

array?int> eleven_element_array (11);

array?int> twelve_element_array (12);

array?int> thirteen_element_array (13);

Теперь у нас есть только одно определение класса (и один вариант operator[]()) с четырьмя объектами этого класса. 

Листинг 14. Шаблон массива (второй проход).

  1. template ?class type>
  2. class array
  3. {
  4. type *array;
  5. int size;
  6. public:
  7. virtual ~array( void );
  8. array( int size = 128 );
  9. class out_of_bounds {}; // возбуждается исключение, если вы используете
  10. // индекс за пределами массива
  11. type ?operator[](int index);
  12. };
  13. template ?class type>
  14. array?type>::array( int sz /*= 128*/ ): size(sz)
  15. , array( new type[ sz ] )
  16. {}
  17. template ?class type>
  18. array?type>::~array( void )
  19. {
  20. delete [] array;
  21. }
  22. template ?class type>
  23. inline type ?array?type>::operator[](int index)
  24. {
  25. if( 0 ?= index ?? index ? size )
  26. return array[ index ]
  27. throw out_of_bounds;
  28. }
Главным недостатком этой второй реализации является то, что вы не можете объявить двухмерный массив. Определение на листинге 13 разрешает следующее: array? array?int, 10>, 20> ar;(20-элементный массив из 10-элементных массивов). Определение на листинге 14 устанавливает размер массива, используя конструктор, поэтому лучшее, что вы можете получить, это: array? array?int> > ar2(20);Внутренний array?int> создан с использованием конструктора по умолчанию, поэтому это 128-элементный массив; мы объявили 20-элементный массив из 128-элементных массивов.

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

template? class type, int size >

class sized_array : public array?type>

{

public:

sized_array() : array?type>(size) {}};Здесь ничего нет, кроме единственной встроенной функции, поэтому это определение очень маленького класса. Оно совсем не будет увеличивать размер программы, вне зависимости от того, сколько раз будет расширен шаблон. Вы теперь можете записать: sized_array? sized_array?int,10>, 20> ar3;

для того, чтобы получить 20-элементный массив из 10-элементных массивов.


 

157. Шаблоны классов должны обычно определять производные классы.

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

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

Во-первых, давайте взглянем на то, что не нужно делать. Класс 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

{

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

// на себя самого, имеющий тот же ключ, что и "r". Обычно,

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

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

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_cast?storable_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 storable?t_key>::operator==( const storable?t_key> ?r ) const

{

return key == r.key ;}

template ?class t_key>

storable?t_key> *storable?t_key>::successor( void ) const

{

return next;}

template ?class t_key>

storable *storable?t_key>::find( const storable?t_key> ?match_me ) const

{

storable?t_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_tem?t_key>::operator==( const storable ?r ) const

{

t_key *right = dynamic_cast?t_key *>( ?r );

return right ? (s == r.s) : NULL;

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

Полезным результатом является существенное сокращение размера кода. Механизм шаблонов может рассматриваться как средство автоматизации производства шаблонных производных классов.



Глава 8.И. Исключения

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

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

  • Нет другого способа сообщить об ошибке (например, конструкторов, перегруженных операций и т.д.).
  • Ошибка неисправимая (например, нехватка памяти).
  • Ошибка настолько непонятная или неожиданная, что никому не придет в голову ее протестировать (например, 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::modeRead );

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::modeRead );

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 NT, которая поддерживает обработку исключений на уровне операционной системы. Вы можете рассчитывать на 10-20% увеличение размера кода и падение скорости выполнения на несколько процентов при интенсивном использовании исключений.14 Следовательно, исключения должны использоваться лишь тогда, когда непроизводительные затраты не берутся в расчет, обычно при наличии возможности лучше предпочесть возврат ошибки. 

 

160. По возможности возбуждайте объекты типа 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. Классы исключений
  1. class file
  2. {
  3. public:
  4. class error {};
  5. class open_error : public error {};
  6. class io_error : public error {};
  7. // ...
  8. }
Этот код работает, потому что объект 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(...).


 

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

Я начну этот раздел с замечания о том, что компиляторы, которые соответствуют рабочим документам комитета 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, потому что деструктор еще не вызван.

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

Листинг 16. except.cpp - возбуждение исключения из конструктора

  1. class с
  2. {
  3. public:
  4. class error
  5. {
  6. c *p; // NULL при успешном выполнении конструктора
  7. public:
  8. error( c *p_this );
  9. void destroy( void );
  10. };
  11. private:
  12. unsigned has_been_destroyed : 1;
  13. int *pi;
  14. private: friend class error;
  15. int been_destroyed( void );
  16. public:
  17. c() ;
  18. ~c();
  19. };
  20. //=============================================================
  21. c::error::error( c *p_this )
  22. : p( p_this )
  23. {}
  24. //----------------------------------------------------------------------------------------------------------------
  25. void c::error::destroy( void )
  26. {
  27. if( p ?? !p->been_destroyed() )
  28. delete p;
  29. }
  30. //=============================================================
  31. c::c() : has_been_destroyed( 0 )
  32. {
  33. // ...
  34. throw error(this);
  35. // ...
  36. }
  37. //----------------------------------------------------------------------------------------------------------------
  38. c::~c()
  39. {
  40. // ...
  41. has_beeb_destroyed = 1;
  42. }
  43. //--------------------------------------------------------------
  44. int c::been_destroyed( void )
  45. {
  46. return has_been_destroyed;
  47. }
  48. //===============================================================
  49. void main( void )
  50. {
  51. try
  52. {
  53. c *cp = new c;
  54. c a_c_object;
  55. delete cp;
  56. }
  57. catch( c::error ?err )
  58. {
  59. err.destroy(); // деструктор вызывается, только если объект создан оператором new
  60. }
  61. }


 

Заключение

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

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

Об авторе

Ален Голуб - программист, консультант и преподаватель, специализирующийся на С++, объектно-ориентированном проектировании и операционных системах Microsoft. Он проводит семинары по приглашению частных фирм повсюду на территории США и преподает в филиалах Калифорнийского университета, расположенных в Беркли и Санта-Круз. Он также работает программистом и консультантом по объектно-ориентированному проектированию, используя С и С++ в операционных средах Micrisoft 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". М-р Голуб сочиняет музыку и имеет лицензию частного пилота.

Вы можете связаться с ним через Internet по адресу Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в вашем браузере должен быть включен Javascript. или через его фирму Software Engineering Consultants, P.O.Box 5679, Berkeley, CA 94705 (телефон и факс: (510)540-7954).