Полиморфизм подтипов — один из главных принципов объектно-ориентированного программирования (ООП), на ошибочном представлении о котором основано большинство ее реализаций. В этой статье представлен альтернативный вгляд на парадигму в целом и на полиморфизм — в частности.
Это адаптированный перевод статьи Inheritance is not Subtyping Маттиа Мальдини, программиста и магистра компьютерных наук. Повествование ведется от лица автора оригинала.
Сегодня каждый начинающий программист, у которого есть несколько месяцев практики, должен понимать недостатки объектно-ориентированного программирования (ООП). Их множество, на протяжении многих лет они наносили огромный ущерб отрасли. Вдвойне странно, что сравнительно новые и перспективные языки программирования опираются на ООП: несмотря на вопиющие недостатки парадигмы, она считается стандартом в отрасли.
Объектно-ориентированное программирование основано на трех основных принципах:
- Инкапсуляция — каждый объект независим, а все необходимые данные находятся внутри этого объекта;
- Наследование — принцип повторного использования кода, при котором один объект может приобретать свойства другого;
- Полиморфизм подтипов — свойство системы, позволяющее использовать объекты с одинаковым интерфейсом без информации о типе и внутренней структуре объекта.
Инкапсуляция не уникальна для ООП: в большинстве языков программирования реализованы некоторые ее формы, это одни из самых полезных инструментов в любой библиотеке. То же самое касается наследования — этот принцип широко распространен за пределами ООП и признан сообществом практически бесполезным. Многие программисты постепенно отказываются от его использования, и их код ничего от этого не теряет.
Полиморфизм подтипов — единственный уникальный принцип ООП и он заслуживает отдельного внимания. Это одна из форм полиморфизма, и, на мой взгляд, не самая удачная. В современных объектно-ориентированных языках программирования существуют альтернативы (например, интерфейсы, полиморфизм строк, параметрический полиморфизм и другие), но подтипы по инерции используются даже в новых языках.
Хотя при описании основных принципов ООП подтипы упоминаются отдельно от наследования, на практике они прочно связаны между собой. Например, единственный способ создать подтип B от А — наследовать его от А, как на картинке ниже:
Такая зависимость кажется ошибочной — могут существовать подтипы или другие формы полиморфизма, которые не наследуются. Кроме того, доступ к подтипам только через наследование, как правило, не очень надежен. Многие принципы ООП работают, но опираются на шаткую теоретическую основу: это приводит к появлению реализаций с множеством патчей и дыр, которые возникают по не интуитивно понятным причинам.
Что такое полиморфизм подтипов
Разберемся, что такое подтип: для наглядности используем пример на картинке выше. Тип B называется подтипом типа A, если B может использоваться в любой ситуации, когда требуется A. Отчасти подтип выполняет ту же функцию, что и «родительский» тип, но им можно заменить «родительский» тип.
Это концепция проста для базовых типов. Например, в большинстве языков программирования целые числа считаются подтипами чисел с плавающей запятой. В данном случае легко увидеть, что целые числа являются просто специальными числами с плавающей запятой. Но для сложных типов это проблема: например, как определить, что тип объекта на самом деле является подтипом?
По сути, объекты — это наборы данных, содержащих поля и функции. Для B важно, является ли он подтипом А при условии, что содержимое последнего является подтипом. Запись трех целых чисел является подтипом записи с тремя плавающими числами. Это относительно просто определить для полей и переменных, но как быть с методами?
Функции, как и любой другой инструмент в языке программирования, имеют типы. Они определяются стрелками(->), указывающими на передачу аргумента и получение результата. Тип функции bool equalfloat(float, float)
вернет true
, если float->;float->;bool
. Интуитивно понятно, что подтип этой функции должен иметь подтипы, как в аргументах, так и в возвращаемых значениях: bool equalint(int, int)
. Но, к сожалению, это не так.
Определение подтипа гласит, что он должен использоваться вместо супертипа. Но при вызове equalfloat
(1.5, 2.2) мы видим, что он не может быть заменен на equalint
(1.5, 2.2) (давайте представим, что значения с плавающей точкой и ints
можно сравнить с одним и тем же оператором).
Отношения подтипов работают, когда параметры в типах функций обрабатываются по-разному: они должны двигаться в противоположном направлении и становиться супертипами аргументов «родительской функции». В этом случае equalfloat
является подтипом equalint
. Типы, которые следуют этому направлению, называются ковариативными.
Важно отметить, что возвращаемый тип функции работает в соответствии с интуитивным соотношением подтипов. foo
— это подтип bar
, он ковариантен по типу возвращаемого значения и контравариантен по своим аргументам.
Это было краткое введение в подтипы — теперь рассмотрим простой практический пример.
Numbers и Colored Numbers
Предположим, мы хотим работать с числами как с целыми, но с индивидуальными именами. Чтобы нагляднее показать недостаток ООП, в котором идет речь в этой статье, я буду использовать синтаксис, подобный Java.
Наши объекты будут очень простыми: целочисленное значение и метод eq
для сравнения чисел:
class Number {
int n;
Number(int n) {
this.n = n;
}
bool eq(Number other) {
if (this.n == other.n) {
System.out.println("The two numbers are equal");
return true;
}
else {
System.out.println("The two numbers are equal");
return false;
}
}
Прежде чем продолжить, разберем важную деталь: природу this/self
, то есть рекурсию объекта в целом. Объекты в ООП — это записи, которые могут ссылаться сами на себя с помощью рекурсии. Часто для этого используются специальные переменные — this
или self
. Эти переменные не рождаются сами в глубинах компилятора — они неявно передаются среди параметров каждого метода.
Это означает, что семантически практически нет разницы между использованием точечной нотации и явной обработкой ссылки на объект с помощью независимой функции.
java num.eq (othernum); // Это похоже на запись eq (num, othernum)
Точечная нотация, к которой все привыкли — это просто обозначение, которое не имеет решающего значения кроме выделения предмета метода. Поэтому метод — не что-то более особенное, чем функция. Именно определение объекта связывает их вместе.
Вернемся к примеру: метод eq
класса Number
имеет тип Number->; Number->; bool
несмотря на то, что содержит только один параметр. Воспользуемся инструментами ООП и создадим расширенную версию класса Number
, добавив поле для отслеживания ColorNumber
:
class ColorNumber extends Number {
String color;
ColorNumber(int n, String color);
bool eq(ColorNumber other) {
if (this.n == other.n && this.color == other.color) {
System.out.println("The two ColorNumbers are equal");
return True;
} else {
System.out.println("The two ColorNumbers are not equal");
return False;
}
}
}
Зададим произвольный порядок для примера, а затем создадим пару экземпляров таких классов, используя наследование:
Number number = new Number(1);
ColorNumber cnumber = new ColorNumber(1, "black");
number.eq(cnumber);
cnumber.eq(number);
В данном случае number
можно заменить на объект Number
, а cnumber
— на ColorNumber
.
Первая часть проста для понимания: в ней мы сравниваем Number
с ColorNumber
. Второе является подтипом первого, поэтому они взаимозаменяемы. В данном случае из родительского класса вызывается метод eq
, а вызов функции покажет, что два числа равны.
Вторая часть интереснее. В ней вызывается метод eq
из класса ColorNumber
с числом в качестве аргумента. Проблема в том, что класс не имеет такого метода: мы определили eq
только в терминах объектов ColorNumber
, и число в данном случае не является подтипом. Теоретически такой код невозможно скомпилировать, но на деле он работает и выводит ответ «Эти два числа равны».
Разберемся, что происходит. Метод eq
вызывается классом Number
, даже если number — просто ссылка на ColorNumber
, которая действительно содержит ColorNumber
. Даже если это так, мы никогда не отменяли метод eq из родительского классы — мы просто перегружали его. ColorNumber
содержит два одинаково вызываемых метода с разными типами аргументов. Поэтому number обрабатывается как число: единственный вариант для компилятора — привести его к своему супертипу, а затем использовать метод типа Number->; Number->; bool
.
Такое поведение компилятора может показаться очевидным, но для меня эта логика слишком запутанна. Я думал, что заменяю метод eq
, а не добавляю дополнительный случай. С другой стороны, если бы это действительно была замена, я не смог бы использовать тип ColorNumber
в качестве аргумента из-за особенностей работы подтипа функции. eq
в ColorNumber
не является подтипом того же метода для родительского класса.
Как участвовать в Open Source проектах Хекслета: На Хекслете есть множество Open Source проектов разной сложности — нам всегда нужна помощь разработчиков для развития этих сервисов.
Я попробовал повторить этот пример в самых в популярных объектно-ориентированных языках программирования и результат был одним и тем же: Java, C++, C#, Scala и Kotlin ведут себя одинаково. Единственным исключением стал Dart, который требует более точного подхода. При определении eq
для унаследованного класса язык не позволяет использовать одно и то же имя без перезаписи, используя в процессе контравариантные аргументы. Выполнение того же примера приводит к такой ошибке:
Эта ошибка должна была появляться во всех языках при попытке заменить метод подтипом. Пример показывает, что объекты проще, чем мне казалось раньше — это повод задуматься, стоит ли основывать на них целые языки.
Заключение
Этот текст возник из задачи, которую я увидел много лет назад во время занятий по языкам программирования в школе. Надеюсь, что перечисленные в этой статье особенности объектно-ориентированных языков программирования заставят вас подумать в сторону того, чтобы избегать этой парадигмы.