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

Добавлено 2 ноября 2020 в 01:30

Что там с перегрузкой операторов?

Она позволяет вам предоставить интуитивно понятный интерфейс для пользователей вашего класса, а также позволяет шаблонам одинаково хорошо работать с классами и со встроенными/внутренними типами.

Перегрузка операторов позволяет операторам C/C++ иметь определяемое пользователем значение для определяемых пользователем типов (классов). Перегруженные операторы – это синтаксический сахар для вызовов функций:

class Fred {
public:
  // ...
};

#if 0

  // без перегрузки операторов:
  Fred add(const Fred& x, const Fred& y);
  Fred mul(const Fred& x, const Fred& y);

  Fred f(const Fred& a, const Fred& b, const Fred& c)
  {
    return add(add(mul(a,b), mul(b,c)), mul(c,a));    // мда...
  }

#else

  // с перегрузкой операторов:
  Fred operator+ (const Fred& x, const Fred& y);
  Fred operator* (const Fred& x, const Fred& y);

  Fred f(const Fred& a, const Fred& b, const Fred& c)
  {
    return a*b + b*c + c*a;
  }

#endif

Каковы преимущества перегрузки операторов?

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

Конечная цель – сократить как кривую обучения, так и количество брака.


Можно увидеть примеры перегрузки операторов?

Вот несколько из многих примеров перегрузки операторов:

  • myString + yourString может объединять два объекта std::string;
  • myDate++ может инкрементировать объект Date;
  • a * b может умножать два объекта Number;
  • a[i] может получать доступ к элементу объекта Array;
  • x = *p может разыменовать «умный указатель», который «указывает» на запись на диске – он может искать место на диске, куда «указывает» p, и возвращать соответствующую запись в x.

Но перегрузка операторов делает мой класс уродливым; разве она не должна сделать мой код более понятным?

Перегрузка операторов облегчает жизнь пользователям класса, а не его разработчикам!

Рассмотрим следующий пример.

class Array {
public:
  int& operator[] (unsigned i);      // Некоторым людям не нравится этот синтаксис
  // ...
};

inline
int& Array::operator[] (unsigned i)  // Некоторым людям не нравится этот синтаксис
{
  // ...
}

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

int main()
{
  Array a;
  a[3] = 4;   // Пользовательский код должен быть очевиден и легко понимаем...
  // ...
}

Помните: в мире, ориентированном на повторное использование, обычно будет много людей, которые используют ваш класс, но только один человек, который создает его (вы сами); поэтому вы должны делать то, что приносит пользу многим, а не немногим.


Какие операторы можно/нельзя перегружать?

Большинство операторов может быть перегружено. Единственные операторы C, которые не могут быть перегружены, – это . и ?:sizeof, который технически является оператором). C++ добавляет несколько собственных операторов, большинство из которых могут быть перегружены; исключение составляют операторы :: и .*.

Ниже показан пример оператора индекса (он возвращает ссылку). Сначала без перегрузки оператора:

class Array {
public:
  int& elem(unsigned i)        { if (i > 99) error(); return data[i]; }
private:
  int data[100];
};

int main()
{
  Array a;
  a.elem(10) = 42;
  a.elem(12) += a.elem(13);
  // ...
}

А ниже показана та же логика с перегрузкой оператора:

class Array {
public:
  int& operator[] (unsigned i) { if (i > 99) error(); return data[i]; }
private:
  int data[100];
};

int main()
{
  Array a;
  a[10] = 42;
  a[12] += a[13];
  // ...
}

Почему я не могу перегружать . (точка), ::, sizeof и т.п.?

Программистом может быть перегружено большинство операторов. Исключения составляют:

. (dot)  ::  ?:  sizeof

Нет принципиальной причины запрещать перегрузку ?:. До сих пор комитет просто не видел необходимости вводить частный случай перегрузки тернарного оператора. Обратите внимание, что функция, перегружающая expr1?expr2:expr3, не сможет гарантировать, что будет выполнено только одно из expr2 и expr3.

sizeof нельзя перегружать, потому что от него неявно зависят встроенные операции, такие как инкрементирование указателя в массиве. Рассмотрим пример:

