« К оглавлению
‹ Перегрузка функций ↟ Вверх Наследование ›

Полиморфизм, первая часть

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

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


rational a(2, 3);
rational b(a);
rational c("3/15");

очевидно, что одна и та же функция (конструктор rational) принимает разные типы данных в качестве аргументов: то пару int, то объект rational, а то и указатель на строку char*. Вы уже знаете, что это на самом деле достигается не полиморфными типами данных, а перегрузкой – наличием разных функций с одинаковым названием, но с точки зрения программы это тоже своего рода полиморфизм, а точнее один из примеров “ad hoc* полиморфизма.

Другой пример “ad hoc” полиморфизма – приведение типов – вы тоже, скорее всего, видели, но не знали, что это называют таким термином. Если вам в голову не приходит конкретный пример, внимательно посмотрите на следующую строку:

rational b(2.5, 3.8);

Подумайте, как будет выглядеть конечный объект b. Очевидно, что оба аргумента являются значениями типа float, то есть не представлены целыми числами, как того требует интерфейс структуры rational. Тем не менее, объект будет создан без ошибок, и если вы предположили, что его значением будет число 2⁄3, вы угадали. Так как при вызове подходящего конструктора вариант с дробными числами найден не будет, компилятор попробует выбрать ближайший из возможных**, согласно правилам системы типов языка С++. Язык позволяет переводить числовые данные из целых в дробные и обратно, при условии, что часть данных будет утеряна, и программист это осознает. Благодаря этому компилятор знает, что возможна инициализация объекта rational дробными числами с помощью конструктора, который содержит целые числа в списке аргументов, так как дробные числа легко могут быть приведены к целым в момент инициализации аргументов.

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

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


struct rational
{    
    rational(const float x, const float y) = delete;
    rational(const double x, const double y) = delete;
private:
    int p{1};
    int q{1};
};    

При такой версии этих конструкторов любая попытка создать объект заменой целых чисел на дробные будет вызывать ошибку компиляции примерно следующего содержания, “error: use of deleted function 'rational::rational(double, double)'”. Это сообщение вам напомнит, а вашим пользователям подскажет о необходимости более точно определять тип аргументов.

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


void swap(rational &a, rational &b)
{
    rational tmp = a;
    a = b;
    b = tmp;
}    

Но вы уже знаете о существовании функции std::move, и у вас уже реализован конструктор переноса, поэтому нет смысла копировать значения. Пример измененной функции можно посмотреть ниже.


struct rational
{
    static void swap(rational &a, rational &b)
    {
        rational tmp(std::move(a));
        a = std::move(b);
        b = std::move(tmp);
    }
private:
    int p{1};
    int q{1};
};
    
rational a(1, 2);
assert(rational(1, 2) == a);
rational b(2, 3);
assert(rational(2, 3) == b);
    
rational::swap(a, b);
assert(rational(2, 3) == a);
assert(rational(1, 2) == b);

Кому-то из читателей эта реализация может показаться лишней. В конечном итоге, зачем добавлять вызов функции туда, где и без нее можно обойтись? По большому счету это правильный ход мыслей, так как на уровне процессора новая версия функции swap точно так же копирует данные как и наивная. Если сравнить генерируемый GCC код для двух реализаций выше, они отличаются только следующими строчками:


movq -24(%rbp), %rax
movq %rax, %rdi
call std::remove_reference<rational&>::type&& std::move<rational&>(rational&)

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


return static_cast<typename std::remove_reference<_Tp>::type&&>(__t);

Не обращая внимания на синтаксические элементы, которые вам раньше не встречались, нужно выделить три момента. Ключевое слово _Tp - это псевдоним (alias), присвоенный компилятором нашему типу данных rational. Это явно видно из дизассемблированного фрагмента кода, где тип данных упоминается явно. О том, зачем компилятор это делает, будет сказано в одной из следующих частей. Интересно же в функции std::move то, что она делает последовательные вызовы функций remove_reference и static_cast(&&) на переданный ей объект. Другими словами она сначала избавляется от ссылки на переданный объект, получая L-значение, а потом явно приводит полученный тип к ссылке на R-значение. Это, в свою очередь, заставляет компилятор обратиться к конструктору переноса, а не копирования, для чего весь сыр-бор и затевался.


 

Упражнения

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


 

Сноски

*Ad hoc - решение, специально придуманное под конкретную задачу, буквально “для этого” (лат.).

**Это поведение можно запретить с помощью ключевого слова explicit в объявлении конструктора.


 

Источники

  1. От греческого “моно” - одна, “морфи” - форма; языки, основанные на идее функций и процедур, а также их операндов, которые имеют уникальный тип, принято называть мономорфными, так как каждое значение и каждую переменную можно интерпретировать единственным типом данных; Cardelli, L., Wegner, P., “On Understanding Types, Data Abstraction, and Polymorphism”, Computing Surveys, Vol 17 n. 4, pp 471-522, 1985

 

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