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

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

Любые операции, заданные уровнем выше в иерархии наследования, также будут доступны через объекты подтипов. Например, как было отмечено чуть ранее, операция сложения должна быть доступна для любых чисел, дробных и целых. При этом сама процедура проходит по-разному для целых и для дробных чисел. Перед разработчиком стоят две задачи: реализовать операцию сложения для соответствующих типов чисел и убедиться, что каждый новый тип, добавляемый в иерархию чисел, также будет реализовывать эту операцию обязательно. Для чего это надо на практике? Чтобы понять первопричину этих требований посмотрите на следующий пример кода.


‘a add(‘a x, ‘a y) {
    return x + y;
}

Здесь запись ‘a1 является переменной, которая представляет неопределенный тип данных. То есть, такой тип данных, который неизвестен компилятору до тех пор, пока программа не будет запущена, и в качестве аргументов для этой функции не будут переданы конкретные значения. На основании этих значений программа определит какие типы были переданы, и после этого попытается произвести операцию сложения между объектами 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 участвует и в условной операции, и в операции сложения. Это не делает тип двояким (он может быть либо числовым, либо булевым значением, но не обоими сразу), при этом все равно оставляет его неопределенным.

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

Примеры выше возможны в С++ благодаря системе выявления типов2 переменных. Выявление типов позволяет писать программы, не задавая явно типы данных для переменных. Хорошо спроектированная система типов в языке позволяет не отвлекаться на это. Правда, на программиста накладывается ответственность за правильность алгоритмов в рамках такой системы, так как необходимо заранее предвидеть возможные варианты трактовки системой типов того или иного выражения. Это может немного ограничить количество вариантов решения той или иной задачи. С другой стороны, использование системы неявных типов вовсе не обязательно. С помощью такой системы часто реализуется универсальный полиморфизм – способ реализации функций, поведение которых не привязано к типам данных переданных аргументов. В С++ нет полноценной системы выявления типов аргументов3, поэтому в данном языке широко используется полиморфизм подтипов. Для этого существует несколько инструментов: наследование и шаблоны типов.

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


class Shape {
public:
    virtual double area() = 0;
};

class Circle : Shape {
public:
    Circle(double x): r(x) {}
    double area() override { return 3.14159265359 * r; }
private:
    double r{.0};
};

class Square : Shape {
public:
    Square(double x) : length(x) {}
    double area() override { return length * length; }
private:
    double length{.0};
};

Первое, что может вызвать вопросы – ключевое слово virtual. Для методов это слово помечает их как динамически или виртуально вызываемые [CPP(c)]. Виртуальный вызов функции означает, что функция может быть вызвана для переменной любого типа из иерархии какого-то базового типа [Chu]. Другими словами, в этих случаях компилятору не обязательно знать как работает метод, он все равно позволит его вызвать, используя указатель на базовый класс, если в дочернем классе есть версия определения такого метода. Для примера выше это означает, что в программе можно будет создать переменную типа Shape* или Shape&4, инициализировать ее объектом дочернего класса, и операция сложения будет работать так, как описано в дочернем классе.

Другой момент – присваивание нуля прототипу функции. Это помечает функцию как чисто виртуальную, что делает сам класс абстрактным5. Если вспомнить пример иерархии классов в языке Smalltalk, упомянутый в предыдущей части, там говорилось о том, что корень всего дерева типов является абстрактным классом, потому что он не инициализируется как объект какого-то родительского класса. По этой же схеме в других языках программирования абстрактные классы помещают в корень иерархии для того, чтобы они служили описанием всех методов, доступных в каждом из объектов иерархии – то есть, описанием интерфейса. В некоторых языках такие классы выделяют в отдельную категорию “интерфейсов” [Whitney(b)].

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


void draw(Shape &fig, int x) {    
    int density = x * fig.area();
    // вывод пикселей на экран
    return;
}    

Обратите внимание, что первый аргумент представлен ссылкой на базовый тип Shape: то, какая версия метода area вызывается, зависит от вызова функции draw, то есть окончательно известно только после запуска программы.


Circle circ(15.5);
draw(circ, 55);

Подведем промежуточные итоги. Чтобы понять принципиальную разницу между двумя основными видами полиморфизма нужно иметь ввиду, что универсальный полиморфизм позволяет использовать один и тот же код для аргументов любого типа, тогда как ad hoc полиморфизм может использовать разный код для аргументов разных типов. Какой из видов полиморфизма использовать, как правило, диктует характер поставленной перед программистом задачи. Например, подумайте над следующим вопросом: если перед вами стоит задача определить длину произвольного массива, имеет ли для вас значение каким типом данных представлен каждый элемент массива? Ответив на этот вопрос можно определить то, каким видом полиморфизма можно было бы воспользоваться для ее решения.


 

Упражнения

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


class number {
    // чистый абстрактный класс без полей с данными
public:
    virtual number &operator+(const number &other) = 0;
};

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


class integer : number {
public:
    integer &operator+(const number &other) override;
    // поля с данными и конструкторы
};

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


class positive : integer {
public:
    positive &operator+(const number &other) override;
    // поля с данными и конструкторы
};

Если перед программой будет стоять выбор: какую версию метода operator+ вызвать – он будет зависеть от типа данных объекта. Для объекта типа positive будет вызван оператор сложения, в нем же и переопределенный.


integer a = -5;
positive b = 5;
auto c = a + b;
auto d = b + a;

Например, подумайте о том, какого типа будут переменные c и d для фрагмента выше. Как можно видеть из этого сценария, работа с интерфейсами в С++ часто сопряжена с решением нестандартных задач, которые могут вызвать серьезные затруднения. Это частично связано с тем, что идея интерфейсов пришла в этот язык из других языков программирования, где с ее помощью решали проблемы, связанные с условностями реализации наследования в этих языках. В С++ для этих целей используется множественное наследование. О нем речь пойдет в последней главе.


 

Сноски

1Читается как “альфа”.

2Type inference – общее название семейства алгоритмов, которые частично или полностью определяют типы всех переменных на основании уже определенных ранее типов [Clarkson].

3На момент написания данного текста.

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

5Абстрактный базовый класс (ABC, англ.) – класс или структура, которая не может быть использована для создания переменных.


 

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