Мне нравятся простые языки программирования, такие как Gleam, Go и C. Знаю, я не один такой. Есть что-то чудесное в работе с простым языком: каково его читать, использовать в команде, возвращаться к нему спустя долгое время и т.д.
В этом посте я хочу конкретизировать, в чём заключается такая простота, осветить пару причин, по которым она так важна. Я предложу пять ключевых идей, которые должны быть реализованы в простом языке программирования:
- Возможности, которые всегда под рукой
- Быстрые циклы итераций
- Единообразие выполнения любых вещей
- Принципы работы с функциями
- Простые системы статических типов
Ниже подробно обсудим каждую из этих идей.
Всегда под рукой
В философии технологии различаются две очень полезные концепции: «наличность» (presence-at-hand) и «подручность» (readiness-at-hand). Некая концепция считается наличной, если она сейчас занимает ваши мысли, находится у вас в оперативной памяти. Подручной же концепция считается в том случае, если мы можем даже не догадываться о её наличии, пока не попытаемся ею воспользоваться. Например, в тысячный раз заходя к себе на кухню, мы не вполне точно представляем, что именно лежит во всех этих шкафчиках, пакетиках, ящичках с продуктами, на столе, какие в кухне есть украшения и что-либо ещё. Я сравниваю такой заход на кухню с тем, как будто ты спонтанно заглянул в холодильник, чтобы перекусить. С другой стороны, когда я сажусь за стол, именно стол и стулья переходят для меня в категорию наличных, хотя только что были подручными. У меня в этот момент холодильник отходит на задний план и становится подручным.
Если вы чем-то пользуетесь, это еще не значит автоматического перехода из подручного в наличное. Например, даже когда вы читаете в очках, очки остаются подручными, а не наличными, так как ваш мозг обрабатывает увиденное, совершенно не задумываясь об очках. Однако, если в ходе эксплуатации подручные вещи
выходят из строя, они сразу становятся наличными. Продолжая пример: если на стекле очков окажется грязное пятнышко, мозг сразу чётко осознает, что, на самом деле, книгу вы читали в очках.
Эта идея тесно связана с ограничениями человеческой оперативной памяти. Память ограничивает количество концепций, которыми вы можете оперировать одновременно, но умеет обобщать, помогая отсеивать шум (в котором мы постоянно существуем) от всех нужных нам информативных данных.
Я разобрал здесь эту идею подручности, поскольку в простых языках программирования зачастую действительно
есть много таких возможностей, которые специально реализованы так, чтобы не «забивать эфир», когда мы ими не пользуемся. Например, для Gleam, Go и C характерна выраженная кроссплатформенность, и поддержка множества платформ — это большой кусок работы, которой приходится заниматься при программировании на них. Когда требуется обеспечить работоспособность вашего кода в браузере, или на Raspberry Pi, или на смартфоне, или на сервере, в язык для этого добавляются конкретные возможности, которые, однако, никак не вредят его простоте. Ещё один пример — поддержка протокола языкового сервера (LSP), которому уделяется большое внимание среди разработчиков под Gleam и Go, а на C этот протокол поддерживается очень прилично, несмотря на возраст языка.
Не буду об этом чрезмерно распространяться, полагаю, в следующих разделах станет немного понятнее, почему я привёл именно такой пример. Рекомендую вам почитать
эту статью, в которой подробнее разобраны вышеупомянутые идеи о философии технологии.
Циклы итераций
Очень быстрый цикл итераций (обычно итерация приравнивается к времени компиляции) — как раз тот аспект, реализовать который стремятся разработчики большинства простых языков. Если итерация проходит быстро, то и затраты на прототипирование и эксперименты совсем невелики, а разработчик может не выпадать из состояния потока.
Очевидно, C в данном отношении немного недостаёт лоска, поскольку изначально этот язык разрабатывался как однопроходный компилятор, но вообще вся структура языка очень хорошо рассчитана на это ограничение. Можете сколько угодно рассказывать, как вас раздражает работа с заголовочными файлами — мне они с учётом таких обстоятельств кажутся весьма эргономичными. Они дают нам возможность неупорядоченного исполнения команд, которую мы принимаем как должное, но такую возможность определённо следует записать в «подручные».
Но в Gleam и Go производительность компилятора — одна из лучших в своём классе. Go этим славится, так что здесь я не буду особенно распространяться. Компилятор Gleam написан на Rust, и его разработчики ясно дали понять, что автономным (self-hosted) ему не бывать, поскольку из-за этого снизится производительность и усложнится дистрибуция. Когда это только возможно, синтаксический разбор и обработка файлов распараллеливаются, и, как минимум, мои проекты на Gleam компилируются мгновенно.
Также стоит упомянуть действующую в Gleam систему зависимостей, она крайне симпатичная. Она работает с менеджером пакетов Hex, применяемым в Erlang и Elixir, поэтому здесь генерируются аккуратные страницы документации в формате HexDocs. Поэтому вам несложно находить библиотеки, а хорошая документация становится нормой. Чтобы убедиться, насколько удобнее всё делается в Gleam, рассмотрим, какие варианты предоставляются, когда я ввожу в командную строку gleam и жму enter:
$ gleam
gleam 1.0.0
USAGE:
gleam <SUBCOMMAND>
OPTIONS:
-h, --help Print help information
-V, --version Print version information
SUBCOMMANDS:
add Add new project dependencies
build Build the project
check Type check the project
clean Clean build artifacts
deps Work with dependency packages
docs Render HTML documentation
export Export something useful from the Gleam project
fix Rewrite deprecated Gleam code
format Format source code
help Print this message or the help of the given subcommand(s)
hex Work with the Hex package manager
lsp Run the language server, to be used by editors
new Create a new project
publish Publish the project to the Hex package manager
remove Remove project dependencies
run Run the project
shell Start an Erlang shell
test Run the project tests
update Update dependency packages to their latest versions
Много совершенно прямолинейных и удобных подкоманд! Я успел поработать с Gleam уже несколько месяцев, опубликовал пару пакетов, а много других добавил в мои проекты, и пока меня всё устраивает.
Единообразие выполнения любых вещей
Когда проектируешь язык, рассчитанный на быструю компиляцию, это зачастую означает, что придётся обойтись без изысков. Например, в Go не планируется добавлять метапрограммирование, более того, достаточно долго там даже дженерики не предусматривались.
Но зачастую в таких языках это аргументируется так: жертвуя чем-то ради производительности, мы делаем отдельные компоненты языка только лучше. В Go принято укладывать весь циклический код в цикл for, весь код из разряда «то или это» должен быть заключён в операторах if, а любой код на «выбор из множества» — в операторах switch. Именно поэтому циклы for и операторы switch в Go немного необычные, а цикла while нет вообще. История о конкурентности в Go подчиняется одному подходу, а в Rust — совершенно противоположному. В какой-то степени здесь можно писать код и в функциональном стиле, но писать на Go лямбда-выражения — это настоящий геморрой. В системе типов Go любые нетривиальные задачи решаются при помощи интерфейсов.
В Gleam эта идея развита ещё сильнее. У него функциональная родословная, поэтому там
нет циклических конструкций, только рекурсия и такие вещи как map и fold. Применяется оптимизация хвостовых вызовов, поэтому подобный код компилируется во многом именно так, как если бы он был заключён в цикл while. Более того, в Gleam даже нет if! Напротив, там есть только (мощный) механизм сопоставления с шаблоном при наличии (мощных) ограничивающих условий. Вычисление ряда Фибоначчи можно было бы запрограммировать так:
pub fn fib(n: Int) -> Int {
case n < 2 {
True -> n
False -> fib(n - 1) + fib(n - 2)
}
}
Сопоставление с шаблоном в соответствии с True и False работает точно, как оператор if, поэтому на практике подобное «ограничение» никогда особо не раздражает.
Кроме того, в Gleam навязывается змеиный регистр (snake_case) при именовании переменных и функций, а типы именуются в стиле Pascal (PascalCase). Кроме того, в Gleam есть отличная система догматичного форматирования кода (точно, как в Go). При запуске проекта в Gleam среди прочего по умолчанию выполняется действие github по проверке форматирования. Да, так и есть! Ограничения такие, что вас быстро загоняют к специфическому стилю программирования, которым пользуются и все ваши коллеги.
В Gleam открыто ставится цель сделать небольшой синергический набор возможностей, оптимизировать работу в пользу
быстрого обучения и лёгкости
чтения кода. В соответствии с девизом, этот язык
можно выучить за вечер. Такая концентрация очень важна и, определённо, перекликается именно с теми чертами, которые мне особенно нравятся и в Go. Сложно представить, насколько это полезно, пока сам немного не поработаешь с этим языком.
По мере того, как набирает популярность автозавершение кода с поддержкой ИИ, такой однонаправленный подход становится тем более ценным.
Генеративный ИИ представляется мне эстетически-ориентированным в философском смысле, так как по природе своей он обрабатывает код «пословно» (а не как поток сознания), а также в силу его статистической основы. Таким образом, при работе с простыми языками вроде C, Go и Gleam, программы на которых всегда пишутся одинаково, подсказки ИИ будут отличаться высокой точностью. На этих языках во многом согласуется «эстетичность» кода с точки зрения человека и с точки зрения компьютера. Выше я привёл функцию для вычисления ряда Фибоначчи, и она была почти полностью сгенерирована Claude, без какого-либо редактирования, просто по ходу подготовки базы кода для этого поста (речь о небольшом или среднем приложении на Gleam). Я практически уверен, что в обучающем множестве Claude не было или почти не было кода на Gleam, а также этот язык было бы легко перепутать с Rust, поскольку синтаксис этих языков (намеренно) получился схожим. Но ИИ всё равно справился очень достойно.
Принципы работы с функциями
В академических кругах используется язык
OBJ, по замыслу разработчиков представляющий собой функциональный язык без лямбда-выражений (строго говоря, это язык с «переписыванием термов»). Его учёные создатели настаивают, что человеку сложно рассуждать о функциях высшего порядка, поэтому предлагают интересные способы передать иными способами большую часть той выразительности, что обычно заключена в замыканиях. Такие способы можно условно назвать «слегка объектно-ориентированными».
C и Go в этом отношении явно выделяются на фоне других языков. Оба этих языка
поддерживают функции высшего порядка (Хотя, в C замыкания организованы во многом по принципу «сделай сам», и это неудивительно), но такой стиль кода совсем не идиоматический. Как я сказал выше, циклы должны снабжаться готовыми цикловыми конструкциями, а динамическое поведение, как правило, следует достигать иными способами. Это практически очевидно, когда пишешь код на Go и C, и в Go это определённо делается в большей степени по идеологическим, а не по технологическим причинам. Примерно так же устроены и лямбда-выражения в Python, но там такие черты менее выражены.
Можно подумать, что Gleam плохо укладывается в эту категорию, поскольку это чисто функциональный язык, но в его структуре предусмотрены механизмы и для работы в таком стиле. Привязки локальных переменных в Gleam не рекурсивны, это явно сделано для того, чтобы простимулировать программиста поднимать функции на верхний уровень. В Gleam применяется оператор |>, благодаря которому код высшего порядка гораздо проще читать и судить о нём. (Классный!) синтаксис use, применяемый в Gleam, охватывает большинство вариантов практического применения лямбда-выражений в функциях, поэтому складывается ощущение, будто пишешь удобный простой императвный код. Например, можно запрограммировать что-то подобное, напоминающее циклы for:
import gleam/int
import gleam/list
import gleam/io
/// для каждого i в списке выводим на экран i+1
pub fn print_all_plus_one(l: List(Int)) {
// этот пример надуманный; как правило, приходится использовать всего один цикл
//один цикл:
let res = {
use i <- list.map(l)
int.to_string(i + 1)
}
// другой цикл:
use s <- list.each(res)
io.print(s)
}
Обратите внимание, что код в таком стиле немного неаккуратный. В таком случае обычно не приходится пользоваться use, более того, можно обойтись вызовами list.each, в нормальном порядке. Я просто хотел показать, как use превращает функции высшего порядка в своего рода императивный код. Действительно, код такого типа время от времени попадался мне в базах кода на Gleam.
Если эти аспекты проектирования языков вам интересны, то, думаю, вам понравится и
этот крутой пост.
Системы типов
Может возникнуть вопрос, а почему в моём списке нет Python. Причина, по которой писать код на Python приходится настолько иначе — это
рефакторинг. Мне очень помогают системы типов из Gleam, Go и C, когда приходится вносить серьёзные изменения в мой код; таким образом, я могу не держать в голове много лишней информации. В Python я словно блуждаю в потомках, мне остаётся только догадываться, на какую следующую ошибку системы типов я наткнусь во время исполнения. В Python почти ничего не делается для того, чтобы облегчить мне управление проектом, поэтому до проекта просто страшно дотрагиваться сколь-либо значительным образом. Оптимизация удобочитаемости обычно идёт рука об руку с оптимизацией под рефакторинг.
С другой стороны, могут найтись читатели, которые добавили бы в мой список Haskell. Во-первых, в нём нет практически ничего кроме лямбд, верно? Да уж, простой язык. Честно говоря, я не думаю, что кому-то может прийти в голову добавить в этот список Haskell. В силу того, как там устроена система типов (и порочной культуры «брать случайные последовательности символов и с их помощью выражать сложные идеи — всё ради того, чтобы любая функция получалась однострочной»), Haskell никак не прост. Есть множество способов писать на нём код, и читать такой код невероятно сложно (хотя и очень весело, как только набьёшь в этом руку).
Простые языки удивительным образом балансируют на грани между выразительностью и скованностью. В C, Go и Gleam в той или иной форме предлагается динамическая типизация, явно рассчитанная на очень ограниченную область применения. Кроме того, во всех них есть некоторая причудливость для выражения нужных вам вещей без применения динамической типизации. В Go это делается при помощи интерфейсов, в Gleam — при помощи мощного полиморфизма, а в C — при помощи макросов препроцессора и приведений. В конце концов, системы типов очень лаконичны и ограничительны. Баланс, достигнутый в простых языках программирования, на практике очень приятен. Всё равно, как в мудрой семье удаётся найти золотую середину между свободой для ребёнка и разумным контролем. Ребёнку ничего не угрожает, и при этом он счастлив.
Заключение
Надеюсь, эта статья вам понравилась. Я знаю, как много есть людей, ценящих простые языки именно за их простоту, но почти не видел статей с попытками такой систематизации, какую я сделал здесь.
Как вы уже догадываетесь, я весьма интересуюсь простыми языками программирования и планирую сам написать небольшой язык, в котором вышеописанные идеи были бы развиты ещё сильнее, чем в Gleam. В этом языке не должно быть лямбд, как и в OBJ. Кроме того, у меня есть некоторые идеи, как организовать работу без сборщика мусора, идеи для этого я позаимствую из языка Mojo, где есть интересная система проверки заимствований, удобная для взаимодействия с Python.
В общем, я думаю, что подручность языка, высокая скорость итераций, единообразие при выполнении всех операций, вышеописанные принципы работы с функциями и простые системы статических типов — это ключевые идеи, которыми стоит руководствоваться при проектировании новых языков. В данном случае не стоит стремиться изобрести новый Haskell и Rust.
P.S. Обращаем ваше внимание на то, что у нас на сайте проходит
распродажа.