13.2 – Перегрузка арифметических операторов, используя дружественные функции

Добавлено 18 июля 2021 в 09:47

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

Оказывается, есть три разных способа перегрузки операторов:

  1. способ функции-члена;
  2. способ дружественной функции друга;
  3. способ обычной функции.

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

Перегрузка операторов с помощью дружественных функций

Рассмотрим следующий тривиальный класс:

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;
}

Теги

C++ / CppfriendLearnCppДля начинающихОбучениеОператор (программирование)Перегрузка (программирование)Перегрузка операторовПрограммирование

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

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