c++ - чайников - Каковы основные правила и идиомы для перегрузки оператора?




перегрузка операторов c++ для чайников (5)

Общие операторы для перегрузки

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

Оператор присваивания

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

X& X::operator=(X rhs)
{
  swap(rhs);
  return *this;
}

Операторы Bitshift (используются для потоков ввода / вывода)

Операторы бит-сдвига << и >> , хотя они все еще используются в аппаратном интерфейсе для функций манипуляции бит, которые они наследуют от C, стали более распространенными как перегруженные операторы ввода и вывода потока в большинстве приложений. Для перегрузки инструкций как операторов бит-манипуляции см. Раздел ниже о двоичных арифметических операциях. Для реализации собственного пользовательского формата и логики синтаксического анализа, когда ваш объект используется с iostreams, продолжайте.

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

std::ostream& operator<<(std::ostream& os, const T& obj)
{
  // write obj to stream

  return os;
}

std::istream& operator>>(std::istream& is, T& obj)
{
  // read obj from stream

  if( /* no valid object of T found in stream */ )
    is.setstate(std::ios::failbit);

  return is;
}

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

Оператор вызова функции

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

Вот пример синтаксиса:

class foo {
public:
    // Overloaded call operator
    int operator()(const std::string& y) {
        // ...
    }
};

Использование:

foo f;
int a = f("hello");

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

Операторы сравнения

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

Алгоритмы стандартной библиотеки (например, std::sort() ) и типы (например, std::map ) всегда будут ожидать, что operator< будет присутствовать. Тем не менее, пользователи вашего типа ожидают, что все остальные операторы тоже будут присутствовать , поэтому, если вы определяете operator< , обязательно следуйте третьему основополагающему правилу перегрузки оператора, а также определите все остальные логические операторы сравнения. Канонический способ их реализации заключается в следующем:

inline bool operator==(const X& lhs, const X& rhs){ /* do actual comparison */ }
inline bool operator!=(const X& lhs, const X& rhs){return !operator==(lhs,rhs);}
inline bool operator< (const X& lhs, const X& rhs){ /* do actual comparison */ }
inline bool operator> (const X& lhs, const X& rhs){return  operator< (rhs,lhs);}
inline bool operator<=(const X& lhs, const X& rhs){return !operator> (lhs,rhs);}
inline bool operator>=(const X& lhs, const X& rhs){return !operator< (lhs,rhs);}

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

Синтаксис перегрузки оставшихся двоичных булевых операторов ( || , && ) следует правилам операторов сравнения. Однако очень маловероятно, что вы найдете разумный прецедент для этих 2 .

1 Как и во всех эмпирических правилах, иногда могут быть и причины разбить этот. Если это так, не забывайте, что левый операнд двоичных операторов сравнения, который для функций-членов будет *this , также должен быть const . Таким образом, оператор сравнения, реализованный как функция-член, должен иметь эту подпись:

bool operator<(const X& rhs) const { /* do actual comparison with *this */ }

(Обратите внимание на const в конце.)

2 Следует отметить, что встроенная версия || и && использовать семантику ярлыков. Хотя определенные пользователем (поскольку они являются синтаксическим сахаром для вызовов методов), не используйте семантику ярлыков. Пользователь будет ожидать, что у этих операторов будет ярлык семантики, и их код может зависеть от него, поэтому настоятельно рекомендуется НИКОГДА их не определять.

Арифметические операторы

Унарные арифметические операторы

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

class X {
  X& operator++()
  {
    // do actual increment
    return *this;
  }
  X operator++(int)
  {
    X tmp(*this);
    operator++();
    return tmp;
  }
};

Обратите внимание, что постфиксный вариант реализован в терминах префикса. Также обратите внимание, что postfix выполняет дополнительную копию. 2

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

2 Также обратите внимание, что вариант постфикса делает больше работы и поэтому менее эффективен для использования, чем вариант префикса. Это хорошая причина, как правило, предпочитает приращение префикса по приращению постфикса. Хотя компиляторы обычно могут оптимизировать дополнительную работу поэтапного приращения для встроенных типов, они, возможно, не смогут сделать то же самое для пользовательских типов (что может быть что-то невинно выглядящим как итератор списка). После того, как вы привыкли делать i++ , становится очень трудно запомнить ++i вместо того, чтобы i не был встроенным типом (плюс вам пришлось бы менять код при смене типа), поэтому лучше используйте привычку всегда использовать приращение префикса, если postfix явно не требуется.

Двоичные арифметические операторы

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

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

class X {
  X& operator+=(const X& rhs)
  {
    // actual addition of rhs to *this
    return *this;
  }
};
inline X operator+(X lhs, const X& rhs)
{
  lhs += rhs;
  return lhs;
}

