« К оглавлению
‹ Шаблоны типов ↟ Вверх

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

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

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


struct rational
{
    int p{1};
    int q{1};
};
    
union r_int_variant
{
    int a{0};
    rational r;
};    

Oбъект структуры union r_int_variant будет занимать в памяти не 12 байтов, как может сначала показаться, а 8 – ровно столько, сколько нужно для хранения поля r. Благодаря этому для инициализации поля a будет использоваться тот же адрес, что и для поля r. Очевидно, что в таких условиях эти поля не могут существовать в памяти одновременно, изменение одного будет стирать предыдущее значение другого.

Это свойство объединений можно использовать для цели, обозначенной выше. Так как одновременно в объединении может существовать только целое или дробное представление, проблема заключается в правильной инициализации и контролировании операции присваивания. Для этого нужно следить за тем, какое из полей последний раз было инициализировано, благодаря чему можно определить к какому из полей нужно обращаться в тот или иной момент.


struct r_int
{
    int _type;
    r_int_variant data;
};
    
#define r_int_cons(c, val) if (                 \
    ((sizeof(val) == sizeof(int))               \
            ? (c._type = ((c.data.a = val), 2)) \
            : (c._type = ((c.data.r = val), 1))))

В примере выше код написан ближе к тому, как он бы выглядел в языке С. Роль непосредственного типа играет специальная структура, которая контролирует доступ к объединению с данными. Специальный “флаг” – переменная _type – следит за тем, какое из полей объединения сейчас инициализировано. Для инициализации используется макро – именованная инструкция, которая автоматически выставляет “флаг” согласно размеру переданного аргумента.


r_int u;
r_int_cons(u, 1);

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

Естественно, все эти операции можно включить в новую структуру в виде методов, скрыть “флаг” и данные от прямого внешнего воздействия. Создатели С++ именно так и поступили, добавив в язык библиотеку variant[1].


std::variant<int, rational> v(1);
std::cout << std::get<int>(v) << "\n";
    
std::variant<int, rational> w(rational(4, 5));
std::cout << std::get<rational>(w) << "\n";

Класс std::variant использует шаблоны типов для того, чтобы правильно инициализировать память. Он следит за тем, какой из типов в каждый конкретный момент доступен для переменной. Чтобы получить значение, которое хранится в переменной варианта, необходимо использовать стандартную функцию std::get, которую необходимо вызвать с соответствующим типом в качестве шаблонного аргумента. Функция попытается привести данные в “варианте” к указанному типу. Таким образом, если тип указан правильно, приведение сработает без ошибок, и результат функции будет содержать нужное значение. Если же тип будет указан для того поля объекта, которое в данный момент не активно, это вызовет исключение в программе.

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

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

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

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


struct r_int : rational, integer
{
    r_int() = delete;
    r_int(const int x) : rational(x, 1), integer(x) {}
    r_int(const int x, const int y) : rational(x, y), integer(x) {}
    friend std::ostream &operator<<(std::ostream &os, const r_int &rhs)
    {
        return r_int::build_os(os, rhs);
    }
private:
    static std::ostream &build_os(std::ostream &os, const r_int &obj)
    {
        if (obj.q == 1)
            os << obj.a;
        else
            os << obj.p << "/" << obj.q;
        return os;
    }
};

Тип integer, включенный в иерархию наследования, является простой “оберткой” встроенного типа int – поле нужного типа оборачивается в структуру для того, чтобы добавить в один с полем контекст необходимые операции. В качестве примера операции с переменным результатом реализован оператор вывода. Так как оператор вывода вызывается только на объекты типов, определенных в библиотеке iostream, его перегрузка внутри других структур обязана быть дружественной функцией (помните, что для обычных методов есть обязательно условие: первый аргумент – это всегда неявный указатель на сам объект – this). Первый аргумент – это ссылка на объект потока, в который будет осуществлен вывод; второй – объект, который надо поместить в поток вывода. Другими словами:


r_int x(1);
std::cout << x;

B инструкции будет вызван оператор вывода, перегруженный в структуре r_int, первым аргументом для него будет объект std::cout, а вторым – объект x. Этот фрагмент кода выведет в поток строку “1”.


