При работе внутри иерархии классов, любые операции, заданные уровнем выше, также будут доступны через объекты подтипов. Например, операция сложения должна быть доступна для любых чисел, дробных и целых. При этом сама процедура проходит по-разному для целых и для дробных чисел. Перед разработчиком стоят две задачи: реализовать операцию сложения для соответствующих типов чисел и убедиться, что каждый новый тип, добавляемый в иерархию чисел, также будет реализовывать эту операцию обязательно. Для чего это надо на практике? Чтобы понять первопричину этих манипуляций посмотрите на следующий пример кода.
‘a add(‘a x, ‘a y) {
return x + y;
}
Здесь запись ‘a
является переменной*, которая представляет неопределенный тип данных. То есть, такой тип данных, который неизвестен компилятору до тех пор, пока программа не будет запущена, и в качестве аргументов для этой функции не будут переданы конкретные значения. На основании этих значений программа определит какие типы были переданы, и после этого попытается произвести операцию сложения между объектами x
и y
.
К примеру, если оба аргумента будут целыми числами, то в качестве операции сложения будет выбрана реализация для целых чисел, и ее результатом тоже будет целое число. Аналогично это сработает для двух дробных чисел. Но вот интересный вопрос: какая операция будет выбрана для случая, в котором аргумент x
- целое число, а аргумент y
- дробное? Прежде чем ответить на этот вопрос немного усложним пример.
‘a add(‘a x, ‘a y, ‘b z) {
if (z)
return x + y;
else
return 0;
}
Если смотреть с конца, картина немного проясняется. Так как последняя строка в функции возвращает целое число (0), то результат сложения аргументов x
и y
обязан вернуть или целое число, или значение типа данных, приводимого к целому числу. В противном случае возвращаемое значение функции было бы двояким, чего компилятор допустить не может.
Интересней дело обстоит с аргументом z
. Он участвует в условной операции if
, поэтому изначально можно предположить, что его тип данных – bool
. Но вы уже знаете, что условные операции принимают значения любых типов, возвращая false
или true
в зависимости от того, равны они нулю или нет, поэтому аргумент z
не обязательно имеет тип bool
. Тем не менее, мы свободно можем переписать функцию так и не ошибемся:
int add(int x, int y, bool z) {
if (z)
return x + y;
else
return 0;
}
Теперь внесем в нее еще одно изменение.
‘a add(‘a x, ‘a y, ‘a z) {
if (z)
return x + z;
else
return x + y;
}
Здесь аргумент z
участвует и в условной операции, и в операции сложения. Это не делает тип двояким (он может быть либо числовым, либо булевым значением, но не обоими сразу), оставляя его неопределенным.
Обратите внимание, что типы данных функции не стали динамическими в классическом смысле этого слова. Динамические системы типов в языках программирования как раз допускают некоторую двоякость при обращении с переменными (например, одной и той же переменной можно присваивать значения разных типов в разных инструкциях при условии, что последующие операции учитывают этот факт). С++ является языком со статической системой типов, а это значит, что компилятор должен быть уверен в правильности написанного кода для всех участвующих в том или ином выражении значений. И тем не менее, примеры выше в С++ возможны благодаря системе выявления типов переменных[1]. Выявление типов позволяет писать программы, не задавая явно типы данных для переменных. Хорошо спроектированная система типов в языке позволяет не отвлекаться на это. Правда, на программиста накладывается ответственность за правильность алгоритмов в рамках такой системы, так как необходимо заранее предвидеть возможные варианты трактовки системой типов того или иного выражения. Это может немного ограничить количество вариантов решения той или иной задачи. С другой стороны, использование системы неявных типов вовсе не обязательно. С помощью такой системы часто реализуется универсальный полиморфизм – способ реализации функций, поведение которых не привязано к типам данных переданных аргументов. В С++ нет полноценной системы выявления типов аргументов, поэтому в данном языке широко используется полиморфизм подтипов. Для этого существует несколько инструментов: наследование и шаблоны типов.
С помощью наследования полиморфизм реализуется через отношение дочерних типов данных к базовому типу. Базовый тип данных играет роль полиморфного типа – такого типа, который может принимать разные формы в зависимости от типов переданных аргументов, аналогично переменным типам ‘a
или ‘b
. Выглядит это примерно следующим образом.
struct Shape
{
virtual float area() = 0;
};
struct Circle : Shape
{
Circle(float x): r(x) {}
float area() override { return 3.14159265359 * r; }
private:
float r{.0};
};
struct Square : Shape
{
Square(float x) : length(x) {}
float area() override { return length * length; }
private:
float length{.0};
};
Первое, что может вызвать вопросы – ключевое слово virtual
. Для методов это слово помечает их как динамически или виртуально вызываемые[2]. Виртуальный вызов функции означает, что функция может быть вызвана для переменной любого типа из иерархии какого-то базового типа[3]. Другими словами, в этих случаях компилятору не обязательно знать как работает метод, он все равно позволит его вызвать, используя указатель на базовую структуру, если в дочерней структуре есть версия определения такого метода. Для примера выше это означает, что в программе можно будет создать переменную типа Shape*
или Shape&
**, инициализировать ее объектом дочерней структуры, и операция сложения будет работать так, как описано в дочерней структуре.
Другой момент - присваивание нуля прототипу функции. Это помечает функцию как чисто виртуальную, что делает саму структуру абстрактной***. Если вспомнить пример иерархии классов в языке Smalltalk, упомянутый выше, там говорилось о том, что корень всего дерева типов является абстрактным классом, потому что он не инициализируется как объект какого-то родительского класса. По этой же схеме в других языках программирования абстрактные классы помещают в корень иерархии для того, чтобы они служили описанием всех методов, доступных в каждом из объектов иерархии – то есть, описанием интерфейса. В некоторых языках такие классы выделяют в отдельную категорию "интерфейсов"[4].
Наконец, ключевое слово override
после списка параметров функции помечает функцию как виртуальную, заменяющую определение такой же функции в базовом классе. Таким образом, любая дочерняя структура может быть использована для инициализации ссылки на ее базовую структуру, так как анонимный объект базового типа в ней тоже содержится. После этого, если функция базовой структуры будет вызвана посредством такой ссылки, компилятор будет знать, что ее определение нужно искать в дочерней структуре, которая использовалась для инициализации объекта. В коде это могло бы выглядеть так:
void draw(Shape &fig, const int x) {
int density = x * fig.area();
// …
}
Обратите внимание, что первый аргумент представлен ссылкой на базовый тип Shape
: то, какая версия метода area
вызывается, зависит от вызова функции draw
, то есть окончательно известно только после запуска программы.
Подведем промежуточные итоги. Чтобы понять принципиальную разницу между двумя основными видами полиморфизма нужно иметь ввиду, что универсальный полиморфизм позволяет использовать один и тот же код для аргументов любого типа, тогда как ad hoc полиморфизм может использовать разный код для аргументов разных типов. Какой из видов полиморфизма использовать, как правило, диктует характер поставленной перед программистом задачи. Например, подумайте над следующим вопросом: если перед вами стоит задача определить длину произвольного массива, имеет ли для вас значение каким типом данных представлен каждый элемент массива? Ответив на этот вопрос можно определить то, каким видом полиморфизма нужно было бы воспользоваться для ее решения.
Упражнения
Вернемся к проблеме сложения двух полиморфных типов. Используя модель, представленную выше, можно определить основные требования к структуре “число”. Первое, базовая структура не может быть объектом, поэтому нужно убедиться, что компилятор не разрешает создавать переменные такого типа. Второе, все дочерние структуры должны включать в себя определенные операции (которые составляют интерфейс класса “число”). Третье, полиморфные функции и методы, работающие с числами, должны принимать любое число, дробное или целое. Учитывая эти требования можно написать первую версию структуры “число”.
struct number
{
virtual number &operator+(const number &other) = 0;
};
В С++ абстрактный класс, реализованный через структуру, требует включения в него чистой виртуальной функции. Такая функция обязательно должна возвращать ссылку или указатель, потому что объект абстрактного класса возвращать будет невозможно, и возвращаемое значение будет к базовому типу приведено (см. выше). Целые числа также можно представить абстрактным классом, так как инициализировать целое число без информации о знаке смысла не имеет.
struct integer : number
{
virtual integer &operator+(const number &other) = 0;
};
На основании этого класса создается класс положительных целых чисел.
struct positive_integer : integer
{
positive_integer(long long int a)
{
this->x = (a < 0) ? -a : a;
}
positive_integer &operator+(const number &other) override {}
private:
positive_integer() {}
};
Если перед программой будет стоять выбор: какую версию метода operator+
вызвать – он будет зависеть от типа данных объекта. Для объекта типа positive_integer
будет вызван оператор сложения, в нем же и переопределенный. Использование переменной this->x
подразумевает наличие такой переменной либо в этой структуре, либо в одной из структур выше нее уровнем. До сих пор такая переменная нигде не появлялась, ее надо объявить, но остается вопрос: на каком именно уровне это сделать?
Иерархия, описанная выше, моделирует отношение между числами. Необходимо создать условия, при которых дочерние структуры базовой структуры number могут представлять как целые, так и дробные числа. Это означает, что либо включать в себя оба представления, которые используются дочерними классами по мере необходимости, должна базовая структура, либо базовая структура должна остаться чистым интерфейсом, а данные должны содержаться на более низком уровне. В зависимости от выбранного подхода возникнут характерные “подводные камни”, попробуйте представить себе, с какими трудностями вам пришлось бы столкнутся в том, и в другом случае. Так может выглядеть “наивный” вариант добавления переменных в базовую структуру.
struct number
{
virtual number &operator+(const number &other) = 0;
protected:
long long int x{0};
long double e{.0};
};
Они должны быть private
, чтобы к ним не было прямого доступа через дочерние объекты. При этом они должны быть доступны из любой дочерней структуры. Ярлык protected
обозначает уровень доступа к данным под ним. Как и аналогичные ему private
и public
, он контролирует области видимости, из которых можно получить доступ к этим данным. Если в случае с public
доступ возможен из любого места в программе, в случае с private
– только внутри своей структуры, то в случае с protected
доступ совершается и изнутри структуры, и изнутри любых дочерних структур, если такие есть. Это не единственный способ добиться такого эффекта. Другой способ заключается в более явном ограничении доступа для конкретных дочерних структур.
struct positive_integer;
struct negative_integer;
struct number
{
friend positive_integer;
friend negative_integer;
virtual number &operator+(const number &other) = 0;
private:
long long int x{0};
long double e{.0};
};
Такой вариант будет означать, что добавление новых структур в иерархию не будет автоматически давать им доступ к полям x
и e
базовой структуры, что регулируется ключевым словом friend
. Структуры, объявленные дружескими внутри базовой структуры, имеют доступ к полям, защищенным ярлыком private
. Чтобы базовая структура знала о своих дочерних структурах заранее, их имен необходимо заявить где-то над определением базовой структуры.
Теперь можно решить проблему дочерней структуры positive_integer
, которая должна реализовывать все операции, присущие целым числам. Рассмотрим реализацию операции сложения. Согласно свойству замкнутости**** операция сложения должна производиться между двумя числами и производить какое-то третье число[5]. Для функций в С++ это предполагает новую область памяти для хранения результата (возвращаемое “по значению”). В случае с виртуальными функциями новое значение возвращать напрямую нельзя, как уже неоднократно упоминалось выше. Это значит, что можно либо создать новый объект внутри метода, либо вернуть объект, которому принадлежит метод.
positive_integer &operator+(const number &other) override
{
this->x = this->x + ((other.x < 0) ? -other.x : other.x);
return *(this);
}
В таком случае это бы значило, что инструкция positive_integer a = b + c;
не просто присваивала бы в a
сумму b
и c
, но и изменяла значение b
на величину с
, так как оператор присваивания в данном случае вызывается именно для b
(находится справа от него). Такой сценарий противоречит характеру операции сложения в арифметике, что вынуждает обратиться к первому способу: созданию нового объекта внутри метода с последующим возвратом ссылки на него.
positive_integer &operator+(const number &other) override
{
positive_integer *p = new positive_integer(
this->x + ((other.x < 0) ? -other.x : other.x));
return *p;
}
Здесь проглядывается новая проблема: какой объект или функция отвечает за освобождение памяти, занятой новым объектом p? Переменная, к которой он привязан, не существует вне метода, поэтому после операции сложения будет удалена. Можно было сохранить адрес этой переменной и потом вручную вызвать на него оператор delete
, но делать так каждый раз после операции сложения было бы неразумно (следить за всеми такими указателями, которые создаются при сложении, было бы трудоемкой задачей[6]). Подумайте над тем, как бы вы обошли этот трудный момент, сохранив семантику операции сложения. Ниже будет показан один из способов автоматической очистки памяти, встроенный в язык С++.
#include
struct positive_integer : integer
{
~positive_integer() {}
positive_integer(long long int a)
{
this->x = (a < 0) ? -a : a;
}
positive_integer &operator+(const number &other) override
{
this->p = std::shared_ptr<positive_integer>(new positive_integer(this->x + ((other.x < 0) ? -other.x : other.x)));
return *(this->p);
}
private:
positive_integer() {}
std::shared_ptr<positive_integer> p;
};
Данный фрагмент использует объект типа std::shared_ptr
, который объявлен в стандартной библиотеке memory
. Этот объект представляет собой сущность, которая будет уничтожена вместе с объектом, которому она принадлежит[7]. В данном случае этот объект инициализируется значением суммы двух аргументов, после чего ссылка на него возвращается из метода в место вызова, где новое значение будет скопировано в другой объект типа positive_integer
.
positive_integer x(1);
positive_integer y(-1);
positive_integer z = x + y;
Здесь объект x
отвечает за создание ссылки на новый объект, который будет передан в конструктор объекта z
. Созданный объектом x
объект будет удален программой вместе с объектом x
.
Самые догадливые читатели уже должны были заметить, что в такой последовательности есть слабое место. Что происходит с адресом созданного в недрах positive_integer::operator+
при последующих попытках сложить его объект с другим? В случае обычного указателя переменная p
внутри него перезаписывалась бы новым адресом, теряя старый. К счастью для нас, создатели класса std::shared_ptr
предусмотрели и этот момент: объект уничтожается автоматически в случае, если переменной типа std::shared_ptr
присваивается какое-то другое значение[8].
Как можно видеть из этого примера, работа с интерфейсами в С++ часто сопряжена с решением нестандартных задач, которые могут вызвать серьезные затруднения. Это частично связано с тем, что идея интерфейсов пришла в этот язык из других языков программирования, где с ее помощью решали проблемы, связанные с условностями реализации наследования в этих языках. В С++ для этих целей используется множественное наследование. О нем речь пойдет в завершающей части.
Пример выше является типичным, несмотря на то, что опытные программисты стараются избегать таких ситуаций. То, что такая ситуация сложилась при построении нашей иерархии наследования, косвенно указывает на плохо продуманную структуру наследования. В данном конкретном случае мы всего лишь имитировали иерархию, взятую из другого языка, и в С++ она бы выглядела совсем по-другому. Тем не менее, этот пример позволил познакомить вас с неочевидными моментами реализации полиморфизма подтипов в С++, а о том, как это можно было бы сделать по-другому, вы сможете прочитать ниже.
Сноски
*Читается как “альфа”.
**Это важный момент, потому что он имеет серьезные последствия; наследование позволяет обращаться к данным базового класса через дочерний класс, что означает возможность приводить объекты дочернего типа к базовому; из этого следует что можно изначально объявить переменную как базовый тип, а присвоить ей значение дочернего типа, но только если переменная является ссылкой или указателем, потому что компилятору заранее должен быть известен размер переменной в байтах, и только для указателей и ссылок размер не зависит от типа значения.
***Абстрактный базовый класс (abstract base class, ABC, англ.) – класс или структура, которая не может быть использована для создания конкретных объектов.
****Замкнутость (или замыкание) -- это термин, пришедший в программирование из математики, где он обозначает свойство оператора возвращать значение из того множества, из которого происходят его операнды; например, натуральные числа замкнуты относительно операции сложения, но не замкнуты относительно операции вычитания, так как 1 - 2
производит ненатуральное число -1. В топологии замкнутость -- это конструкция, дающая наименьшее замкнутое множество, содержащее в себе заданное множество, включая все точки соприкосновения. Отсюда возникла идея замкнутостей в программировании, где так обозначают фунции, замкнутые в другой сущности (среде) вместе со своими локальными переменными (аргументами, свободными переменными, объявленными в самой функции) и связанными переменными (которые находятся вне функции). Как правило, среда и функция инкапсулируются в структуре данных.
Источники
- Oбщее название семейства алгоритмов, которые частично или полностью определяют типы всех переменных на основании уже определенных ранее типов; Clarkson, M.R. et al., "Type Inference", OCaml Programming: Correct + Efficient + Beautiful, Cornell University, cs.cornell.edu/courses/cs3110/
- "“Virtual” function specifier", C++ Reference, cppreference.com
- “Dynamic Dispatch in Object Oriented Languages”, CSC447 Programming Language Concepts, DePaul University, condor.depaul.edu/ichu/csc447/
- “__interface”, Classes and Inheritance, C++ Language Reference, Microsoft Docs, docs.microsoft.com/en-us/cpp/
- Овчинников, А.В., “Множество, замкнутое относительно операции”, Теория групп. Лекционный курс, Москва, 2016, с. 10.
- Подсчет ссылок (reference counting, англ.) – техника отслеживания всех используемых указателей в программе, с немедленным освобождением памяти в тот момент, когда объект, использующий такой указатель, уничтожается; “Reference Counting”, Iotr documentation, University of California San Diego, ccom.ucsd.edu
- “Smart pointers”, C++ Reference, cppreference.com
- “std::shared_ptr”, Dynamic memory management, C++ Reference, cppreference.com
Copyright © 2022 Брынзан, Л.В.
GNU General Public License
“Commons Clause” License Condition v1.0
GPL Attribution-ShareAlike 4.0 International