operator+= возвращает свой результат за ссылку, а operator+ возвращает копию своего результата. Конечно, возврат ссылки обычно более эффективен, чем возврат копии, но в случае с operator+ нет способа копирования. Когда вы пишете a + b , вы ожидаете, что результатом будет новое значение, поэтому operator+ должен вернуть новое значение. 3 Также обратите внимание, что operator+ берет свой левый операнд скопированной, а не константной ссылкой. Причиной этого является то же, что причина, по которой operator= принимает свой аргумент за копию.

Операторы манипуляции бит ~ & | ^ << >> должно быть реализовано так же, как и арифметические операторы. Однако (за исключением перегрузки << и >> для вывода и ввода) существует очень мало разумных вариантов использования для перегрузки.

3 Опять же, урок, который следует извлечь из этого, состоит в том, что a += b , в общем, более эффективен, чем a + b и должен быть предпочтительным, если это возможно.

Подписчики массива

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

class X {
        value_type& operator[](index_type idx);
  const value_type& operator[](index_type idx) const;
  // ...
};

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

Если известно, что value_type ссылается на встроенный тип, вариант const оператора должен возвращать копию вместо ссылки const.

Операторы для указательных типов

Для определения ваших собственных итераторов или интеллектуальных указателей вам необходимо перегрузить унарный префикс-оператор разыменования * и оператор доступа к двоичному указателю-указателю -> :

class my_ptr {
        value_type& operator*();
  const value_type& operator*() const;
        value_type* operator->();
  const value_type* operator->() const;
};

Обратите внимание, что для них тоже почти всегда потребуется как const, так и неконстантная версия. Для оператора -> , если value_type имеет class (или struct или union ), другой operator->() вызывается рекурсивно, пока operator->() возвращает значение типа некласса.

Унарный адрес оператора никогда не должен перегружаться.

Для operator->*() см. Этот вопрос . Он редко используется и, как правило, редко перегружается. На самом деле даже итераторы не перегружают его.

Продолжить конверсию

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

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


Общий синтаксис перегрузки оператора в C ++

Вы не можете изменить значение операторов для встроенных типов в C ++, операторы могут быть перегружены только для пользовательских типов 1 . То есть, по крайней мере, один из операндов должен быть определенного пользователем типа. Как и в случае с другими перегруженными функциями, операторы могут быть перегружены для определенного набора параметров только один раз.

Не все операторы могут быть перегружены в C ++. Среди операторов, которые не могут быть перегружены, являются:. :: sizeof typeid .* и единственный тернарный оператор в C ++, ?:

Среди операторов, которые могут быть перегружены в C ++, являются следующие:

  • арифметические операторы: + - * / % и += -= *= /= %= (все двоичные инфикс); + - (унарный префикс); ++ -- (унарный префикс и постфикс)
  • бит: & | ^ << >> и &= |= ^= <<= >>= (все двоичные инфикс); ~ (унарный префикс)
  • булева алгебра: == != < > <= >= || && (все двоичные инфикс); ! (унарный префикс)
  • управление памятью: new new[] delete delete[]
  • неявные операторы преобразования
  • miscellany: = [] -> ->* , (все двоичные инфикс); * & (весь унарный префикс) () (вызов функции, n-ary infix)

Однако тот факт, что вы можете перегрузить все это, не означает, что вы должны это сделать. См. Основные правила перегрузки оператора.

В C ++ операторы перегружены в виде функций со специальными именами . Как и в случае с другими функциями, перегруженные операторы обычно могут быть реализованы либо как функция-член их типа левого операнда, либо как не-членные функции . Независимо от того, можете ли вы выбрать или использовать его, каждый из них зависит от нескольких критериев. 2 Унарный оператор @ 3 , применяемый к объекту x, вызывается либо как [email protected](x) либо как [email protected]() . Бинарный инфикс-оператор @ , применяемый к объектам x и y , вызывается либо как [email protected](x,y) либо как [email protected](y) . 4

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

1 Термин «определяемый пользователем» может немного вводить в заблуждение. C ++ делает различие между встроенными типами и определенными пользователем типами. К первым относятся, например, int, char и double; к последним относятся все типы struct, class, union и enum, в том числе из стандартной библиотеки, даже если они не являются, как таковые, определенными пользователями.

2 Это описано в более поздней части этого FAQ.

3 @ не является корректным оператором в C ++, поэтому я использую его как заполнитель.

4 Единственный тернарный оператор в C ++ не может быть перегружен, и единственный n-арный оператор всегда должен быть реализован как функция-член.

Перейдите к трем основным правилам перегрузки операторов на C ++ .


Перегрузка new и delete

Примечание. Это касается только синтаксиса перегрузки new и delete , а не реализации таких перегруженных операторов. Я думаю, что семантика перегрузки new и delete заслуживает собственных FAQ , в рамках перегрузки оператора я никогда не смогу оправдать это.

основы

