Изучай Haskell во имя добра!

Липовача Миран

2

Типы и классы типов

 

 

Поверь в типы

Мы уже говорили о том, что Haskell является статически типизированным языком. Тип каждого выражения известен во время компиляции – это залог безопасного кода. Если вы напишете программу, которая попытается поделить булевский тип на число, то она даже не скомпилируется.

И хорошо, потому что уж лучше ловить такие ошибки на этапе компиляции, чем наблюдать, как ваша программа аварийно закрывается во время работы! Всему в языке Haskell назначен свой тип, так что компилятор может сделать довольно много выводов о программе перед её компиляцией.

В отличие от языков Java или Pascal, у Haskell есть механизм вывода типов. Если мы напишем число, то нет необходимости указывать, что это число. Язык Haskell может вывести это сам, так что нам не приходится явно обозначать типы функций и выражений.

Мы изучили некоторые основы языка, лишь вскользь упомянув о типах. Тем не менее понимание системы типов – очень важная часть обучения языку Haskell.

Тип – это нечто вроде ярлыка, который есть у каждого выражения. Он говорит нам, к какой категории относится данное выражение. Выражение True – булево, "привет" – это строка, и т. д.

 

Явное определение типов

А сейчас воспользуемся интерпретатором GHCi для определения типов нескольких выражений. Мы сделаем это с помощью команды :t, которая, если за ней следует любое правильное выражение, выдаст нам тип последнего. Итак…

ghci> :t 'a'

'a' :: Char

ghci> :t True

True :: Bool

ghci> :t "ПРИВЕТ!"

"ПРИВЕТ!" :: [Char]

ghci> :t (True, 'a')

(True, 'a') :: (Bool, Char)

ghci> :t 4 == 5

4 == 5 :: Bool

Мы видим, что :t печатает выражения, за которыми следуют :: и их тип. Символы :: означают: «имеет тип». У явно указанных типов первый символ всегда в верхнем регистре. Символ 'a', как вы заметили, имеет тип Char. Несложно сообразить, что это сокращение от «character» – символ. Константа True имеет тип Bool. Выглядит логично… Идём дальше.

Исследуя тип "ПРИВЕТ!", получим [Char]. Квадратные скобки указывают на список – следовательно, перед нами «список символов». В отличие от списков, каждый кортеж любой длины имеет свой тип. Так выражение (True, 'a') имеет тип (Bool, Char), тогда как выражение ('a','b','c') будет иметь тип (Char, Char, Char). Выражение 4==5 всегда вернёт False, поэтому его тип – Bool.

У функций тоже есть типы. Когда мы пишем свои собственные функции, то можем указывать их тип явно. Обычно это считается нормой, исключая случаи написания очень коротких функций. Здесь и далее мы будем декларировать типы для всех создаваемых нами функций.

Помните генератор списка, который мы использовали ранее: он фильтровал строку так, что оставались только прописные буквы? Вот как это выглядит с объявлением типа:

removeNonUppercase :: [Char] –> [Char]

removeNonUppercase st = [ c | c <– st, c `elem` ['А'..'Я']]

Функция removeNonUppercase имеет тип [Char] –> [Char]. Эта запись означает, что функция принимает одну строку в качестве параметра и возвращает другую в качестве результата.

А как записать тип функции, которая принимает несколько параметров? Вот, например, простая функция, принимающая три целых числа и складывающая их:

addThree :: Int –> Int –> Int –> Int

addThree x y z = x + y + z

Параметры разделены символами –>, и здесь нет никакого различия между параметрами и типом возвращаемого значения. Возвращаемый тип – это последний элемент в объявлении, а параметры – первые три.

Позже мы увидим, почему они просто разделяются с помощью символов –>, вместо того чтобы тип возвращаемого значения как-то специально отделялся от типов параметров (например, Int, Int, Int –> Int или что-то в этом духе).

Если вы хотите объявить тип вашей функции, но не уверены, каким он должен быть, то всегда можно написать функцию без него, а затем проверить тип с помощью :t. Функции – тоже выражения, так что :t будет работать с ними без проблем.

 