r_int y(4, 5);
std::cout << y;

А фрагмент кода выше – строку “4/5".

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

В конечном итоге, все объекты типа r_int могут использоваться и в качестве int, и в качестве rational, в зависимости от контекста, в котором они используются. Аналогично оператору вывода можно перегрузить все остальные операторы, либо использовать те, которые перегружены для родительских типов rational и integer.


 

Упражнения

Предположим, что перед вами стоит задача интегрировать в иерархию наследования, показанную в этой главе, структуры, которые рассматривались в предыдущих главах (number, positive_integer и т.д.). Текущая иерархия может быть схематично представлена как сходящиеся к типу r_int ветви.

Иерархия чисел
Простая иерархия чисел.

Если в иерархию добавить общую для целых и дробных чисел базовую структуру “Числа”, при инициализации объекта копия этой структуры будет содержаться и в объекте типа “Целое”, и в объекте “Дробное”.

Иерархия чисел ромбовидной структуры
Ромбовидная иерархия чисел[2].

Но вы уже знаете, что в объекте “Рациональное” при инициализации будут созданы копии объектов и “Целого”, и “Дробного” типов. Отсюда можно сделать вывод, что из объекта типа “Рациональное” будет доступ к двум разным объектам типа “Число”, так как тип “Рациональное” по факту наследует тип “Число” дважды. Если попытаться обратиться к одному из них изнутри структуры “Рациональное”, какая из копий будет использоваться?

Такая ромбовидная структура иерархии наследования намекает на возможные проблемы в ваших программах в будущем. Необходимо заставить компилятор не создавать лишние копии объектов при построении объекта, который расположен внизу иерархии. Конечно, в описании структуры “Рациональное” можно явно указывать к какой именно из двух копий обращаться, используя пространства имен. Но это нужно будет делать везде, что не очень удобно. Для того, чтобы компилятор взял на себя решение этой проблемы, нужно использовать ключевое слово virtual, только теперь не для методов, а для объявленных родительских классов. Виртуальное наследование будет выглядеть примерно следующим образом (в сокращенном виде).


struct number
{
private:
    long long int x{0};
};
    
struct rational : virtual number { /* тело структуры */};
struct integer: virtual number { /* тело структуры */};
struct r_int : rational, integer { /* тело структуры */};

Правильная реализация использует виртуальное наследование для тех структур, которые находятся непосредственно под общей базовой структурой. То есть, в данном случае структура number объявляется виртуальной с точки зрения наследования, поэтому такая структура в иерархии наследования будет инициализироваться только один раз независимо от того, сколько раз она указана в дочерних классах. А это именно тот эффект, которого мы и добивались.

Виртуальное наследование широко используется в стандартной библиотеке, так как иерархия классов часто имеет сегменты ромбовидной формы. Один из таких примеров – библиотека iostream[3].

Иерархия классов в библиотеке iostream
Иерархия классов в библиотеке iostream[3].

Сам буфер ввода/вывода (streambuf) принадлежит базовому классу ios, таким образом работа с буфером описывается в дочерних классах, но все они имеют к нему доступ через общий источник – объект класса ios. Чтобы избежать двоякости с адресами этого объекта при двойном наследовании, в классах istream и ostream он наследуется виртуально.

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


r_int x(1);
std::cout << x;

может быть заменена на другую:


std::ofstream out(“database.txt”);
r_int x(1);
out << x;

Cемантика операций при этом сохраняется, что делает объекты взаимозаменяемыми.


 

Сноски

*Такие классы принято называть ортогональными, то есть, не пересекающимися в плане функциональности.


 

Источники

  1. "std::variant", Utilities library, cppreference.com
  2. В специальной литературе этот феномен называется “dreaded diamond”; “Inheritance – Multiple and Virtual Inheritance”, Standard C++ Foundation, isocpp.org
  3. Delroy, A., Binkerhoff, Ph. D., “Introduction to files and I/O streams”, Streams, Object-Oriented Programming Using C++, Weber State University, 2015-2022, icarus.cs.weber.edu/~dab/cs1410/

 

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