X a[10];
X* p = &a[3];
X* q = &a[3];
p++;    // p указывает на a[4]
        // таким образом, целочисленное значение p должно быть на
        // sizeof(X) больше, чем целочисленное значение q

Таким образом, программист не может придать sizeof(X) новое и иное значение без нарушения основных правил языка.

Как насчет ::? В N::m ни N, ни m не являются выражениями со значениями; N и m – имена, известные компилятору, а :: выполняет разрешение области (во время компиляции), а не оценку выражения. Можно представить себе возможность перегрузки x::y, где x – это объект, а не пространство имен или класс, но это (в отличие от первого примера) потребует введения нового синтаксиса (чтобы разрешить expr::expr). Неизвестно, какие преимущества принесет такое усложнение.

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

class Y {
public:
  void f();
  // ...
};

class X {   // предположим, что вы можете перегрузить .
  Y* p;
  Y& operator.() { return *p; }
  void f();
  // ...
};

void g(X& x)
{
  x.f();  // X::f или Y::f или ошибка?
}

Эту проблему можно решить несколькими способами. До сих пор в области стандартизации не было очевидно, какой путь лучше всего. Для получения дополнительной информации смотрите D&E.


Могу ли я определять свои операторы?

Извините, но нет. Возможность рассматривалась несколько раз, но каждый раз решалось, что вероятные проблемы перевешивают вероятные преимущества.

Это не языково-техническая проблема. Даже когда Страуструп впервые рассматривал это в 1983 году, он знал, как это можно реализовать. Однако опыт показывает, что, когда мы выходим за рамки самых тривиальных примеров, люди, кажется, имеют несколько разные мнения об «очевидном» значении использования операторов. Классический пример – a**b**c. Предположим, что ** было сделано для обозначения возведения в степень. Теперь a**b**c должно означать (a**b)**c или a**(b**c)? Эксперты посчитали ответ очевидным, и их друзья согласились, а затем обнаружили, что они не пришли к единому мнению, какое решение было очевидным. Кажется, что такие проблемы приводят к появлению трудноуловимых ошибок.


Могу ли я перегрузить оператор ==, чтобы он позволял мне сравнивать два char[], используя сравнение строк?

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

Но даже если бы C++ позволил вам это сделать (а это не так), вы всё равно не захотите этого делать, поскольку вам следует использовать в первую очередь класс, подобный std::string, а не массив char, потому что массивы – это зло.


Могу ли я создать operator** для операций «возведения в степень»?

Нет.

Имена, приоритет, ассоциативность и арность операторов фиксируются языком. В C++ нет оператора **, поэтому вы не можете создать его для класса.

Если вы сомневаетесь, считайте, что x ** y совпадает с x * (*y) (другими словами, компилятор предполагает, что y является указателем). Кроме того, перегрузка оператора – это просто синтаксический сахар для вызовов функций. Хотя этот синтаксический сахар может быть очень сладким, он не добавляет ничего фундаментального. Я предлагаю вам перегрузить pow(base, exponent) (версия с двойной точностью находится в <cmath>).

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


В предыдущих ответах FAQ говорится, какие операторы я могу переопределить; но какие операторы я должен переопределить?

Определяющий фактор: не вводите пользователей в заблуждение.

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