В C ++ при написании нового выражения, такого как new T(arg) две вещи случаются, когда это выражение оценивается: первый operator new вызывается для получения необработанной памяти, а затем вызывается соответствующий конструктор T чтобы превратить эту необработанную память в действительный объект. Аналогично, когда вы удаляете объект, сначала вызывается его деструктор, а затем память возвращается operator delete .
C ++ позволяет вам настраивать обе эти операции: управление памятью и строительство / уничтожение объекта в выделенной памяти. Последнее делается путем написания конструкторов и деструкторов для класса. Точная настройка управления памятью осуществляется путем записи собственного operator new и operator delete .

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

Стандартная библиотека C ++ поставляется с набором предопределенных new и delete операторов. Самые важные из них:

void* operator new(std::size_t) throw(std::bad_alloc); 
void  operator delete(void*) throw(); 
void* operator new[](std::size_t) throw(std::bad_alloc); 
void  operator delete[](void*) throw(); 

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

Размещение new

C ++ позволяет операторам new и delete принимать дополнительные аргументы.
Так называемое размещение new позволяет вам создать объект по определенному адресу, который передается:

class X { /* ... */ };
char buffer[ sizeof(X) ];
void f()
{ 
  X* p = new(buffer) X(/*...*/);
  // ... 
  p->~X(); // call destructor 
} 

Стандартная библиотека поставляется с соответствующими перегрузками для новых и удаленных операторов:

void* operator new(std::size_t,void* p) throw(std::bad_alloc); 
void  operator delete(void* p,void*) throw(); 
void* operator new[](std::size_t,void* p) throw(std::bad_alloc); 
void  operator delete[](void* p,void*) throw(); 

Обратите внимание, что в приведенном выше примере кода для размещения new, operator delete никогда не вызывается, если конструктор X не выбрасывает исключение.

Вы также можете перегружать new и delete другими аргументами. Как и в случае с дополнительным аргументом для размещения new, эти аргументы также перечисляются в круглых скобках после ключевого слова new . Просто по историческим причинам такие варианты часто также называют размещением нового, даже если их аргументы не предназначены для размещения объекта по определенному адресу.

Новый класс и удаление

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

class my_class { 
  public: 
    // ... 
    void* operator new();
    void  operator delete(void*,std::size_t);
    void* operator new[](size_t);
    void  operator delete[](void*,std::size_t);
    // ... 
}; 

Таким образом, перегруженные, новые и удаленные ведут себя как статические функции-члены. Для объектов my_class, то std::size_tаргумент будет всегда sizeof(my_class). Однако эти операторы также вызываются для динамически распределенных объектов производных классов , и в этом случае он может быть больше.

Глобальный новый и удалить

Чтобы перегрузить глобальные новые и удалить, просто замените предварительно определенные операторы стандартной библиотеки на собственные. Однако это редко нужно делать.


Три основных правила перегрузки операторов на C ++

Когда дело доходит до перегрузки оператора на C ++, вы должны следовать трем основным правилам . Как и во всех таких правилах, действительно есть исключения. Иногда люди отклонялись от них, и результат был неплохим кодом, но таких положительных отклонений мало и далеко. По крайней мере, 99 из 100 таких отклонений, которые я видел, были необоснованными. Тем не менее, это могло быть также 999 из 1000. Таким образом, вы должны придерживаться следующих правил.

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

  2. Всегда придерживайтесь известной семантики оператора.
    C ++ не создает ограничений по семантике перегруженных операторов. Ваш компилятор с радостью примет код, который реализует оператор binary + чтобы вычесть его правый операнд. Однако пользователи такого оператора никогда не будут подозревать выражение a + b для вычитания a из b . Конечно, это предполагает, что семантика оператора в области приложения неоспорима.

  3. Всегда предоставляйте все из набора связанных операций.
    Операторы связаны друг с другом и с другими операциями. Если ваш тип поддерживает a + b , пользователи ожидают, что смогут также называть a += b . Если он поддерживает префикс increment ++a , они ожидают, что a++ будет работать. Если они смогут проверить, a < b ли a < b , они, безусловно, будут ожидать, что они также смогут проверить, a > b ли a > b . Если они могут копировать-построить ваш тип, они ожидают, что назначение также будет работать.

Продолжить решение между членом и нечленом .


Почему не может operator<<функционировать для потоковой передачи объектов std::coutв файл или в качестве функции-члена?

Допустим, у вас есть:

struct Foo
{
   int a;
   double b;

   std::ostream& operator<<(std::ostream& out) const
   {
      return out << a << " " << b;
   }
};

Учитывая это, вы не можете использовать:

Foo f = {10, 20.0};
std::cout << f;

Поскольку operator<<он перегружен как функция-член Foo, LHS оператора должен быть Fooобъектом. Это означает, что вам необходимо будет использовать:

Foo f = {10, 20.0};
f << std::cout

что очень неинтуитивно.

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

struct Foo
{
   int a;
   double b;
};

std::ostream& operator<<(std::ostream& out, Foo const& f)
{
   return out << f.a << " " << f.b;
}

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

Foo f = {10, 20.0};
std::cout << f;

который очень интуитивно понятен.





c++-faq