В первой лекции уже было показано, что функциональная парадигма программирования поддерживает чистый или параметрический полиморфизм. Однако большинство современных языков программирования не обходятся без так называемого полиморфизма ad hoc, или перегрузки. Например, перегрузка практически повсеместно используется для следующих целей:
· Литералы 1, 2, 3 и т.д. (т.е. цифры) используются как для записи целых чисел, так и для записи чисел произвольной точности.
· Арифметические операции (например, сложение — знак " + ") используются для работы с данными различных типов (в том числе и с нечисловыми данными, например — конкатенация для строк).
· Оператор сравнения (в Haskell’е знак двойного равенства — " == ") используется для сравнения данных различных типов.
Перегруженное поведение операций различное для каждого типа данных (зачастую такое поведение вообще может быть не определено или определено ошибочно), в то время, как в параметрическом полиморфизме тип данных, вообще говоря, не важен. Однако в Haskell’е есть механизм для использования перегрузки.
Рассмотреть возможность использования полиморфизма ad hoc проще всего на примере операции сравнения. Существует большое множество типов, для которых можно и целесообразно переопределить операцию сравнения, но для некоторых типов эта операция бесполезна. Например, сравнение функций бессмысленно, функции необходимо вычислять и сравнивать результаты этих вычислений. Однако, например, может возникнуть необходимость сравнивать списки. Конечно, здесь речь идет о сравнении значений, а не сравнении указателей (как, например, это сделано в Java).
Рассмотрим функцию element, которая возвращает значение истины в зависимости от того, присутствует заданный элемент в заданном списке, или нет (в целях стиля данная функция описана в инфиксной форме):
x `element` [] = False
x `element` (y:ys) = (x == y) || (x `element` ys)
Здесь видно, что у функции element тип (a ® [a] ® Bool), но при этом тип операции " == " должен быть (a ® a ® Bool). Т.к. переменная типа a может обозначать любой тип (в том числе и список), целесообразно переопределить операцию " == " для работы с любым типом данных.
Классы типов являются решением этой проблемы в Haskell’е. Для того, чтобы рассмотреть этот механизм в действии далее определяется класс типов, содержащий оператор равенства.
class Eq a where
(==) :: a -> a -> Bool
В этой конструкции использованы служебные слова "class" и "where", а также переменная типов a. Символ "Eq" является именем определяемого класса. Эта запись может быть прочитана следующим образом: "Тип a является экземпляром класса Eq, если для этого типа перегружена операция сравнения " == " соответствующего типа". Необходимо отметить, что операция сравнения должна быть определена на паре объектов одного и того же типа.
Ограничение того факта, что тип a должен быть экземпляром класса Eq записывается как (Eq a). Поэтому выражение (Eq a) не является описанием типа, но оно описывает ограничение, накладываемое на тип a, и это ограничение в Haskell’е называется контекстом. Контексты располагаются перед определением типов и отделяются от них символами " => ":
(==) :: (Eq a) => a -> a -> Bool
Эта запись может быть прочитана как "Для каждого типа a, который является экземпляром класса Eq, определена операция " == ", которая имеет тип (a ® a ® Bool)". Эта операция должна быть использована в функции element, поэтому ограничение распространяется и на неё. В этом случае необходимо явно указывать тип функции:
element :: (Eq a) => a -> [a] -> Bool
Этой записью декларируется тот необходимый факт, что функция element определена не для всех типов данных, но только для тех, для которых определена соответствующая операция сравнения.
Однако теперь возникает проблема определения того, какие типы являются экземплярами класса Eq. Для этого в Haskell’е есть служебное слово "instance". Например, для того, чтобы предписать, что тип Integer является экземпляром класса Eq, необходимо написать:
instance Eq Integer where
x == y = x `integerEq` y
В этом выражении определение операции " == " называется определением метода (как в объектно-ориентированной парадигме). Функция integerEq может быть любой (и не обязательно инфиксной), главное, чтобы у нее был тип (a ® a ® Bool). В этом случае скорее всего подойдет примитивная функция сравнения двух натуральных чисел. В свою очередь прочитать написанное выражение можно следующим образом: "Тип Integer является экземпляром класса Eq, и далее приводится определение метода, который производит сравнение двух объектов типа Integer".
Таким же образом можно определить операцию сравнения и для бесконечных рекурсивных типов. Например, для типа Tree (см. лекцию 4) определение будет выглядеть следующим образом:
instance (Eq a) => Eq (Tree a) where
Leaf a == Leaf b = a == b
(Branch l1 r1) == (Branch l2 r2) = (l1 == l2) && (r1 == r2)
_ == _ = False
Естественно, что если в языке определено понятие класса, должно быть определена и концепция наследования. Хотя в Haskell’е под классом понимается нечто более абстрактное, чем в обычных объектно-ориентированных языках, в Haskell’е также есть и наследование. Но в то же время концепция наследования определяется столь же изощренно, что и понятие класса. Например, от определённого выше класса Eq можно унаследовать класс Ord, который будет представлять сравнимые типы данных. Его определение будет выглядеть следующим образом:
class (Eq a) => Ord a where
(<), (>), (<=), (>=) :: a -> a -> Bool
min, max :: a -> a -> a
Все экземпляры класса Ord должны определять кроме операций "меньше", "больше", "меньше или равно", "больше или равно", "минимум" и "максимум" ещё и операцию сравнения " == ", т.к. её класс Ord наследует от класса Eq.
Самое удивительно заключается в том, что Haskell поддерживает множественное наследование. В случае использования нескольких базовых классов, всех их просто надо перечислить через запятую в соответствующей секции. При этом экземпляры классов, унаследованных от нескольких базовых классов, должны, естественно, поддерживать и все методы базовых классов.