Есть ли рекомендации / «практические правила» для перегрузки операторов?

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

  1. Используйте здравый смысл. Если ваш перегруженный оператор делает жизнь ваших пользователей проще и безопаснее, перегружайте; если это не так, то лучше не надо. Это самая важная рекомендация. Фактически, это, в самом прямом смысле, единственное правило; остальное – просто частные случаи.
  2. Если вы определяете арифметические операторы, сохраняйте обычные арифметические тождества. Например, если ваш класс определяет x + y и x - y, то x + y - y должно возвращать объект, который поведенчески эквивалентен x. Термин «поведенческий эквивалент» определен в пункте x == y ниже, но, проще говоря, это означает, что в идеале два объекта должны действовать так, как будто они находятся в одном состоянии. Это должно быть правдой, даже если вы решите не определять оператор == для объектов вашего класса.
  3. Вы должны предоставлять арифметические операторы только тогда, когда они имеют логический смысл для пользователей. Вычитание двух дат имеет смысл, логически возвращая продолжительность между этими датами, поэтому вы можете разрешить date1 - date2 для объектов вашего класса Date (при условии, что у вас есть подходящий класс/тип для представления продолжительности между двумя объектами Date). Однако сложение двух дат не имеет смысла: что означает прибавление 4 июля 1776 г. к 5 июня 1959 г.? Точно так же нет смысла умножать или делить даты, поэтому вы не должны определять какие-либо из этих операторов.
  4. Вы должны предоставлять арифметические операторы смешанного режима только тогда, когда они имеют логический смысл для пользователей. Например, имеет смысл добавить длительность (скажем, 35 дней) к дате (скажем, 4 июля 1776 г.), чтобы вы могли определить date + duration для возврата Date. Аналогично date - duration (вычитание длительности из даты) также может возвращать Date. Но duration - date не имеет смысла на концептуальном уровне (что означает вычесть 4 июля 1776 г. из 35 дней?), поэтому вам не следует определять этот оператор.
  5. Если вы предоставляете конструктивные операторы, они должны возвращать свой результат по значению. Например, x + y должно возвращать результат по значению. Если результат возвращается по ссылке, вы, вероятно, столкнетесь с множеством проблем, выясняя, кому принадлежит объект ссылки, и когда этот объект ссылки будет уничтожен. Неважно, эффективнее ли возврат по ссылке; но это наверное неправильно. Для получения дополнительной информации по этому поводу смотрите следующий пункт.
  6. Если вы предоставляете конструктивные операторы, они не должны изменять свои операнды. Например, x + y не должно изменять x. По какой-то безумной причине программисты часто определяют x + y логически как то же самое, что x += y, потому что последнее быстрее. Но помните, ваши пользователи ожидают, что x + y сделает копию. Фактически они выбрали оператор + (например, вместо оператора +=) именно потому, что им нужна была копия. Если бы они хотели изменить x, они бы использовали вместо этого то, что эквивалентно x += y. Не принимайте семантических решений за пользователей; это их решение, а не ваше, хотят ли они семантики x + y или x += y. Если хотите, то скажите им, что одно из этих выражений будет быстрее, но затем сделайте шаг назад и позвольте им принять окончательное решение – они знают, чего они пытаются достичь, а вы – нет.
  7. Если вы предоставляете конструктивные операторы, они должны разрешать продвижение левого операнда (по крайней мере, в случае, когда класс имеет конструктор с одним параметром, который не помечен ключевым словом explicit). Например, если ваш класс Fraction поддерживает переход от int к Fraction (через неявный конструкторFraction::Fraction(int)), и если вы разрешаете x - y для двух объектов Fraction, вы также должны разрешить 42 - y. На практике это просто означает, что ваш operator-() не должен быть функцией-членом Fraction. Как правило, вы сделаете его другом, хотя бы по той простой причине, что заставить его войти в часть public: класса, но даже если он не является другом, он не должен быть его членом.
  8. В общем, ваш оператор должен изменять свои операнды тогда и только тогда, когда операнды изменяются, когда вы применяете тот же оператор к встроенным типам. x == y и x << y не должны изменять ни один из операндов; x *= y и x <<= y должны (но только левый операнд).
  9. Если вы определяете x++ и ++x, сохраняйте общепринятые идентичности. Например, x++ и ++x должны иметь одинаковый наблюдаемый эффект на x и должны отличаться только тем, что они возвращают. ++x должен возвращать x по ссылке; x++ должен либо возвращать копию (по значению) исходного состояния x, либо иметь возвращаемый тип void. Обычно лучше возвращать копию исходного состояния x по значению, особенно если ваш класс будет использоваться в общих алгоритмах. Самый простой способ сделать это – реализовать x++ с использованием трех строк: создать локальную копию *this, вызвать ++x (т.е. this->operator++()), а затем вернуть локальную копию. Аналогичные комментарии подходят и для x-- и --x.
  10. Если вы определяете ++x и x += 1, сохраняйте общепринятые идентичности. Например, эти выражения должны иметь одинаковое наблюдаемое поведение, включая одинаковый результат. Среди прочего, это означает, что ваш оператор += должен возвращать x по ссылке. Аналогичные комментарии подходят и для --x и x -= 1.
  11. Если вы определяете *p и p[0] для объектов, подобных указателю, сохраняйте общепринятые идентичности. Например, эти два выражения должны иметь одинаковый результат, и ни одно из них не должно изменять p.
  12. Если вы определяете p[i] и *(p + i) для объектов, подобных указателю, сохраняйте общепринятые идентичности. Например, эти два выражения должны иметь одинаковый результат, и ни одно из них не должно изменять p. Аналогичные комментарии подходят и для p[-i] и *(p-i).
  13. Операторы индекса обычно идут парами; смотрите ответ о перегрузке const.
  14. Если вы определяете x == y, то x == y должно быть истинным тогда и только тогда, когда эти два объекта поведенчески эквивалентны. В этом пункте термин «поведенческий эквивалент» означает, что наблюдаемое поведение любой операции или последовательности операций, применяемых к x, будет таким же, как и при применении к y. Термин «операция» означает методы, друзей, операторов или что-либо еще, что вы можете делать с этими объектами (кроме, конечно, оператора адресации). Вы не всегда сможете достичь этой цели, но вам следует стремиться к этому и документировать любые отклонения (кроме оператора адреса).
  15. Если вы определяете x == y и x = y, сохраняйте общепринятые идентичности. Например, после присвоения два объекта должны быть равны. Даже если вы не определите x == y, эти два объекта должны быть поведенчески эквивалентными (значение этой фразы смотрите выше) после присваивания.
  16. Если вы определяете x == y и x != y, вы должны поддерживать общепринятые идентичности. Например, эти выражения должны возвращать что-то, что можно преобразовать в bool, ни одно из них не должно изменять свои операнды, а x == y должно иметь тот же результат, что и !(x != y), и наоборот.
  17. Если вы определяете операторы неравенства, такие как x <= y и x < y, вы должны поддерживать общепринятые идентичности. Например, если x < y и y < z истинны, то x < z также должно быть истинным и т.д. Подобные комментарии подходят и для x >= y и x > y.
  18. Если вы определяете операторы неравенства, такие как x < y и x >= y, вы должны поддерживать общепринятые идентичности. Например, x < y должно иметь результат что и !(x >= y). Вы не всегда сможете это сделать, но вам следует стремиться к этому и задокументировать любые отклонения. Подобные комментарии подходят и для x > y и !(x <= y) и т.д.
  19. Избегайте перегрузки операторов короткозамкнутых вычислений: x || y или x && y. Их перегруженные версии не являются короткозамкнутыми – они оценивают оба операнда, даже если левый операнд «определяет» результат, что сбивает пользователей с толку.
  20. Избегайте перегрузки оператора запятой: x, y. Перегруженный оператор запятой не имеет тех же свойств упорядочивания, которые он имеет, когда не перегружен, и это сбивает пользователей с толку.
  21. Не перегружайте оператора, который не интуитивно понятен вашим пользователям. Это называется доктриной наименьшего удивления. Например, хотя C++ использует для печати std::cout << x, и хотя печать технически называется вставкой, и хотя вставка звучит похоже на то, что происходит, когда вы помещаете элемент в стек, не перегружайте myStack << x, чтобы поместить элемент в стек. Это может иметь смысл, когда вы действительно устали или психически неполноценны, и некоторые из ваших друзей могут подумать, что это «круто», но просто скажите «Нет».
  22. Используйте здравый смысл. Если вы не видите здесь «своего» оператора, вы можете разобраться с ним сами. Просто помните конечные цели перегрузки операторов: облегчить жизнь вашим пользователям, в частности, сделать их код более дешевым для написания и более понятным.