Обычные типы в языке Haskell

А вот обзор некоторых часто используемых типов.

• Тип Int обозначает целое число. Число 7 может быть типа Int, но 7.2 – нет. Тип Int ограничен: у него есть минимальное и максимальное значения. Обычно на 32-битных машинах максимально возможное значение типа Int – это 2 147 483 647, а минимально возможное – соответственно, –2 147 483 648.

ПРИМЕЧАНИЕ. Мы используем компилятор GHC, в котором множество возможных значений типа Int определено размером машинного слова на используемом компьютере. Так что если у вас 64-битный процессор, вполне вероятно, что наименьшим значением типа Int будет –2 63 , а наибольшим 2 63 –1.

• Тип Integer обозначает… э-э-э… тоже целое число. Основная разница в том, что он не имеет ограничения, поэтому может представлять большие числа. Я имею в виду – очень большие. Между тем тип Int более эффективен. В качестве примера сохраните следующую функцию в файл:

factorial :: Integer –> Integer

factorial n = product [1..n]

Затем загрузите этот файл в GHCi с помощью команды :l и проверьте её:

ghci> factorial 50

30414093201713378043612608166064768844377641568960512000000000000

• Тип Float – это действительное число с плавающей точкой одинарной точности. Добавьте в файл ещё одну функцию:

circumference :: Float –> Float

circumference r = 2 * pi * r

Загрузите дополненный файл и запустите новую функцию:

ghci> circumference 4.0

25.132742

• Тип Double – это действительное число с плавающей точкой двойной точности. Двойная точность означает, что для представления чисел используется вдвое больше битов, поэтому дополнительная точность требует большего расхода памяти. Добавим в файл ещё одну функцию:

circumference' :: Double –> Double

circumference' r = 2 * pi * r

Загрузите дополненный файл и запустите новую функцию

ghci> circumference' 4.0

25.132741228718345

• Тип Bool – булевский. Он может принимать только два значения: True и False.

• Тип Char представляет символ Unicode. Его значения записываются в одинарных кавычках. Список символов является строкой.

• Кортежи – это типы, но тип кортежа зависит от его длины и от типа его компонентов. Так что теоретически количество типов кортежей бесконечно – а стало быть, перечислить их все в этой книге нет возможности. Заметьте, что пустой кортеж () – это тоже тип, который может содержать единственное значение: ().

 

Типовые переменные

Некоторые функции могут работать с данными разных типов. Например, функция head принимает список и возвращает его первый элемент. При этом неважно, что именно этот список содержит – числа, символы или вообще другие списки. Функция должна работать со списками, что бы они ни содержали.

Как вы думаете, каков тип функции head? Проверим, воспользовавшись командой :t.

ghci> :t head

head :: [a] –> a

Гм-м! Что такое a? Тип ли это? Мы уже отмечали, что все типы пишутся с большой буквы, так что это точно не может быть типом. В действительности это типовая переменная. Иначе говоря, a может быть любым типом.

Подобные элементы напоминают «дженерики» в других языках – но только в Haskell они гораздо более мощные, так как позволяют нам легко писать самые общие функции (конечно, если эти функции не используют какие-нибудь специальные свойства конкретных типов).

Функции, в объявлении которых встречаются переменные типа, называются полиморфными. Объявление типа функции head выше означает, что она принимает список любого типа и возвращает один элемент того же типа.

ПРИМЕЧАНИЕ. Несмотря на то что переменные типа могут иметь имена, состоящие более чем из одной буквы, мы обычно называем их a, b, c, d…

Помните функцию fst? Она возвращает первый компонент в паре. Проверим её тип:

ghci> :t fst

fst :: (a, b) –> a

Можно заметить, что функция fst принимает в качестве параметра кортеж, который состоит из двух компонентов, и возвращает значение того же типа, что и первый компонент пары. Поэтому мы можем применить функцию fst к паре, которая содержит значения любых двух типов.

