Рич на проводе :)

Рич Хики уже ответил на твой вопрос!

Список часто задаваемых вопросов о дизайне языка и причинах, почему Clojure такой, какой есть, с ответами самого Рича (даже будучи данными несколько лет назад, они актуальны и сейчас).
Отправляйте друзей и коллег сюда, если они задают эти вопросы в очередной раз.
Ответы были взяты из списка рассылки, статей и чатов и переданы практически дословно (автор подборки сделал лишь небольшие корректировки). Ссылки на оригинал присутствуют.

Примечание переводчика: некоторые куски оставлены как есть. Нужна твоя помощь в их переводе и уточнениях или корректировках! Первоначальная подборка на английском тут.

Почему нет паттерн-матчинга?

Я не фанат сопоставления по образцу как способа контролировать поток вычислений по условию в структуре данных. По-моему, это похоже на switch выражения - хрупко и нерасширяемо. Я думаю, что множество примеров их использования в функциональных языках - это признак недостатка полиморфизма в языке. Это хорошо в некоторых простых случаях, например, пустой список и нет, но if в Clojure это решает напрямую. Более сложные сопоставления сходятся к сравнению типов со следующими недостатками:

Полиморфные функции и ассоциативные массивы, на мой взгляд, лучше:

Я скорее использую (defrecord Color [r g b a]) и буду пользоваться именами ключей, чем структурой типа real*real*real*real, и буду вынужден декомпозитить ее части при сопоставлении, каждый раз думая, было это rgba или argb.

Я не утверждаю, что паттерн-матчинга вреден или бесполезен, но думаю, что в идиоматичном Clojure коде потребности в нем должно быть мало. Я бы действительно хотел видеть часть паттерн-матчинга, которая присваивает значения переменным, но отдельно от контроля потока выполнения, для всех структур данных, не только для списков.

Примечание переводчика: ответ 2008 года, с тех пор можно присваивающий
паттерн-матчинга появился и для Vector, и для Map. Если последний кажется непривычным, стоит посмотреть на defnk.

Где моя оптимизация хвостовой рекурсии?

Если говорить в общем, то мы подразумеваем полную оптимизацию - так, что оптимизированы должны быть и случаи вызова других функций. В этом смысле на данный момент она невозможна на JVM в связи с гарантиями вызова функции (i.e without interpreting or inserting a trampoline etc).

В то же время превратить рекурсивные вызовы текущей функции в jump просто (в конце концов, recur это и делает). Однако, будучи незаметным, это будет производить неверное впечатление для тех, кто пришел, скажем, из Scheme, где такая оптимизация присутствует в полной мере. Поэтому у нас есть явная конструкция recur для этого.

Собственно, это сводится к разнице между простой оптимизацией и семантикой ее обещания. До тех пор, пока я не могу обещать полную оптимизацию, мне кажется, не стоит иметь частичную.

Некоторые даже предпочитают recur излишнему упоминанию имени функции. Кроме того, recur может гарантировать правильное (оптимизируемое) конечное положение в ветви.

Полная оптимизация хвостовой рекурсии работает в конечных ветвях логики, невзирая на то, что именно будет вызвано в этом месте. На JVM нет языка, сохраняющего конвенции вызова (Clojure или Scala), так как это потребует либо манипуляции со стеком, поддержки чего нет в байткоде, либо непосредственной поддержки в самой JVM, но я не уверен, что это в приоритете для ее разработчиков. Например, такая оптимизация не свойственна JRuby/Jython/
Groovy.

Я думаю, либо язык имеет полную оптимизацию, либо язык не должен использовать имена функций для управления потоком выполнения, в том числе и этот термин. Я не сделал оптимизацию вызова функции самой себя по имени, потому что думаю, для людей, которые полагаются на нее в одном месте, но не в другом, это источник ошибок, а не фича.

В общем, в Clojure есть recur, который явно обозначает ситуации, где стек не будет расти. Извините, что нет полной оптимизации, и давайте не притворяться, используя само имя функции для управления потоком выполнения. Ждем с нетерпением ее полной поддержки в JVM.