Предостережение: этот список не является исчерпывающим. Это означает, что есть и другие пункты, которые вы можете считать «пропущенными». Я знаю.

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


Как создать оператор индекса для класса Matrix?

Используйте operator() вместо operator[].

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

Например:

class Matrix {
public:
  Matrix(unsigned rows, unsigned cols);
  double& operator() (unsigned row, unsigned col);        // Операторы индекса часто идут в паре
  double  operator() (unsigned row, unsigned col) const;  // Операторы индекса часто идут в паре
  // ...
 ~Matrix();                              // Деструктор
  Matrix(const Matrix& m);               // Конструктор копирования
  Matrix& operator= (const Matrix& m);   // Конструктор присваивания
  // ...
private:
  unsigned rows_, cols_;
  double* data_;
};

inline
Matrix::Matrix(unsigned rows, unsigned cols)
  : rows_ (rows)
  , cols_ (cols)
//, data_ ← инициализировано ниже после выражения if...throw
{
  if (rows == 0 || cols == 0)
    throw BadIndex("Matrix constructor has 0 size");
  data_ = new double[rows * cols];
}

inline
Matrix::~Matrix()
{
  delete[] data_;
}

inline
double& Matrix::operator() (unsigned row, unsigned col)
{
  if (row >= rows_ || col >= cols_)
    throw BadIndex("Matrix subscript out of bounds");
  return data_[cols_*row + col];
}

