Pull to refresh

Почему я остаюсь с Лиспом (и вам тоже стоит)

Reading time 15 min
Views 14K
Original author: Anurag Mendhekar

Зрелый язык может использоваться немногими. Но он остаётся частью моей кодовой базы.

Как давнего пользователя (и активного сторонника) Scheme/Common Lisp/Racket, меня иногда спрашивают, почему я предпочитаю их. К счастью, я всегда возглавлял собственные инженерные организации, поэтому мне никогда не приходилось оправдывать это перед руководством. Но есть еще более важная аудитория - мои собственные коллеги-инженеры, которые никогда не имели удовольствия использовать эти языки. Хотя им не требуются оправдания, они все же спрашивают из интеллектуального любопытства, а иногда и из-за удивления, почему я не схожу с ума по поводу следующей крутой функции, которая будет в этом месяце добавлена в Python или Scala, или что бы там ни было в их вкусе.

Хотя фактическая разновидность используемого мной Lisp изменялась (Scheme, Common Lisp, Racket, Lisp-for-Erlang), ядро всегда оставалось неизменным: язык программирования, базирующийся на S-выражениях, динамически типизированный, в основном функциональный, с вызовом по значению и основанный на λ-исчислении.

Я начал серьёзно программировать в подростковом возрасте на BASIC на ZX Spectrum+, хотя раньше я пробовал писать (вручную) программы на Fortran. Это был определяющий период для меня, поскольку он по-настоящему определил мой карьерный путь. Я очень быстро подошел к пределу языка и пытался писать программы, которые выходили далеко за ограниченные возможности языка и его реализации. Я ненадолго перешел на Паскаль (Turbo Pascal в системе DOS), что некоторое время было забавно, пока я не открыл для себя C в Unix (Santa Cruz Operation Xenix!). Благодаря этому я получил степень бакалавра компьютерных наук, но мне всегда хотелось большей выразительности в моих программах.

Это было, когда я обнаружил функциональное программирование (спасибо IISc!) В Миранде (прекрасной матери уродливого Haskell), и это открыло мне глаза на стремление к красоте в моих программах. Моё представление о выразительности языка программирования стало очень стремительно развиваться. Моя концепция того, как должны выглядеть программы, теперь заключалась в краткости, элегантности и удобочитаемости.

Миранда была не очень быстрым языком, поэтому скорость выполнения была проблемой. Миранда также была статически типизированным языком с выводом типов в стиле Standard-ML. Вначале я был очарован системой типов. Однако со временем я стал её презирать. Хотя это помогло мне уловить несколько вещей во время компиляции, в основном это мешало (подробнее об этом позже).

Примерно через год после этого я закончил изучать языки программирования в Университете Индианы у Дэна Фридмана (известного как «Маленький Лиспер» / «Маленький Схемер»). Это было мое знакомство со Scheme и миром Lisp. Я наконец понял, что нашел идеальное средство для выражения своих программ. И ничего не изменилось за последние 25 лет.

В этой статье я пытаюсь объяснить и исследовать, почему так произошло. Я просто старый динозавр, который не меняет своего образа жизни? Не слишком ли я высокомерен или может быть я пренебрежительно отношусь к новым идеям? Или я просто устал пробовать новое? Ничего из вышеперечисленного. Я нашёл совершенство, и ещё ничего лучше не появилось, чтобы повергнуть этот идеал.

Давайте разберёмся немного. Я сказал это несколькими абзацами выше:

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

Я собираюсь начать объяснять это - задом наперёд.

Язык, основанный на λ-исчислении

Основная сущность всех программ - это функция. Функции имеют интенсиональный характер и составляют фундамент процесса проектирования программного обеспечения. Вы всегда думаете о том, как информация обрабатывается, как она трансформируется и как производится. Я ещё не нашёл фундаментальную основу, которая улавливает эту врождённую интенсиональность («как»), лучше, чем λ-исчисление.

Возможно, слово "интенсиональность" сбивает с толку. У математики есть два способа думать о функциях. Во-первых, как набор упорядоченных пар: (вход, выход). Хотя это представление - отличный способ доказать теоремы о функциях, оно совершенно бесполезно при кодировании. Это также известно как "экстенсиональный" взгляд на функции.

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

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

Языки, основанные на λ-исчислении, позволяют легко «воспроизвести код» в голове. Простые правила λ-исчисления означают, что у вас в голове меньше вещей, а код легко читать и понимать.