Кроме recur есть ленивые последовательности и lazy-cons. В некоторой степени вы можете структурировать код, используя ленивость, включая собственные вызовы ленивого cons, можно писать выглядящие обычным образом функциональные и рекурсивные программы, которые не только не взрывают стек, но и не располагают в куче полного результата выполнения, что чертовски эффективно. Я не утверждаю, что это можно провернуть везде, но тут есть другие приятные особенности, например, сделать генератор списка эффективным по памяти.

Почему не принимаются pull requests?

Мне нравятся патчи. Я понимаю, что некоторым они не нравятся. Мы можем позволить друг другу не соглашаться? Зачем постоянно поднимать этот вопрос?

Как я это вижу: я потратил по крайней мере в 100,000x раз больше времени на Clojure, чем ты, спорящий о патчах и пулл-реквестах. Выполни это:

git format-patch master --stdout > your-patch-file.diff

Есть две стороны управления изменениями - сделать/отправить и учесть/оценить/наложить и т. д. Люди, которые утверждают, что процесс должен быть оптимизирован для легкой подачи, не правы. Что если я скажу, что патчи для меня дважды эффективнее в оценке и управлении ими, чем пулл-реквесты? (или даже больше) Посчитайте, как лучше распределить усилия.

Я не думаю, что настаивать на патчах - это просить слишком много, и очень ценю тех людей, которые превозмогают это усилие. И я уважаю мнение других людей, которые решают не участвовать в проекте, потому что не согласны. Однако, я не хочу оправдываться снова и снова. Давайте уважать меня и других участников рассылки. От таких душераздирающих сообщений ценность рассылки уменьшается для ее читателей. Излейте душу бармену :)

Зачем “rest” и “next”? Почему nil punning вместо Maybe?

Трудно перечислить (и даже вспомнить) все компромиссы вокруг того, как Clojure работает с nil.

Несомненно, nil punning - это форма усложнения. Но нельзя избежать всех проблем, используя пустые коллекции и empty?, необходимо что-то похожее на Maybe и тут (имхо, для языка с динамической типизацией) начинается хардкор.

Мне нравится nil punning, и я думаю, это отличный способ обобщения и ухода от граничных случаев в целом, создавая, однако, другие прецеденты. Я и Тимом предпочитаем подход Common Lisp, нежели Scheme, и признаю собственную необъективность, выбирая определенный уровень удобства за счет (небольшого) усложнения.

К сожалению, это поведение нельзя сохранить везде. В частности, в двух местах. Во-первых, это ленивость. Нельзя вернуть nil в rest списка без того, чтобы вычислить следующий элемент. Старожилы помнят, когда это было не так, и какие проблемы это причиняло. Я не согласен с Марком, что это существенное усложнение. nil это не пустой список, это ничто.

Во-вторых, в отличие от Common Lisp (где единственный тип пустой коллекции это nil, а cons не полиморфен), в Clojure conj полиморфен и нужно выбрать единственный тип возврата из (conj nil …), поэтому у нас есть [], {} и empty?. Если бы структуры при опустошении превращались в nil, они бы не смогли “восстановиться” при повторном наполнении.

Тут разговор становится предметом научного обсуждения, и, возможно, с этим ничего не поделать.

Самый простой способ думать, что nil - это ничто, а не пустая коллекция. Операции над последовательностью (возможно ленивой) возвращают коллекцию, а seq/next вычисляют следующий элемент. Никто не запрещает использовать rest и empty?, как и next в условиях.

Почему нет быстрого “last” на массиве?

Легко взять некоторую часть Clojure и признать ее неудовлетворительной. Язык не идеален. Но хорошо бы помнить, что может быть особый контекст, условия и другие влияющие аспекты, незаметные на первый взгляд.

Марк очень прав в том, что алгоритмическая сложность повлияла на это решение. До тех пор, пока функции определены в терминах абстракции - эти абстракции можно переработать.