inline
double Matrix::operator() (unsigned row, unsigned col) const
{
  if (row >= rows_ || col >= cols_)
    throw BadIndex("const Matrix subscript out of bounds");
  return data_[cols_*row + col];
}

Затем вы можете получить доступ к элементу Matrix m, используя m(i,j) вместо m[i][j]:

int main()
{
  Matrix m(10,10);
  m(5,8) = 106.15;
  std::cout << m(5,8);
  // ...
}

Для более подробной информации о причинах использования m(i,j), вместо m[i][j], смотрите ответ на следующий вопрос.


Почему интерфейс моего класса Matrix не должен выглядеть как массив массивов?

Вот о чем на самом деле этот ответ FAQ: некоторые люди создают класс Matrix, который имеет operator[], который возвращает ссылку на объект Array (или, возможно, на необработанный массив, аж передергивает), и этот объект Array имеет operator[], который возвращает элемент Matrix (например, ссылку на double). Таким образом, они обращаются к элементам матрицы, используя синтаксис типа m[i][j], а не синтаксис типа m(i,j).

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

Например, самый простой способ реализовать подход [][] – использовать физическую компоновку матрицы как плотную матрицу, которая хранится в виде на основе строк (или на основе столбцов; я не могу вспомнить). Подход operator(), напротив, полностью скрывает физическое размещение матрицы и в некоторых случаях может привести к повышению производительности.

Скажем так: подход operator() никогда не бывает хуже, а иногда бывает и лучше, чем подход [][].

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

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

Конечно, есть много подобных примеров из численных методов, и разреженные матрицы – это совершенно другое измерение в этом вопросе. Поскольку, как правило, с использованием подхода operator() реализовать разреженную матрицу или поменять местами порядок строк/столбцов проще, подход operator() ничего не теряет и может что-то получить – у него нет недостатков, и есть потенциальные преимущества.

Используйте подход operator().


Я всё еще не понимаю. Почему интерфейс моего класса Matrix не должен выглядеть как массив массивов?

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

Некоторые люди используют [][], несмотря на его ограничения, утверждая, что [][] лучше, потому что он быстрее или потому, что он использует C-синтаксис. Проблема с аргументом «это быстрее» в том, что это не так – по крайней мере, не в последних версиях двух самых известных в мире компиляторов C++. Проблема с аргументом «использует C-синтаксис» заключается в том, что C++ не является C. Плюс C-синтаксис затрудняет изменение структуры данных и затрудняет проверку значений параметров.

Суть предыдущих двух ответов FAQ заключается в том, что m(i,j) дает вам чистый и простой способ проверить все параметры и скрыть (и, следовательно, при желании изменить) внутреннюю структуру данных. В мире уже слишком много открытых структур данных и слишком много параметров, выходящих за границы, и это стоит слишком больших денег и вызывает слишком много задержек и слишком много проблем.

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

