Главная страница
Библиотека (скачать книги)
Скачать софт
Введение в программирование
Стандарты для C++
Уроки по C#
Уроки по Python
HTML
Веб-дизайн
Ассемблер в среде Windows
ActiveX
Javascript
Общее о Линукс
Линукс - подробно
Линукс - новое
Delphi
Паскаль для начинающих
Турбопаскаль
Новости
Партнеры
Наши предложения
Архив новостей







27. Отдавайте предпочтение каноническим формам арифметических операторов и операторов присваивания

Резюме

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

Обсуждение

В общем случае для некоторого бинарного оператора @ (+, -, * и т.д.) вы должны также определить его присваивающую версию, так чтобы a@=b и a=a@b имели один и тот же смысл (причем первая версия может быть более эффективна). Канонический способ достижения данной цели состоит в определении @ посредством @= следующим образом:

T& T::operator@=( const T& ) {
    // ... реализация ...
    return *this;
}

T operator@( const T& lhs, const T& rhs ) {
    T temp( lhs );
    return temp @= rhs;
}

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

Обратите внимание, что здесь operator@ — функция-не член, так что она обладает желательным свойством возможности неявного преобразования как левого, так и правого параметра (см. рекомендацию 44). Например, если вы определите класс String, который имеет неявный конструктор, получающий аргумент типа char, то оператор operator+(const String&, const String&), который не является членом класса, позволяет осуществлять операции как типа char+String, так и String+char; функция-член String::operator+(const String&) позволяет использовать только операцию String+char. Реализация, основной целью которой является эффективность, может определить ряд перегрузок оператора operator@, не являющихся членами класса, чтобы избежать увеличения количества временных переменных в процессе преобразований типов (см. рекомендацию 29).

Также делайте не членом функцию operator@=везде, где это возможно (см. рекомендацию 44). В любом случае, все операторы, не являющиеся членами, должны быть помещены в то же пространство имен, что и класс T, так что они будут легко доступны для вызывающих функций при отсутствии каких-либо сюрпризов со стороны поиска имен (см. рекомендацию 57).

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

T& operator@=(T& lhs, const T& rhs) {
    // ... реализация ...
    return lhs;
}

T operator@(T lhs, const T& rhs) {// lhs передано по значению
    return lhs @= rhs;
}

Еще один вариант — оператор operator@, который возвращает const-значение. Эта методика имеет то преимущество, что при этом запрещается такой не имеющий смысла код, как а+b=с, но в этом случае мы теряем возможность применения потенциально полезных конструкций наподобие a = (b+c).replace(pos, n, d). А это весьма выразительный код, который в одной строчке выполняет конкатенацию строк b и c, заменяет некоторые символы и присваивает полученный результат переменной a.

Примеры

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

String& String::operator+=( const String& rhs ) {
    // ... Реализация ...
    return *this;
}

String operator+( const String& lhs, const String& rhs ) {
    String temp;    // изначально пуста
    // выделение достаточного количества памяти
    temp.Reserve(lhs.size() + rhs.size());
    // Конкатенация строк и возврат
    return (temp += lhs) += rhs;
}

Исключения

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

28. Предпочитайте канонический вид ++ и --, и вызов префиксных операторов

Резюме

Особенность операторов инкремента и декремента состоит в том, что у них есть префиксная и постфиксная формы с немного отличающейся семантикой. Определяйте операторы operator++ и operator-- так, чтобы они подражали поведению своих встроенных двойников. Если только вам не требуется исходное значение — используйте префиксные версии операторов.

Обсуждение

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

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

// --- Префиксные операторы ------------------
T& T::operator++()      // Префиксный вид:
{
    // Выполнение       // - Выполнение
    // инкремента       //   действий
    return *this;       // - return *this;
}

T& T::operator--()      // Префиксный вид:
    // Выполнение       // - Выполнение
    // декремента       //   действий
    return *this;       // - return *this;
}

// --- Постфиксные операторы -----------------
T T::operator++(int)    // Постфиксный вид:
{
    T old( *this );     // - Запоминаем старое значение
    ++*this;            // - Вызов префиксной версии
    return old;         // - Возврат старого значения
}

T Т::operator--(int)    // постфиксный вид:
    T old( *this )      // - Запоминаем старое значение
    --*this;            // -  вызов  префиксной версии
    return old;         // -  возврат старого значения
}

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

Исключения

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

29. Используйте перегрузку, чтобы избежать неявного преобразования типов

Резюме

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

Обсуждение

