Языки 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) модели выполнения. Они также прекрасно сочетаются с предпочитаемым стилем написания прикладных программ и соответствующей системой модулей.