Языки программирования - это, конечно, практические инструменты, поэтому их ядро должно быть возможно более простым, чтобы соответствовать максимально широким целям. Вот почему я люблю Scheme (и мой нынешний любимый вариант, Racket - CS, как раз для тех, кто заботится о таких вещах). То, что он добавляет к основному λ-исчислению, - это минимум, необходимый для его использования. Даже когда эти дополнения следуют основным принципам λ-исчисления, есть несколько сюрпризов.

Это, конечно, означает, что рекурсия - это образ жизни. Если вы один из тех людей, для которых рекурсия никогда не имела смысла, или если вы все еще считаете, что «рекурсия неэффективна», то самое время вернуться к ней. Scheme (и Racket) эффективно реализуют рекурсию в виде циклов везде, где это возможно. Более того, этого требует стандарт Scheme.

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

Это причина номер один. Scheme/Racket - разумная реализация языка программирования, основанного на λ-исчислении. Как вы могли заметить, я не использую слово "функциональный язык" для описания схемы. Это потому, что, хотя он в первую очередь функциональный, он не полностью исключает возможность мутабельности. Несмотря на то, что Scheme не одобряет её использование, он признает, что существуют реальные контексты, в которых может быть полезна мутабельность, и разрешает её без использования вспомогательных трюков. Я не буду спорить здесь с пуристами о том, почему это так, но это связано с тем, о чем я расскажу позже в этой статье.

Вызов по значению (Call-By-Value)

Те из вас, кто знаком с деталями λ-исчисления, возможно, поняли, почему я решил провести это различие. Вспомните мою историю: в отношении функционального программирования мои зубы прорезались на Miranda, который является ленивым функциональным языком (как и Haskell). Это означает, что выражения вычисляются только тогда, когда требуются их значения. Это также то, как определяется исходное λ-исчисление. Это означает, что аргументы функции вычисляются при их использовании, а не при вызове функции.

Это тонкое различие, и у него действительно есть некоторые вкусные математические свойства, но оно имеет далеко идущие последствия для «воспроизведения кода» в вашей голове. Во многих случаях это становится неожиданностью (даже для опытных программистов), но есть один случай, который, возможно, касается вас больше, чем другие.

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

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

Вызов по значению имеет некоторые приложения в том, как доказывать формальные теоремы о программах, но, к счастью, существует чудовище, называемое "λ-исчислением по значению", на которое мы можем положиться в случае необходимости.

Scheme позволяет вам иметь явное ленивое вычисление за счёт использования thunk-ов и мутаций, которые можно удобно абстрагировать, чтобы у вас был call-by-need, когда вам это нужно. Это подводит нас к следующему этапу.

В основном функциональный

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

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

Как и любой другой инструмент, не-мутирующий код, доведённый до крайности, может быть вредным. Язык, который дает мне разумное использование мутаций для реализации большого объёма кода в более элегантной манере, всегда выигрывает у языка, который заставляет применять (даже хорошую) конструкцию в любой ситуации.

Итак, врождённая предвзятость Scheme к отсутствию мутаций, но его отношение «используйте это, если нужно» по отношению к мутациям (или побочным эффектам, как их называют), делает его гораздо более эффективным инструментом для меня.

Я упомянул монады выше, так что неплохо было бы немного поговорить о них, поскольку они являются чисто функциональным способом получения эффектов. Написав о них докторскую диссертацию, я думаю, что имею некоторое представление о них. Мне нравится элегантность и чистая красота оригинальной концепции монад Eugenio Moggi. Идея отделения вычисления от значения, производимого этим вычислением, а затем преобразование этого вычисления в тип, блестящая во всех смыслах этого слова. Это отличный способ математически понять семантику языков программирования.

Но как инструмент программирования, монады вызывают у меня смешанные эмоции. Это сложный способ изолировать эффекты и затем пропустить их через всю вашу программу, когда вы можете легко создать простые абстракции, которые упростят работу с остальной частью вашей программы. Как однажды сказал выдающийся теоретик типов (который останется безымянным): «Монады полезны только каждый второй вторник».