Когда вы работаете в офисе и у вас заканчивается бумага, что вы делаете? Правильно — вы идете к копировальному аппарату и делаете несколько копий чистого листа бумаги.

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

class String { // ...
    String( const char* text );  // Обеспечивает неявное
                                 // преобразование типов
};
bool operator==( const String&, const String& );

// ... Где-то в коде ...
if( someString == "Hello" ) { ... }

Ознакомившись с приведенными выше определениями, компилятор компилирует приведенное сравнение таким образом, как если бы оно было записано в виде someString == String("Hellо"). Это слишком расточительно — копировать символы, чтобы потом просто прочесть их. Решение этой проблемы очень простое — определить перегруженные функции, чтобы избежать преобразования типов, например:

bool operator==( const String& lhs, const String& rhs ); // #1
bool operator==( const String& lhs, const char* rhs );       // #2
bool operator==( const char* lhs, const String& rhs );       // #3

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

30. Избегайте перегрузки &&, || и , (запятой)

Резюме

Мудрость — это знание того, когда надо воздержаться. Встроенные операторы &&, || и , (запятая) трактуются компилятором специальным образом. После перегрузки они становятся обычными функциями с весьма отличной семантикой (при этом вы нарушаете рекомендации 26 и 31), а это прямой путь к трудноопределимым ошибкам и ненадежности. Не перегружайте эти операторы без крайней необходимости и глубокого понимания.

Обсуждение

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

Встроенные версии && и || сначала вычисляют левую часть выражения, и если она полностью определяет результат (falseдля &&, true для ||), то вычислять правое выражение незачем — и оно гарантированно не будет вычисляться. Таким образом мы используем эту возможность, позволяя корректности правого выражения зависеть от успешного вычисления левого:

Employee* e = TryToGetEmployee();
if( e && e->Manager() )
// ...

Корректность этого кода обусловлена тем, что e->Manager() не будет вычисляться, если e имеет нулевое значение. Это совершенно обычно и корректно — до тех пор, пока не используется перегруженный оператор operator&&, поскольку в таком случае выражение, включающее &&, будет следовать правилам функции:

  • вызовы функций всегда вычисляют все аргументы до выполнения кода функции;

  • порядок вычисления аргументов функций не определен (см. также рекомендацию 31).

Давайте рассмотрим модернизированную версию приведенного ранее фрагмента, которая использует интеллектуальные указатели:

some_smart_ptr<Employee> e = TryToGetEmployee();
if( e && e->Manager() )
// ...

Пусть в этом коде используется перегруженный оператор operator&& (предоставленный автором some_smart_ptrили Employee). Тогда мы получаем код, который для читателя выглядит совершенно корректно, но потенциально может вызвать e->Manager() при нулевом значении e.

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

if( DisplayPrompt() && GetLine() )
// ...

Если оператор operator&& переопределен пользователем, то неизвестно, какая из функций — DisplayPrompt или GetLine — будет вызвана первой. Программа в результате может ожидать ввода пользователя до того, как будет выведено соответствующее поясняющее приглашение.

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

Та же ненадежность наблюдается и в случае оператора-запятой. Так же, как и операторы && и ||, встроенный оператор-запятая гарантирует, что выражения будут вычислены слева направо (в отличие от && и ||, здесь всегда вычисляются оба выражения). Пользовательский оператор-запятая не может гарантировать вычислений слева направо, что обычно приводит к удивительным результатам. Например, если в следующем коде используется пользовательский оператор-запятая, то неизвестно, получит ли функция g аргумент 0 или 1:

int i = 0;
f( i++ ), g( i );    //См. также рекомендацию 31

Примеры

Пример. Инициализация библиотеки при помощи перегруженного оператора operator, для последовательности инициализаций. Некоторая библиотека пытается упростить добавление нескольких значений в контейнер за один раз путем перегрузки оператора-запятой. Например, для добавления в vector<string> letters:

set_cont(letters) += "a", "b";

Все в порядке, пока в один прекрасный день пользователь не напишет:

set_cont(letters) += getstr(), getstr();
    // порядок не определен при использовании
    // перегруженного оператора ","

Если функция getstr получает, например, ввод пользователя и он введет строки "с" и "d" в указанном порядке, то в действительности строки могут оказаться внесены в любом порядке. Это может оказаться сюрпризом, поскольку при использовании встроенного оператора operator, такой проблемы не возникает:

String s;
s = getstr(), getstr();  // порядок строго определен
                         // при использовании
                         // встроенного оператора ","

Исключения

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



 
 

Библиотека программиста. 2009.
Администратор: admin@programmer-lib.ru