13.2 – Перегрузка арифметических операторов, используя дружественные функции
Одни из наиболее часто используемых операторов в C++ – это арифметические операторы, то есть оператор плюса (+
), оператор минуса (-
), оператор умножения (*
) и оператор деления (/
). Обратите внимание, что все арифметические операторы являются бинарными, то есть они принимают два операнда – по одному с каждой стороны оператора. Все четыре оператора перегружены одинаково.
Оказывается, есть три разных способа перегрузки операторов:
- способ функции-члена;
- способ дружественной функции друга;
- способ обычной функции.
В этом уроке мы рассмотрим способ дружественной функции (потому что он более интуитивно понятен для большинства бинарных операторов). В следующем уроке мы обсудим способ обычной функции. Наконец, еще через один урок мы рассмотрим способ функции-члена. И, конечно же, мы также расскажем более подробно, когда использовать каждый из них.
Перегрузка операторов с помощью дружественных функций
Рассмотрим следующий тривиальный класс:
class Cents
{
private:
int m_cents;
public:
Cents(int cents) { m_cents = cents; }
int getCents() const { return m_cents; }
};
В следующем примере показано, как перегрузить оператор плюс (+
), чтобы сложить два объекта Cents
:
#include <iostream>
class Cents
{
private:
int m_cents;
public:
Cents(int cents) { m_cents = cents; }
// складываем Cents + Cents с помощью дружественной функции
friend Cents operator+(const Cents &c1, const Cents &c2);
int getCents() const { return m_cents; }
};
// обратите внимание: эта функция не является функцией-членом!
Cents operator+(const Cents &c1, const Cents &c2)
{
// используем конструктор Cents и operator+(int, int)
// мы можем получить доступ к m_cents напрямую,
// потому что это дружественная функция
return Cents(c1.m_cents + c2.m_cents);
}
int main()
{
Cents cents1{ 6 };
Cents cents2{ 8 };
Cents centsSum{ cents1 + cents2 };
std::cout << "I have " << centsSum.getCents() << " cents.\n";
return 0;
}
Этот код дает результат:
I have 14 cents.
Перегрузка оператора плюса (+
) проста: объявление функции с именем operator+
, передача ей двух параметров типа операндов, которые мы хотим складывать, выбор соответствующего типа возвращаемого значения и затем написание функции.
В случае нашего объекта Cents
реализовать нашу функцию operator+()
очень просто. Во-первых, типы параметров: в этой версии оператора +
мы собираемся складывать два объекта Cents
, поэтому наша функция будет принимать два объекта типа Cents
. Во-вторых, тип возвращаемого значения: наш оператор +
вернет результат типа Cents
, так что это и есть наш возвращаемый тип.
Наконец, реализация: чтобы сложить два объекта Cents
, нам нужно сложить члены m_cents
из каждого объекта Cents
. Поскольку наша перегруженная функция operator+()
является другом класса, мы можем напрямую обращаться к членам m_cents
наших параметров. Кроме того, поскольку m_cents
является числом int
, а C++ знает, как складывать числа int
. Используя встроенную версию оператора плюс, который работает с целочисленными операндами, мы можем просто использовать этот оператор +
для выполнения сложения.
Перегрузка оператора вычитания (-
) также проста:
#include <iostream>
class Cents
{
private:
int m_cents;
public:
Cents(int cents) { m_cents = cents; }
// складываем Cents + Cents с помощью дружественной функции
friend Cents operator+(const Cents &c1, const Cents &c2);
// вычитаем Cents - Cents с помощью дружественной функции
friend Cents operator-(const Cents &c1, const Cents &c2);
int getCents() const { return m_cents; }
};
// обратите внимание: эта функция не является функцией-членом!
Cents operator+(const Cents &c1, const Cents &c2)
{
// используем конструктор Cents и operator+(int, int)
// мы можем получить доступ к m_cents напрямую,
// потому что это дружественная функция
return Cents(c1.m_cents + c2.m_cents);
}
// обратите внимание: эта функция не является функцией-членом!
Cents operator-(const Cents &c1, const Cents &c2)
{
// используем конструктор Cents и operator-(int, int)
// мы можем получить доступ к m_cents напрямую,
// потому что это дружественная функция
return Cents(c1.m_cents - c2.m_cents);
}
int main()
{
Cents cents1{ 6 };
Cents cents2{ 2 };
Cents centsSum{ cents1 - cents2 };
std::cout << "I have " << centsSum.getCents() << " cents.\n";
return 0;
}
Перегрузить оператор умножения (*
) и оператор деления (/
) так же просто: нужно определить функции operator*
и operator/
соответственно.
Дружественные функции могут быть определены внутри класса
Несмотря на то, что дружественные функции не являются членами класса, при желании они могут быть определены внутри класса:
#include <iostream>
class Cents
{
private:
int m_cents;
public:
Cents(int cents) { m_cents = cents; }
// складываем Cents + Cents с помощью дружественной функции
// Эта функция не считается членом класса,
// даже если определение находится внутри класса
friend Cents operator+(const Cents &c1, const Cents &c2)
{
// используем конструктор Cents и operator+(int, int)
// мы можем получить доступ к m_cents напрямую,
// потому что это дружественная функция
return Cents(c1.m_cents + c2.m_cents);
}
int getCents() const { return m_cents; }
};
int main()
{
Cents cents1{ 6 };
Cents cents2{ 8 };
Cents centsSum{ cents1 + cents2 };
std::cout << "I have " << centsSum.getCents() << " cents.\n";
return 0;
}
Обычно мы не рекомендуем так делать, поскольку определения нетривиальных функций лучше хранить в отдельном файле .cpp, за пределами определения класса. Однако мы будем использовать этот шаблон в будущих уроках, чтобы примеры были короче.
Перегрузка операторов для операндов разных типов
Часто бывает так, что вы хотите, чтобы ваши перегруженные операторы работали с операндами разных типов. Например, если у нас есть Cents(4)
, мы можем добавить к нему целое число 6, чтобы получить результат Cents(10)
.
Когда C++ вычисляет выражение x + y
, x
становится первым параметром, а y
– вторым параметром. Когда x
и y
имеют один и тот же тип, не имеет значения, складываете ли вы x + y
или y + x
– в любом случае вызывается одна и та же версия оператора +
. Однако, когда операнды имеют разные типы, x + y
не вызывает ту же функцию, что и y + x
.
Например, Cents(4) + 6
вызовет operator+(Cents, int)
, а 6 + Cents(4)
вызовет operator+(int, Cents)
. Следовательно, всякий раз, когда мы перегружаем бинарные операторы для операндов разных типов, нам нужно написать две функции – по одной для каждого случая. Например:
#include <iostream>
class Cents
{
private:
int m_cents;
public:
Cents(int cents) { m_cents = cents; }
// складываем Cents + int с помощью дружественной функции
friend Cents operator+(const Cents &c1, int value);
// складываем int + Cents с помощью дружественной функции
friend Cents operator+(int value, const Cents &c1);
int getCents() const { return m_cents; }
};
// обратите внимание: эта функция не является функцией-членом!
Cents operator+(const Cents &c1, int value)
{
// используем конструктор Cents и operator+(int, int)
// мы можем получить доступ к m_cents напрямую,
// потому что это дружественная функция
return { c1.m_cents + value };
}
// обратите внимание: эта функция не является функцией-членом!
Cents operator+(int value, const Cents &c1)
{
// используем конструктор Cents и operator+(int, int)
// мы можем получить доступ к m_cents напрямую,
// потому что это дружественная функция
return { c1.m_cents + value };
}
int main()
{
Cents c1{ Cents{ 4 } + 6 };
Cents c2{ 6 + Cents{ 4 } };
std::cout << "I have " << c1.getCents() << " cents.\n";
std::cout << "I have " << c2.getCents() << " cents.\n";
return 0;
}
Обратите внимание, что обе перегруженные функции имеют одинаковую реализацию – это потому, что они делают одно и то же, они просто принимают параметры в разном порядке.
Еще один пример
Давайте посмотрим на другой пример:
#include <iostream>
class MinMax
{
private:
int m_min; // Минимальное встреченное значение
int m_max; // Максимальное встреченное значение
public:
MinMax(int min, int max)
{
m_min = min;
m_max = max;
}
int getMin() const { return m_min; }
int getMax() const { return m_max; }
friend MinMax operator+(const MinMax &m1, const MinMax &m2);
friend MinMax operator+(const MinMax &m, int value);
friend MinMax operator+(int value, const MinMax &m);
};
MinMax operator+(const MinMax &m1, const MinMax &m2)
{
// Получить минимальное значение из m1 и m2
int min{ m1.m_min < m2.m_min ? m1.m_min : m2.m_min };
// Получить максимальное значение из m1 и m2
int max{ m1.m_max > m2.m_max ? m1.m_max : m2.m_max };
return { min, max };
}
MinMax operator+(const MinMax &m, int value)
{
// Получить минимальное значение из m и value
int min{ m.m_min < value ? m.m_min : value };
// Получить максимальное значение из m и value
int max{ m.m_max > value ? m.m_max : value };
return { min, max };
}
MinMax operator+(int value, const MinMax &m)
{
// вызов operator+(MinMax, int)
return { m + value };
}
int main()
{
MinMax m1{ 10, 15 };
MinMax m2{ 8, 11 };
MinMax m3{ 3, 12 };
MinMax mFinal{ m1 + m2 + 5 + 8 + m3 + 16 };
std::cout << "Result: (" << mFinal.getMin() << ", " <<
mFinal.getMax() << ")\n";
return 0;
}
Класс MinMax
отслеживает минимальные и максимальные значения, которые он встретил. Мы перегрузили оператор +
3 раза, чтобы мы могли сложить два объекта MinMax
или добавить целое число к объекту MinMax
.
Этот пример дает следующий результат:
Result: (3, 16)
что является минимальным и максимальным значениями, которые мы получили, складывая в mFinal
.
Давайте поговорим немного подробнее о том, как вычисляется MinMax mFinal = m1 + m2 + 5 + 8 + m3 + 16
. Помните, что operator+
имеет более высокий приоритет, чем operator=
, и operator+
вычисляется слева направо, поэтому сначала вычисляется m1 + m2
. Это становится вызовом operator+(m1, m2)
, который возвращает значение MinMax(8, 15)
. Затем вычисляется MinMax(8, 15) + 5
. Это становится вызовом operator+(MinMax (8, 15), 5)
, который возвращает значение MinMax(5, 15)
. Затем MinMax(5, 15) + 8
вычисляется таким же образом для получения MinMax(5, 15)
. Затем MinMax(5, 15) + m3
вычисляется для получения MinMax(3, 15)
. И, наконец, MinMax(3, 15) + 16
вычисляется как MinMax(3, 16)
. Этот окончательный результат затем присваивается mFinal
.
Другими словами, это выражение вычисляется как MinMax mFinal = (((((m1 + m2) + 5) + 8) + m3) + 16)
, при этом каждая последующая операция возвращает объект MinMax
, который становится левым операндом для следующего оператора.
Реализация операторов с использованием других операторов
Обратите внимание, что в приведенном выше примере мы определили operator+(int, MinMax)
, вызвав operator+(MinMax, int)
(который дает тот же результат). Это позволяет нам сократить реализацию operator+(int, MinMax)
до одной строки, что упрощает обслуживание нашего кода за счет минимизации избыточности и упрощения понимания функции.
Часто можно определить перегруженные операторы, вызвав другие перегруженные операторы. Вы должны делать так, если и когда это приведет к более простому коду. В случаях, когда реализация тривиальна (например, одна строка), часто не стоит так делать, поскольку дополнительное косвенное обращение к дополнительному вызову функции сложнее, чем просто реализация функции напрямую.
Небольшой тест
Вопрос 1
a) Напишите класс дроби с именем Fraction
, который имеет целочисленные члены числителя и знаменателя. Напишите функцию print()
, которая выводит дробь.
Должен компилироваться следующий код:
#include <iostream>
int main()
{
Fraction f1{ 1, 4 };
f1.print();
Fraction f2{ 1, 2 };
f2.print();
return 0;
}
Он должен напечатать:
1/4
1/2
Ответ
#include <iostream> class Fraction { private: int m_numerator{ 0 }; int m_denominator{ 1 }; public: Fraction(int numerator=0, int denominator=1): m_numerator{numerator}, m_denominator{denominator} { } void print() const { std::cout << m_numerator << '/' << m_denominator << '\n'; } }; int main() { Fraction f1{1, 4}; f1.print(); Fraction f2{1, 2}; f2.print(); return 0; }
b) Добавьте перегруженные операторы умножения для обработки умножения между дробью и целым числом, а также между двумя дробями. Используйте метод дружественной функции.
Подсказка: чтобы перемножить две дроби, сначала перемножьте числители, а затем перемножьте знаменатели. Чтобы умножить дробь на целое число, умножьте числитель дроби на это целое число и оставьте знаменатель неизменным.
Должен компилироваться следующий код:
#include <iostream>
int main()
{
Fraction f1{2, 5};
f1.print();
Fraction f2{3, 8};
f2.print();
Fraction f3{ f1 * f2 };
f3.print();
Fraction f4{ f1 * 2 };
f4.print();
Fraction f5{ 2 * f2 };
f5.print();
Fraction f6{ Fraction{1, 2} * Fraction{2, 3} * Fraction{3, 4} };
f6.print();
return 0;
}
Он должен напечатать:
2/5
3/8
6/40
4/5
6/8
6/24
Ответ
#include <iostream> class Fraction { private: int m_numerator; int m_denominator; public: Fraction(int numerator=0, int denominator=1): m_numerator{numerator}, m_denominator{denominator} { } // Мы не хотим передавать по значению, потому что копирование происходит медленно. // Мы не можем и не должны передавать по неконстантной ссылке, потому что тогда // наши функции не будут работать с r-значениями. friend Fraction operator*(const Fraction &f1, const Fraction &f2); friend Fraction operator*(const Fraction &f1, int value); friend Fraction operator*(int value, const Fraction &f1); void print() const { std::cout << m_numerator << '/' << m_denominator << '\n'; } }; Fraction operator*(const Fraction &f1, const Fraction &f2) { return { f1.m_numerator * f2.m_numerator, f1.m_denominator * f2.m_denominator }; } Fraction operator*(const Fraction &f1, int value) { return { f1.m_numerator * value, f1.m_denominator }; } Fraction operator*(int value, const Fraction &f1) { return { f1.m_numerator * value, f1.m_denominator }; } int main() { Fraction f1{2, 5}; f1.print(); Fraction f2{3, 8}; f2.print(); Fraction f3{ f1 * f2 }; f3.print(); Fraction f4{ f1 * 2 }; f4.print(); Fraction f5{ 2 * f2 }; f5.print(); Fraction f6{ Fraction{1, 2} * Fraction{2, 3} * Fraction{3, 4} }; f6.print(); return 0; }
c) Почему программа продолжает работать правильно, если мы удалим целочисленное умножение из предыдущего решения?
// Мы можем удалить эти операторы, и программа продолжит работу
Fraction operator*(const Fraction &f1, int value);
Fraction operator*(int value, const Fraction &f1);
Ответ
У нас всё еще есть
Fraction operator*(const Fraction &f1, const Fraction &f2)
Когда мы умножаем дробь на целое число, например
Fraction f5{ 2 * f2 }
Конструктор
Fraction(int, int)
будет использоваться для создания нового объектаFraction
из значения 2. Этот новый объектFraction
затем умножается наf2
с помощью оператораFraction * Fraction
.Дополнительное преобразование из 2 во
Fraction
замедляет работу программы, делая ее медленнее, чем реализация с перегруженными операторами для целочисленного умножения.
d) Если мы удалим const
из оператора Fraction * Fraction
, следующая строка из функции main
больше не будет работать. Почему?
// Оператор неконстантного умножения выглядит так
Fraction operator*(Fraction &f1, Fraction &f2)
// Это больше не работает
Fraction f6{ Fraction{1, 2} * Fraction{2, 3} * Fraction{3, 4} };
Ответ
Мы перемножаем временные объекты
Fraction
, а неконстантные ссылки не могут связываться с временными объектами.
e) Дополнительный вопрос: дробь 2/4 равна 1/2, но 2/4 у нас не сокращается до наименьших членов. Мы можем сократить любую заданную дробь до наименьших членов, найдя наибольший общий делитель (НОД, GCD, «greatest common divisor») между числителем и знаменателем, а затем разделив числитель и знаменатель на НОД.
Ниже приведена функция для поиска НОД:
int gcd(int a, int b)
{
return (b == 0) ? (a > 0 ? a : -a) : gcd(b, a % b);
}
Добавьте эту функцию в свой класс и напишите функцию-член с именем reduce()
, которая сокращает дробь. Убедитесь, что все дроби сокращаются правильно.
Должен компилироваться следующий код:
#include <iostream>
int main()
{
Fraction f1{2, 5};
f1.print();
Fraction f2{3, 8};
f2.print();
Fraction f3{ f1 * f2 };
f3.print();
Fraction f4{ f1 * 2 };
f4.print();
Fraction f5{ 2 * f2 };
f5.print();
Fraction f6{ Fraction{1, 2} * Fraction{2, 3} * Fraction{3, 4} };
f6.print();
Fraction f7{0, 6};
f7.print();
return 0;
}
Он должен напечатать:
2/5
3/8
3/20
4/5
3/4
1/4
0/6
Ответ
#include <iostream> // Эта версия класса Fraction автоматически сокращает дроби class Fraction { private: int m_numerator; int m_denominator; public: Fraction(int numerator=0, int denominator=1): m_numerator{numerator}, m_denominator{denominator} { // Мы помещаем reduce() в конструктор, чтобы убедиться, // что любые дроби, которые мы создаем, сокращаются! // Поскольку все перегруженные операторы создают новые дроби, // мы можем гарантировать, что здесь она будет вызвана reduce(); } // Мы сделаем функцию gcd статической, чтобы она могла быть // частью класса Fraction, не требуя использования объекта типа Fraction static int gcd(int a, int b) { return (b == 0) ? (a > 0 ? a : -a) : gcd(b, a % b); } void reduce() { if (m_numerator != 0 && m_denominator != 0) { int gcd{ Fraction::gcd(m_numerator, m_denominator) }; m_numerator /= gcd; m_denominator /= gcd; } } friend Fraction operator*(const Fraction &f1, const Fraction &f2); friend Fraction operator*(const Fraction &f1, int value); friend Fraction operator*(int value, const Fraction &f1); void print() const { std::cout << m_numerator << '/' << m_denominator << '\n'; } }; Fraction operator*(const Fraction &f1, const Fraction &f2) { return { f1.m_numerator * f2.m_numerator, f1.m_denominator * f2.m_denominator }; } Fraction operator*(const Fraction &f1, int value) { return { f1.m_numerator * value, f1.m_denominator }; } Fraction operator*(int value, const Fraction &f1) { return { f1.m_numerator * value, f1.m_denominator }; } int main() { Fraction f1{2, 5}; f1.print(); Fraction f2{3, 8}; f2.print(); Fraction f3{ f1 * f2 }; f3.print(); Fraction f4{ f1 * 2 }; f4.print(); Fraction f5{ 2 * f2 }; f5.print(); Fraction f6{ Fraction{1, 2} * Fraction{2, 3} * Fraction{3, 4} }; f6.print(); Fraction f7{0, 6}; f7.print(); return 0; }