Функциональные языки (на примере Haskell)
Функциональные языки программирования (ФЯП) представляют собой в первую очередь новый (для многих) способ думать о программах и программировании. Часто хорошо написанный функциональный код оказывается гораздо более выразительным и аккуратным, чем эквивалентный императивный или ООП-код.
Большинство современных функциональных языков так же предоставляют некоторые гарантии касательно программ. В частности, Haskell в большинстве случаев гарантирует, что программа не завершится с ошибкой доступа к памяти, как, скажем C++, или с ошибкой преобразования типов, как, скажем, Python или Ruby. Большинство подобных ошибок будут обнаружены на этапе компиляции программы.
В основе функционального программирования лежит простое представление о том, что функции в языке ничем не отличаются от переменных. Обычно к этому добавляют, что функции в целом должны быть функциями в математическом смысле, то есть не должны иметь некого внутреннего состояния и не должны неявно изменять внешнего состояния (окружения). Прямым следствием отсутствия состояния является тот факт, что функция, вызванная с одними и теми же аргументами возвращает одно и то же значение. Следствием неизменности внешнего состояния является отсутствие побочных эффектов. Например, printf
в C/C++ принимает некие аргументы, и возвращает число. Но кроме этого, printf
выводит символы в терминал. Последнее является побочным эффектом. В общем случае, неявные изменения внешнего (по отношнению к программе) состояния считаются побочными эффектами.
Но если внешнее состояние неизменно, программа неизбежно превращается в некую Кантовскую “вещь в себе” – она не может читать/записывать файлы, терминал, и т.д. Это не выглядит очень полезным. На самом деле, ФЯП выходят из положения, вводя некую псевдопеременную “мир”, которая может явно быть передана главной функции программы (main
), и ее измененное состояние явно возвращается главной функцией программы. Таким образом, функциональную программу можно алгебраически представить как некую функцию:
\[ W_{n+1} = P(W_{n}), \]
где \(W\) – состояние окружения (“мир”), а \(P\) – это программа.
Другая особенность ФЯП, прямо следующая из того, что функции не имеют и не изменяют состояния заключается в том, что в ФЯП очень часто нет “переменных”. Любые выражения являются константами, если смотреть с точки зрения императивных языков. Это свойство называется “ссылочной прозрачностью”: если вы видите, что функция принимает переменную, вы всегда точно знаете, какое значение имеет эта переменная.
Подобный подход значительно упрощает “думание” о программах: больше не нужно держать в голове состояние – код работает ровно так, как читается – никаких скрытых параметров.
Использование чистых функций, помимо прочего, позволяет значительно упростить реализацию функций высших порядков, т.е. функций, оперирующих с функциями (которые тоже могут оперировать с функциями, и так далее). Поэтому, большинство ФЯП имеют поддержку функций высшего порядка “встроенными” в язык интуитивным образом. В качестве небольшого примера, приведу сравнение реализаций функции for_each
, работающей со списком на C++ и Haskell.
Функции высшего порядка
#include <list>
#include <iostream>
template<class T, class F>
std::list<T> for_each(F f, const std::list<T> &list) {
std::list<T> result;
for(typename std::list<T>::const_iterator i=list.begin(); i!=list.end(); ++i) {
result.push_back(f(* i));
}return result;
}
// для использования этой функции нам придется объявить функтор
struct UnaryFunction {
template<class T>
operator()(const T &arg) {
T //здесь может быть в общем-то любое действие
return arg+1;
}
};
// и использовать его как-то так
int main() {
std::list<int> x;
1); x.push_back(2);
x.push_back(
std::list<int> y = for_each<int, UnaryFunction>
(UnaryFunction(), x);
for(std::list<int>::iterator i=y.begin(); i!=y.end(); ++i) {
std::cout<<* i<<",";
}std::cout<<std::endl;
}
На Haskell аналогичный код будет выглядеть так:
= if (null(list))
for_each(f,list) then []
else f(head(list)) : for_each(f,tail(list))
-- Мы можем использовать эту функцию, например, так:
= print (for_each((1+),[1,2])) main
На самом деле, вызов функции с аргументами в Haskell является настолько фундаментальным, что не имеет специального синтаксиса, поэтому аналогичный код можно записать так:
= if null list
for_each f list then []
else f (head list) : for_each f (tail list)
= print (for_each (1+) [1,2]) main
На самом деле в языке уже есть такая функция, и она называется map
. Вернемся к ней чуть позже.
Каррирование
Очень интересным мометном здесь является использование (1+)
для добавления 1. Для этого придется рассказать про нотацию Хаскеля Брукса Карри (американского математика, 1900-1982), в честь которого назван язык. Эта нотация называется частичной аппликацией функций или каррирование.
Вкратце, любая функция нескольких аргументов \(f(x,y)\) может быть разложена на последовательное применение нескольких функций одного аргумента. Например, \(f(2,3)\) может быть разложена как:
\[ g(y) = f(2,y) \] \[ f(2,3) = h(g(3)) \]
Можно заметить, что функция двух аргументов \(f(x,y)\) превращается в функцию одного аргумента \(g(y)=f(x,y)\). Это и есть каррирование. Если записать то же самое в нотации Haskell:
= f x y g y
Здесь применима операция, называемая алгебраической эта-редукцией. Повторяющиеся аргументы можно опустить. В результате получаем
= f x g
Какое отношение это имеет к (1+)
? Самое прямое. (1+)
. Сложение является функцией двух аргументов и может быть записано как x+y = (+) x y
. Тогда применение к одному аргументу (+) 1
даст нам функцию одного аргумента, которая прибавляет к аргументу 1. (1+)
– это просто синтаксическое соглашение (“сахар”), которое сводится к (+) 1
.
Порядок применения функции
Чтобы каррирование работало, аргументы функции применяются слева направо (или лево-ассоциативно). Таким образом, выражение вида
f a b c d
Означает то же самое, что
(((f a) b) c) d
Это отвечает на вопрос, почему нельзя записать print for_each (1+) [1,2]
и ожидать адекватных результатов (на самом деле такой код просто не скомпилируется, поскольку print for_each не может принимать аргументы)
Существует оператор $
, который является право-ассоциативным применением функции. Поэтому код можно записать так:
print $ for_each (1+) [1,2]
Это позволяет слегка экономить на скобках, однако применимость зависит от конкретного случая.
Эта-редукция, композиция функций и pointfree-нотация
Другой способ экономии – это эта-редкукция.
Эта-редукция позволяет писать достаточно компактный и при этом вполне понятный код, опуская “лишние” переменные. Такая нотация называется “pointfree” (аргументы иногда называют “точками”, отсюда такое название). Напишем программу, которая читает строки и выводит их в обратном порядке.
= unlines (reverse (lines x))
revlines x
= getContents >>= return . revlines >>= putStrLn main
Пока не обращая внимания на последнюю строчку (считаем что там магия), рассмотрим функцию revlines. Очевидно, что x здесь вполне избыточен. В математике есть нотация композиции функций, обозначаемая точкой:
\[ f(g(x)) = (f\cdot g)(x) \]
Haskell имеет аналогичную нотацию: f (g x) = (f . g) x
. Тогда мы можем переписать revlines
в виде
= (unlines . reverse . lines) x revlines x
Или, применяя эта-редукцию:
= unlines . reverse . lines revlines
Использование функций высшего порядка возможно и в этом контексте. Скажем, мы можем хотеть не только вывести строки в обратном порядке, но и произвести каую-то другую операцию над массивом строк. Тогда мы можем написать:
= unlines . f . lines
withLines f
= withLines reverse
revlines
= withLines sort
sortlines
= withlines . take takelines
Аннотации типов
Можно заметить, что во всем до сих пор написанном коде нигде не указаны типы. Haskell является строго типизированным языком, но типы указывать не обязательно . Это потому, что Haskell выводит типы автоматически. Тем не менее, для повышения читаемости кода (и для уменьшения вероятности ошибки), типы можно указывать1. Запишем типы некоторых функций, и обсудим что это значит:
for_each :: (a->b) -> [a] -> [b]
revlines :: String -> String
withLines :: ([String]->[String]) -> String -> String
По соглашению, типы начинаются с заглавной буквы. Если тип обозначен маленькой буквой, то Haskell автоматически выводит его исходя из контекста. Так, например, мы использовали for_each
с целыми. Тогда a=Int
, b=Int
. С тем же успехом, мы могли бы использовать ее со строками и т.д.
Оператор ->
– право-ассоциативен (ввиду каррирования), поэтому
-> b -> c a
т.е. “функция принимает два аргумента”, эквивалентно
-> (b -> c) a
т.е. “функция принимает один аргумент и возвращает функцию одного аргумента”
map
как функция высшего порядка
Попробуем написать свою функцию для работы со строками. Допустим, мы пишем программу, автоматически форматирующую код.
= unlines . f . lines
withLines f
= (++) " "
indent
= withLines indent
indentLines
= getContents >>= return . indentLines >>= putStrLn main
Получаем чудовищное, по виду, сообщение об ошибке
Couldn't match type ‘Char’ with ‘[Char]’
Expected type: [String] -> [String]
Actual type: [Char] -> [Char]
In the first argument of ‘withLines’, namely ‘indent’
In the expression: withLines indent
Почему это происходит? Давайте запишем типы всех функций.
withLines :: ([String]->[String]) -> String -> String
= unlines . f . lines
withLines f
indent :: String -> String
= (++) " "
indent
indentLines :: ???
= withLines indent
indentLines
= getContents >>= return . indentLines >>= putStrLn main
Ошибка становится вполне очевидна: withLines
ожидает функцию типа [String]->[String]
, а мы ему даем String->String
. Вообще, String
определен как [Char]
, т.е. список символов. Это объясняет сообщение компилятора.
Вспомним про функцию map
, которая производит операцию над каждым элементом массива. Ее тип
map :: (a -> b) -> [a] -> [b]
Подставляя a,b=String
, обнаруживаем то, что ищем:
map :: (String -> String) -> ([String] -> [String])
withLines :: ([String]->[String]) -> String -> String
= unlines . f . lines
withLines f
indent :: [String] -> [String]
= map $ (++) " "
indent
indentLines :: String -> String
= withLines indent
indentLines
= getContents >>= return . indentLines >>= putStrLn main
Оказывается, map
– это функция высшего порядка, которая преобразует функцию над элементами в функцию над массивами (списками). Подобные операции называются “поднятием” (lift), поскольку “поднимают” функцию из более простой категории типов в более сложную. Вообще, система типов Haskell сильно основана на теории категорий. Однако про теорию категорий сейчас не будем.
Секционирование операторов
Еще один пример использования функций высшего порядка
import Data.Char (toUpper)
withLines :: ([String] -> [String]) -> String -> String
= (unlines .) . (. lines)
withLines
withWords :: ([String] -> [String]) -> String -> String
= (unwords .) . (. words)
withWords
yell :: String -> String
= (++"!!!") . map toUpper
yell
= withLines $ map $ withWords $ map yell
yellEachWord
= getContents >>= return . yellEachWord >>= putStrLn main
Здесь есть достаточно странный синтаксис. Для начала будем иметь ввиду, что любой бинарный оператор (а вообще говоря любая бинарная функция) может быть частично применен как
= ...
op a b
== (a `op`) op a
Вообще, `op`
– это использование бинарной функции op
как бинарного оператора
`op` b = op a b a
C тем же успехом можно частично применить второй аргумент:
== (`op` b) x op x b
Этот синтаксический сахар называется “секционирование операторов”
.
– это бинарная функция высшего порядка:
(.) :: (b -> c) -> (a -> b) -> (a -> c)
Соответственно, если мы перепишем, скажем, withLines
с аргументом, то получим:
= ((unlines .) . (. lines)) f
withLines f = (unlines .) (f . lines)
= (unlines . (f . lines))
= unlines . f . lines
Типы данных
В примерах раннее мы использовали тип данных списка (массива). Как этот тип устроен? Давайте попробуем сделать пользовательский тип списка.
data Список a = Пусто | Добавить a (Список a)
Это можно прочитать как “список типа a это либо пустой список, либо значение типа a и остальной список типа a (который может быть пустым)”. Haskell кстати понимает UTF-8, поэтому приведенный код абсолютно корректен.
Теперь мы можем записать что-то такое, например:
= 1 `Добавить` 2 `Добавить` 3 `Добавить` Пусто список
В Haskell, естественно, есть встроенный тип списка. Он определен следующим образом:
data [] a = [] | a:[a]
Можно просмотреть параллели. :
– это Добавить
, или cons
, []
– это Пусто
, или nil
, а Список а
– это [a]
. Мы можем легко определить собственные операторы:
infixr 5 :-: -- право-ассоциативный с приоритетом 5
:-:) = Добавить (
Или использовать их сразу в определении типа.
Единственное, что встроено в язык – это синтаксис вида [x,y,...]
, который автоматически преобразуется в x:y:...:[]
.
Здесь Добавить
и Пусто
называются конструкторами типа Список
. Это функции, которые “конструируют” значение типа Список
. Не имеют практически ничего общего с конструкторами объектов в C++/Java.
Несколько примеров с нашим пользовательским типом:
= "один" :-: Пусто
mystery1 = "два" :-: mystery1
mystery2 = "три" :-: mystery3
mystery3 = 4 :-: mystery1 mystery4
С mystery1
, mystery2
все должно быть понятно. mystery3
– это бесконечный список, и Haskell все устраивает (поскольку это ленивый язык – значения вычисляются только при использовании), mystery4
– это ошибка типа. Int
и String
не могут находиться в одном списке.
Аналогично пишутся прочие типы данных.
Шаблоны аргументов
Для простоты, переопределим наш тип списка в латинице и сразу используем оператор:
infixr 5 :-:
data List a = Nil | a :-: List a
Напишем несколько функций для нашего пользовательского списка:
:-: _) = x :-: Nil
takeOne (x Nil = Nil
takeOne :-: xs) = xs
dropOne (_ Nil = Nil dropOne
Выглядит как какая-то черная магия. На самом деле все просто, мы определяем функцию, которая работает только для конкретного конструктора. _
означает только что мы игнорируем это поле. Мы так же могли бы конкретизировать аргументы, например:
:-: Nil) = True
isOne (x = False isOne _
Шаблоны проверяются в порядке объявления, поэтому во втором случае мы можем просто игнорировать значение.
Если мы не указываем все возможные варианты, то компилятор сообщает об этом, например:
:-: Nil) = True isOne (x
Приводит к сообщению
Pattern match(es) are non-exhaustive
In an equation for ‘isOne’: Patterns not matched: Nil
Нужно иметь ввиду, что программа аварийно завершится, если такой частично определенной функции передать значение, которое она не может обработать.
Тип Maybe
Немного странно кажется, что наша функция takeOne
возвращает список, а не значение. Аналогичная библиотечная функция head
возвращает значение, однако на пустом списке кидает исключение (да, в Haskell есть исключения, но их стараются избегать по очевидным причинам). Ее можно было бы записать в виде
head (x:_) = x
head [] = undefined
undefined
– ключевое слово, которое подходит под любой тип и аварийно завершает программу при попытке вычисления. Другой вариант undefined
– это error x
, где x
имеет вид String
. error
выведет строку x
перед завершением.
На помощь приходит тип Maybe
. Он определен так:
data Maybe a = Nothing | Just a
В целом похоже на список, только без списка. takeOne
можно переписать в виде:
:-: _) = Just x
takeOne (x Nil = Nothing takeOne
Выглядит несколько более осмыслено, чем вариант со списком и гораздо более безопасно, чем undefined
.
Попробуем написать поиск символа в строке с использованием типа Maybe
:
findChar :: Char -> String -> Int -> Maybe Int
:xs) pos = if x==c
findChar c (xthen Just pos
else findChar c xs (pos+1)
= Nothing findChar _ [] _
Pattern guards
Предыдущий код можно переписать несколько короче:
:xs) pos | x==c = Just pos
findChar c (x| otherwise = findChar c xs (pos+1)
= Nothing findChar _ [] _
Этот синтаксис называется “pattern guards”, или ограничители шаблонов в вольном переводе на русский. По сути, если выражение после |
вычисляется как True
, то шаблон срабатывает. Если нет – то проверяется следующий ограничитель, или следующий шаблон, если это был последний. otherwise
– это просто более читаемый синоним для True
.
В ограничителях так же возможно производить сравнения шаблонов. Синтаксис для этого:
... | pattern <- expression = ... f
Тогда код выше можно переписать в виде:
| (x:_) <- list, x==c = Just pos
findChar c list pos | (_:xs) <- list = findChar c xs pos
| otherwise = Nothing
Классы типов
На самом деле мы можем записать более общий вариант поиска элемента в списке, практически ничего не делая:
findChar :: Eq a => a -> [a] -> Int -> Maybe Int
:xs) pos | x==c = Just pos
findChar c (x| otherwise = findChar c xs (pos+1)
= Nothing findChar _ [] _
Мы изменили только сигнатуру типа, остальное осталось прежним. Сигнатура выглядит странновато: в ней появилось выражение Eq a =>
. Оно означает, что тип a
принадлежит классу Eq
– классу типов, для которых определена эквивалентность, т.е. опреатор (==)
.
Eq
определен следующим образом:
class Eq a where
(==) :: a -> a -> Bool
Видно, что это просто сигнатура типа. Чтобы определить реализацию для конкретного типа, определяется экземпляр класса:
instance Eq Char where
== b = ... a
Каким конкретно образом реализовано сравнение символов – довольно долгая история. Примитивные типы типа Int
, Char
или скажем Double
достаточно глубоко встроены в язык. Мы, однако, можем определить экземпляры для более сложных типов:
instance Eq a => Eq (Maybe a) where
Just a) == (Just b) = a == b
(Nothing == Nothing = True
== _ = False _
Здесь мы опять вводим ограничение на класс. По сути мы утверждаем, что для всех типов a
в классе Eq
существует тип Maybe a
, так же в классе Eq
. Ничто не мешает добавлять собственные типы.
Классы типов позволяют писать обобщенные, и при этом типобезопасные функции.
Опять про Maybe
Тип Maybe
достаточно простой и при этом удивительно полезный. Очень многие функции, которые возможно определены могут использовать этот тип в качестве возвращаемого значения. Например:
elemIndex :: a -> [a] -> Maybe Int
lookup :: k -> Map k v -> Maybe v
stripPrefix :: [a] -> [a] -> Maybe [a]
Это гораздо лучше, чем возвращать NULL
, как в C/C++
. Во-первых, глядя на сигнатуру типа, сразу становится ясно, чего можно ожидать от функции. Во-вторых, компилятор не позволит производить вычисления с несуществующим значением. Если в “классических” языках типы в основном служат компилятору, то в функциональных типы служат в первую очередь программисту.
Очевидный вопрос с Maybe
заключается в следующем: допустим у нас есть функция
:_) = Just x
takeOne (x= Nothing takeOne []
Рассмотрим такой код:
1,2,3] + 1 takeOne [
Каков результат такого кода? Правильный ответ – ошибка типа. 1
имеет тип Int
, в то время как takeOne
возвращает тип Maybe Int
. Проблема очевидна. Наивное решение этой проблемы в том, чтобы написать условие:
case takeOne [1,2,3] of
Nothing -> undefined
Just x -> x+1
Однако ясно, что для сколько-нибудь сложных выражений это становится весьма трудоемкой задачей.
Теперь давайте вспомним, что, когда я говорил про map
, я сказал, что эта функция “поднимает” свой первый аргумент в категорию списков. Эта же концепция применима ко всем производным типам. В языке сущесвтует обобщенная функция fmap
. Посмотрим, как она работает:
fmap (+1) (takeOne [1,2,3])
Это выражение возвращает Just 2
. fmap “поднимает” свой аргумент в категорию производных типов. Каких именно – компилятор выводит автоматически из контекста.
Тип fmap
определен как
fmap :: Functor f => (a -> b) -> f a -> f b
Здесь используется класс Functor
, единственное требование которого – чтобы был определен fmap
. Для Maybe
экземпляр определяется тривиально:
instance Functor Maybe where
fmap f (Just x) = Just $ f x
fmap _ Nothing = Nothing
В общем случае класс Functor
– это класс типов, принимающих один аргумент типа. Другой пример функтора – список. Таким образом, map
является всего лишь частным случаем fmap
.
Монады
Пока скажу только, что Maybe
, и список являются монадами. Понятие монады происходит из теории категорий. Это понятие в большинстве функциональных языков является ключевым, поскольку, например, Haskell, инкапсулирует состояние окружения (то, которое функции не изменяют) в монаде IO
(ввод-вывод). Наконец мы можем записать сигнатуру для main
:
main :: IO ()
Функция main возвращает “пустоту” (на самом деле читается как unit, и аналогично по смыслу void
в С) в монаде IO
. IO
, в свою очередь, где-то “внутри” содержит информацию о состоянии окружения.
Сточка, которая часто нам встречалась
= getContents >>= return . f >>= putStrLn main
использует следующие функции:
getContents :: IO String
return :: Monad m => a -> m a
putStrLn :: String -> IO ()
(>>=) :: Monad m => m a -> (a -> m b) -> m b
return
и (>>=)
(читается как bind) определены в классе Monad
. return
просто “поднимает” значение в монаду, а bind берет значение в монаде и передает его в функцию, возвращающую монаду. При этом некое “состояние”, инкапсулированное монадой (если оно есть), может неявно передаваться из функции в функцию через оператор bind.
Для интересующихся, хорошее объяснение:
https://www.youtube.com/watch?v=ZhuHCtR3xq8
Если есть интерес, выделим лекцию на продолжение.
На самом деле иногда однозначно определить типы невозможно, и тогда аннотации все-таки необходимы. Так же необходимо использовать аннотации при написании обобщенных функций↩︎