Была на ЛОРе такая тема — [Haskell] простой вопрос. Хотелось бы немножко её развить и высказаться на тему предпочтения того или иного языка при изучении ФП (графомания mode on :)).
У Scheme есть довольно давняя история использования в качестве подопытного языка в курсах изучения ФП. Я не знаю чем это вызвано, но факт остаётся фактом — есть известный курс у MIT (или был?) и разные полезные книжки — SICP, HTDP, PLAI, OOPLAI, которые обычно и советуют читать если нужно ознакомиться с ФП.
Касательно SICP — одним из сторонников использования ML для целей изучения ФП была написана статья (http://www.cs.kent.ac.uk/people/staff/dat/miranda/wadler87.pdf) в которой достаточно хорошо освещены некоторые недостатки Scheme. Если говорить про Haskell, то тут всё так же. Далее, по пунктам (опуская кое-что из того что уже было в той статье).
Более явный синтаксис
Вместо
(define (foo x y z)
(if (> (+ x (* y z) 1) 7) (print (+ x y)) (print (- x y))))
будет
foo x y z = if x + y * z + 1 > 7 then print $ x + y else print $ x - y
при этом по-прежнему можно написать выражение в префиксной форме:
(if' ((>) ((+) x ((*) y z) 1) 7) (print ((+) x y)) (print ((-) x y)))
почти как в Scheme. То есть, кроме префикса также есть (расширяемый пользователем) инфикс (в том числе функции вроде ($) и (.) позволяющие в некоторых случаях опускать лишние аргументы у функций и некоторые скобки в выражениях) и разные специальные формы (вроде if, let, лямбды и т.п.). Во всём что не касается макросов это более удобно. S-выражения обретают свой особый смысл только когда доходит до их цитирования:
'(if (> (+ x (* y z) 1) 7) (print (+ x y)) (print (- x y)))
и разбора с целью написания макросов. Тем не менее, для изучения именно ФП эта возможность незначительна (ФП не про макросы, в SICP и HTDP не ни слова про макросы, в PLAI есть только немного, в OOPLAI — побольше). Про то как правильно (ну, _правильно_, то есть без использования s-выражений) организовывать символьные вычисления (вроде дифференцирования из SICP) также расказывается в упомянутой статье.
Каррированные функции
Такое определение, например:
(define add
(lambda (n)
(lambda (m)
(+ m n))))
заменяется простым
add = (+)
так как все функции уже каррированные (позволяют частичное применение). Фактически, в хаскеле функция с n аргументами сразу задаёт n разных функций (выбор конкретной функции осуществляется во время компиляции и не имеет эффекта во время выполнения). Некаррированные функции это функции от кортежей (те и другие переводятся друг в друга с помощью ФВП carry/uncarry).
Частичное применение, секции, pointfree запись
add2 = (+ 2)
add2 5
7
вместо
(define add2 (add 2))
(add2 5)
7
Мутабельные замыкания
Это единственная вещь которая есть в Scheme и которую можно не увидеть сразу в хаскеле (и про неё нет в той статье). Тот тред был как раз про них. Чтобы прояснить этот момент, ниже приводятся некоторые примеры из OOPLAI и их аналоги на хаскеле.
Простейший вариант:
(define counter
(let ((count 0))
(lambda ()
(begin
(set! count (add1 count))
count))))
(counter)
1
(counter)
2
аналог:
counter = (=~ (+ 1)) <$> new 0
тут (=~ (+ 1)) играет роль мутирующего «метода», а (new 0) — мутируемого «объекта», (<$>) — применение «диспетчера» (тут — просто единичный анонимный «метод»). Вся конструкция функториальная (не монадическая). Использование:
ctr <- counter -- Инстанцирование класса counter объектом ctr.
ctr -- Применение единственного метода ((=~ (+ 1)) который).
1 -- Результат.
ctr -- Снова.
2 -- Другой результат.
Чуть более сложный пример:
(define counter-
(let ((count 0))
(lambda (cmd)
(case cmd
((dec) (begin
(set! count (sub1 count))
count))
((inc) (begin
(set! count (add1 count))
count))))))
(counter- 'inc)
1
(counter- 'dec)
0
Для начала определим имена методов dec и inc:
data CounterMethod = Dec | Inc
это не символы и не строки (так что код не будет ill-typed как в примере на Scheme, иначе говоря, применение несуществующего метода не пройдёт компиляции). И теперь функцию:
counter' = dispatch <$> new 0
where dispatch obj Dec = obj =~ flip (-) 1
dispatch obj Inc = obj =~ (+ 1)
тут dispatch играет роль диспетчеризирующей функции которая получает объект (obj) и имя метода, а затем изменяет объект (как того требует метод). (new 0) — начальный объект.
Пример:
ctr <- counter' -- Инстанцирование класса counter' объектом ctr.
ctr Inc -- Применение метода Inc на объекте ctr.
1
ctr Inc
2
ctr Inc
3
ctr Dec -- Тут уже метод Dec.
2
ctr Dec
1
ctr Dec
0
Тут применение (ctr Inc) весьма похоже на каноничное, через точку, obj.method и является, по сути, посылкой сообщения объекту.
Третий пример:
(define stack
(let ((vals '()))
(define (pop)
(if (empty? vals)
(error "cannot pop from an empty stack")
(let ((val (car vals)))
(set! vals (cdr vals))
val)))
(define (push val)
(set! vals (cons val vals)))
(define (peek)
(if (empty? vals)
(error "cannot peek from an empty stack")
(car vals)))
(lambda (cmd . args)
(case cmd
((pop) (pop))
((push) (push (car args)))
((peek) (peek))
(else (error "invalid command"))))))
(stack 'push 1)
(stack 'push 2)
(stack 'pop)
2
(stack 'peek)
1
(stack 'pop)
1
(stack 'pop)
; cannot pop from an empty stack
аналогично:
data StackMethod = Pop | Push | Peek
stack = dispatch <$> new []
where
dispatch x Pop _ = get x >>= (x =~ tail >>) . return . head
dispatch x Push y = x =~ (y :) >> return y
dispatch x Peek _ = head <$> get x
и пример:
stk <- stack :: IO (StackMethod -> Int -> IO Int)
-- stack это параметрически-полиморфный класс, в данном
-- случае берётся его спецификация когда элементы стека
-- имеют тип Int (можно взять что-то более общее).
-- Этот специфичный класс инстанцируется объектом stk.
mapM_ (stk Push) [1, 2, 3]
-- (stk Push) это применение метода Push на объекте stk,
-- с помощью ФВП mapM_ оно производится для всех элементов
-- списка.
repeat 4 $ stk Pop __ >>= print
-- 4 раза вызывается метод Pop, элементы печатаются.
-- Последний раз вызывается исключение (так как стек пуст).
3
2
1
*** Exception: Prelude.head: empty list
тут точно так же — в StackMethod перечислены нужные методы для стека, функция stack определяет класс, то есть объединение данных и функций с нужным поведением, она имеет тип IO (StackMethod -> a -> IO a), то есть принимает метод, элемент стека и возвращает элемент стека (в IO, мутабельно), сама функция в IO (вся структура данных ведёт себя мутабельно).
Дальше в OOPLAI начинают использовать макросы для придания более удобоваримого вида этим конструкциям. В настоящем (ну, _настоящем_ :)) ФП этого не нужно — примитивные ООП конструкции объединяющие данные и функции выглядят естественно и так, и являются частным случаем использования ФВП, IO и ADT с паттерн-матчингом (последние два — для удобства). Использование макро-системы может иметь смысл разве что если действительно нужно реализовать сложную ООП систему (например, со множественным наследованием и изменяемой иерархией классов, впрочем, обойтись одними функциями тут тоже можно, просто придётся делать больше механических действий).
Ещё пример:
-- | Данные — конструктор и аккессоры.
data Point = Point
{ x :: Double
, y :: Double
} deriving ( Show, Eq ) -- ad-hoc перегруженные функции.
-- | Методы привязываемые к данным (это уже _не_ ad-hoc перегруженные функции).
data PointMethod = Pos | Mov
-- | Класс (= функция), объединяющий данные и методы.
pointClass :: Double -> Double -> IO (PointMethod -> Double -> Double -> IO Point)
pointClass initX initY = dispatch <$> new (Point initX initY)
where
-- | (Динамический) диспетчер по методам. Он принимает объект (Var Point),
-- имя метода (PointMethod, т.е. статическое, в данном случае, сообщение)
-- и два опционных аргумента для методов (Double -> Double). Эту функцию
-- можно помещать непосредственно в Point.
dispatch :: Var Point -> PointMethod -> Double -> Double -> IO Point
dispatch obj Pos _ _ = get obj
dispatch obj Mov x y = obj =: Point x y
pnt <- pointClass 2 4 -- Инстанцирование класса pointClass объектом pnt
-- с начальными значениями полей 2 и 4.
:t pnt
pnt :: PointMethod -> Double -> Double -> IO Point
pnt Pos __ __ -- Вызов метода Pos на объекте pnt.
Point {x = 2.0, y = 4.0}
pnt Mov 3 5 -- Вызов метода Mov.
Point {x = 3.0, y = 5.0}
pnt Pos __ __ -- Положение изменилось:
{x = 3.0, y = 5.0}
Нужно заметить, что это всё довольно примитивные конструкции (простые функции и IO). В случае использования ADT для имён методов получится динамическая диспетчеризация с фиксированным набором методов (well-typed), если же переписать функцию dispatch с завязкой на хэш-табличку (которая должна быть переменной в данных класса), то будет динамическая диспетчеризация с пополняемым набором методов и перегруженными методами (одни и те же сообщения можно посылать разным инстанцированным объектам, их dispatch будет их искать в хэш-таблице и обрабатывать, это уже ill-typed, то есть с исключениями вида «нет такого метода»). Разные прочие вещи вроде наследования и self точно также можно изобразить (аггрегация данных, представление иерархии классов в данных (в переменной или нет, в зависимости от возможности менять иерархию) и более сложная функция dispatch), но как-то не интересно.
P.S.
Код на хаскеле использует такие упрощения:
import Prelude hiding ( repeat )
import Data.IORef
import Control.Applicative
import Control.Monad
type Var a = IORef a
new :: a -> IO (IORef a)
new = newIORef
get :: IORef a -> IO a
get = readIORef
(=~) :: IORef a -> (a -> a) -> IO a
x =~ f = modifyIORef x f >> get x
(=:) :: IORef a -> a -> IO a
x =: x' = x =~ const x'
repeat :: Monad m => Int -> m a -> m ()
repeat = replicateM_
__ :: a
__ = undefined
P.P.S.
OOP / ООП в контексте данного поста — объектно-ориентированное программирование в духе объединения данных и процедур, то есть в духе C++, Java, Python и т.п. _Не_ ООП в духе классы = структуры, методы = перегруженные функции, наследование = схемы агрегаций и распространения методов (как это в CLOS и классах типов Haskell).