Предыстория: last это функция в Lisp. В Common Lisp она работает только со списками. Эта функция заявляет о тормозах своим присутствием. Код, который ее использует подразумевает короткие списки, и обычно так оно и есть: в макросах или вещах, которые обрабатывают код. Люди, которые это видят, подразумевают то же самое.

В целом я думаю, что плохо иметь одну полиморфную функцию с разными категориями алгоритмической сложности в зависимости от аргумента. (Заметьте, это не то же самое, когда детали имплементации структуры используются для оптимизации, это специфично). Контрпример - это nth для seq, запрошенный сообществом, и это ошибка. По крайней мере, было бы хорошо иметь ‘elt’ со специфичной нелинейной сложностью (по причинам ниже).

Обычно есть “быстрая” функция, и кто-то хочет заставить ее работать с чем-то, что будет медленно. Это обратный случай. last медленный и люди хотят ускорить его там, где можно. Конечный результат один.

Почему не быть полиморфным, где это только возможно? В Common Lisp есть набор функций, который позволяет использовать списки как ассоциативные массивы. Производительность становится хуже, когда список растет. Это могло иметь смысл, когда были только списки, но хорошая ли это идея сейчас? Должны ли быть функции для хешей, которые также принимают списки?

По моему опыту, работая везде только со списками, быстрее наваять что-то работающее только на маленьких объемах. А потом заставить это быстро работать очень трудно. В том числе из-за отсутствия конформного интерфейса для замены на хеш. Другая часть проблем в том, что не видно, какие вызовы имеют влияние на производительность, и когда со списками можно жить. Поэтому Clojure вынуждает делать решения о выборе структуры раньше и явно. Но даже при этом он очень полиморфен.

В данным момент я не уверен, станет ли плохо от того, что last будет быстрее на массиве, отмечая в документации, что в худшем случае сложность линейна. Но я знаю, так ли это важно. Есть много более важных вещей.

Есть отличный, приемлемый и документированный способ получить последний элемент на массиве быстро, и код, для которого это важно, может и должен делать так (даже если last станет быстрее). Код, где это не важно, может использовать last на массиве, потому что не важно же. Люди, читающие код, будут знать, что важно, а что нет - это главное.

Почему у Clojure не GPL, MIT или другая лицензия?

MIT и BSD не вирусные лицензии. Я хотел такую. Но я не хотел лицензию, которая диктует условия для не производных программ, а совмещенных с моими, как GPL. Я думаю, делать так неправильно.

Тот факт, что GPL не совместима с этим подходом - это проблема GPL и людей, которые используют GPL программы.

Я не собираюсь подгонять лицензию под себя. Использование хорошо известной лицензии, как она есть, облегчает лицензионную проверку. Для всего остального нужны юристы и мне, и вам.

Я не буду выпускать код под двойной лицензией с GPL или LGPL. Обе лицензии позволяют создание производных продуктов под GPL, что я не могу использовать в своей работе. Создание производных продуктов, которые нельзя использовать, по-моему, не имеет смысла.

Что если я хочу изучать SICP с Clojure вместо Scheme?

Опыт у каждого свой. Вот мое мнение:

Я не думаю, что SICP - это книга о языке. Эта книга о программировании. Она использует Scheme, потому что это во многих отношениях простой язык. Лямбда-исчисление, оптимизация хвостовой рекурсии, continuations для абстракции над управлением, макросы и мутабельное состояние, где надо. Это немного, но этого достаточно.

Эта книга о проблемах в программировании. Модульность, абстракции, состояние, структуры данных, конкурентность и т.д. Она предлагает описания и элементарные реализации generic dispatch, объектов, конкурентности, ленивых списков, (изменяемых) структур, tagging и т.д., пригодных, чтобы подчеркнуть проблемы.

Clojure - не примитивный язык программирования. Я слишком устал/стар/ленив, чтобы программировать с примитивами. Clojure обеспечивает реализации generic dispatch, ассоциативных массивов, метаданных, конкурентности, персистентных структур данных, ленивых последовательностей, полиморфизм на промышленном уровне. Намного лучшие реализации, чем вы бы могли сделать сами на основе SICP, уже есть в Clojure.

