Жанр: Учеба

Правила программирования на Си и Си плюс плюс.

сторінка №16

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

class mydialog : public CDialog
{
// ...
private
:
virtual OnOk( void );
};


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

206


/* виртуальный */ 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
{


Виртуальные функции 207

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, иначе он выполняет


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

208


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

12 Некоторые компиляторы в действительности позволяют выполнить явный
вызов конструктора, поэтому вы, вероятно, сможете сделать точно так же:


Виртуальные функции 209

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

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

const some_class &operator=( const some_class &r )
{
if ( this != &r )
{
this
-"~some_class();
this
-"some_class::some_class( r );
}
}

Тем не менее, такое поведение является нестандартным.


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

210


текущем примере перед присваиванием указатель 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();
}


Перегрузка операций 211

Часть 8е. Перегрузка операций


145. Операция - это сокращение (без сюрпризов)
Операция - это не произвольный значок, означающий все, что вы ни
пожелаете. Это аббревиатура англоязычного слова. Например, символ +
значит "прибавить", поэтому вы не должны заставлять перегруженный
operator
+()
делать что-нибудь еще. Хотя здесь все ясно (вы можете
определить a + b для вычитания b из a, но не должны делать этого), я на
самом деле веду речь о проблемах более творческого характера.
Вы можете благоразумно доказывать, что, когда выполняете
конкатенацию, то "прибавляете" одну строку к концу другой, поэтому
перегрузка + для конкатенации может быть приемлема. Вы также можете
доказывать, что разумно использовать операции сравнения для
лексикографического упорядочивания в классе string, поэтому
перегрузка операций ", == и т.д. также вероятно пойдет. Вы не сможете
аргументировано доказать, что - или * имеют какой-нибудь смысл по
отношению к строкам.
Другим хорошим примером того, как нельзя действовать, является
интерфейс Си++ iostream. Использование сдвига ("") для обозначения
"вывод" является нелепым. Ваши функции вывода в Си назывались
printf()
, а не shiftf(). Я понимаю, что Страуструп выбрал сдвиг,
потому что он сходен с механизмом перенаправления ввода/вывода
различных оболочек UNIX, но этот довод на самом деле не выдерживает
проверки. Страуструп исходил из того, что все программисты на Си++
понимают перенаправление в стиле UNIX, но эта концепция отсутствует в
некоторых операционных системах - например, в Microsoft Windows. К
тому же, для того, чтобы аналогия была полной, операция " должна быть
перегружена для выполнения операции затирания, а "" - добавления в
конец. Тем не менее, тот факт, что " и "" имеют различный приоритет,
делает реализацию такого поведения затруднительной. Дело осложняется
тем, что операторы сдвига имеют неправильный уровень приоритета.
Оператор типа cout "" x += 1 не будет работать так, как вы ожидаете,
потому что у "" более высокий приоритет, чем у +=, поэтому оператор
интерпретируется как (cout "" x) += 1, что неверно. Си++ нуждается
в расширяемости, обеспечиваемой системой iostream, но он вынужден
добиваться ее за счет введения операторов "ввода" и "вывода", имеющих
низший приоритет по отношению к любому оператору языка.


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

212


Аналогия проблеме "сдвиг как вывод" может быть найдена в
проектировании компьютерных систем. Большинство проектировщиков
аппаратуры были бы счастливы использовать + вместо 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 ; )


Перегрузка операций 213

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; Скопировать позицию из одного итератора в другой


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

214


t2 - ti; Расстояние между двумя элементами, адресуемыми
различными итераторами
ti-"msg(); Послать сообщение этому элементу
(*ti).msg(); Послать сообщение этому элементу

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


148. Перегруженные операции должны работать
точно так же, как они работают в Си
Главной новой проблемой здесь являются адресные типы lvalue и
rvalue
. Выражения типа lvalue легко описываются в терминах Си++:
они являются просто ссылками. Компилятор Си, вычисляя выражение,
выполняет операции по одной за раз в порядке, определяемом правилами
сочетательности и старшинства операций. Каждый этап в вычислениях
использует временную переменную, полученную при предыдущей
операции. Некоторые операции генерируют "rvalue" - действительные
объекты, на самом деле содержащие значение. Другие операции создают
"lvalue" - ссылки на объекты. (Кстати, "l" и "r" используются потому,
что в выражении l=r слева от = генерируется тип lvalue. Справа
образуется тип rvalue).
Вы можете сократить эффект неожиданности для своего читателя,
заставив свои перегруженные операции-функции работать тождественно
их эквивалентам на Си в пределах того, что они могут. Далее описано, как
работают операции Си и как имитировать их поведение:
+ Операции присваивания (=, +=, -= и т.д.) и операции автоинкремента и
автодекремента (++, --) требуют операндов типа lvalue для адресата
- части, которая изменяется. Представьте ++ как эквивалент для +=1,
чтобы понять, почему эта операция в той же категории, что и
присваивание.
В перегруженных операциях функций-членов указатель this на самом
деле является lvalue, поэтому здесь не о чем беспокоиться. На
глобальном уровне левый операнд перегруженной бинарной операции


Перегрузка операций 215

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

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


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

216


понимания дополнение к классу string из листинга 7 на странице 155:

class string
{
enum special_ { special };
string( special_ ) {}; // ничего не делает.
// ...
public
:
const string operator+( const string &r ) const;
// ...
};
//------------------------------------------------------------
const string::operator+( const string &r ) const
{
string tmp( special ); // создать пустой объект

tmp.buf = new char[ strlen(buf) + strlen(r.buf) + 1 ];
strcpy( tmp.buf, buf );
strcat( tmp.buf, r.buf );
return tmp;
}

Многие компиляторы, получив вышеуказанное, генерируют довольно
неэффективный код. Объект tmp должен инициализироваться при вызове
конструктора; здесь это не очень дорого, но обычно это ведет к
значительно большим расходам. Конструктор копии должен быть вызван
для выполнения оператора return, и сам объект также должен быть
уничтожен.
Иногда вы можете улучшить такое поведение путем перегрузки
встроенного псевдонима для операции приведения типа:
class string
{
string(const char *left, const char *right );
public
:
const string string::operator+( const string &r ) const ;
};
//-----------------------------------------------------------
string::string(const char *left, const char *right )
{
buf = new char[ strlen(left) + strlen(right) + 1 ];
strcpy( buf, left );
strcat( buf, right );
}
//-----------------------------------------------------------
inline const string::operator+( const string &r ) const
{
return string(buf, r.buf);
}

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


Перегрузка операций 217

следующее:

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; // Бил Гейтс

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


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

218


слишком много преобразований:

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

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

Список сторінок

Поділитися у соцмережах

☏ Замовлення реклами: +3805044-68-872

© Асоціація електронних бібліотек України

☝ Усі матеріали сайту (зокрема статті, зображення, рекламні оголошення тощо) призначені лише для попереднього ознайомлення. Усі права на публікації, представлені на сайті, належать їхнім законним власникам. Просимо Вас не зберігати копії інформації.