Возможно, я утрирую. Но в этом был смысл. Дело в том, что инкапсуляция и проверка параметров – не костыли для слабых. Разумно использовать методы, которые упрощают инкапсуляцию и/или проверку параметров. Синтаксис m(i,j) – один из таких приемов.

После всего этого, если вы обнаружите, что поддерживаете приложение на миллиард строк, в котором первоначальная команда использовала m[i][j], или даже если вы пишете совершенно новое приложение и просто хотите использовать m[i][j], вы всё равно можете инкапсулировать структуру данных и/или проверить все свои параметры. Это даже не так сложно. Однако для этого требуется уровень сложности, которого, нравится вам это или нет, опасаются программисты C++ среднего уровня. К счастью, вы уже далеко не среднего уровня, поэтому читайте дальше.

Если вы просто хотите проверить параметры, просто убедитесь, что внешний operator[] возвращает объект, а не необработанный массив, тогда operator[] этого объекта сможет проверить свой параметр обычным способом. Помните, что это может замедлить вашу программу. В частности, если эти подобные массивам, внутренние объекты в конечном итоге выделяют собственный блок памяти для своей строки матрицы, накладные расходы на производительность для создания/уничтожения ваших объектов матриц могут резко возрасти. Теоретическая стоимость по-прежнему составляет O (строки × столбцы), но на практике накладные расходы распределителя памяти (new или malloc) могут быть намного больше, чем что-либо еще, и другие затраты могут стать незаметными на фоне этих накладных расходов. Например, в двух самых известных компиляторах C++ метод с отдельным выделением памяти для каждой строки был в 10 раз медленнее, чем метод «одно выделение памяти для всей матрицы». 10% – это одно, 10х – другое.

Если вы хотите проверить параметры без вышеуказанных накладных расходов и/или если вы хотите инкапсулировать (и, возможно, изменить) внутреннюю структуру данных матрицы, выполните следующие действия:

  1. Добавьте operator()(unsigned row, unsigned col) в класс Matrix.
  2. Создайте вложенный класс Matrix::Row. У него должен быть конструктор с параметрами (Matrix& matrix, unsigned row), и он должен хранить эти два значения в своем объекте this.
  3. Измените Matrix::operator[](unsigned row), чтобы он возвращал объект класса Matrix::Row, например, { return Row(*this,row); }.
  4. Затем класс Matrix::Row определяет свой собственный operator[](unsigned col), который разворачивается и вызывает, как вы уже догадались, Matrix::operator()(unsigned row, unsigned col). Если члены данных Matrix::Row называются Matrix& matrix_ и unsigned row_, код для Matrix::Row::operator[](unsigned col) будет { return matrix_(row_, col); }

Затем включите перегрузку const, повторив вышеуказанные шаги. Вы создадите константную версию этих методов и создадите новый вложенный класс, вероятно, названный Matrix::ConstRow. Не забудьте использовать const Matrix& вместо Matrix&.

Если у вас есть достойный компилятор, и вы разумно используете встраивание, компилятор должен оптимизировать временные объекты. Другими словами, мы надеемся, что описанный выше подход operator[] – не будет медленнее, чем, если бы вы напрямую вызывали Matrix::operator()(unsigned row, unsigned col). Конечно, вы могли бы упростить себе жизнь и избежать большей части вышеуказанной работы, в первую очередь напрямую вызвав Matrix::operator()(unsigned row, unsigned col). А также вы можете напрямую вызывать Matrix::operator()(unsigned row, unsigned col).


Как мне разрабатывать свои классы, снаружи (сначала интерфейсы) или изнутри (сначала данные)?

Снаружи!

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

Сначала подумайте о том, что объект представляет собой логически, а не о том, как вы собираетесь его физически построить. Например, предположим, что у вас есть класс Stack, который будет построен с использованием LinkedList:

class Stack {
public:
  // ...
private:
  LinkedList list_;
};

Должен ли Stack иметь метод get(), который возвращает LinkedList? Или метод set(), который принимает LinkedList? Или конструктор, который принимает LinkedList? Очевидно, что ответ – нет, поскольку вы должны проектировать свои интерфейсы снаружи. То есть пользователей объектов Stack не волнуют объекты LinkedList; им важны добавление элементов в стек и их удаление из него.

