Перегрузка операторов в C++

    Доброго времени суток!

    Желание написать данную статью появилось после прочтения поста Перегрузка C++ операторов, потому что в нём не были раскрыты многие важные темы.

    Самое главное, что необходимо помнить — перегрузка операторов, это всего лишь более удобный способ вызова функций, поэтому не стоит увлекаться перегрузкой операторов. Использовать её следует только тогда, когда это упростит написание кода. Но, не настолько, чтобы это затрудняло чтение. Ведь, как известно, код читается намного чаще, чем пишется. И не забывайте, что вам никогда не дадут перегрузить операторы в тандеме со встроенными типами, возможность перегрузки есть только для пользовательских типов/классов.

    Синтаксис перегрузки


    Синтаксис перегрузки операторов очень похож на определение функции с именем operator@, где @ — это идентификатор оператора (например +, -, <<, >>). Рассмотрим простейший пример:
    class Integer
    {
    private:
        int value;
    public:
        Integer(int i): value(i) 
        {}
        const Integer operator+(const Integer& rv) const {
            return (value + rv.value);
        }
    };
    

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

    В большинстве случаев, операторы (кроме условных) возвращают объект, или ссылку на тип, к которому относятся его аргументы (если типы разные, то вы сами решаете как интерпретировать результат вычисления оператора).

    Перегрузка унарных операторов


    Рассмотрим примеры перегрузки унарных операторов для определенного выше класса Integer. Заодно определим их в виде дружественных функций и рассмотрим операторы декремента и инкремента:
    class Integer
    {
    private:
        int value;
    public:
        Integer(int i): value(i) 
        {}
    
        //унарный +
        friend const Integer& operator+(const Integer& i);
    
        //унарный -
        friend const Integer operator-(const Integer& i);
    
        //префиксный инкремент
        friend const Integer& operator++(Integer& i);
    
        //постфиксный инкремент
        friend const Integer operator++(Integer& i, int);
    
        //префиксный декремент
        friend const Integer& operator--(Integer& i);
    
        //постфиксный декремент
        friend const Integer operator--(Integer& i, int);
    };
    
    //унарный плюс ничего не делает.
    const Integer& operator+(const Integer& i) {
        return i.value;
    }
    
    const Integer operator-(const Integer& i) {
        return Integer(-i.value);
    }
    
    //префиксная версия возвращает значение после инкремента
    const Integer& operator++(Integer& i) {
        i.value++;
        return i;
    }
    
    //постфиксная версия возвращает значение до инкремента
    const Integer operator++(Integer& i, int) {
        Integer oldValue(i.value);
        i.value++;
        return oldValue;
    }
    
    //префиксная версия возвращает значение после декремента
    const Integer& operator--(Integer& i) {
        i.value--;
        return i;
    }
    
    //постфиксная версия возвращает значение до декремента
    const Integer operator--(Integer& i, int) {
        Integer oldValue(i.value);
        i.value--;
        return oldValue;
    }
    

    Теперь вы знаете, как компилятор различает префиксные и постфиксные версии декремента и инкремента. В случае, когда он видит выражение ++i, то вызывается функция operator++(a). Если же он видит i++, то вызывается operator++(a, int). То есть вызывается перегруженная функция operator++, и именно для этого используется фиктивный параметр int в постфиксной версии.

    Бинарные операторы


    Рассмотрим синтаксис перегрузки бинарных операторов. Перегрузим один оператор, который возвращает l-значение, один условный оператор и один оператор, создающий новое значение (определим их глобально):
    class Integer
    {
    private:
        int value;
    public:
        Integer(int i): value(i) 
        {}
        friend const Integer operator+(const Integer& left, const Integer& right);
    
        friend Integer& operator+=(Integer& left, const Integer& right);
    
        friend bool operator==(const Integer& left, const Integer& right);
    };
    
    const Integer operator+(const Integer& left, const Integer& right) {
        return Integer(left.value + right.value);
    }
    
    Integer& operator+=(Integer& left, const Integer& right) {
        left.value += right.value;
        return left;
    }
    
    bool operator==(const Integer& left, const Integer& right) {
        return left.value == right.value;
    }
    

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

    Аргументы и возвращаемые значения


    Как можно было заметить, в примерах используются различные способы передачи аргументов в функции и возвращения значений операторов.
    • Если аргумент не изменяется оператором, в случае, например унарного плюса, его нужно передавать как ссылку на константу. Вообще, это справедливо для почти всех арифметических операторов (сложение, вычитание, умножение...)
    • Тип возвращаемого значения зависит от сути оператора. Если оператор должен возвращать новое значение, то необходимо создавать новый объект (как в случае бинарного плюса). Если вы хотите запретить изменение объекта как l-value, то нужно возвращать его константным.
    • Для операторов присваивания необходимо возвращать ссылку на измененный элемент. Также, если вы хотите использовать оператор присваивания в конструкциях вида (x=y).f(), где функция f() вызывается для для переменной x, после присваивания ей y, то не возвращайте ссылку на константу, возвращайте просто ссылку.
    • Логические операторы должны возвращать в худшем случае int, а в лучшем bool.


    Оптимизация возвращаемого значения


    При создании новых объектов и возвращении их из функции следует использовать запись как для вышеописанного примера оператора бинарного плюса.
    return Integer(left.value + right.value);

    Честно говоря, не знаю, какая ситуация актуальна для C++11, все рассуждения далее справедливы для C++98.
    На первый взгляд, это похоже на синтаксис создания временного объекта, то есть как будто бы нет разницы между кодом выше и этим:
    Integer temp(left.value + right.value);
    return temp;
    

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

    Особые операторы


    В C++ есть операторы, обладающие специфическим синтаксисом и способом перегрузки. Например оператор индексирования []. Он всегда определяется как член класса и, так как подразумевается поведение индексируемого объекта как массива, то ему следует возвращать ссылку.

    Оператор запятая

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

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

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

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

    Оператор присваивания обязательно определяется в виде функции класса, потому что он неразрывно связан с объектом, находящимся слева от "=". Определение оператора присваивания в глобальном виде сделало бы возможным переопределение стандартного поведения оператора "=". Пример:
    class Integer
    {
    private:
        int value;
    public:
        Integer(int i): value(i) 
        {}
    
        Integer& operator=(const Integer& right) {
            //проверка на самоприсваивание
            if (this == &right) {
                return *this;
            }
            value = right.value;
            return *this;
        }
    };
    


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

    Неперегружаемые операторы

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

    • Оператор выбора члена класса ".".
    • Оператор разыменования указателя на член класса ".*"
    • В С++ отсутствует оператор возведения в степень (как в Fortran) "**".
    • Запрещено определять свои операторы (возможны проблемы с определением приоритетов).
    • Нельзя изменять приоритеты операторов


    Рекомендации к форме определения операторов


    Как мы уже выяснили, существует два способа операторов — в виде функции класса и в виде дружественной глобальной функции.
    Роб Мюррей, в своей книге C++ Strategies and Tactics определил следующие рекомендации по выбору формы оператора:
    Оператор
    Рекомендуемая форма
    Все унарные операторы
    Член класса
    = () [] -> ->*
    Обязательно член класса
    += -= /= *= ^= &= |= %= >>= <<=
    Член класса
    Остальные бинарные операторы
    Не член класса


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

    Литература


    Брюс Эккель — Философия C++. Введение в стандартный C++.
    Share post
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 42

      +1
      У вас в комментариях к методам оператора декремента «инкремент».
        0
        Заметил как раз перед комментарием :-)
        fixed
        +3
        Нету про operator new и operator delete :)
          0
          new и delete вообще отдельная тема, раздувать топик не стал. Могу как-нибудь потом написать пару строк :-)
            +1
            Самое интересное-то вы и не написали :)
              +1
              Я соглаен с автором, new и delete это управление памятью — отдельная тема. И в добавок очень большая. Отдельно операторы рассматривать нет смысла, имеет смысл рассмотреть вместе с созданием своего менеджера памяти.

              А вот конструктор копий к оператору равно все же имело смысл прицепить для галочки и объяснить почему написано =, а вызывается конструктор копий.
          +1
          в постфиксных версиях ошибки
            0
            Точно, очередные издержки копипаста.
            fixed
            +1
            > Использовать её следует только тогда, когда это упростит написание кода. Но, не настолько, чтобы это затрудняло чтение. Ведь, как известно, код читается намного чаще, чем пишется.

            Только вы забыли указать самый существенный «ньюанс», то что код читать придется через полгода-годик, а то и больше. А единственное желание когда смотришь на код, в котором с не очевидно что он делает и как — это переписать его нахрен. Мой многолетний опыт говорит — хочешь чтобы тебя ненавидели — перегружай операторы, делай код неочевидным даже самому себе!

              +6
              А ещё есть операторы преобразования типов:

              class A
              {
                  int x;
              
              public:
                  A(int _x): x(_x) {}
              
                  operator int() const {
                      return x;
                  }
              };
              
              void foo() {
                  A a;
                  int b;
                  b = a;
              }
              
                0
                1. Топик стал бы самодостаточным, если бы перегрузка операторов была показана на примерах в которых она улучшает читаемость кода или позволяет гибко его изменять.

                Например для проверки доступа по одному индексу я всегда перегружаю оператор[], а по нескольким индексам оператор ().
                Перегрузка операторов +, -, *, / часто полезно для записи вычислительных алгоритмов где вместо стандартных числовых типов могут быть матрицы, полиномы, вектора, так называемые «длинные числа» (для них можно перегрузить и сдвиги >>,
                  0
                  и т.д.
                  Для обращения по указателю, например при работе со списком, для доступа к данным часто перегружаю оператор *.

                  2. Желательно было бы привести в разделе «Рекомендации к форме определения операторов» более четкое описание почему именно рекомендован такой выбор формы friend или член класса.

                  friend только для бинарных операторов и когда, с точки зрения типа данных (класса), входные аргументы по смыслу находятся в равном положении. Классический пример арифметические операторы +, -, *? /.
                  Во всех остальных случаях член класса.

                  3. Важное упущение при описании. Оператор = при наследовании не наследуется. Если подумать, то будет видно, что при наследовании могут быть добавлены новые поля или изменено их смысловое значение и следовательно оператор = может не работать корректно.

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

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

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

                      PS. Я сначала обрадовался, Вы дали разумное, грамотное замечание, а потом посмотрел на Ваш рейтинг, он даже хуже чем у меня. Позор Хабру, он ориентирован на «толпу», если кто не понял, посмотри в иннете закон элитных групп и как наступает вырождение.
                        0
                        Оффтоп, я просто имел неосторожность высказать некоторые соображения по поводу платформы Android. Карма резво слетела куда-то вниз.
                          0
                          А я как препод (http://www.sgu.ru/node/20122) с 20 летним стажем, сказал все что думаю о изучении paskal фирмы Borland и о том, что лучше начинать с haskell, работать на python, а для продвинутых C++.

                          Но я суда шел чисто из аудитории, может кто задумается, о чем я говорю. Моя задача проста, из этого болота хоть кого то выдернуть.
                    +9
                    Запрещена перегрузка оператора возведения в степень (как в Fortran) "**".

                    В С++ нет оператора возведения в степень
                      0
                      В продолженtи, также запрещена перегрузка терарного ?: и бинарного,
                        0
                        Нет никаких проблем с тем, чтобы перегрузить оператор «запятая» в виде функции-не-члена класса.

                        #include <iostream>
                        
                        class A {};
                        
                        const A& operator, (const A& a, int i)
                        {
                            cout << i << " ";
                            return a;
                        }
                        
                        int main()
                        {
                            A a;
                            a, 1, 2, 3;
                        }
                        


                        Все это верно, если я правильно понял значение «бинарного,».
                      +1
                      Но на самом деле, в этом случае произойдет вызов конструктора в первой строке, далее вызов конструктора копирования, который скопирует объект, а далее, при раскрутке стека вызовется деструктор

                      В этом, частном, примере сработает NRVO, и все будет не так как Вы описали.
                      Для C++11, как Вы правильно заметили, это тоже будет несколько отличаться при наличии move ctor
                        0
                        Кроме. и .* нельзя также перегружать операторы :: и ?:
                          0
                          так как :: и ?: перегружать вообще бессмысленно.
                          0
                          Ещё бы статью про использование встроенных целых типов, например. А то ни в одной книжке по С++ ничего про них нет.
                            +1
                            А что вы хотите видеть в статье про использование встроенных целых типов, чего нет в книжках? Там вроде всё тривиально.
                              0
                              Вот именно, что это есть в любой книжке для начинающих. И про операторы тоже.
                            0
                            Если оператор должен возвращать новое значение, то необходимо создавать новый объект (как в случае бинарного плюса). Если вы хотите запретить изменение объекта как l-value, то нужно возвращать его константным.


                            Если не секрет, как вы предлагаете возвращать константный объект?

                            Я это к тому, что если как в
                            const Integer Integer::operator-(const Integer& i) ;


                            то номер не пройдёт, т.к. при возврате по значению сохраняется семантика копирования объекта.
                              0
                              Речь идет не о семантике копирования, а о семантике операторов вообще.
                              К примеру, если существует перегрузка Integer Integer::operator-(const Integer& i), возвращающая неконстантный объект, то становится возможным существование следующего кода:

                              Integer a = 0, b = 1;
                              a — b = 2;
                              // или запись вот таких забавных уравнений:
                              Integer x = 1;
                              x — 1 = 0;

                              Cо встроенным int такое, конечно, не работает.
                                0
                                то становится возможным существование следующего кода:

                                Не становится.

                                3.10 Lvalues and rvalues
                                5 The result of calling a function that does not return a reference is an rvalue. User defined operators are
                                functions, and whether such operators expect or yield lvalues is determined by their parameter and return
                                types.

                                  0
                                  Убедитесь сами:

                                  class Integer
                                  {
                                  private:
                                      int value;
                                  public:
                                      Integer(int i): value(i) 
                                      {}
                                      friend const Integer operator+(const Integer& left, const Integer& right);
                                  
                                      friend Integer& operator+=(Integer& left, const Integer& right);
                                  
                                      Integer operator-(const Integer& i) { value= -value; return *this; }
                                  
                                      friend bool operator==(const Integer& left, const Integer& right);
                                  };
                                  
                                  const Integer operator+(const Integer& left, const Integer& right) {
                                      return Integer(left.value + right.value);
                                  }
                                  
                                  Integer& operator+=(Integer& left, const Integer& right) {
                                      left.value += right.value;
                                      return left;
                                  }
                                  
                                  bool operator==(const Integer& left, const Integer& right) {
                                      return left.value == right.value;
                                  }
                                  
                                  int main ()
                                  {
                                      Integer i = 0, j = 1;
                                      i - j = 0;
                                      i - 1 = 0;
                                  }
                                  
                                    0
                                    Спасибо. Я считал, что это противоречит стандарту, но тут видимо есть какие-то правила мне не известные…
                              +2
                              Вы уж извините, но я не понимаю смысла данной статьи на хабре, мы это на первом курсе делали. Если автор хотел показать что-то особенное, то я этого к сожалению не увидел.
                                0
                                Вышлите, пожалуйста, полный список того, что вы делали на первом курсе, чтобы впредь хабраавторы точно знали, какие темы затрагивать не нужно.
                                  0
                                  Говоря, что мы подобное делали на первом курсе, я хотел показать то, что задача тривиальна, и я полагал, что на Хабре собрались специалисты более-менее высокого уровня, для которых это не очень актуальный материал. Иначе так можно слиться до уровня форумов для школьников.

                                  P.S. Никого не хочу обидеть, всё моё личное мнение, автор в любом случае молодец, потому что любая статья — труд.
                                    0
                                    P.P.S. хотя студенты будут рады статье)
                                      0
                                      На Хабре собрались в том числе и студенты ВУЗов, в которых уровень преподавания профильных предметов ниже (или просто по-другому составлен учебный план), чем в том, который заканчивали вы, а также изрядное количество школьников, которые даже до первого курса ВУЗа ещё не доросли, а также изрядное количество самоучек, осваивающих программирование (и C++ в частности) самостоятельно, поэтому хорошее и грамотное освещение подобной темы точно не повредит.

                                      По крайней мере данная статья уж точно более полезна, чем очередное «мегафон ворует деньги» или «мы снова нашли стопицот ошибок копи-пэйста в чьём-то C++-коде».
                                        0
                                        Ну если так, то вы безусловно правы.
                                    0
                                    Соглашусь с вами частично: если бы статья на живом примере была показана, а не на абстрактном Integer, который ни для чего, кроме усложнения понимания не нужен.
                                    0
                                    «фиктивный параметр int в постфиксной версии» вообще-то не такой уж и фиктивный. его можно использовать. Пример из MSDN:
                                    class Int
                                    {
                                    public:
                                        Int &operator++( int n );
                                    private:
                                        int _i;
                                    };
                                    
                                    Int& Int::operator++( int n )
                                    {
                                        if( n != 0 )    // Handle case where an argument is passed.
                                            _i += n;
                                        else
                                            _i++;       // Handle case where no argument is passed.
                                        return *this;
                                    }
                                    int main()
                                    {
                                       Int i;
                                       i.operator++( 25 ); // Increment by 25.
                                    }
                                    
                                      0
                                      Конечно можно. Как говорится в статье, перегрузка операторов — это всего лишь более удобный способ вызова функций.
                                      0
                                      Почему для запятой тяжело придумать оператор? Вот, только что пригодилось.
                                      Есть класс «точка на плоскости» — class Point. Нужно определить ее координаты:
                                      Point &operator= (const long double X) { x = X; return *this; } void operator, (const long double Y) { y = Y; }

                                      В итоге потом использую очень удобную конструкцию:
                                      Point pt; // ... pt = 7, 8;
                                        0
                                        Полезная статья. Плюсанул бы, да поезд ушёл :)
                                          0
                                          «Честно говоря, не знаю, какая ситуация актуальна для C++11, все рассуждения далее справедливы для C++98» —
                                          поправьте меня если я не прав, но ситуация

                                          Integer temp(left.value + right.value);
                                          return temp;

                                          в с++ до 11 стандарта во всех компиляторах (кроме написанных пьяными студентами за ночь) сработает return value optimization,
                                          в с++ 11 сработает move semantic.

                                          Only users with full accounts can post comments. Log in, please.