Монады - это вспомогательные устройства, которые вынуждены использовать функциональные языки, чтобы обеспечить функциональный забор от побочных эффектов. Проблема в том, что забор «заразен», и все, что касается ограды, теперь тоже нужно ограждать и так далее, пока вы не дойдёте до конца детской площадки. Таким образом, вместо того, чтобы сталкиваться с побочным эффектом и элегантно обрабатывать его в абстракции, теперь вам предоставляется сложная абстракция, которую вы вынуждены таскать с собой повсюду. К тому же монады не очень композабельны.

Я не утверждаю, что монады совершенно бесполезны. В некоторых случаях они хорошо работают («каждый второй вторник»), и я использую их, когда они работают. Но когда они являются единственным механизмом для применения вычислений, они серьёзно подрывают выразительность языка программирования.

Это подводит нас к следующему и, возможно, самому противоречивому мнению, которого я придерживаюсь.

Динамически типизированный

В современном мире много говорится о типизированных языках. TypeScript считается спасителем в мире JavaScript. Python и JavaScript осуждают за отсутствие статической типизации. Типы считаются важными для документации и коммуникации в крупных программных проектах. Тимлиды бросаются к ногам вывода типов, чтобы защититься от посредственных инженеров-программистов, создающих некачественный код.

Есть два типа статической типизации. Статическая типизация «в старом стиле» используется в C, C++, Java, Fortran, где типы используются компилятором для создания более эффективного кода. Средства проверки типов здесь очень ограничительны, но не претендуют на предоставление каких-либо гарантий помимо базовой проверки типов. Они, по крайней мере, "понимабельны".

Затем появился новый вид статической типизации, уходящий корнями в систему типов Хиндли-Милнера, который породил новое чудовище: вывод типов. Это создаёт иллюзию, что не все типы нужно объявлять. Если вы играете по правилам, вы получите не только преимущества статической типизации старого стиля, но и некоторые новые интересные вещи, такие как полиморфизм. Это взгляд тоже "понимабелен".

Но за последние пару десятилетий это приобрело новое значение: статическая типизация - это форма проверки ошибок во время компиляции, поэтому она поможет вам создавать более качественный код. Это как если бы статическая типизация - это волшебное средство доказательства теорем, которое проверит некоторые глубокие свойства вашей программы. Это то, что я называю чушью. У меня никогда не было средства проверки статического типа (независимо от того, насколько оно сложное), которое помогло бы мне предотвратить что-либо, кроме очевидной ошибки (которая в любом случае должна быть обнаружена при тестировании).

Однако то, что делают средства проверки статического типа, мешает мне. Всегда. Безошибочно. Как программист, я все время ношу в голове инварианты (причудливое название для свойств вещей в моей программе). Только один из этих инвариантов - это его тип. Иметь инструмент, который может проверить инвариант - это круто, когда вы впервые с ним сталкиваетесь (как я делал с Мирандой).

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

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

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

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

Другими словами, статическая типизация бессмысленна. Она, возможно, имеет некоторую документальную ценность, но не заменяет документацию по другим инвариантам. Например, ваш инвариант может заключаться в том, что вы ожидаете монотонно увеличивающийся массив чисел со средним значением такого-то и такого-то и такого-то стандартного отклонения. Лучшее, что вам может сделать любая проверка статического типа, - это array[float]. Остальная часть вашего инварианта должна быть выражена словами, описывающими функцию. Так зачем подвергать себя страданиям array[float]?

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

Но, как и все остальное, иногда нужно знать типы статически. Например, я много работаю с изображениями, и мне полезно знать, что они представляют собой array[byte], и у меня есть операции, которые будут работать с ними магически быстро. Scheme/Lisp/Racket - все они предоставляют способы сделать это, когда вам это нужно. В Scheme это зависит от реализации, но Racket поставляется с вариантом Typed Racket, который можно смешивать с динамически типизированным вариантом. Common Lisp позволяет объявлять типы в определённых контекстах, в первую очередь для компилятора, чтобы реализовать оптимизацию там, где это возможно.

Итак, опять же, Scheme/Lisp/Racket дают мне преимущества типов, когда они мне нужны, но не навязывают мне ограничения повсюду. Это лучшее из обоих миров.

Базирующийся на S-выражениях

И, наконец, мы подошли к одной из наиболее важных причин, по которой я использую Lisp. Для тех из вас, кто никогда раньше не слышал термин S-выражение, он означает специфический выбор синтаксиса в Лиспе и его потомках. Все синтаксические формы представляют собой атомы или списки. Атомы - это такие вещи, как имена (символы), числа, строки и логические значения. И списки выглядят как «(…)», где содержимое списка также является либо списками, либо атомами, и совершенно нормально иметь пустой список «()». Вот и все.

