« К оглавлению
‹ Конструкторы ↟ Вверх ↡ Перегрузка операторов Полиморфизм I ›

Перегрузка функций

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


assert(1 == g.get_p() && 5 == g.get_q());

Логический оператор “и” (&&) намекает на то, что все выражение в скобках проверяется на истинность. Если результат выполнения метода get_p для объекта g равен единице и результат выполнения метода get_q для этого же объекта равен 5, то строка пропускается, в противном случае работа программы будет прервана.

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

Например, можно заменить проверку каждого поля в отдельности на один вызов специального метода, который отвечает на вопрос, “равны ли поля объекта переданным значениям?”.


struct rational
{
    bool eq(int __p, int __q)
    {
        return this->p == __p && this->q == __q;
    }
private:
    int p{1};
    int q{1};
};

rational e(1, 5);
assert(e.eq(1, 5));

Если позволить фантазии разгуляться можно представить, что будут ситуации, где таким же образом надо сравнить два объекта типа rational. В предыдущих частях вы бы делали это, поочередно сравнивая соответствующие поля двух объектов с помощью методов get_p и get_q. Но лучшим вариантом было бы сделать отдельный метод, который всю “кухню” скрывает внутри структуры, аналогично примеру выше.


struct rational
{
    bool eq(int __p, int __q)
    {
        return this->p == __p && this->q == __q;
    }
    
    bool eq(const rational & other)
    {
        return this->p == other.p && this->q == other.q;
    }
private:
    int p{1};
    int q{1};
};
    
rational a(2, 3);
rational b(a);
assert(b.eq(a));

Было бы просто замечательно не только если бы можно было сохранить оба этих определения, но и если бы можно было делать сколько угодно таких методов с одинаковыми названиями. Разработчики С++ именно так и думали, потому что оно так и работает. До тех пор, пока функции имеют отличающиеся списки аргументов, они могут иметь одинаковое название и возвращаемый тип.

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


 

Упражнение

Используя это новое знание об инструментах в языке С++ было бы неплохо сделать функции для других часто используемых операций: “больше”, “больше либо равно”, “меньше”, “меньше либо равно”, “не равно”. Не забывайте, что для сравнения неправильных дробей необходимо привести их к общему знаменателю.

 
 

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

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


if (a[i].lt(0))
Здесь lt - это метод из структуры rational, который проверяет, если рациональное число слева строго меньше своего целочисленного аргумента. Даже на этом простом примере видно, как синтаксис языка делает достаточно простую операцию сложной для восприятия. Было бы намного проще понять что происходит, если бы выражение выглядело так:


if (a[i] < 0)

К счастью С++ позволяет так сделать. Многие операторы являются функциями, а вы уже знаете, что функции можно перегружать. Следовательно, операторы тоже могут быть перегружены[1]. Для этого достаточно заменить название функции на ключевое слово operator, за которым указывается непосредственно символ нужного оператора. Например, можно переписать функцию eq.


struct rational
{
    bool operator==(const rational & other)
    {
        return this->p == other.p && this->q == other.q;
    }
private:
    int p{1};
    int q{1};
};
    
assert(b == rational(2, 3));

Но не спешите переписывать все методы, сделанные в предыдущей части, как операторы. Перегрузка операторов требует следовать определенным правилам, и не все методы в структуре rational этим правилам соответствуют. Например, нельзя превратить в оператор метод eq, работающий с двумя целыми числами:


bool operator==(const int __p, const int __q);

Вспомним, что методы вызываются на объект с помощью точки, то есть метод всегда имеет аргумент this, а это значит, что для такой формы записи оператора он бы принимал три аргумента. Но вы знаете, что оператор сравнения принимает только два аргумента - слева и справа от оператора. Ошибка компилятора нам примерно это же и говорит, “error: bool rational::operator==(int, int)' must have exactly one argument”. То есть, в такой форме записи оператора уже есть аргумент this, вдобавок к которому можно передавать только один дополнительный аргумент. Если попробовать исправить это, убрав скрытый аргумент this с помощью ключевого слова static, компилятор сгенерирует другую ошибку:


static bool operator==(const int __p, const int __q);

error: static bool rational::operator==(int, int)' must be either a non-static member function or a non-member function”. Другими словами, убрав аргумент this мы избавились от переменных типа rational в этом методе совсем, и пропала необходимость держать его внутри структуры, так как он не оперирует данными структуры. Если же мы явно зададим аргумент типа rational для этого оператора, мы опять вернемся к патовой ситуации с тремя аргументами.

Единственное, что нам позволяется сделать -- это задать оператор сравнения с одним единственным целым числом:


bool operator==(const int x);

Это позволит делать сравнения такого вида: a == 1; при этом сравнения вида 1 == a будут невозможны, так как первоначальная форма оператора предполагает, что объект типа rational всегда слева (он является скрытым аргументом this, который всегда проверяется слева от оператора).

Данное ограничение тоже можно обойти, делается это с помощью ключевого слова friend.


struct rational
{
    friend bool operator==(const int lhs, const rational & rhs)
    {
        return rhs.p == lhs && rhs.q == lhs;
    }
private:
    int p{1};
    int q{1};
};
    
rational a;
assert(1 == a);

