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

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

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


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

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

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

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


class rational {
public:
    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. Но лучшим вариантом было бы сделать отдельный метод, который всю “кухню” скрывает внутри класса, аналогично примеру выше.


class rational {
public:
    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) { … }

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


class rational {
public:
    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==(int __p, int __q); 

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


static bool operator==(int __p, 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==(int x);

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

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


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

Ключевое слово friend позволяет обойти ограничение на обязательное использование аргумента this, достаточно чтобы аргументов было адекватное количество (для бинарного оператора – два), и чтобы один из аргументов был типа rational.


rational a;
assert(1 == a); 

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

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


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

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

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

Другой алгоритм предполагает использование следующего свойства для некоторых натуральных чисел, при условии, что b,d{0}:

a b < c d ad < cb .

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


friend bool operator<(const rational &lhs, const rational &rhs) {
    auto x = (long double)lhs.p / (long double)lhs.q;
    auto y = (long double)rhs.p / (long double)rhs.q;
    return x < y;
}

Синтаксис уже должен быть понятен. Здесь сохраняется проблема переполнения в том случае, когда числитель или знаменатель хотя бы одного из операндов представлены значениями, превышающими максимальное значение типа long double. Соответственно, для данной реализации инвариантом3 является диапазон допустимых значений для числителя и знаменателя аргументов. В данном случае это определяется на уровне класса rational, а следовательно может контролироваться выбранным изначально типом, единственное требование к которому – быть подмножеством (или приводиться к нему) множества чисел, представляемых типом long double (это зависит от конкретного компилятора для конкретной архитектуры процессора, что можно проверять в самой операции). Для такой реализации код ниже будет рабочим.


rational b(2, 3);
rational c(3, 4);
assert(b < c);

Реализовать все остальные операторы сравнения достаточно просто, они могут быть выражены через уже сделанный оператор “меньше”.


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

 

Упражнения

1

Внимательные читатели успели заметить одну деталь в фрагментах кода, представленных выше, которая в тексте пристально не рассматривалась. До того момента, как конструкторы были введены в класс rational, создавать объекты все равно было можно. Более того, можно было создавать объекты на базе других объектов, например, таким образом:


rational b = a;

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

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

2

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


 

Сноски

1Например, сравнение двух чисел rational(2, 3) и rational(-3, -4) даст неправильный результат, так как он зависит от сравнения выражений 2*-4 и -3*3, то есть, -8 и -9, где правая часть меньше левой.

2На практике применяется способ, который не вызывает переполнения при арифметических операциях с числителем и знаменателем; например, реализация оператора “меньше” в библиотеке Boost упрощает аргументы, приводя их к непрерывным дробям, используя алгоритм Евклида для нахождения наибольшего общего делителя [Moore].

3Свойство какой-либо сущности в выражении, которое остается неизменным, независимо от условий.


 

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