Заметьте, что хотя a и b – различные переменные типа, они вовсе не обязаны быть разного типа. Сигнатура функции fst лишь означает, что тип первого компонента и тип возвращаемого значения одинаковы.

 

Классы типов

 

Класс типов – интерфейс, определяющий некоторое поведение. Если тип является экземпляром класса типов, то он поддерживает и реализует поведение, описанное классом типов. Более точно можно сказать, что класс типов определяет набор функций, и если мы решаем сделать тип экземпляром класса типов, то должны явно указать, что эти функции означают применительно к нашему типу.

Хорошим примером будет класс типов, определяющий равенство. Значения многих типов можно сравнивать на равенство с помощью оператора ==. Посмотрим на его сигнатуру:

ghci> :t (==)

(==) :: (Eq a) => a –> a –> Bool

Заметьте: оператор равенства == – это функция. Функциями также являются операторы +, *, –, / и почти все остальные операторы. Если имя функции содержит только специальные символы, по умолчанию подразумевается, что это инфиксная функция. Если мы захотим проверить её тип, передать её другой функции или вызвать как префиксную функцию, мы должны поместить её в круглые скобки.

Интересно… мы видим здесь что-то новое, а именно символ =>. Всё, что находится перед символом =>, называется ограничением класса. Мы можем прочитать предыдущее объявление типа следующим образом: «функция сравнения на равенство принимает два значения одинакового типа и возвращает значение типа Bool. Тип этих двух значений должен быть экземпляром класса Eq» (это и есть ограничение класса).

Класс типа Eq предоставляет интерфейс для проверки на равенство. Каждый тип, для значений которого операция проверки на равенство имеет смысл, должен быть экземпляром класса Eq. Все стандартные типы языка Haskell (кроме типов для ввода-вывода и функций) являются экземплярами Eq.

ПРИМЕЧАНИЕ. Важно отметить, что классы типов в языке Haskell не являются тем же самым, что и классы в объектно-ориентированных языках программирования.

У функции elem тип (Eq a) => a –> [a] –> Bool, потому что она применяет оператор == к элементам списка, чтобы проверить, есть ли в этом списке значение, которое мы ищем.

Далее приводятся описания нескольких базовых классов типов.

 

Класс Eq

Класс Eq используется для типов, которые поддерживают проверку равенства. Типы, являющиеся его экземплярами, должны реализовывать функции == и /=. Так что если у нас есть ограничение класса Eq для переменной типа в функции, то она может использовать == или /= внутри своего определения. Все типы, которые мы упоминали выше, за исключением функций, входят в класс Eq, и, следовательно, могут быть проверены на равенство.

ghci> 5 == 5

True

ghci> 5 /= 5

False

ghci> 'a' == 'a'

True

ghci> "Хо Хо" == "Хо Хо"

True

ghci> 3.432 == 3.432

True

 

Класс Ord

Класс Ord предназначен для типов, которые поддерживают отношение порядка.

ghci> :t (>)

(>) :: (Ord a) => a –> a –> Bool

Все типы, упоминавшиеся ранее, за исключением функций, имеют экземпляры класса Ord. Класс Ord содержит все стандартные функции сравнения, такие как >, <, >= и <=. Функция compare принимает два значения одного и того же типа, являющегося экземпляром класса Ord, и возвращает значение типа Ordering. Тип Ordering может принимать значения GT, LT или EQ, означая, соответственно, «больше чем», «меньше чем» и «равно».

ghci> "Абракадабра" < "Зебра"

True

ghci> "Абракадабра" `compare` "Зебра"

LT

ghci> 5 >= 2

True

ghci> 5 `compare` 3

GT

 

Класс Show

Значения, типы которых являются экземплярами класса типов Show, могут быть представлены как строки. Все рассматривавшиеся до сих пор типы (кроме функций) являются экземплярами Show. Наиболее часто используемая функция в классе типов Show – это, собственно, функция show. Она берёт значение, для типа которого определён экземпляр класса Show, и представляет его в виде строки.

ghci> show 3

"3"

ghci> show 5.334

"5.334"

ghci> show True

