Полиморфизм в python

Полиморфизм в python реализован во многих ипостасях.

Ad-hoc полиморфизм

Ad-hoc полиморфизм пример полиморфизма, который не свойственен для python, т.к. перегружать функции честным образом в нем нельзя: каждое следующее объявление функции с таким же именем затрет предыдущее.

Рассмотрим пример такого полиморфизма в C++.

#include <vector>
#include <iostream>

int double_it(int x){
    return 2*x;
}

std::vector<int> double_it(const std::vector<int> &x){
    size_t n = x.size();
	std::vector<int> result(n);
	
	for(int i=0; i < n; ++i)
		result[i] = 2*x[i];
	
	return result;
}

int main(){
	int int_result = double_it(42);
	std::vector<int> vector_result = double_it({1, 2, 3});
	
	std::cout << int_result << std::endl; // Вывод: 84
	for(auto x: vector_result)
		std::cout << x << " "; // Вывод: 2 4 6
	
	return 0;
}

В данном примере функция double_it как бы векторизована: её можно вызывать и для одного целого числа и сразу для вектора целых чисел.

В python нельзя перегружать функции по типу параметров. Лучшее что вы можете сделать — проверить в runtime, какого типа аргумент, и в соответствии с этим проделать необходимые операции.

def double_it(x):
    if isinstance(x, int):
        return 2 * x
    if isinstance(x, list):
        return [double_it(value) for value in x]
    raise TypeError(f"Функция double_it вызвана с неподдерживаемым типом аргумента {type(x).__name__}")


print(double_it(1))
print(double_it([1, 2, 3]))
2
[2, 4, 6]

В данном примере, если аргумент не целое число и не список, то возбуждается исключение TypeError, чтобы в наибольшей степени воспроизвести поведение кода из C++.

singledispatch

В ряде ситуаций удобно применить singledispatch из модуля functools, который позволяет как бы перегружать функции по типу первого аргумента.

from functools import singledispatch

@singledispatch
def double_it(x):
    """
    Generic function.
    Общая функция.
    Она будет вызвана, 
    если тип аргумента не соответствует ни одному зарегистрированному. 
    """
    raise TypeError(f"Функция double_it вызвана с неподдерживаемым типом аргумента {type(x).__name__}")

@double_it.register(int)
def _(x):
    return 2 * x


@double_it.register(list)
def _(x):
    return [double_it(value) for value in x]

print(double_it(1))
print(double_it([1, 2, 3]))
2
[2, 4, 6]

Сначала объявляется и декорируется наиболее общая функция, которую называют generic. Это приводит к тому, что создаётся объект-обертка с именем этой функции, у которого есть метод-декоратор register. С помощью него регистрируются все остальные реализации этой функции для разного типа первого параметра.

Note

Перегруженные версии функции объявляются с именем, отличным от имени исходной функции. Обычно это "_".

При вызове функции объект-обертка сравнивает тип первого параметра с зарегистрированными функциями и на основе этой информации вызывает подходящую реализацию. Если подходящей реализация не находится, то вызывается исходная generic функция.

print(double_it.registry.keys())

try:
    double_it(1.)
except TypeError as msg:
    print(msg)
dict_keys([<class 'object'>, <class 'int'>, <class 'list'>])
Функция double_it вызвана с неподдерживаемым типом аргумента float

Note

Декоратора для множественной диспетчеризации в стандартной библиотеке python нет, но существует сторонняя библиотека multidispatch.

Параметрический полиморфизм

Параметрический полиморфизм в C++ реализован через шаблонные функции.

#include <iostream>
#include <string>

template<class T>
T add(T x, T y){
	return x + y;
}

int main(){
	int x1 = 2, y1 = 2;
	double x2 = 3.14, y2 = 2.71;
	std::string x3 = "Hello, ", y3 = "World!";
	
	std::cout << "int   : " << add(x1, y1) << std::endl; // int   : 4
	std::cout << "double: " << add(x2, y2) << std::endl; // double: 5.85
	std::cout << "string: " << add(x3, y3) << std::endl; // string: Hello, World!
	
	return 0;
}

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

Note

Компилятор C++ сгенерирует свою версию шаблонной функции для каждого типа данных, с котором встретит вызов это функции, что может привести к кратному увеличению объёма получаемого машинного кода. Такой эффект получил известность как «раздувание кода».

В итоге формально каждый тип данных обрабатывается своим собственным отдельным куском кода, что понижает полиморфность этого приёма.

В python большинство функций можно считать параметрически полиморфными. В функцию add ниже можно передать аргументы любых типов. Функция успешно вернет значение для любых аргументов, для которых выражение x + y не возбуждает ошибку. При этом в отличие от примера с C++ эту функцию можно вызывать с аргументами разных типов.

def add(x, y):
    return x + y


x1, y1 = 2, 2
x2, y2 = 3.14, 2.71
x3, y3 = "Hello, ", "World!" 


print(f"int   : {add(x1, y1)}")
print(f"double: {add(x2, y2)}")
print(f"string: {add(x3, y3)}")
int   : 4
double: 5.85
string: Hello, World!

