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

В главе Выражения мы изучили множество операций, определенных для встроенных типов. Например, для переменных типа double мы могли вычислить выражение:

double a = 2.0b = 3.0c = 5.0;
double d = a * b + c;

Было бы удобно использовать похожий синтаксис при работе с пользовательскими типами, например, матрицами:

Matrix a(33), b(33), c(33); // создаем матрицы 3x3
// ... как-то заполняем a, b, c
Matrix d = a * b + c;

MQL5 предоставляет такую возможность за счет перегрузки операторов.

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

тип_результата operator@ ( [тип имя_параметра] );

Здесь @ - символ(ы) операций.

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

Запрещены для перегрузки:

  • двоеточия '::', разрешение контекста;
  • круглые скобки '()', "вызов функции" или "группировка";
  • точка '.', "разыменование";
  • амперсанд '&', "взятие адреса", унарный оператор (однако доступен амперсанд как бинарный оператор "побитовое И");
  • условный тернарный '?:';
  • запятая ','.

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

Создать перегрузку для какого-то нового символа, не входящего в стандартный перечень, нельзя.

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

Методы перегрузки операторов имеют следующий вид (вместо символа '@' подставляется символ(ы) требуемого оператора):

Название

Заголовок метода

Использование
в выражении

Функциональный
эквивалент

унарные префиксные

тип operator@()

@object

object.operator@()

унарные постфиксные

тип operator@(int)

object@

object.operator@(0)

бинарные

тип operator@(тип имя_параметра)

object@argument

object.operator@(argument)

индекс

тип operator[](тип имя_индекса)

object[argument]

object.operator[](argument)

Унарные операторы не принимают параметров. Из унарных только операторы инкремента '++' и декремента '--' поддерживают постфиксную форму в дополнение к префиксной, все остальные унарные операторы — только префиксные. Указание анонимного параметра типа int используется для обозначения постфиксной формы (чтобы отличить от префиксной), но сам параметр игнорируется.

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

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

Отсюда следует, что перегруженные операторы не обладают свойством коммутативности: a@b в общем случае не равно b@a, потому что для a оператор @ может быть перегружен, а для b — нет. Более того, если b является переменной или значением встроенного типа, то для него в принципе нельзя перегрузить стандартное поведение.

В качестве первого примера рассмотрим класс Fibo для генерации чисел из ряда Фибоначчи (мы уже делали одну реализацию данной задачи с помощью функций, см. Определение функции). В классе предусмотрим 2 поля для хранения текущего и предыдущего числа ряда: current и previous соответственно. Конструктор по умолчанию будет их инициализировать значениями 1 и 0. Также предусмотрим конструктор копирования (FiboMonad.mq5).

class Fibo
{
   int previous;
   int current;
public:
   Fibo() : current(1), previous(0) { }
   Fibo(const Fibo &other) : current(other.current), previous(other.previous) { }
   ...
};

