Голуб Ален И. - Веревка достаточной длины, чтобы... выстрелить с. Руководство по программированию. Автору удается сделать изложение столь серьезной темы живым и интересным за счет рассыпанного по тексту юмора и глубокого знания предмета
Скачать 1.36 Mb.
|
Часть 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 ; ) visit( *p++ ); // функции visit() передается строка. Перегрузка операций 213 Аналог в Си++ может выглядеть вот так ( keys является деревом, чьи узлы имеют строковые ключи; здесь могут быть любые другие структуры данных): tree // строковые ключи 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; Расстояние между двумя элементами, адресуемыми различными итераторами Правила программирования на Си++ 214 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) псевдонимом операции приведения типа Это правило относится к числу тех, которые будут изменены с улучшением качества компиляторов. Рассмотрим следующее, простое для понимания дополнение к классу string из листинга 7 на странице 155: class string Правила программирования на Си++ 216 { enum special_ { special }; string( special_ ) {}; // ничего не делает. // ... public : const string operator +( const string &r ) const ; // ... }; //------------------------------------------------------------ const string:: operator +( cons t 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; Перегрузка операций 217 как если бы вы сказали следующее (вы не можете сделать этого сами, потому что 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 : Правила программирования на Си++ 218 operator int ( void ); operator const char * ( void ); }; то простой оператор, подобный: some_class x; cout << x; не сработает. Проблема в том, что класс stream определяет те же два преобразования: ostream &ostream:: operator <<( int x ); ostream &ostream:: operator <<( const char *s ); Так как имеется два варианта преобразований, то компилятор не знает, какой из них выбрать. Лучше выполнять все преобразования типов при помощи конструкторов и определять минимально необходимый их набор. Например, если у вас есть преобразование из типа double , то вам не нужны int , long и так далее, потому что нормальные правила преобразования типов Си применяются компилятором при вызове вашего конструктора. Управление памятью 219 Часть 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() Помните, что эти две строки могут быть в различных файлах. |