Короче, ценность SICP в том, что она помогает осознать программирование концептуально. Если вы уже понимаете основы, Clojure позволит вам писать интересные и производительные программы быстрее, имхо. И я полагаю, что ядро Clojure не сильно больше, чем Scheme, что думают схемщики?

Я считаю, что лиспы до Clojure вели людей в правильном направлении функционального программирования и списков, но оставляли наедине с недостатком структур данных, а когда предоставляли их - делали их изменяемыми и заставляли обращаться с ними императивно. Прежние лиспы были спроектированы до всеохватного наступления многопоточности, а значение высокопроизводительного полиморфного вызова (виртуальные функции) было недооценено для экосистемы библиотек. Их библиотеки специально ограничивали полиморфизм.

В общем, сейчас нет книги по Clojure. (примечание переводчика: в 2008 году, сейчас уже немало). Но для той степени погружения в Scheme, которая необходима для обеспечения полной функциональности, тоже нет книг. Только документация в обоих случаях.

Изучение Scheme или Common Lisp перед Clojure нормально. Будет непереносимая специфика (из Scheme - нет оптимизации хвостовой рекурсии, отличия false/nil/(), нет continuations; из Common Lisp - Lisp-1, разделение на symbol/var). Лично я не думаю, что SICP сильно поможет вам с Clojure, но решайте сами.

Почему (nth nil 1) не бросает исключение, если нет значения по умолчанию?

И слава богу, что так (а также first/rest и т.д.), иначе паттерн-деструкция (и многие Clojure идиомы) были бы невозможны.

Семантика функций, оперирующих на nil и не бросающих исключение, это влияние Common Lisp. Однажды усвоив правила, можно начать писать более элегантный код. Я упоминал это ранее: http://people.cs.uchicago.edu/~wiseman/humor/large-programs.html

Почему в Clojure нет обобщенных insert, lookup, append, которые бы работали на всех видах коллекций?

Также отвечает на вопрос: почему только некоторые функции полиморфны (например, into, conj, count), а другие специфичны (например, contains?, assoc)?

Гарантии производительности - это важный аспект языка в целом. В частности, функции не работают со структурами, где они не производительны.

Вы не можете просто взять и заменить списки на хеши или сеты в программе. Для правильной работы вам нужна определенная структура данных и тогда вам будет доступна нужная функциональность. Тогда, когда это имеет смысл, функции полиморфны (например, seq или into). Но поиск, под любым названием, имхо, не должен быть, как оно и есть в Clojure. Также, нет вставки в начало массива или добавления в конец списка, поиска в хеше по значению, вставки в середину последовательности и т.д. Просто не хочется давать людям в руки инструмент, чтобы писать программы с квадратичной сложностью алгоритмов - от этого проигрывают все. Но я видел такие проседания производительности в программах на Common Lisp, которые сделаны на O(n) функциях, оперирующих списками.

Если у вас есть ассоциативные сопоставления, используйте сет или хеш; если есть доступ по индексу - массив. У вас будет get, contains? и nth. И производительность будет хорошей, что будет ясно из самого кода.

seq-contains? существует, потому что иногда, например в макросе, у вас есть короткий список и надо проверить наличие элемента в нем. Нет смысла копировать данные в сет. Так люди писали свои includes? и т.д. для этого. Теперь можно использовать seq-contains?. Его плохая сложность подчеркнута именем и документацией. Если кому-то надо будет оптимизировать кусок, содержащий seq-contains? будет сразу ясно, где затык.

Аргумент про проверку типа несостоятелен в Clojure практике. Люди склонны выбирать структуры данных под задачу, а не алгоритмы по проверке типа.

В Clojure не очень-то работает утиная типизация. В общем случае вещи объединены не просто именем функции, а общими базовыми абстракциями. Просто потому, что протоколы могут быть использованы с различными типами, это не значит, что надо унифицировать концептуально разные вещи.