Начальное состояние объекта: текущее число 1, предыдущее — 0. Для нахождения следующего числа в ряду перегрузим префиксные и постфиксные операции инкремента.

   Fibo *operator++() // prefix
   {
      int temp = current;
      current = current + previous;
      previous = temp;
      return &this;
   }
   
   Fibo operator++(int// postfix
   {
      Fibo temp = this;
      ++this;
      return temp;
   }

Обратите внимание, что префиксный метод возвращает указатель не текущий объект Fibo после модификации числа, а постфиксный — на новый объект с сохраненным предыдущим счетчиком, что соответствует принципам работы постфиксного инкремента.

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

Операции декремента реализуем по похожему принципу: они будут возвращать предыдущее число ряда.

   Fibo *operator--() // prefix
   {
      int diff = current - previous;
      current = previous;
      previous = diff;
      return &this;
   }
   
   Fibo operator--(int// postfix
   {
      Fibo temp = this;
      --this;
      return temp;
   }

Для получения числа из ряда по заданному номеру перегрузим операцию доступа по индексу.

   Fibo *operator[](int index)
   {
      current = 1;
      previous = 0;
      for(int i = 0i < index; ++i)
      {
         ++this;
      }
      return &this;
   }

Для получения текущего числа, содержащегося в переменной current, перегрузим оператор '~' (как редко используемый).

   int operator~() const
   {
      return current;
   }

Без этой перегрузки потребовалось бы все равно реализовать какой-либо публичный метод для чтения закрытого поля current. Мы воспользуемся этим оператором для вывода чисел с помощью Print.

Также для удобства следует перегрузить присваивание.

   Fibo *operator=(const Fibo &other)
   {
      current = other.current;
      previous = other.previous;
      return &this;
   }
   
   Fibo *operator=(const Fibo *other)
   {
      current = other.current;
      previous = other.previous;
      return &this;
   }

Проверим, как это все работает.

void OnStart()
{
   Fibo f1f2f3f4;
   for(int i = 0i < 10; ++i, ++f1// префиксный инкремент
   {
      f4 = f3++; // постфиксный инкремент и перегрузка присваивания
   }
   
   // сравниваем все значения, полученные инкрементами и по индексу [10]
   Print(~f1" ", ~f2[10], " ", ~f3" ", ~f4); // 89 89 89 55
   
   // отсчет в обратную сторону до 0
   Fibo f0;
   Fibo f = f0[10]; // конструктор копирования (из-за инициализации)
   for(int i = 0i < 10; ++i)
   {
      // префиксный декремент
      Print(~--f); // 55, 34, 21, 13, 8, 5, 3, 2, 1, 1
   }
}

Результаты совпадают с ожидаемыми. Осталось рассмотреть один нюанс.

   Fibo f5;
   Fibo *pf5 = &f5;
   
   f5 = f4;   // вызов Fibo *operator=(const Fibo &other) 
   f5 = &f4;  // вызов Fibo *operator=(const Fibo *other)
   pf5 = &f4// ничего не вызывает, присваивает &f4 в pf5!

Перегрузка оператора присваивания для указателя работает только при обращении через объект. Если обращение идет через указатель, то происходит стандартное присваивание одного указателя другому.

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

Для возврата объекта (экземпляра, а не ссылки) в классе должен быть реализован конструктор копирования. Такой способ вызовет дублирование экземпляра, что может сказаться на эффективности кода. По возможности следует возвращать указатель.

Вместе с тем, при возврате указателя нужно убедиться, что возвращается не локальный автоматической объект (который будет удален при выходе из функции, и указатель станет недействительным), а какой-либо уже существующий — как правило, возвращается &this.

Возврат объекта или указателя на объект позволяет "отправлять" результат работы одного перегруженного оператора в другой, и тем самым конструировать сложные выражения по аналогии с тем, как мы привыкли делать со встроенными типами. Возврат void приведет к невозможности использовать оператор в выражениях. Например, если оператор '=' будет определен с типом void, то перестанет работать множественное присваивание:

Type xyz = 1// конструкторы и инициализация переменных некоего класса
x = y = z// присваивания, ошибка компиляции 

Цепочка присваиваний выполняется справа налево, а y = z вернет пустоту.

Если объекты содержат поля только встроенных типов (включая массивы), то оператор присваивания/копирования '=' из объектов того же класса переопределять не обязательно: MQL5 по умолчанию обеспечивает копирование всех полей "один в один". Не следует путать оператор присваивания/копирования с конструктором копирования и инициализацией.

Теперь обратимся ко второму примеру: работе с матрицами (Matrix.mq5).

Отметим, между прочим, что недавно в MQL5 появились встроенные объектные типы матриц и векторов. Использовать ли встроенные типы или свои (а может быть, комбинировать их) — выбор каждого разработчика. Готовая и быстрая реализация многих востребованных методов во встроенных типах удобна и избавляет от рутинного кодирования. С другой стороны, собственные классы позволяют адаптировать алгоритмы под свои задачи. Здесь мы приводим класс Matrix в качестве учебного пособия.

В классе матрицы обеспечим хранение её элементов в одномерном динамическом массиве m. Под размеры выделим переменные rows и columns.

class Matrix
{
protected:
   double m[];
   int rows;
   int columns;
   void assign(const int rconst int cconst double v)
   {
      m[r * columns + c] = v;
   }
      
public:
   Matrix(const Matrix &other) : rows(other.rows), columns(other.columns)
   {
      ArrayCopy(mother.m);
   }
   
   Matrix(const int rconst int c) : rows(r), columns(c)
   {
      ArrayResize(mrows * columns);
      ArrayInitialize(m0);
   }

Основной конструктор принимает два параметра (размеры матрицы) и выделяет память под массив. Также имеется конструктор копирования из другой матрицы other. Здесь и далее массово используются встроенные функции для работы с массивами (в частности, ArrayCopy, ArrayResize, ArrayInitialize) — они будут рассмотрены в отдельной главе.

Заполнение элементов организуем из внешнего массива, перегрузив оператор присваивания:

   Matrix *operator=(const double &a[])
   {
      if(ArraySize(a) == ArraySize(m))
      {
         ArrayCopy(ma);
      }
      return &this;
   }

Для реализации сложения двух матриц перегрузим операции '+=' и '+':

   Matrix *operator+=(const Matrix &other)
   {
      for(int i = 0i < rows * columns; ++i)
      {
         m[i] += other.m[i];
      }
      return &this;
   }
   
   Matrix operator+(const Matrix &other) const
   {
      Matrix temp(this);
      return temp += other;
   }

Обратите внимание, что оператор '+=' возвращает указатель на текущий объект после его модификации, а оператор '+' — новый экземпляр по значению (будет использован конструктор копирования), причем сам оператор имеет модификатор const, так как не меняет текущий объект.

Оператор '+' является по сути оберткой, которая делегирует всю работу оператору '+=', предварительно создав для его вызова временную копию текущей матрицы под именем temp. Таким образом, temp суммируется с other при помощи внутреннего вызова оператора '+=' (при этом temp модифицируется) и затем возвращается как результат оператора '+'.

Умножение матриц перегружается аналогично, с помощью двух операторов '*=' и '*'.

   Matrix *operator*=(const Matrix &other)
   {
      // условие перемножения: this.columns == other.rows
      // результатом будет матрица размером this.rows на other.columns
      Matrix temp(rows, other.columns);
      
      for(int r = 0r < temp.rows; ++r)
      {
         for(int c = 0c < temp.columns; ++c)
         {
            double t = 0;
            // суммируем попарные произведения i-х элементов
            // ряда 'r' текущей матрицы и столбца 'c' матрицы other
            for(int i = 0i < columns; ++i)
            {
               t += m[r * columns + i] * other.m[i * other.columns + c];
            }
            temp.assign(rct);
         }
      }
      // копируем результат в текущий объект матрицы this
      this = temp// вызов перегруженного оператора присваивания
      return &this;
   }
   
   Matrix operator*(const Matrix &other) const
   {
      Matrix temp(this);
      return temp *= other;
   }

Наконец, умножение матрицы на число:

   Matrix *operator*=(const double v)
   {
      for(int i = 0i < ArraySize(m); ++i)
      {
         m[i] *= v;
      }
      return &this;
   }
   
   Matrix operator*(const double vconst
   {
      Matrix temp(this);
      return temp *= v;
   }

Для сравнения двух матриц предоставим операторы '==' и '!=':

   bool operator==(const Matrix &otherconst
   {
      return ArrayCompare(mother.m) == 0;
   }
   
   bool operator!=(const Matrix &otherconst
   {
      return !(this == other);
   }

Для отладочных целей реализуем вывод массива матрицы в журнал.

   void print() const
   {
      ArrayPrint(m);
   }

Кроме описанных перегрузок в классе Matrix дополнительно имеется перегрузка оператора []: он возвращает объект вложенного класса MatrixRow, то есть строку с заданным номером.

   MatrixRow operator[](int r)
   {
      return MatrixRow(thisr);
   }

Сам класс MatrixRow обеспечивает более "глубокий" доступ к элементам матрицы путем перегрузки того же оператора [] (то есть для матрицы можно будет естественным образом указать два индекса m[i][j]).

   class MatrixRow
   {
   protected:
      const Matrix *owner;
      const int row;
      
   public:
      class MatrixElement
      {
      protected:
         const MatrixRow *row;
         const int column;
         
      public:
         MatrixElement(const MatrixRow &mrconst int c) : row(&mr), column(c) { }
         MatrixElement(const MatrixElement &other) : row(other.row), column(other.column) { }
         
         double operator~() const
         {
            return row.owner.m[row.row * row.owner.columns + column];
         }
         
         double operator=(const double v)
         {
            row.owner.m[row.row * row.owner.columns + column] = v;
            return v;
         }
      };
   
      MatrixRow(const Matrix &mconst int r) : owner(&m), row(r) { }
      MatrixRow(const MatrixRow &other) : owner(other.owner), row(other.row) { }
      
      MatrixElement operator[](int c)
      {
         return MatrixElement(thisc);
      }
   
      double operator[](uint c)
      {
         return owner.m[row * owner.columns + c];
      }
   };

Оператор [] для параметра типа int возвращает объект класса MatrixElement, через который можно записывать конкретный элемент в массиве. Для чтения элемента применяется оператор [] с параметром типа uint. Это похоже на трюк, но таково ограничение языка: перегрузки должны отличаться типом параметра. В качестве альтернативы для чтения элемента в классе MatrixElement предусмотрена перегрузка оператора '~'.

При работе с матрицами часто нужна единичная, поэтому создадим для неё производный класс:

class MatrixIdentity : public Matrix
{
public:
   MatrixIdentity(const int n) : Matrix(nn)
   {
      for(int i = 0i < n; ++i)
      {
         m[i * rows + i] = 1;
      }
   }
};

Теперь попробуем матричные выражения в действии.

void OnStart()
{
   Matrix m(23), n(32); // описание
   MatrixIdentity p(2);     // единичная
   
   double ma[] = {-1,  0, -3,
                   4, -5,  6};
   double na[] = {7,  8,
                  9,  1,
                  2,  3};
   m = ma// заполнение данные
   n = na;
   
   // можем читать и записывать элементы отдельно
   m[0][0] = m[0][(uint)0] + 2// вариант 1 
   m[0][1] = ~m[0][1] + 2;      // вариант 2 
   
   Matrix r = m * n + p;                    // выражение
   Matrix r2 = m.operator*(n).operator+(p); // эквивалент
   Print(r == r2); // true
   
   m.print(); // 1.00000  2.00000 -3.00000  4.00000 -5.00000  6.00000
   n.print(); // 7.00000 8.00000 9.00000 1.00000 2.00000 3.00000
   r.print(); // 20.00000  1.00000 -5.00000  46.00000
}

Здесь мы создали 2 матрицы размером 3 на 2 и 2 на 3 соответственно, затем заполнили их значениями из массивов и отредактировали выборочный элемент с помощью синтаксиса двух индексов [][]. Наконец вычислили выражение m * n + p, где все операнды — это матрицы. Строкой ниже показано то же самое выражение в форме вызовов методов. Результаты совпадают.

В MQL5, в отличие от C++, не поддерживается перегрузка операторов на глобальном уровне. В MQL5 можно перегрузить оператор только в контексте класса или структуры, то есть с помощью их метода. Также MQL5 не поддерживает перегрузку приведения типов, операторов new и delete.