Ключевое слово friend позволяет обойти ограничение на обязательное использование аргумента this, достаточно чтобы аргументов было адекватное количество (для бинарного оператора - два), и чтобы один из аргументов был типа rational. Проблема только в том, чтобы запомнить все эти тонкости. Это очень показательная ситуация. Язык С++ позволяет сделать значительную часть кода легко читаемой при условии, что программист готов затратить большие усилия на предварительную работу по подготовке всех нужных для этого инструментов.

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


struct rational
{
    friend bool operator!=(const int lhs, const rational & rhs)
    {
        return !(lhs == rhs);
    }
private:
    int p{1};
    int q{1};
};

Аналогичным способом можно перегрузить оператор “меньше”. Особое внимание следует обратить на версию этого оператора, которая сравнивает два рациональных числа. Этот оператор используется компилятором для сортировки, поэтому при необходимости отсортировать список рациональных чисел определение этого оператора нужно предоставить.


struct rational
{
    friend bool operator<(const rational & lhs, const rational & rhs)
    {
        if (lhs.q == rhs.q)
        {
            return lhs.p < rhs.p;
        }
        else
        {
            int c = lhs.q * rhs.q;
            rational x, y;
            x.p = lhs.p * rhs.q;
            x.q = c;
            y.p = rhs.p * lhs.q;
            y.q = c;
            return x < y;
        }
    }
private:
    int p{1};
    int q{1};
};
    
rational b(2, 3);
rational c(3, 4);
assert(b < c);

Синтаксис уже должен быть понятен, логика же очень проста: если знаменатель левой части равен знаменателю правой части, меньше то число, числитель которого меньше; если знаменатели не равны, оба числа предварительно необходимо привести к общему знаменателю и сравнить полученные таким образом два числа. Особое внимание стоит обратить на то, что второе сравнение - это рекурсивный вызов этого же оператора, поэтому в зависимости от вашей версии реализации вы можете столкнуться с проблемой бесконечной рекурсии. Именно по этой причине в фрагменте выше поля новых объектов задаются вручную, а не с помощью конструктора. Реализовать все остальные операторы сравнения достаточно просто, они могут быть выражены через уже сделанный оператор “меньше”.


struct rational
{
    friend bool operator>(const rational & lhs, const rational & rhs)
    {
        return rhs < lhs;
    }
    
    friend bool operator>=(const rational & lhs, const rational & rhs)
    {
        return !(lhs < rhs);
    }
    
    friend bool operator<=(const rational & lhs, const rational & rhs)
    {
        return !(rhs < lhs);
    }
private:
    int p{1};
    int q{1};
};

 

Упражнения

1.

Внимательные читатели успели заметить одну деталь в фрагментах кода, представленных выше, которая в тексте пристально не рассматривалась. До того момента, как конструкторы были введены в структуру rational, создавать объекты все равно было можно. Более того, можно было создавать объекты на базе других объектов, например, таким образом: rational b = a; -- где объект a тоже имеет тип rational. Это возможно благодаря сгенерированному автоматически конструктору копирования. Нюанс же заключается в следующем. Что произойдет, если дополнить строку выше такой инструкцией: a = b? Здесь конструирования уже не происходит, потому что оба объекта уже были инициализированы выше, но данные должны копироваться из области, связанной с именем справа от оператора присваивания, в область имени слева от оператора присваивания.

Секрет как раз и состоит в том, что оператор присваивания для структуры rational тоже будет сгенерирован автоматически. Как вы уже можете догадываться, этот оператор тоже можно перегрузить. Он во многом повторяет логику соответствующего конструктора копирования за одним исключением. Если при конструировании объекта не нужно беспокоиться о его состоянии, то при вызове оператора присваивания объект, владеющий этим оператором (то есть, аргумент this), может уже существовать в программе какое-то время, и его состояние этим оператором не устанавливается начисто, а изменяется с одного на другое. В таких ситуациях предварительно приходится объект готовить к присваиванию, для чего оператор должен делать определенные проверки. Конкретно для типа rational такая проверка проста: желательно убедиться что присваивание не производится объектом над самим собой (b = b). В более сложных случаях нужны дополнительные проверки текущего состояния объекта, которые будут рассмотрены в последующих частях. Имея это ввиду попробуйте написать перегрузку для оператора присваивания как для L-значений, так и для R-значений.

Единственное, что может вызвать вопросы - это возвращаемое значение. Так как оператор может быть использован в цепочке вызовов (например, a = b = c), а вызовы эти делаются один за другим, возникает ситуация, при которой каждый последующий вызов делается на результат предыдущего вызова оператора присваивания. Очевидно, что результатом должно быть L-значение того же типа, который находится справа от оператора. Поэтому оператор присваивания в качестве результата своего вызова возвращает то, с чем он сам совместим -- ссылку на объект, который его вызвал (ссылку потому, что мы экономим память и не хотим просто копировать данные из существующей области памяти в новую, чтобы потом оттуда скопировать данные обратно.

2.

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

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


int lcm(int a, int b)
{
    return (a * b) / gcd(a, b);
}    

 

Источники

  1. “Operator overloading”, C++ Reference, cppreference.com

 

Copyright © 2022 Брынзан, Л.В.
GNU General Public License
“Commons Clause” License Condition v1.0
GPL Attribution-ShareAlike 4.0 International