Теперь другой пример. Предположим, что класс LinkedList построен с использованием связанного списка объектов Node, где каждый объект Node имеет указатель на следующий узел:

class Node { /*...*/ };

class LinkedList {
public:
  // ...
private:
  Node* first_;
};

Должен ли класс LinkedList иметь метод get(), который позволит пользователям получить доступ к первому узлу? Должен ли объект Node иметь метод get(), который позволит пользователям проследовать к следующему узлу в цепочке? Другими словами, как должен выглядеть LinkedList снаружи? Действительно ли LinkedList представляет собой цепочку объектов Node? Или это просто деталь реализации? И если это всего лишь деталь реализации, как LinkedList позволит пользователям получать доступ к каждому из элементов LinkedList по отдельности?

Ключевым моментом является осознание того, что LinkedList не является цепочкой узлов. Может быть, так он устроен, но он ею не является. Это последовательность элементов. Следовательно, абстракция LinkedList должна также предоставлять класс LinkedListIterator, и этот LinkedListIterator может иметь operator++ для перехода к следующему элементу, и у него может быть пара get()/set() для доступа к его значению, хранящемуся в Node (значение в элементе Node – это исключительная ответственность пользователя LinkedList, поэтому существует пара get()/set(), которая позволяет пользователю свободно манипулировать этим значением).

Начиная с точки зрения пользователя, нам может потребоваться, чтобы наш класс LinkedList поддерживал операции, похожие на доступ к элементам массива с использованием арифметики указателей:

void userCode(LinkedList& a)
{
  for (LinkedListIterator p = a.begin(); p != a.end(); ++p)
    std::cout << *p << '\n';
}

Для реализации этого интерфейса LinkedList понадобится метод begin() и метод end(). Они возвращают объект LinkedListIterator. LinkedListIterator будут нужны метод для продвижения вперед, ++p; метод доступа к текущему элементу, *p; и оператор сравнения, p != a.end().

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

#include <cassert>    // обработка исключений для бедных

class LinkedListIterator;
class LinkedList;

class Node {
  // Нет публичных методов; это "приватный класс"
  friend class LinkedListIterator;   // дружественный класс
  friend class LinkedList;
  Node* next_;
  int elem_;
};

class LinkedListIterator {
public:
  bool operator== (LinkedListIterator i) const;
  bool operator!= (LinkedListIterator i) const;
  void operator++ ();   // перейти к следующему элементу
  int& operator*  ();   // доступ к текущему элементу
private:
  LinkedListIterator(Node* p);
  Node* p_;
  friend class LinkedList;  // поэтому LinkedList может создавать LinkedListIterator
};

class LinkedList {
public:
  void append(int elem);    // добавляет элемент в конец
  void prepend(int elem);   // добавляет элемент в начало
  // ...
  LinkedListIterator begin();
  LinkedListIterator end();
  // ...
private:
  Node* first_;
};

Вот методы, которые явно являются встраиваемыми (и, вероятно, находятся в том же заголовочном файле):

inline bool LinkedListIterator::operator== (LinkedListIterator i) const
{
  return p_ == i.p_;
}

inline bool LinkedListIterator::operator!= (LinkedListIterator i) const
{
  return p_ != i.p_;
}

inline void LinkedListIterator::operator++()
{
  assert(p_ != NULL);  // или, если (p_==NULL), выкинуть...
  p_ = p_->next_;
}

inline int& LinkedListIterator::operator*()
{
  assert(p_ != NULL);  // или, если (p_==NULL), выкинуть...
  return p_->elem_;
}

inline LinkedListIterator::LinkedListIterator(Node* p)
  : p_(p)
{ }

inline LinkedListIterator LinkedList::begin()
{
  return first_;
}

inline LinkedListIterator LinkedList::end()
{
  return NULL;
}

Заключение: связанный список имеет два разных типа данных. Значения элементов, хранящихся в связанном списке, находятся в зоне ответственности пользователя связанного списка (и только пользователя; сам связанный список не пытается запретить пользователям изменять третий элемент на 5), а данные инфраструктуры связанного списка (указатели next и т.д.) и их значения находятся в зоне ответственности связанного списка (и только связанного списка; например, связанный список не позволяет пользователям изменять (или даже просматривать!) различные указатели next).

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