Итого, проектируя функцию вы всегда ориентируетесь лишь на интерфейс объектов, которые будут в неё переданы. Часто говорят, что в python работает утиная типизация.

Полиморфизм подтипа

Полиморфизм подтипа в C++ реализован через виртуальные методы.

#include <string>
#include <iostream>
#include <math.h>

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

class Circle: public Shape
{
	double radius;
public:	
	Circle(double r) : radius(r) {};
	virtual double area() const {return M_PI * radius * radius;}
	
};

class Square: public Shape
{
	double side;
public:
	Square(double l) : side(l){};
	virtual double area() const {return side * side;}
	
};

void print_area(const Shape* p){
	std::cout << "The area of the shape is " << p->area() << std::endl;
}

int main(){
	Circle A(1.);
	Square B(2.);
	
	print_area(&A); // The area of the shape is 3.14159
	print_area(&B); // The area of the shape is 4
	return 0;
}

Здесь функция print_area заранее не знает, какую конкретную реализацию метода area она будет вызывать. Это определяется в runtime в зависимости от истинного типа объекта по указателю. В итоге функция print_area может обрабатывать все подклассы базового класса Shape одинаково, что и считается полиморфным поведением. При этом в отличие от шаблонных функций для виртуальных функций не генерируется свой машинный код для каждого подкласса, все по-настоящему обрабатывается одним и тем же кодом.

В python почти все методы класса проявляют виртуальные свойства. Воспроизведем пример из C++ в python.

from math import pi
from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        raise NotImplementedError


class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return pi * self.radius * self.radius

class Square(Shape):
    def __init__(self, side):
        self.side = side

    def area(self):
        return self.side * self.side

def print_area(shape):
    print(f"The area of the shape is {shape.area()}")

print_area(Circle(1.))
print_area(Square(2.))
The area of the shape is 3.141592653589793
The area of the shape is 4.0

Как уже обсуждалось, в python обычно роль играет лишь интерфейс или поведение объекта, а не его тип, а значит предыдущий пример мог быть реализован и без наследования: можно было объявить классы Circle и Square без базовых классов, тогда функция print_area работала бы так же, если бы у обоих классов реализован метод area.

Абстрактные базовые классы, как средство достижения полиморфизма

Тем не менее полиморфизм подтипа полезен в python не только, чтобы явно обозначить иерархию между типами данных, или, чтобы напомнить программисту реализовать определенный интерфейс, но и для более совершенной реализации Ad-hoc полиморфизма. Это достигается за счет того, что встроенная функция isinstance возвращает True и для объектов производных классов.

В качестве примера рассмотрим рассмотренную выше реализацию функции double_it.

def double_it(x):
    if isinstance(x, int):
        return 2 * x
    if isinstance(x, list):
        return [double_it(value) for value in x]
    raise TypeError(f"Функция double_it вызвана с неподдерживаемым типом аргумента {type(x).__name__}")


print(double_it(1))
print(double_it([1, 2, 3]))


try: 
    double_it(1.)
except TypeError as msg:
    print(msg)
2
[2, 4, 6]
Функция double_it вызвана с неподдерживаемым типом аргумента float

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

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

from numbers import Number
from decimal import Decimal
from fractions import Fraction

types = [bool, int, Fraction, Decimal, float, complex]

for t in types:
    if issubclass(t, Number):
        print(f"Тип {t.__name__} расширяет класс Number.")
Тип bool расширяет класс Number.
Тип int расширяет класс Number.
Тип Fraction расширяет класс Number.
Тип Decimal расширяет класс Number.
Тип float расширяет класс Number.
Тип complex расширяет класс Number.

А модуль collections кроме очень полезных контейнеров определяет абстрактные базовые классы для коллекций в подмодуле collections.abc. Для целей примера выше сгодится абстрактный базовый класс Collection.

from collections.abc import Collection
from array import array

types = [tuple, list, array, str, dict]

for t in types:
    if issubclass(t, Collection):
        print(f"Тип {t.__name__} расширяет класс Collection.")
Тип tuple расширяет класс Collection.
Тип list расширяет класс Collection.
Тип array расширяет класс Collection.
Тип str расширяет класс Collection.
Тип dict расширяет класс Collection.

Отредактируем исходный пример, чтобы он работал с любыми числами и любыми коллекциями чисел.

from collections.abc import Collection
from numbers import Number
from fractions import Fraction

def double_it(x):
    if isinstance(x, Number):
        return 2 * x
    if isinstance(x, Collection) and not isinstance(x, str):
        return [double_it(value) for value in x]
    raise TypeError(f"Функция double_it вызвана с неподдерживаемым типом аргумента {type(x).__name__}")


print(double_it(
    [
        1,              # int  
        2.,             # float
        3. + 4.j,       # complex
        Fraction(5, 6), # Fraction
        {9., 10.}       # set of floats
    ]
)) 
[2, 4.0, (6+8j), Fraction(5, 3), [18.0, 20.0]]