Нет никаких инфиксных операций, никакого приоритета операторов, никакой ассоциативности, никаких ложных разделителей, никаких висячих else, ничего. Все функции являются префиксными, поэтому вместо того, чтобы говорить «(a + b)», вы должны сказать «(+ a b)», что дополнительно позволяет гибко сказать такие вещи, как «(+ a b c)». «+» - это просто имя функции, которую вы можете переопределить, если захотите.

Существуют «keywords», которые заставляют данный список распознаваться определенным образом, но правила иерархичны и чётко определены. Другими словами, S-выражения представляют собой древовидные представления ваших программ.

Такая простота синтаксиса часто сбивает с толку новичков. Вероятно, это оттолкнуло многих программистов, которым не повезло, что они не увидели красоту этого способа написания программ.

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

Это еще даже не половина дела. Если ваши программы представляют собой деревья, вы можете писать программы для манипуляции этими деревьями. Лисперы (а также схемеры и, гхм, рэкетиры, т.е. Racketeers) называют эти вещи макросами или синтаксическими расширениями. Другими словами, вы можете расширить синтаксис своего языка, чтобы ввести новые абстракции.

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

Вывод

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

Вот почему я все еще использую Scheme/Racket/Lisp и, вероятно, буду использовать его до конца своей жизни. Использую ли я другие языки? Конечно, их много. Но никакой из них не сравнится с этим. Особенно новые. Похоже, что изобретение новых языков - это упражнение, через которое проходит каждое новое поколение плохо информированных инженеров-программистов, когда старые языки намного лучше, чем все, что они могли придумать даже во сне (я представляю вам Ruby, который, хотя номинально имеет свои корни в Лисп, напрашивается вопрос: почему вы просто не использовали сам Лисп).

Как и у всякого предубеждения, у моего тоже есть недостатки. Примерно 15 лет назад все сторонние SDK были написаны полностью на C/C ++, которые могли легко взаимодействовать с Lisp. Появление Java создало этому препятствие, поскольку JVM плохо взаимодействует со Scheme/Lisp/Racket. Это усложняло включение сторонних библиотек в мои программы без выполнения большого количества работы.

Ещё один недостаток заключается в том, что с появлением API в Интернете большинство вендоров выпускают библиотеки на распространённых в Интернете языках (Java, Ruby, Python, JavaScript, а в последнее время - Go и Rust), но никогда в Scheme/Lisp/Racket, если только это не вклад сообщества, которое также редко использует и C/C++. Это часто оставляет меня в положении, когда мне приходится самому создавать уровень API, что, конечно, не очень практично. Racket (мой нынешний фаворит) имеет довольно активное сообщество, которое вносит свой вклад в большие проекты, но обычно оно немного отстает от времени, а когда дело доходит до новейших вещей, я часто остаюсь с тем что есть. Возможно, это основная причина, по которой я буду использовать Clojure в будущем, но это еще предстоит выяснить.

Меня это, конечно, еще не отпугнуло. Во всяком случае, это заставило меня лучше понять, что сообщество Lisp должно распространять своё слово все шире и шире и привлекать новое поколение Lisp, чтобы укрепить экосистему в быстро меняющейся среде.

И, наконец, проблема производительности. Во-первых, давайте развеем распространённое заблуждение: Лисп не является интерпретируемым языком. Он не медленный, и все реализации имеют множество рычагов для настройки производительности большинства программ. В некоторых случаях программам может потребоваться помощь более быстрых языков, таких как C и C++, поскольку они ближе к оборудованию, но с более быстрым оборудованием даже эта разница становится несущественной. Эти языки являются прекрасным выбором для production-quality кода и, вероятно, более стабильны, чем большинство других вариантов, благодаря десятилетиям работы над ними.

Я признаю, что изучение Scheme/Lisp/Racket немного сложнее, чем изучение Python (но намного проще, чем изучение Java/JavaScript). Однако, если вы это сделаете, вы станете гораздо лучшим программистом и научитесь ценить красоту этих языков так, что ничего другого будет недостаточно.

Anurag Mendhekar (Tech Entrepreneur and Software Artist)

Tags:
Hubs:
+24
Comments 136
Comments Comments 136

Articles