"True"

 

Класс Read

Класс Read – это нечто противоположное классу типов Show. Функция read принимает строку и возвращает значение, тип которого является экземпляром класса Read.

ghci> read "True" || False

True

ghci> read "8.2" + 3.8

12.0

ghci> read "5" – 2

3

ghci> read "[1,2,3,4]" ++ [3]

[1,2,3,4,3]

Отлично. Но что случится, если попробовать вызвать read "4"?

ghci> read "4"

:1:0:

    Ambiguous type variable `a' in the constraint:

     `Read a' arising from a use of `read' at :1:0–7

    Probable fix: add a type signature that fixes these type variable(s)

Интерпретатор GHCi пытается нам сказать, что он не знает, что именно мы хотим получить в результате. Заметьте: во время предыдущих вызовов функции read мы что-то делали с результатом функции. Таким образом, интерпретатор GHCi мог вычислить, какой тип ответа из функции read мы хотим получить.

Когда мы использовали результат как булево выражение, GHCi «понимал», что надо вернуть значение типа Bool. А в данном случае он знает, что нам нужен некий тип, входящий в класс Read, но не знает, какой именно. Давайте посмотрим на сигнатуру функции read.

ghci> :t read

read :: (Read a) => String –> a

ПРИМЕЧАНИЕ. Идентификатор String – альтернативное наименование типа [Char] . Идентификаторы String и [Char] могут быть использованы взаимозаменяемо, но далее будет использоваться только String , поскольку это удобнее и писать, и читать.

Видите? Функция возвращает тип, имеющий экземпляр класса Read, но если мы не воспользуемся им позже, то у компилятора не будет способа определить, какой именно это тип. Вот почему используются явные аннотации типа. Аннотации типа – способ явно указать, какого типа должно быть выражение. Делается это с помощью добавления символов :: в конец выражения и указания типа. Смотрите:

ghci> read "5" :: Int

5

ghci> read "5" :: Float

5.0

ghci> (read "5" :: Float) * 4

20.0

ghci> read "[1,2,3,4]" :: [Int]

[1,2,3,4]

ghci> read "(3, 'a')" :: (Int, Char)

(3, 'a')

Для большинства выражений компилятор может вывести тип самостоятельно. Но иногда он не знает, вернуть ли значение типа Int или Float для выражения вроде read "5". Чтобы узнать, какой у него тип, язык Haskell должен был бы фактически вычислить read "5".

Но так как Haskell – статически типизированный язык, он должен знать все типы до того, как скомпилируется код (или, в случае GHCi, вычислится). Так что мы должны сказать языку: «Эй, это выражение должно иметь вот такой тип, если ты сам случайно не понял!»

Обычно компилятору достаточно минимума информации, чтобы определить, значение какого именно типа должна вернуть функция read. Скажем, если результат функции read помещается в список, то Haskell использует тип списка, полученный благодаря наличию других элементов списка:

ghci> [read "True" , False, True, False]

[True, False, True, False]

Так как read "True" используется как элемент списка булевых значений, Haskell самостоятельно определяет, что тип read "True" должен быть Bool.

 

Класс Enum

Экземплярами класса Enum являются последовательно упорядоченные типы; их значения можно перенумеровать. Основное преимущество класса типов Enum в том, что мы можем использовать его типы в интервалах списков. Кроме того, у них есть предыдущие и последующие элементы, которые можно получить с помощью функций succ и pred. Типы, входящие в этот класс: (), Bool, Char, Ordering, Int, Integer, Float и Double.

ghci> ['a'..'e']

"abcde"

ghci> [LT .. GT]

[LT,EQ,GT]

ghci> [3 .. 5]

[3,4,5]

ghci>succ 'B'

'C'

 

Класс Bounded

Экземпляры класса типов Bounded имеют верхнюю и нижнюю границу.

ghci> minBound :: Int

–2147483648

ghci> maxBound :: Char

'\1114111'

ghci> maxBound :: Bool

True

ghci> minBound :: Bool

False

