В этой статье мы попробуем разобраться с тем, что такое полиморфизм, для чего он нужен и какой он бывает. А также мы посмотрим на то как те или иные его проявления реализуются в языке Scala.
я планировал написать этот пост для habrahabr.ru, но так как времени довести его до ума у меня критически не хватает, то я решил опубликовать его “как есть”, ибо он и так уже пропылился у меня 2 месяца в черновиках. Так что, чтобы он не пропал в туне, я решил выложить его в своем блоге.
Разговор о полиморфизме - это разговор о системе типов, потому что полиморфизм является свойством ситемы типов языка. А основная задача системы типов, это сокращение ошибок в программах, поэтому типы накладывают своего рода ограничения, которые помогают усилить корректность программ. Scala - язык с сильной, статической типизацией и обладает мощной системой типов. В языках со статической типизацией типы всех переменных и выражений должны быть определены на этапе компиляции, что является довольно строгим ограничением. Так вот, полиморфизм позволяет увеличить гибкость и выразительность языка, сохраняя при этом безопасность типов, даже если тип выражения статически не известен.
Полиморфизм бывает четырех видов, которые в общем объединяются в две категории:
- Универсальный (Universal)
- Параметрический полиморфизм (parametric polymotphism/generics)
- Полиморфизм включения (including polimorphism/subtyping)
- Cпециальный (Ad-hoc)
- Перегрузка (overloading) и Тайпклассы (typeclasses)
- Приведение типов (coercion)
Существует также некоторые разночтения в терминах, например в ООП мире параметрический полиморфизм в основном называют generics
(обобщенное программирование), в то время как в мире ФП такой тип полиморфизма называют просто полиморфизм
. В то же время в ООП просто полиморфизмом называют полиморфизм включения, его еще называют выделением подтипа (subtyping или subtype polimorphism). В этой статье мы коснемся универсального полиморфизма.
Универсальный полиморфизм
Особенность универсально полиморфных функций и типов в том, что они могут одинаково обработать (потенциально) бесконечное количество типов. Такая особенность может достигаться разными способами.
Например, использование параметрического полиморфизма позволяет создавать обобщенные функции и типы данных, указывая вместо действительных типов (таких как Int, String) - переменные типов, вместо которых, в последствии, будут подставлены действительные типы. Такая возможность позволяет обрабатывать значения независимо от их типа. В случае использования полиморфизма включения поведение полиморфной функции ограничивается множеством типов, связанных иерархией наследования (отношения супертип-подтип).
Связь этих двух подвидов универсального полиморфизма, обеспечивается посредством связанной квантификации и вариантности конструкторов типов. При программировании в Scala оба этих вида тесно переплетаются между собой, поэтому нет смысла разделять их на отдельные темы, я просто буду указывать где проявляется тот или иной вид.
Для того, что бы лучше понять применение полиморфизма давайте представим, что мы решили написать свою библиотеку для работы с коллекциями. В нашем случае мы будем рассматривать пример реализации списка. Начнем с того, что определим список целых чисел:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
Реализация нашего списка состоит из трех частей, это базовый тип IntList
и наследующие от него класс Cons
и объект Nil
. Такая структура представляет собой пример полиморфизма включения. Благодаря такому полиморфизму, мы можем использовать Cons
и Nil
там, где требуется IntList
Рассмотрим реализацию более подробно.
Итак, в первой строке мы объявили новый тип данных IntList
. Слово trait
в определении типа говорит о том, что данный тип является чем-то вроде интерфейсов в Java, с той разницей (не единственной), что может содержать не только абстрактные методы (такие как isEmpty
, head
, tail
), но и методы с реализацией (например toString
). Как и интерфейсы в Java мы не можем напрямую создать экземпляр трейта при помощи ключевого слова new
. Слово sealed
перед trait
означает, что все реализации данного трейта должны быть помещены в тот же самый файл, где объявлен наш трейт. Таким образом, наш список может конструироваться только при помощи строительных блоков которые мы предоставим - Cons
и Nil
, они так же называются конструкторами данных.
В принципе, так как функциональность базового типа будет использоваться только двумя наследниками и мы не планируем предоставлять возможность наследования от
List
в каких-то других несвязанных классах, то с тем же успехом можно было использоватьabstract class
вместоtrait
, характеристик абстрактного класса было бы вполне достаточно, к тому же такой вариант будет немного более производительным. Но в рамках статьи это не имеет особого значения, поэтому здесь и далее будет использован именно трейт.
Конструктор Nil
, создает пустой список и так как он не содержит элементов, то операции доступа к ним вызывают исключения. Мы будем использовать его как маркер конца не пустого списка. Конструктор не пустого списка Cons
содержит первый элемент и список остальных элементов, который может быть в том числе и пустым списком. Используя ключевое слово case
при объявлении конструкторов списка, мы получаем некоторые преимущества над обычными классами. Например, case-классы могут использоваться при сопоставлении с образцом (pattern matching), таким образом позволяя деконструировать список. Также, для case
-классов создается объект-компаньон содержащий метод apply
позволяющий создавать экземпляры класса без ключевого слова new
, а параметры конструктора становятся публичными полями класса. В Scala конструктор является частью определения класса, поэтому параметры конструктора в классе Cons
становятся публичными и переопределяют абстрактные методы базового класса. Так как Nil
не принимает значений в качестве параметров, то мы определяем его как объект-одиночку - case object
.
Рассмотрим пример создания списка целых чисел:
1
|
|
1 2 |
|
Но что если нам однажды потребуется работать не только с целыми числами, но и с другими типами данных такими как числа с плавающей точкой или строки? Один из вариантов - скопировать реализацию для целых чисел и заменить тип Int
на нужный. Но таким образом нам придется повторять это снова и снова для каждого нового типа с которым мы захотим работать, при этом код будет разрастаться, что увеличит количество ошибок, усложнит рефакторинг и т.д и т.п. Если посмотреть более пристально на наш список, то можно заметить, что его функциональность никак не зависит от типа содержащихся в них данных. Поведение списка пудет одинаковым и для строк и для чисел с плавающей точкой и для объектов пользовательских классов. Было бы очень удобно иметь возможность указать компилятору на то, что какая-либо структура и поведение является общей для всех типов. Как раз для этого мы можем использовать параметрический полиморфизм. Для реализации нашей задумки нам нужно добавить параметр типа и заменить жескто заданный тип Int
, на переменную типа, которую мы указываем в качестве параметра типа. Давайте попробуем усовершенствовать нашу реализацию:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
Итак, прежде чем мы перейдем к деталям того, что и зачем мы изменили, давайте посмотрим на пару примеров использования нашей новой реализации обобщенного списка:
1 2 3 4 5 |
|
1 2 3 4 5 6 7 |
|
В первом выражении примера мы объявляем переменную intList
в качестве списка целых чисел, заменив формальный параметр T
типом Int
, затем конструируем список целых чисел при помощи конструктора данных Cons
, с передачей действительного параметра - Int
и получившийся список присваиваем переменной intList
. Во втором выражении мы конструируем список строк и присваеваем его переменной stringList
имеющий тип List[String]
. Таким образом мы можем использовать одну реализацию списка для данных различного типа.
Давайте же вернемся к самой реализации и перейдем к описанию сделанных изменений. Во-первых, тип данных список целых чисел IntList
превратился в полиморфный список - List[+T]
. Также, к классам List
и Cons
, мы добавили новую конструкцию, называемую параметром типа, которая записывается в виде произвольного символьного имени заключенного в квадратные скобки - [+T]
, тем самым мы указали, что теперь внутри определений данных классов мы можем использовать имя T
в качестве переменной типа, в которую при инстанцировании будет подставлен конкретный тип, такой как Int
или String
, что было продемонстрированно примерами выше. Полиморфный класс List[+T]
также называют конструктором типа, потому что теперь при помощи данного класса вы можете создавать (конструировать) другие типы, просто заменив параметр обобщенного типа на конкретный. То есть конструктор типов List
может создавать семейство новых типов, таких как List[Int]
, List[String]
и так далее. Но параметрический полиморфизм можно применять не только для создания конструкторов типов, но и для обобщения функций. Что бы это продемонстрировать давайте отвлекемся от описания уже сделанных изменений и добавим к нашему списку еще одну функцию, которая будет обеспечивать удобный способ изменения данных, сохраняя при этом общий контекст. Мы назовем данную функцию map
и в качестве параметра она будет принимать любую функцию, способную обрабатывать данные хранящиеся в нашем списке и возвращающую новые данные для хранения в новом списке. Но при реализации данной функциональности возникает один важный вопрос. Как быть с тем фактом, что мы не можем за ранее предсказать какой будет тип результата работы такой функции, ведь она будет предоставляться пользователем нашего списка. Как мы уже выяснили, в таких случаях ограничивать реализацию одним типом не очень дальновидно и конечно нам опять поможет параметрический полиморфизм. Для этого к нашей новой функции мы добавим параметр типа, который будет использоваться в качестве переменной типа для результата функции изменяющей данные и для типа данных списка, который получится в результате применения такой функции к элементам исходного списка:
1 2 3 4 5 6 7 8 |
|
И данную функцию можно использовать следующим образом:
1 2 3 4 |
|
1 2 3 4 5 6 |
|
В первом выражении мы конструируем список целых чисел и присваеиваем его переменной intList
. Во втором выражении мы вызываем полиморфную функцию map
на только что созданном списке и в качестве параметра типа передаем тип Double
, который конкретизирует типовую переменную U
. Следом за ним в качестве аргумента передаем анонимную функцию, которая в качестве параметра принимает элемент типа Int
, а возвращает элемент типа Double
.
Внимательные заметят, что в первом выражении, конструирование списка целых чисел стало выглядеть чище, потому что исчезли анатации типа у конструктора
Cons
. На самом деле в Scala можно обойтись без явных анотаций типа, потому что Scala обладает мощной системой вывода типов и может вывести тип выражения даже когда он явно не указан, основываясь, например, на типах аргументов функции или на типе ожидаемого результата. Тем не менее, считается хорошей практикой всегда аннотировать публичные функции и переменные которым присваивается результат сложных выражений. Используя вывод типов и особый синтаксис создания лямбд в Scala, второе выражение из примера выше можно переписать следующим образом:val doubleList = intList.map { _ * 0.1 }
Итак теперь продолжим описывать сделанные ранее изменения. Мы разобрались с тем как создавать обобщенные типы и функции, но иногда при их реализации можно столкнуться с другими интересными проблемами и решениями. Давайте рассмотрим объект Nil
. Так как объект это по сути экземлпяр конкретного (не обобщенного) класса, то объект Nil
не может расширять (инстанцироваться от) обощенный тип List[T]
. Таким образом нам нужно указать конкретный тип в качестве действительного параметра базового класса, что бы объект Nil
был экземпляром конкретного (а не обощенного) типа. Но какой тип указать? Ведь нам нужно что бы Nil
мог работать с любым типом, с которым работает List
, ведь если мы укажем что Nil
является экземпляром типа List[Int]
, то мы не сможем его использовать, например, при конструировании списка строк. Но тут нам на помощь придет полиморфизм включения. В частности, мы можем использовать специальный тип Nothing
, который является подтипом всех остальных типов в Scala и таким образом может быть передан туда, где ожидаются данные любого другого типа. Поэтому мы указываем, что объект Nil
является экземпляром типа List[Nothing]
.
В качестве последнего изменения мы добавили знак +
у параметра типа нашего списка List[+T]
. Прежде чем объяснить, что это значит, давайте уберем его и попробуем скомпилировать следующий код:
1
|
|
1 2 3 4 5 6 7 8 |
|
В результате мы получим ошибку сообщающую о несоответствии типа, потому что класс List
является инвариантным относительно T
. Давайте разберем по шагам, что происходит. При создании экземпляра класса Cons
в качестве действительного параметра типа указывается тип Double
, это значит, что конструктор класса Cons
будет ожидать, что ему передадут два значения с типами Double
и List[Double]
соответственно. В качестве первого значения мы передаем 0.5
, что соответствует ожидаемому типу Double
, а вот в качестве второго значения мы передаем экземпляр типа List[Nothing]
и это не тот тип, который ожидает компилятор. Несмотря на то, что Nothing
является подтипом Double
, мы не можем сказать того же о типах List[Double]
и List[Nothing]
. В Scala все параметризованные типы по-умолчанию инвариантны. Для того, чтобы List[Nothing]
, мог рассматриваться в качестве допустимого аргумента, там где ожидается List[Double]
мы должны поменять вариантность полиморфного типа List
, добавив к параметру типа знак +
. Использование типа Nothing
и объявление списка контрвариантным к своему параметру типа это пример где параметрический полиморфизм и полиморфизм включения дополняют друг-друга. Давайте рассмотрим эту тему подробнее.
Вариантность
В Scala существует возможность проецировать отношения родства между типами на отношения родства между конструкторами типов. То есть указать ответ на вопрос, каким образом два типа имеющие наследственную связь, будут связаны между собой при использовании в полиморфном типе. По умолчанию все параметрические типы в Scala инвариантные(перед именем формального параметра типа не стоит никаких знаков), так же они могут быть ковариантные (знак +
перед именем формального параметра типа) и контравариантные (знак -
перед именем формального параметра типа).
Вариантность | Значение | Нотация языка Scala |
---|---|---|
ковариант | C[T’] это подкласс класса C[T] | [+T] |
контрвариант | C[T] это подкласс класса C[T’] | [-T] |
инвариант | C[T] и C[T’] не взаимосвязаны | [T] |
Что же это значит?
Ковариантность ([+T])
Давайте представим, что у нас есть тип данных студент, который является подтипом более общего типа человек:
1 2 3 4 5 6 |
|
И на основе отношений родства данных типов возникает вопрос. Считать ли тип List[Student]
подтипом более общего типа List[Person]
, если следовать логике, то “группа студентов” это частный случай более общего понятия “группа людей”, а это значит, что студенты должны обладать всеми качествами людей и поэтому могут быть использованы там, где требуются люди. Значит ответ на наш вопрос должен быть - “да”. Для того чтобы соответствовать таким отношениям наследственности, наш полиморфный список должен быть ковариантным к своему параметру типа, что обозначается как List[+T]
. Более кратко ковариантность можно описать следующим образом: если S подтип T, то С[S] подтип C[T], что позволяет использовать C[S], там где на самом деле требуется C[T]. Пример использования ковариантности мы уже видели ранее. Благодаря тому, что наш список ковариантный, мы можем передать Nil
в качестве второго параметра конструктора Cons
, указав тем самым на конец списка.
Контравариантность ([-T])
Существует так же обратный вид вариантности - контравариантность, которая удовлетворяет следующему положению - если T подтип S, то C[S] подтип C[T], то есть отношения наследования между типами переносятся на конструкторы типов в обратом порядке. Это значит, что список людей List[Person]
может рассматриваться в качестве подтипа списка студентов List[Student]
. Полезность контравариантности трудно понять интуитивно. Поэтому рассмотрим использование контравариантности на примере.
Так как в Scala функции являются объектами, существует конструктор (в том числе) унарных функций, который мы и рассмотрим в качестве примера. Определяется он следующим образом:
1 2 3 |
|
Мы видим, что функция контравариантна к своему аргументу (-T
) и ковариантна к своему результату (+R
). Давайте теперь создадим некий список студентов:
1
|
|
Так же определим функцию, которая возвращает имя человека, переданного ей в качестве параметра. Мы определим ее более наглядным способом:
1
|
|
Теперь для того, что бы получить список имен всех студентов мы можем написать следующее:
1
|
|
1 2 3 4 5 6 7 8 |
|
Благодаря тому, что Function1
контравариантна к своему аргументу, приведенный выше код будет работать без ошибок. Несмотря на то, что функция map
в качестве аргумента ожидает функцию типа Student => String
, но мы предоставляем функцию типа Person => String
.
Осталось рассмотреть последний вид вариантности.
Инвариантность ([T])
Инвариантность это отсутствие наследственных взаимосвязей относительно параметра типа. Если параметр типа ни ковариантный, ни контравариантный, значит он инвариантный. Инвариантность в основном применяется с изменяемыми структурами данных. Если посмотреть на коллекцию Scala с изменяемыми классами (scala.collection.mutable), то мы увидим что все они инвариантны. Чтобы понять почему, давайте рассмотрим пример с массивами. Предположим, что массивы в Scala ковариантные (на самом деле инвариантны), тогда следующий код был бы корректным с точки зрения компилятора:
1 2 3 4 5 |
|
1 2 3 4 5 6 7 8 9 10 11 |
|
Данный пример показывает, что если бы массивы были ковариантны, то в mutableStudents
уже хранился бы массив не с двумя студентами Bob и Joe, а массив, где вместо Bob’a было бы “Oooops”.
Возможно с первого раза разобраться с вариантностью будет сложно. Но есть некоторые правила для выбора вариантности. Они зависят от того, в какой позиции будет стоять переменная типа. Существуют ковариантные или положительные позиции и контравариантные или отрицательные позиции. Положительными считаются следующие позиции - это типы значений (полей данных) в классах/трейтах, типы результатов возвращаемых методами класса/трейта, а так же позиция в качестве аргумента для параметров типов других ковариантных классов. Отрицательными позициями считаются типы формальных параметров метода класса/трейта. Если сказать проще, то в общем случае изменяемые контейнеры должны быть инвариантные, неизменяемые контейнеры - ковариантные, входные значения для функций - контравариантные, а выходные - ковариантные. В любом случае компилятор Scala проверяет вариантность каждого параметра типа и если вы где-то напутали с вариантностью, то компилятор обязательно сообщит об этом и откажется компилировать код.
Границы изменения типов
Но иногда проверка вариативности мешает реализовать, в принципе, корректные вещи. Например, давайте добавим к нашему списку метод для добавления нового элемента:
1
|
|
Мы добавляем этот метод непосредственно в базовый класс List[+T]
. Между делом, данный метод не совсем обычный, дело в том, что в Scala все методы, которые заканчиваются на двоеточие имеют правую ассоциативность (мы увидим что это значит на примере ниже). К сожалению, приведенный код не компилируется и компилятор будет ругаться, что ковариантный тип T оказался в контравариантной позиции, то есть в качестве типа формального параметра hd
. Обойти эту проблему можно сделав метод полиморфным, добавив еще один параметр типа:
1
|
|
Теперь у метода ::
появился свой параметр типа B
, и так как он является инвариантным (инвариантные типы могут появляться в любой позиции), то его появление в качестве типа формального параметра функции не должно вызывать проблем. Однако, проблема появляется в реализации данного метода. Теперь, мы не можем конструировать новый список путем добавления элемента типа B
к списку элементов типа T
. И это логически верно, ведь параметр типа B
может представлять любой тип. Вот если бы его можно было как-то ограничить только допустимыми типами. Это делается следующим образом:
1
|
|
То есть мы добавили >: T
к параметру типа, тем самым указав, что тип T
является нижней границей типа B
(B
должен быть супер-типом для T
). И с указанием данного условия код успешно компилируется. Также, благодаря добавлению параметра типа к методу ::
, мы дополнительно улучшили его реализацию. Если мы добавим элемент с типом отличающимся от исходного типа списка, то результирующий список изменит свой тип в сторону типа нового элемента. Давайте посмотрим на примере:
1 2 3 |
|
1 2 3 4 5 |
|
Обратите внимание на то, как конструируется новый список в первой строке. Такая запись возможна благодаря правой ассоциативности метода ::
. Если переписать это выражение в более привычный вид, это будет выглядеть вот так:
1
|
|
Помимо указания нижней границы, можно также указывать и верхнюю границу типа T <: U
, например:
1 2 3 4 5 6 7 8 9 10 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
Из примера видно, что у параметра типа функции personName
в качестве верхней границы указан тип Person
и данная функция успешно выполняется как с аргументами типа Person
, так и с его подтипами, что соответствует заданной границе. Но в тоже время функция studentName
вызывает ошибку при попытке передачи в нее аргументов типа Person
, так как для параметра типа верхняя граница задана типом Student
, соответственно эта функция допускает только аргументы с типом Student
или его подтипами.
Заключение
Итак, мы прошли долгий путь и рассмотрели различные формы универсального полиморфизма. Мы узнали как при помощи выделения подтипов и параметризации достигать большей гибкости, увидели как эти возможности вместе с вариативностью помогают создавать обобщенный код и увеличивают возможности его повторного использования. Узнали как задание границ типов позволяет разрабатывать более типобезопасные решения.
В следующей статье мы рассмотрим ad-hoc полиморфизм и в его рамках познакомимся с такими понятиями как неявные преобразования, границы контекста и границы отображения, познакомимся с реализацией typeclass’ов.
Для того, что бы не раздувать объем статьи, за рамками рассмотрения остались такие связанные темы как: структурные типы, абстрактные типы, экзистенциальные типы, рекурсивные типы и возможно еще целый спектр возможностей системы типов Scala.