- BrainTools - https://www.braintools.ru -
Разработчики часто сталкиваются с задачами, в которых одна функция должна работать с разными типами данных и количеством аргументов. Чтобы каждый раз не создавать множество функций с разными именами, существует перегрузка (overload). Она позволяет использовать одно имя операции для обработки различных комбинаций входных данных. Благодаря перегрузке одна функция может адаптироваться под различные сценарии и делать код лаконичным и понятным.
В статье разберемся, как работает перегрузка в статических и динамических языках программирования. В конце покажу, как и зачем мы реализовали перегрузку на Python своим собственным способом.

Перегрузка функций (function overloading) — это концепция, которая позволяет определять несколько параметров с одинаковым именем, но с разными сигнатурами: количеством, типами или порядком аргументов.
Компилятор или интерпретатор выбирает подходящую версию функции на основе переданных аргументов. Это используется в строго типизированных языках, чтобы писать гибкий и читаемый код было проще.
C++ — классический пример языка с поддержкой перегрузки «из коробки». У нас есть две функции с одинаковым названием sum, но с разным типом параметров – int и double:
#include <iostream>
#include <string>
int sum(int a, int b) {
return a + b;
}
double sum(double a, double b) {
return a + b;
}
int main() {
std::cout << sum(2, 3) << std::endl; // Вызовет sum(int, int)
std::cout << sum(2.5, 3.1) << std::endl; // Вызовет sum(double, double)
return 0;
}
В TypeScript перегрузка функций реализуется на уровне типов. Здесь две сигнатуры объявлены как перегрузка функции greet, а сама реализация одна. Она проверяет, какие аргументы пришли:
function greet(name: string): string;
function greet(name: string, age: number): string;
function greet(name: string, age?: number): string {
if (age !== undefined) {
return `Hello, ${name}! You are ${age} years old.`;
} else {
return `Hello, ${name}!`;
}
}
console.log(greet("Alice")); // "Hello, Alice!"
console.log(greet("Bob", 30)); // "Hello, Bob! You are 30 years old."
Почему перегрузки в чистом виде нет в Python и других динамических языках?
Python — язык с динамической типизацией. Во время исполнения любая переменная может содержать объект почти любого типа. Получается, что единственная «актуальная» сигнатура функции видна только в рантайме.
Если сделать в Python две функции с одинаковым именем, то последняя «затрет» предыдущую:
def hello(name: str):
print(f"Hello {name}")
def hello(age: int):
print(f"Your age is {age}")
hello("Alice") # "Your age is Alice" – ошибка: вызовется вторая версия, но она ждёт int.
По умолчанию никакого отдельного механизма перегрузки в Python нет. Но это не значит, что перегрузка невозможно в принципе (:
Ниже опишу подходы, которые часто используются в реальном Python-коде. Первый — самый популярный, остальные — максимально простые в реализации.
1. Проверка типов внутри функции:
def hello(name_or_age):
if isinstance(name_or_age, str):
print(f"Hello {name_or_age}")
elif isinstance(name_or_age, int):
print(f"Your age is {name_or_age}")
else:
raise TypeError("Expected str or int")
Минус такого подхода — единая монолитная функция, которая со временем может раздуться и стать нечитаемой. А еще она не дает возможности задавать несколько версий функции с разными сигнатурами.
2. Проверка типов внутри функции:
Вместо перегрузки можно использовать разные имена функций для каждой комбинации аргументов.
def hello_str(name):
print(f"Hello {name}")
def hello_int(age):
print(f"Your age is {age}")
Такой метод максимально прост и прозрачен, не требует дополнительных инструментов или проверок, что делает его удобным для случаев, где важна явность. Однако это увеличивает количество функций в коде и не соответствует концепции перегрузки, так как нет единой точки входа, что может быть неудобно при работе с похожими по смыслу операциями и может сделать API библиотеки громоздким.
3. Использование декоратора functools.singledispatch (доступен с Python 3.4):
from functools import singledispatch
@singledispatch
def hello(arg):
raise TypeError("Unsupported type")
@hello.register
def _(arg: str):
print(f"Hello {arg}")
@hello.register
def _(arg: int):
print(f"Your age is {arg}")
Этот декоратор позволяет регистрировать функции-обработчики для разных типов аргументов. Но singledispatch ориентирован на тип первого аргумента, а для многих случаев (например, учитывая несколько параметров, Union, Optional и т. д.) этого может быть недостаточно.
4. Использование библиотеки multipledispatch:
from multipledispatch import dispatch
@dispatch(str)
def hello(arg):
print(f"Hello {arg}")
@dispatch(int)
def hello(arg):
print(f"Your age is {arg}")
Эта библиотека позволяет регистрировать функции для разных сигнатур, но она не входит в стандартную библиотеку Python — так что придется устанавливать ее отдельно. Кроме того, она не поддерживает аннотации типов.
5. Использование сторонних библиотек
Нестандартные библиотеки или самодельные решения, которые пытаются проанализировать типы через аннотации и хранить разные реализации одной функции в разных местах. Именно в эту категорию и попадает описанная в вопросе реализация.
Мы с командой пришли к тому, что нет такого подхода, который бы идеально нам подошел. Они:
имеют ограничения по количеству аргументов, по которым перегружаются
не умеют работать с generic’ами по типу Union, Optional, с аргументами по умолчанию, с args, **kwargs.
Наше решение по перегрузке адаптировано под запросы команды, поэтому в нем можно использовать и другие наши техники: например, LazyImport. Это удобно, и коллеги довольны (:
Наша реализация состоит из двух ключевых классов — OverloadManager, OverloadFunction, и декоратора @overload. Давайте разберем, как они взаимодействуют и решают сложные задачи перегрузки.
1. Регистрация функций и методов (OverloadManager.register)
Когда вы применяете декоратор @overload к функции, она регистрируется в OverloadManager. Здесь происходит первый важный шаг — определение, является ли объект обычной функцией или методом класса.
Мы используем атрибут __qualname__, который возвращает полное имя функции или метода. Например:
Для обычной функции: __qualname__ = "process".
Для метода класса: __qualname__ = "MyClass.process".
Если в __qualname__ есть точка (.), это означает, что функция — метод класса. Тогда мы извлекаем имя класса и сохраняем метод в словаре self.methods с ключом (module, class_name, method_name). Для обычных функций используется просто имя в словаре self.functions.
Зачем это нужно? Это позволяет различать перегрузку на уровне функций и методов, а также поддерживать перегрузку методов с учетом наследования (через __mro__, о чем ниже).
2. Анализ сигнатуры (OverloadFunction.register)
После определения типа объекта мы анализируем его сигнатуру, чтобы зарегистрировать конкретную перегрузку. Анализ разбит на этапы:
Извлечение типов параметров
Используется inspect.signature, который возвращает объект Signature. Мы проходимcя по всем параметрам и извлекаем их аннотации типов, исключая *args и **kwargs (переменное число аргументов), так как они не участвуют в строгой перегрузке. Результат — кортеж типов, например: (int, str).
Нормализация аннотаций (_normalize_annotation)
Аннотации могут быть сложными (например, Union[int, str], List[str], Optional[float]), и их нужно привести к удобному виду:
Обработка Union: Если тип — Union, мы вызываем get_origin (возвращает Union) и get_args (возвращает (int, str)), сохраняя подтипы для последующей проверки.
Обработка generic-типов: Для List[str] get_origin вернет list, а get_args — (str,).
Ленивые импорты: Если аннотация — объект LazyImport, мы оборачиваем её в LazyTypeWrapper, чтобы отложить разрешение типа до момента вызова.
Результат
Каждый вариант перегрузки сохраняется в словаре self.overloads с ключом — кортежем типов, а значением — самой функцией.
3. Вызов функции (OverloadFunction.__call__)
Когда вызывается перегруженная функция, мы определяем, какая версия должна быть выполнена, по такому алгоритму:
Сбор типов аргументов
Для переданных аргументов (args) мы создаем кортеж их фактических типов с помощью tuple(type(arg) for arg in args). Например, вызов process(42, "hello") дает (int, str).
Сопоставление типов (_match_types)
Это сердце проверки, где сравниваются фактические типы аргументов с ожидаемыми типами параметров:
Проверка длины: Если аргументов больше, чем параметров, это сразу несовпадение.
Методы классов: Если первый параметр — self (пустая аннотация), он пропускается при сравнении, чтобы поддерживать методы.
Обработка Union: Если параметр имеет тип Union[int, str], мы используем get_args для извлечения (int, str) и проверяем, является ли тип аргумента подклассом хотя бы одного из них через issubclass. Если в Union есть None, он исключается из проверки, если аргумент не None.
Ленивые типы: Для LazyTypeWrapper мы пытаемся разрешить тип через resolve(). Если это не удается (например, из-за циклического импорта), сравниваем имена типов как запасной вариант.
Generic-типы: Если параметр — list, проверяем, является ли аргумент подклассом list (через issubclass).
Выбор функции
Если типы совпадают, мы используем inspect.signature(fn).bind_partial, чтобы привязать аргументы (включая значения по умолчанию), и вызываем функцию.
4. Поддержка наследования (OverloadManager.call)
Для методов классов мы учитываем иерархию наследования:
Если первый аргумент — объект класса, мы проверяем его тип через
__class__и проходим по цепочке базовых классов (__mro__). Например, если метод перегружен в базовом классеBase, а вызывается на объектеDerived, мы найдем подходящую версию.
Итоговая картинка вызова перегруженной функции
from typing import Union, Optional
from copy import deepcopy
import pandas as pd
import numpy as np
from sklearn.datasets import load_iris
from sklearn.linear_model import LogisticRegression
from my_library.overloading import overload # функция-декоратор перегрузки
DataFrame = Union[pd.DataFrame, np.ndarray]
DataArray = Union[pd.Series, pd.Index, np.ndarray, list, tuple]
class Dataset:
"Кастомный класс для работы с данными"
def __init__(self, data: DataFrame, target_column: str):
self.data = data
self.target_column = target_column
...
class Model:
"Кастомный класс для моделей"
def __init__(self, model):
self.model = model
self.features = None
@overload
def fit(self, dataset: Dataset, features: Optional[DataArray] = None, **kwargs):
self.features = (
self._get_features(dataset.data, dataset.target_column)
if features is None
else features
)
return self.fit(dataset.data[self.features], dataset.data[dataset.target_column], **kwargs)
@overload
def fit(self, X: DataFrame, y: DataArray, **kwargs):
self.features = self._get_features(X, None)
self.model.fit(X, y, **kwargs)
return self
@overload
def predict(self, dataset: Dataset, **kwargs):
return self.predict(dataset.data[self.features], **kwargs)
@overload
def predict(self, X: DataFrame, **kwargs):
return self.model.predict(X, **kwargs)
def _get_features(self, data: DataFrame, target_col: Optional[str]) -> list:
if hasattr(data, 'columns'):
if target_col is not None:
return data.columns.drop(target_col).tolist()
return data.columns.tolist()
else:
return list(range(data.shape[1] - (1 if target_col is None else 0)))
X, y = load_iris(as_frame=True, return_X_y=True)
dataset = Dataset(pd.concat([X, y], axis=1), 'target')
sklearn_model = LogisticRegression(solver='liblinear', multi_class='ovr', random_state=42)
# Перегрузка сработает в зависимости от типов аргументов
features = ['sepal length (cm)', 'sepal width (cm)', 'petal length (cm)', 'petal width (cm)']
model1.fit(dataset, features)
model2.fit(dataset)
model3.fit(X, y)
# Проверка на равенство
np.array_equal(
model1.predict(dataset),
model2.predict(dataset)
) # True
np.array_equal(
model2.predict(dataset),
model3.predict(dataset)
) # True
np.array_equal(
model1.predict(dataset),
model1.predict(X)
) # True
Теперь посмотрим на содержание нашего менеджера.
В коде нашей библиотеки нам достаточно объявить один объект
manager = OverloadManager(), далее в ней функция-декораторoverloadбудет регистрировать все перегрузки.
>>> from my_library import overloading
>>> overloading.manager.methods
{('__main__',
'Model',
'fit'): <my_library.overloading.OverloadFunction at 0x12fa82ec0>,
('__main__',
'Model',
'predict'): <my_library.overloading.OverloadFunction at 0x14f8b0b80>}
# название модуля здесь `__main__` так как мы тестировали наши
# перегрузки выше в jupyter notebook.
Посмотрим теперь на конкретные перегрузки. Так как мы работаем с методами, а не функциями, то ключом к каждому из них будет кортеж, который состоит из названия модуля, класса и самой функции.
>>> overloading.manager.methods[('__main__', 'Model', 'fit')].overloads
{(inspect._empty,
__main__.Dataset,
typing.Union[pandas.core.series.Series, pandas.core.indexes.base.Index, numpy.ndarray, list, tuple, NoneType]): <function __main__.Model.fit(self, dataset: __main__.Dataset, features: Union[pandas.core.series.Series, pandas.core.indexes.base.Index, numpy.ndarray, list, tuple, NoneType] = None, **kwargs)>,
(inspect._empty,
typing.Union[pandas.core.frame.DataFrame, numpy.ndarray],
typing.Union[pandas.core.series.Series, pandas.core.indexes.base.Index, numpy.ndarray, list, tuple]): <function __main__.Model.fit(self, X: Union[pandas.core.frame.DataFrame, numpy.ndarray], y: Union[pandas.core.series.Series, pandas.core.indexes.base.Index, numpy.ndarray, list, tuple], **kwargs)>}
>>> overloading.manager.methods[('__main__', 'Model', 'predict')].overloads
{(inspect._empty,
__main__.Dataset): <function __main__.Model.predict(self, dataset: __main__.Dataset, **kwargs)>,
(inspect._empty,
typing.Union[pandas.core.frame.DataFrame, numpy.ndarray]): <function __main__.Model.predict(self, X: Union[pandas.core.frame.DataFrame, numpy.ndarray], **kwargs)>}
1. Работа с Union и Optional
Union[int, str] разбирается на подтипы (int, str), и проверка проходит для каждого аргумента отдельно.
Optional[float] (то есть Union[float, None]) обрабатывается так, чтобы None не мешал, если аргумент — float. Это делает перегрузку интуитивной.
2. Ленивые импорты
Если тип импортируется лениво (в нашем случае с LazyImport), мы не загружаем его сразу, а откладываем до момента вызова. Это решает проблему циклических зависимостей, так как использование from future import annotations ломает нашу реализацию.
LazyImport: Наш кастомный класс для отложенного разрешения типов. В основном используется для решения проблем с циклическими импортами и также откладывает импорт тяжелых библиотек. При вызовеresolve()он импортирует модуль и возвращает тип.
3. Значения по умолчанию
Благодаря bind_partial и apply_defaults мы корректно обрабатываем параметры с дефолтными значениями, даже если они не переданы в вызове.
4. Обработка ошибок
Если подходящей перегрузки не найдено, выбрасывается информативное исключение с указанием типов аргументов, что упрощает отладку.
5. Работа с *args и **kwargs
Для пропуска переменного числа аргументов, мы используем inspect.Parameter.VAR_POSITIONAL и inspect.Parameter.VAR_KEYWORD, сравнивая с ним все переданные аргументы.
6. Пропуск self в методах
Если первый параметр — self, мы пропускаем его при сравнении типов, чтобы поддерживать методы классов. Вычислить, является ли аргумент self, помогает сравнение с inspect.Parameter.empty.
7. Различия в работе с методами и функциями
При обработке функций достаточно хранить только их имена в менеджере, тогда как для методов требуется использовать кортеж, включающий имя метода, а также имена модуля и класса. Это обусловлено тем, что методы с одинаковыми именами в разных классах (за исключением случаев наследования) выполняют различные задачи. Например, в пользовательских классах Model для обучения [1] и GridSearch для подбора гиперпараметров может быть метод fit(), но его назначение и реализация в каждом случае будет различным.
1. typing — Support for type hints — Python documentation [2] — официальная документация библиотеки typing для использования аннотаций типов (get_type_hints, get_origin, get_args).
2. inspect — Inspect live objects — Python documentation [3] — официальная документация модуля inspect для работы с объектами во время исполнения.
3. PEP 484 — Type Hints [4] — официальное описание синтаксиса аннотаций типов в Python.
4. PEP 563 — postponed evaluation of type annotations [5] — официальное описание отложенного разрешения аннотаций типов.
Автор: MagnusMeier
Источник [6]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/14051
URLs in this post:
[1] обучения: http://www.braintools.ru/article/5125
[2] typing — Support for type hints — Python documentation: https://docs.python.org/3/library/typing.html
[3] inspect — Inspect live objects — Python documentation: https://docs.python.org/3/library/inspect.html
[4] PEP 484 — Type Hints: https://www.python.org/dev/peps/pep-0484/
[5] PEP 563 — postponed evaluation of type annotations: https://www.python.org/dev/peps/pep-0563/
[6] Источник: https://habr.com/ru/companies/beeline_tech/articles/898530/?utm_campaign=898530&utm_source=habrahabr&utm_medium=rss
Нажмите здесь для печати.