Функции minBound и maxBound интересны тем, что имеют тип (Bounded a) => a. В этом смысле они являются полиморфными константами.

Все кортежи также являются частью класса Bounded, если их компоненты принадлежат классу Bounded.

ghci> maxBound :: (Bool, Int, Char)

(True,2147483647,'\1114111')

 

Класс Num

Класс Num – это класс типов для чисел. Его экземпляры могут вести себя как числа. Давайте проверим тип некоторого числа:

ghci> :t 20

20 :: (Num t) => t

Похоже, что все числа также являются полиморфными константами. Они могут вести себя как любой тип, являющийся экземпляром класса Num (Int, Integer, Float или Double).

ghci> 20 :: Int

20

ghci> 20 :: Integer

20

ghci> 20 :: Float

20.0

ghci> 20 :: Double

20.0

Если проверить тип оператора *, можно увидеть, что он принимает любые числа.

ghci> :t (*)

(*) :: (Num a) => a –> a –> a

Он принимает два числа одинакового типа и возвращает число этого же типа. Именно поэтому (5 :: Int) * (6 :: Integer) приведёт к ошибке, а 5 * (6 :: Integer) будет работать нормально и вернёт значение типа Integer потому, что 5 может вести себя и как Integer, и как Int.

Чтобы присоединиться к классу Num, тип должен «подружиться» с классами Show и Eq.

 

Класс Floating

Класс Floating включает в себя только числа с плавающей точкой, то есть типы Float и Double.

Функции, которые принимают и возвращают значения, являющиеся экземплярами класса Floating, требуют, чтобы эти значения могли быть представлены в виде числа с плавающей точкой для выполнения осмысленных вычислений. Некоторые примеры: функции sin, cos и sqrt.

 

Класс Integral

Класс Integral – тоже числовой класс типов. Если класс Num включает в себя все типы, в том числе действительные и целые числа, то в класс Integral входят только целые числа. Для типов Int и Integer определены экземпляры данного класса.

Очень полезной функцией для работы с числами является fromIntegral. Вот её объявление типа:

fromIntegral :: (Num b, Integral a) => a –> b

Из этой сигнатуры мы видим, что функция принимает целое число (Integral) и превращает его как более общее число (Num).

ПРИМЕЧАНИЕ. Необходимо отметить, что функция fromIntegral имеет несколько ограничений классов в своей сигнатуре. Такое вполне допустимо – несколько ограничений разделяются запятыми и заключаются в круглые скобки.

Это окажется полезно, когда потребуется, чтобы целые числа и числа с плавающей точкой могли «сработаться» вместе. Например, функция вычисления длины length имеет объявление length :: [a] –> Int, вместо того чтобы использовать более общий тип (Num b) => length :: [a] –> b. (Наверное, так сложилось исторически – хотя, по-моему, какова бы ни была причина, это довольно глупо.) В любом случае, если мы попробуем вычислить длину списка и добавить к ней 3.2, то получим ошибку, потому что мы попытались сложить значения типа Int и число с плавающей точкой. В этом случае можно использовать функцию fromIntegral:

ghci> fromIntegral (length [1,2,3,4]) + 3.2

7.2

 

Несколько заключительных слов о классах типов

Поскольку класс типа определяет абстрактный интерфейс, один и тот же тип данных может иметь экземпляры для различных классов, а для одного и того же класса могут быть определены экземпляры различных типов. Например, тип Char имеет экземпляры для многих классов, два из которых – Eq и Ord, поскольку мы можем сравнивать символы на равенство и располагать их в алфавитном порядке.

Иногда для типа данных должен быть определён экземпляр некоторого класса для того, чтобы имелась возможность определить для него экземпляр другого класса. Например, для определения экземпляра класса Ord необходимо предварительно иметь экземпляр класса Eq. Другими словами, наличие экземпляра класса Eq является предварительным (необходимым) условием для определения экземпляра класса Ord. Если поразмыслить, это вполне логично: раз уж допускается расположение неких значений в определённом порядке, то должна быть предусмотрена и возможность проверить их на равенство.