Почему компилятор Clojure работает в 1 проход? Разве так не теряется возможность для оптимизаций?

Проблема не в одном или нескольких проходах. Она в том, что составляет единицу компиляции, над чем выполняется проход.

Clojure, как и многие лиспы до него, не имеет строгого определения единицы компиляции. Лиспы сделаны так, чтобы принимать набор инструкций (форм) через REPL, а не компилировать файлы/модули/программы и т.д. Это означает, что вы можете писать программу на лиспе интерактивно, очень мелкими кусочками, переключаясь между пространствами имен. Это очень ценная черта программирования на лиспах. Это подразумевает, что вы можете отправлять поток фрагментов программы по одной форме по сети, и они будут компилироваться и выполняться в точке назначения. Это также подразумевает, что вы можете определить макрос и немедленно использовать его при компиляции следующего выражения, или обработать небольшую часть “сломанного” в остальном файла. И так далее и тому подобное. Эта “шутка из 80х” все еще имеет место и позволяет делать вещи, которые не могут сложные или мультипроходные компиляторы. Строго говоря, компилятор Clojure двухпроходной, но единицы мелкие (выражения верхнего уровня).

На самом деле вопрос о многопроходной компиляции с целью разрешения циклических ссылок, когда одна часть может ссылаться на другую и наоборот. И компиляция обоих частей оставит в неопределенности некоторые участки, которые могут быть определены только после разбора другой части, соединяя вещи в одном шаге. Что будет единицей компиляции Clojure? Нужно ли подгружать файлы и определять семантику для них? (сейчас этого нет)

Ссылка на будущий объект не требует нескольких проходов или выделения единицы компиляции. Common Lisp позволяет ссылаться на необъявленные и неопределенные вещи и генерирует исключения при исполнении. Clojure мог бы использовать тот же подход со следующими уступками:

  1. меньше информации во время компиляции
  2. внутренние конфликты

В то время как #1 это фундаментальная уступка языков с динамической типизацией, проверки, несомненно, удобны и полезны. Clojure поддерживает объявление с помощью declare, и вам не обязательно определять функции в определенном порядке.

С #2 дьявол кроется в деталях. Clojure, как и Common Lisp, сделан компилируемым и не делает поиск переменных по имени во время исполнения. (Вы конечно можете сделать быстрый язык, который так делает, как например, Smalltalk реализации, но надо помнить, что эти языки, в отличие от лиспов, имеют дело с объектами построенными на словарях). И Clojure, и Common Lisp превращают имена в сущности, чьи адреса зафиксированы в скомпилированном коде
(symbols в Common Lisp, vars в Clojure). Эти сущности интернируются так, что внутренняя ссылка будет указывать на эту же сущность постоянно, и поэтому компиляция может быть продолжена после того места, где значение еще не определено.

Что должно произойти здесь, если компилятор не знает, что такое bar?

(defn foo [] (bar))

или в Common Lisp:

(defun foo () (bar))

Common Lisp с удовольствием скомпилирует этот код, и если bar так и не будет определена, то во время выполнения будет брошена ошибка. Хорошо, но какая внутренняя ссылка должна быть использована для bar во время компиляции? Та, что интернирована во время чтения выражения. А что случится после того, как будет брошена ошибка, вы поймете, что bar определена в другом пакете? Вы попытаетесь импортировать его, но - упс! - другая ошибка - bar из другого пакета будет конфликтовать с bar из текущего. На этом месте идем читать про деинтернирование.

В Clojure выражение не скомпилируется, вы увидите сообщение, и для bar не будет внутренней ссылки. Вы подключаете, импортируете другой пакет и продолжаете работать. Я абсолютно точно предпочитаю это поведение, так и был сделан выбор. При использовании не интернирующего парсера, а такого, который интернирует только при определении, есть и много других преимуществ. Я не склонен отказываться от них, как и от преимуществ, упомянутых выше, для того, чтобы поддерживать циклические ссылки.