Языки Ocaml и Haskell ведут родословную из языка ISWIM,
описанного в знаменитой статье Питера Лендина "The next 700
programming languages". В ней автор, отталкиваясь от языка LISP,
создаёт новый язык программирования и, в частности, вводит ключевые
слова let
, and
и where
,
которые широко используются в языках семейства ML. Рано или поздно
у всякого пытливого ума, занимающегося функциональным
программированием возникает вопрос: почему в Ocaml не прижилось
ключевое слово where
, широко используемое в
Haskell?
С моей точки зрения, это, в основном, обусловлено различиями в семантике этих языков, а именно императивно-энергичным характером Ocaml и чистотой-ленивостью вычислений в Haskell (которые непосредственно и жёстко связаны с impure/pure характерами этих языков).
Оба эти выражения, let
и where
,
произошли от (let ...)
из языка LISP, которое имеет
два варианта этой особой формы: (let ...)
и
(let* ...)
. Вариант (let* ...)
отличается
тем, что все связывания в блоке происходят последовательно и могут
зависеть друг от друга:
(let* ((x 3)
(y (+ x 2))
(z (+ x y 5)))
(* x z))
В некоторых диалектах Scheme объявления переменных могут быть
автоматически переупорядочены интерпретатором, поэтому их
становится необязательно писать в "правильном" порядке. Оба
варианта связывания, let ... in
и where
соответствуют вот этому, "продвинутому" варианту (let*
...)
. При этом в Ocaml для разделения "параллельных
связываний" используется ключевое слово and
, а в
Haskell они просто помещаются в один блок.
Если смотреть исключительно в суть вещей, видно, что выражения
let ... in
и where
различаются в двух
аспектах: место, где ставится связывание, и количество выражений в
блоке.
Связывание имён до и после использования.
Первое принципиальное отличие - связывание let ...
in
ставится перед выражением, в котором используется
связанное имя, а where
употребляется после:
let x = 1 in
x + 1
z = x + 1
where x = 1
Таким образом, let ... in лучше описывает семантику последовательного выполнения программы Ocaml, с её энергичными и, в целом, императивными/псевдоимперативными вычислениями. Если связывание содержит побочные эффекты, например
let x = Printf.printf "Hello ";
"World!"
in
Printf.printf "%s" x
мы интуитивно будем ожидать последовательного выполнения
программы сверху вниз. И это прекрасно сочетается с
последовательным вычислением top-level выражений в Ocaml, которые
обрабатываются именно сверху вниз, с первой директивы
open
до последней строчки в традиционном let ()
= ...
В то же время, связывание where прекрасно передаёт non-strict
семантику языка Haskell, когда в качестве модели вычисления
используется term/graph reduction. Фактически, мы используем блок
связываний where
как сноски, примечания:
main = putStrLn (x ++ y)
where x = "Hello "
y = "World!"
z = undefined
И программа читается естественным образом: мы хотим вывести
x
и y
, которые даны ниже. А если сноска
z
не используется, так и читать её не надо - она ведь
не упоминается в основном тексте.
А вот связывание x
, y
, z
в блоке let ... in
, который тоже поддерживается языком
Haskell, будет выглядеть ненатурально - вроде z
и
читается глазом, но вычисляться ведь точно не будет. С другой
стороны, внутри псевдоимперативного блока do
,
связывание let
очень к месту.
Переиспользование имён или shadowing
Cвязывание let ... in
, как в Ocaml, так и в
Haskell, может употребляться несколько раз в одном и том же блоке.
А where
- лишь однократно на одном уровне
вложенности:
let x = 1 in
let y = 1 in
x + y
z = x + y
where x = 1
y = 1
Это опять таки, играет на руку соответствующей модели выполнения, поощряя или, наоборот, запрещая переопределение имён, или, как ещё оно романтично называется "shadowing". В коде прикладных программ на Ocaml shadowing используется повсеместно, позволяя эмулировать присвоение и писать в псевдоимперативном стиле:
let x = 1 in
let x = x * 10 in
x * x
В результате, хотя программа выше и написана в функциональном стиле, мы можем читать её как императивную:
x := 1;
x := x * 10;
return x*x;
А в лагере Haskell, однократность where
явно,
"лингвистически", запрещает shadowing внутри одного блока,
заставляя использовать многочисленные апострофы. Этот запрет
shadowing великолепно сочетается с тем, что все top-level имена в
модуле должны быть уникальными, ведь из-за non-strict порядка
вычислений мы их не можем переопределять. А также с тем, что по
семантике языка Haskell вычисление
x = x + 1
обязано зацикливаться.
Такое принципиально противоположное отношение к shadowing в
Ocaml и Haskell косвенно, помимо традиционного для Ocaml
псевдоимперативного стиля, вызвано отличием сложной системы модулей
Ocaml и простыми модулями Haskell (backpack не взлетел, и к счастью
- поиск значения очередного типа t
из модуля
M
в коде на Ocaml так же утомителен как и отладка
фабрики фабрик в ООП).
Поскольку у модулей Ocaml может быть несколько сигнатур, по-умолчанию, язык использует разные файлы для сигнатуры (.mli) и для кода самого модуля (.ml). Причём, опять таки, по-умолчанию, компилятор автоматически генерирует файл сигнатуры, экспортируя все top-level выражения модуля, написанные программистом. Из-за этого, в прикладном коде на Ocaml разработчики склонны минимизировать количество top-level выражений, скрывая все детали внутри них. То есть, писать функции по несколько страниц с большим количеством let ... in связываний (см., к примеру report_constructor_mismatch в файле https://github.com/ocaml/ocaml/blob/trunk/typing/includecore.ml#L212 )
В Haskell упрощённая система модулей совмещает сигнатуру и тело модуля, позволяя легко создавать список экспорта. А поэтому, в типичном случае для прикладного кода, когда из модуля нужно лишь одна-две функции, а остальное содержимое инкапсулировано, этот подход позволяет создавать большое количество top-level выражений малого размера. А значит, в каждом из этих выражений можно обойтись связыванием where без shadowing.
Кстати, легко назвать язык, органично сочетающий недостатки обоих подходов - это Clean.
Заключение
Для полноты, необходимо упомянуть, что where
лучше,
чем let ... in
поддерживает стиль программирования
"сверху-вниз", поскольку с ним мы сначала пишем болванку
результата, а уже потом заполняем пропущенные места. Но это, в
общем, согласуется с тем, что Haskell лучше подходит для
прототипирования, а у Ocaml проще предсказывать
производительность.
Конечно, в хорошо написанном простом библиотечном коде на языке
Ocaml, уровня Stdlib
совсем бы не помешала директива
where для того, чтобы подчёркивать особенности кода, написанного в
чистом, функциональном стиле. Например, в функциях
List.mapi
и List.rev_map
. Но, положа руку
на сердце, большая часть текстов на Ocaml значительно хуже по
качеству, и требует несоизмеримых усилий для того, чтобы понять -
можно ли использовать интерпретацию в стиле graph rewriting или же
стоит предерживаться традиционного псевдоимперативного понимания.
Поэтому, программируя на Ocaml мы вполне можем обойтись без
where
, точно также, как для чистого функционального
кода на Haskell мы почти не используем let ... in
.
Таким образом, как хорошие инженерные произведения, языки Ocaml
и Haskell создают синергию синтаксиса и семантики. Директивы
связывания let
и where
играют свою роль,
подчёркивая подчёркивая линейную псевдоимперативную и "ленивую"
(graph reduction) модели выполнения. Они также прекрасно сочетаются
с предпочитаемым стилем написания прикладных программ и
соответствующей системой модулей.