Ad-hoc полиморфизм, перегрузка и субтипизация
Три разных явления под одним словом «полиморфизм» — и почему их важно не путать.
Ad-hoc полиморфизм — один и тот же интерфейс с разными реализациями для разных типов; субтипизация — возможность использовать подтип там, где ждут супертип.
Три вида полиморфизма
| Вид | Суть | Пример |
| Параметрический | один код для всех типов | length, generics |
| Ad-hoc (перегрузка) | разный код под каждый тип | + для Int и для String, classes |
| Субтипизация | подтип годится вместо супертипа | Cat <: Animal, ООП-наследование |
Параметрический мы разобрали: a непрозрачен, поведение единообразно. Ad-hoc — противоположность: show 5 и show "hi" выполняют разный код, хотя имя одно. Субтипизация — третья ось: если Cat — подтип Animal (пишут Cat <: Animal), то значение Cat можно подставить туда, где ждут Animal.
Ad-hoc: перегрузка и диспетчеризация
Перегрузка выбирает реализацию по типу. В C++/Java — по сигнатуре на этапе компиляции; в Haskell — через классы типов (отдельный урок) и словари; в динамических языках — по типу в рантайме. Ключевое отличие от параметрического: функция знает тип и ведёт себя по-разному.
Субтипизация и принцип подстановки
Подтипизация задаётся отношением S <: T («S — подтип T»). Правило подстановки: значение типа S можно использовать везде, где ожидается T. Тонкое место — вариантность функций. Если Cat <: Animal, то функция Animal -> X является подтипом Cat -> X (а не наоборот!): по аргументу стрелка контравариантна, а по результату — ковариантна.
Cat <: Animal
по результату (ковариантно): (X -> Cat) <: (X -> Animal)
по аргументу (контравариантно):(Animal -> X) <: (Cat -> X)
Демонстрация: подстановка и контравариантность аргумента
Смоделируем иерархию Cat <: Animal и проверим: функция, принимающая любого Animal, безопасно работает там, где нужна функция над Cat (контравариантность аргумента), но не наоборот.
subtype = {"Cat": "Animal", "Dog": "Animal"} # Cat <: Animal, Dog <: Animal
def is_sub(s, t):
return s == t or subtype.get(s) == t
# функция over Animal принимает и Cat (подстановка работает)
def feed(animal_kind):
return "кормлю как Animal: " + animal_kind
print("feed(Cat) :", feed("Cat")) # Cat годится там, где ждут Animal
print("Cat <: Animal :", is_sub("Cat", "Animal"))
print("Animal <: Cat :", is_sub("Animal", "Cat")) # неверно
# контравариантность: (Animal -> X) можно дать туда, где нужна (Cat -> X)
print("(Animal->X) <: (Cat->X):", is_sub("Cat", "Animal"))
Вывод:
feed(Cat) : кормлю как Animal: Cat Cat <: Animal : True Animal <: Cat : False (Animal->X) <: (Cat->X): True
Как работает под капотом
Субтипизация усложняет вывод типов: вместо равенства типов (унификация) появляются неравенства S <: T, и решать приходится систему ограничений-подтипов, что тяжелее. Поэтому языки с богатым выводом (ML, Haskell) исторически избегали структурной подтипизации, делая ставку на параметрический полиморфизм и классы типов. TypeScript, наоборот, строит всё на структурной подтипизации — и платит более слабым выводом и сложной вариантностью.
Частые ошибки
- Считать наследование единственным полиморфизмом. ООП-наследование — лишь субтипизация; есть ещё параметрический и ad-hoc.
- Перепутать вариантность. Аргумент функции контравариантен; наивное «всё ковариантно» ломает безопасность (классическая дыра с массивами в ранней Java).
- Смешивать перегрузку и параметрический полиморфизм. Перегрузка зависит от типа и делает разное; параметрическая функция типа не видит.
Итоги
- Три полиморфизма: параметрический (один код), ad-hoc (разный код), субтипизация (подтип вместо супертипа).
- Перегрузка/классы типов выбирают реализацию по типу — это ad-hoc.
- Стрелка контравариантна по аргументу и ковариантна по результату.
- Субтипизация усложняет вывод (неравенства вместо равенств), отсюда выбор языков.