Pull to refresh

Let vs where в Ocaml/Haskell

Reading time5 min
Views3.4K

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

Tags:
Hubs:
Total votes 19: ↑19 and ↓0+19
Comments7

Articles