Итак, мы видим здесь пример, в котором значения некоторых данных класса являются ответственностью пользователей (в этом случае класс должен иметь методы get()/set() для этих данных), а данные, которыми хочет управлять сам класс, не обязательно иметь методы get()/set().

Примечание: цель этого примера – не показать вам, как написать класс связанного списка. Фактически, вам не следует «раскатывать свой собственный» класс связанного списка, поскольку вы должны использовать один из «классов-контейнеров», предоставляемых вашим компилятором. В идеале вы должны использовать один из стандартных контейнерных классов, например шаблон std::list<T>.


Как я могу перегрузить префиксную и постфиксную формы операторов ++ и --?

Через фиктивный параметр.

Поскольку префиксный и постфиксный операторы ++ могут иметь два определения, язык C++ дает нам две разные сигнатуры. Обе они называются operator++(), но префиксная версия не принимает параметров, а постфиксная версия принимает фиктивный int. (Хотя это обсуждение вращается вокруг оператора ++, оператор -- полностью симметричен, и все правила и рекомендации, применимые к одному, применимы и к другому.)

class Number {
public:
  Number& operator++ ();    // префиксный ++
  Number  operator++ (int); // постфиксный ++
};

Обратите внимание на разные типы возвращаемых значений: префиксная версия возвращает результат по ссылке, постфиксная версия – по значению. Если причина этого была вам не очевидна сразу, то ситуация прояснится после того, как вы увидите определения (и после того, как вы вспомните, что y = x++ и y = ++x присваивают y разные значения).

Number& Number::operator++ ()
{
  // ...
  return *this;
}

Number Number::operator++ (int)
{
  Number ans = *this;
  ++(*this);  // или просто вызов operator++()
  return ans;
}

Другой вариант для постфиксной версии – ничего не возвращать:

class Number {
public:
  Number& operator++ ();
  void    operator++ (int);
};

Number& Number::operator++ ()
{
  // ...
  return *this;
}

void Number::operator++ (int)
{
  ++(*this);  // или просто вызов operator++()
}

Однако вы не должны заставлять постфиксную версию возвращать объект this по ссылке; вы были предупреждены.

Вот как вы используете эти операторы:

Number x = /* ... */;
++x;  // вызывает Number::operator++(), т.е. вызывает x.operator++()
x++;  // вызывает Number::operator++(int), т.е. вызывает x.operator++(0)

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

Number x = /* ... */;
Number y = ++x;  // y будет равно новому значению x
Number z = x++;  // z будет равно старому значению x

Что более эффективно: i++ или ++i?

++i иногда быстрее, но никогда не медленнее, чем i++.

Для встроенных типов, таких как int, это не имеет значения: ++i и i++ имеют одинаковую скорость. Для таких типов классов, как итераторы или класс Number из предыдущего ответа FAQ, ++i вполне может быть быстрее, чем i++, поскольку последний может создавать копию объекта this.

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

Итак, если вы пишете i++ как отдельное выражение, а не как часть более крупного выражения, почему бы просто не написать вместо него ++i? Вы никогда ничего не теряете, а иногда что-то приобретаете. Старые программисты C привыкли писать i++ вместо ++i. Например, они скажут for (i = 0; i < 10; i++) .... Поскольку здесь i++ используется как отдельное выражение, а не как часть более крупного выражения, вы можете вместо него использовать ++i. Что касается симметрии, я лично защищаю этот стиль, даже если он не улучшает скорость, например, для встроенных типов и для типов классов с постфиксными операторами, которые возвращают void.

Очевидно, что когда i++ появляется как часть более крупного выражения, это другое дело: он используется потому, что это единственное логически правильное решение, а не потому, что это старая привычка, которую вы приобрели при программировании на C.

Теги

C++ / CppFAQВысокоуровневые языки программированияОператор (программирование)Перегрузка (программирование)ПрограммированиеЯзыки программирования

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.