Предисловие
В этой статье будет много моих выводов, гипотез и идей. Это моя первая статья, поэтому буду рад услышать аргументированную критику. В статье часто используется псевдокод, чей синтаксис напоминает смесь C#, C++ и Python.
Немного о терминологии
Много времени я программирую на C#, и в этом языке существуют некоторые термины, которые могут ввести в заблуждение программистов на C++:
Под словом коллекция я подразумеваю любой набор элементов: список, массив, стек, куча, итератор, множество и т.п.
Говоря список, я подразумеваю динамический массив
Язык программирования - инструмент
Языки можно поделить на две большие группы: предметно-ориентированные языки (aka DSL) и языки общего назначения (ЯОП). DSLи точно можно назвать инструментами, так как они по определению решают конкретную задачу. С языками общего назначения все несколько сложнее. ЯОПы имеют техническую возможность реализовать любой алгоритм, то есть полны по Тьюрингу. Но это не значит, что для каждой задачи будет эффективно использовать язык X, ведь язык Y может подходить для решения каких-то задач лучше.
Под словом "эффективно" я подразумеваю, что программистам будет морально приятно писать код на языке для решения конкретной задачи, а также, что код будет написан за минимальное количество времени.
Рассмотрим банальный пример. C# лучше использовать для написания бекенда сайтов, чем Си. Используя C#, вы делаете вклад в возможность поддержки проекта, уменьшаете порог входа в проект, пишете платформо-независимый код. Си лучше использовать в задачах, где важна производительность или низкоуровневый доступ к ресурсам. Например при написании runtime библиотеки языка, игрового движка или кода под микроконтроллеры с небольшими ресурсами.
Также некоторые языки имеют хорошую поддержку сообщества в каких-то специфических областях. Например C# и C++ - доминируют в создании игр, Python и R - преобладают в data science, JavaScript и PHP - в создании сайтов и т.п. В силу своих реализаций не все ЯОП могут решать задачи друг друга эффективно. Интерпретируемый язык никак не может быть также быстр в исполнении, как статически компилируемый, но может выигрывать в скорости кодирования.
В итоге приходим к выводу, что язык программирования, даже общего назначения, - это просто инструмент, который умеет выполнять задачи эффективно только из своей области.
А в чем проблема?
Проблемы начинаются тогда, когда мы обнаружим, что используемый язык программирования по какой-то причине не умеет решать специфическую задачу своей области эффективно. Например C# не всегда бывает лаконичен: иногда не хватает функциональной парадигмы или нет возможностей сократить лишний синтаксис при композиции. В таких случаях придется смириться и реализовывать функционал через костыли, например, создавая лишние интерфейсы, нужные чтобы обойти запреты языка.
Python не станет быстрым, C++ не будет хорошо подходить для написания скриптов, а Fortran не получит современный синтаксический сахар.
Если вы программировали на C# и Unity, возможно, вы писали какие-нибудь математические функции или методы-расширения. Я с разочарованием для себя обнаружил, что Unity использует C# 9-той версии, в которой нет обобщенной арифметики (ее введут в C# 11). Скорее всего, красиво и лаконично реализовать какой-нибудь метод SquareMagnitude не получится: придется писать одну и ту же реализацию для каждого типа по отдельности (дженерики не помогут: нет обобщающего интерфейса).
double SquareMagnitude(double a, double b) => a * a + b * b;
float SquareMagnitude(float a, float b) => a * a + b * b;
long SquareMagnitude(long a, long b) => a * a + b * b;
int SquareMagnitude(int a, int b) => a * a + b * b;
short SquareMagnitude(short a, short b) => a * a + b * b;
...
Помимо этих реализаций, было бы неплохо иметь реализацию с типами: byte, sbyte, ushort, uint, ulong, decimal. Хорошо, что метод SquareMagnitude достаточно маленький, но если бы это был большой метод, код стал бы еще хуже. Принцип DRY (don't repeat yourself) плачет в сторонке.
Extensible Programming
Решением видится расширяемое программирование (Extensible Programming) - это парадигма программирования, когда расширяемость возводится в абсолют. Возможности языка не фиксированы, а могут быть расширены. Программист пишет не только расширяемые модульные программы, но и программирует на расширяемом языке. Программист имеет возможность изменять синтаксис языка, исполняемый движок (компилятор/интерпретатор/JIT'а и т.п.), среду исполнения (environment) - все то, что мы называем Toolchain'ом языка. Эта парадигма позволяет кастомизировать язык программирования, добавлять eDSL (embedded DSL, встроенные в язык. Как пример: LINQ в C#). Зачастую кастомизация даже представляется как одно из основных средств программирования. Например, на расширяемом языке есть возможность добавлять новые синтаксические конструкции, изменять AST, байткод, контролировать процесс компиляции, изменять среду исполнения и т.п. Самые известные из расширяемых языков: Ruby, Lisp, Lua.
Предлагаю посмотреть эту статью на хабре, в которой демонстрируется пример написания простенького генератора кода для элегантных лямбд на Lua. Это пример расширяемого синтаксиса языка. Ruby тоже имеет расширяемый синтаксис, например для него написана библиотека superators19, позволяющая добавлять новые операторы в язык.
Конечно, расширяемое программирование нужно не всем, но эта парадигма - мощный инструмент для сложных систем, которым важны какие-то аспекты языка, которые не идут из коробки. Не всегда в языке не хватает каких-нибудь мелочей по типу лаконичности синтаксиса, но и таких важных механизмов, как верифицируемости корректности исполнения, формального распараллеливания и т.п. Используя расширения, можно добавлять такие механизмы, которые не были реализованы ранее или улучшать уже имеющиеся имплементации. Подобные фичи - достаточно сложные и реализовать их на уровне, например, препроцессора, невозможно. Приходится использовать более глубокое внедрение в используемый язык.
Моя модель идеального языка программирования
Идея заключается в возможности построить микро-язык на модульной архитектуре, который будет содержать в себе ядро и несколько дополнительных модулей для его комфортного расширения. Сам микро-язык будет не очень полезен, но на его основе можно будет сделать диалекты, которые уже можно будет считать полноценными языками общего назначения. При надобности, программист сможет отключать, модифицировать или добавлять модули в язык. Так язык превратится в набор модулей, буквально конструктор. Чтобы внести изменения в язык, будет достаточно понимать его работу в целом, а также понимать работу только тех модулей, с которыми предстоит работать.
Например, представим что JavaScript сделан по данной идее, и хочется добавить в язык рантайм-проверку типов (на минуту забудем про существование TypeScript). Можно было бы написать модуль, который добавляет немного нового синтаксиса в объявление переменных, обрабатывает AST (т.е. использует модуль парсера) и добавляет в каждое использование переменной/поля/т.п. проверку на ее тип.
function sum(a : "number", b : "number") {
return a + b;
}
// код выше превратится в:
function sum(a : "number", b : "number") {
assert(typeof a == "number");
assert(typeof b == "number");
return a + b;
}
Псевдо-код реализации модуля мог бы выглядеть как-то так:
// превратим [a, :, "number"] в [a [:, "number"]]
// то есть сделаем последние две ноды дочерними для индентификатора
Modules<IParser>.AddChildrenPattern([Identifier, Colon, StringLiteral], 0)
// теперь найдем использования переменных и добавим проверки
Modules<Parser>.OnProstprocessAst += visit
Action<Node> visit = null
Map<string, string> variableTypes = []
// рекурсивная функция обхода дерева и обработки ноды
visit = node => {
foreach child in node.Children:
if child.Type == Variable:
// если встретили переменную, у которой в дочерних нодах есть двоеточие
if child.Children[0].Type == Colon:
// сопоставляем названию_переменнной -- ее_тип
variableTypes[child.Text] = child.Children[1].Text
if child.Children.Length == 0 and child.Text in variableTypes:
// удаляем переменную из словаря, если тип не указан
variableTypes.Remove(child.Text)
if child.Text in variableTypes:
// вставляем проверку на тип, при использовании переменной
condition = f'assert(typeof {child.Text} == "{variableTypes[child.Text]}")'
node.Children.InsertBefore(child, Node(condition))
}
Абстрактные детали реализации
Итак, какие задачи языка мы имеем на данный момент? ЯП должен:
иметь возможность расширять свои frontend, middle-end, backend
иметь простой API для расширения
поддерживать большинство парадигм (функциональное, декларативное программирование)
Сначала разберемся с первыми двумя задачами. Как уже было сказано ранее, архитектура языка будет модульной. Значит нужно просто для каждого модуля предусмотреть возможность интеграции с другими модулями. При этом желательно предоставить интуитивный API для доступа к различным частям модуля. Кроме того, каждому модулю не плохо иметь свое краткое описание работы, ведь для написания своих модулей придется иметь понимание всех модулей, которые должны использоваться.
Рассмотрим пример с созданием простейшего фронтенда. Простой фронтенд может быть разделен на небольших модуля: лексер и парсер. Допустим, программист захотел добавить препроцессор перед лексером. Какой удобный API можно предоставить? По моему мнению, хорошо подходят события. Пусть лексер и парсер имеют события OnPreprocess и OnPostprocess. Таким образом, например, программист будет иметь возможность обработки последовательности лексем уже после ее генерации. Также программист сможет изменять AST уже после его генерации.
Но как добавлять новые правила построения дерева или лексемизации текста? Можно создать методы по типу AddLexerRule, AddParserRule, которые будут принимать в себя экземпляры классов, имплементирующих интерфейсы ILexerNodeBuilder и IParserNodeBuilder. Но стоит учесть, что в большинстве языков грамматика не самая простая, а значит, что парсер желательно делать по мощности LR(k), LALR(k) или ALL(*). Возможно стоит сделать небольшой DSL для этих модулей, так как это сильно упростит поддержку языка в дальнейшем (или использовать уже готовый, такой как ANTLR). DSL поможет программистам не погружаться в технические подробности реализации и достаточно просто добавлять изменения в грамматику.
Но как же коллизии имен? Конечно, они будут возникать гораздо чаще, поэтому стоит предусмотреть некое подобие namespace'ов. Например, если в начале файла написано use parser.math
, то парсер активирует дополнение к текущей грамматике, добавляющее математические операторы.
Модули
Что, если язык окажется сложным? Например, парсер будет очень тяжелым, язык будет использовать супероптимизаторы или просто модуль окажется проприетарным. В таком случае желательно иметь возможность разбросать модули по разным физическим местам. Например, чтобы разместить супероптимизатор в облаке, парсер поставить на компьютер с бОльшим количеством оперативной памяти, а доступ к проприетарному модулю давать только через web-request'ы (чтобы нельзя было получить исходный код).
Хорошим решением оказывается представлять модули как web-сервисы. Это делает модули гораздо более гибкими. Плюсы модулей как веб-сервисов:
можно расположить в разных физических местах
IDE будет способна контролировать все процессы языка - особенно удобно, если нужно глубокое понимание языка, например просмотр промежуточных представлений
при возникновении потребности можно легко использовать утиную типизацию над модулями
можно использовать вообще любые языки программирования и технологии
модули гораздо проще воспринимать как логически разделенные части - повышается гибкость системы и уменьшается ее связность
Буквально получается язык программирования на микросервисах. Возможно, сначала покажется, что такой подход сильно замедлит процесс компиляции, но, скорее всего, это скажется достаточно незначительно. На моем слабеньком ноутбуке запросы к локальным веб-серверам почти никогда не превышали 10мс, а значит чтобы добиться задержки хотя бы в половину секунды, придется сделать 50 запросов. Стоит учитывать, что общение между модулями происходит не очень часто, поэтому, думаю, беспокоиться о сильном замедлении не стоит.
Стоит заметить, что я предлагаю использовать модули как веб-сервисы исключительно на этапе компиляции. Конечно, модули, которые используются в рантайме должны быть сделаны эффективно, чтобы не создавать серьезного замедления исполнения.
Парадигмы
Язык программирования должен поддерживать большинство существующих парадигм. Но основными являются императивное и функциональное программирование. С возможностью написания модулей, из них строятся все остальные парадигмы.
Рассмотрим поддержку функционального программирования. Как известно, сердце функциональной парадигмы - это лямбда-исчисления. Думаю, что как ядро языка - отлично подойдут чистые нетипизированные лямбда-исчисления. Уже с помощью модулей можно дополнять λ-исчисления до system F, λ-исчислений с зависимыми типами или других исчислений. То есть достраивать ядро до нужной мощности и уже на имеющейся основе строить язык. Также фишка такого подхода в том, что, используя синтаксический сахар и монады, в язык можно добавить лаконичное императивное программирование. Но стоит быть аккуратным с чистыми функциями. Нужно учесть, что функция не будет чистой, если внутри себя она использует платформозависимые функции или вызывает внешние нечистые функции.
Но язык, ядром которого будут лямбда-исчисления, скорее всего, не будет иметь возможности быть оптимально скомпилирован под популярные архитектуры (x86-64, ARM, RISC-V). Для языков, которым важна производительность, размер программы, потребление памяти и т.п., предлагаю ввести иное ядро. Это ядро должно быть универсальным для любого императивного языка программирования, а также должно иметь возможность глубоких оптимизаций.
У ядра на лямбда-исчислениях есть еще некоторые проблемы: я не придумал, как устранить зависимости между модулями в такой реализации, поэтому, возможно, будет хорошо эмулировать работу исчислений на байткоде императивного языка. Будет написан еще один модуль, превращающий промежуточное представление лямбда-исчислений в байткод (не скупимся на количество IR'ов ради гибкости и удобства).
Байткод императивного языка
Итак, ядро императивного языка программирования, конечно, будет представлять собой байткод. Сначала я думал использовать какую-нибудь видоизмененную машину Тьюринга, но решил, что она слишком сильно оторвана от текущих процессоров и нет смысла даже пытаться адаптировать ее под современные системы. Самая сложная задача проектирования байткода - он должен подходить под любой императивный язык, но при этом иметь возможность оптимизировать выходной код настолько, насколько это возможно. Сразу же хочется добавить поддержку LLVM для статической компиляции, а также компиляцию в dotnet bytecode, java bytecode для поддержки JIT компиляции. Если тщательно формализовать байткод, то даже есть надежда написать супероптимизатор на middle-end уровне.
Сделать один универсальный байткод, конечно, невозможно. Снова прибегнем к модульной архитектуре. Пусть инструкция байткода представляет собой такой тип: (Type, ([], [], []))
. То есть кортеж из типа байткодовой инструкции и трех списков. Изначально байткод будет иметь только один тип инструкции: call. Массивы содержат только делегаты на функции (указатель + сигнатура + доп. информация). Байткод буквально представляет собой шитый код (threaded code) - массив вызовов функций (но не обязательно чистых). Массивы различаются лишь порядком применения операций. В массив [0] добавляются делегаты, которые должны быть исполнены перед делегатами массивов [1] и [2]. Грубо говоря получаем делегаты, которые обязательно должны быть исполнены перед или после исполнения других делегатов. Зачем это нужно, рассмотрим позже. Стоит отметить, что вместо таких массивов подошел бы также multiset<(float, delegate)> делегатов (отсортированный список по приоритету вызовов).
Существует несколько видов байткода: стековый, регистровый и в SSA форме. Для упрощения будем использовать стековый байткод, так как он легко переводится в регистровую и SSA форму. Да и сам по себе, гораздо более абстрактный. Регистровый байткод имеет привязку к количеству регистров, а байткод должен не зависеть от архитектурных особенностей. SSA форма - удобный вариант, но он более сложный. При разработке модулей он может сильно помешать, так как придется "жанглировать" передачами регистров, строить CFG.
Продолжение байткода
Представим ситуацию. Мы уже имеем минималистичный язык программирования: уже существуют локальные переменные, метки, вызовы внешних функций и другой примитивный функционал. Сейчас типизация динамическая, но хочется внедрить рантайм проверки типов, как в примере с JavaScript. Реализация примерно такая же: добавляем новое правило в лексер, обходим узлы AST с использованием переменной, внедряем assert как ноду перед использованием переменной. Но вдруг программист обнаруживает, что ему не хватает структур в языке. Обычных базовых структур, которые просто хранят значения, в которых нет даже методов (их можно добавить, но без методов проще). Итак, программист написал модуль с поддержкой подобного синтаксиса:
struct Vector2(x, y)
vec = Vector2(2, 3)
vec.x = vec.y * 2
print(vec)
который превращается в подобный байткод:
// vec = Vector2(2, 3)
(call, [PushInt2], [], []) // push 2
(call, [PushInt3], [], []) // push 3
(call, [PushStrVector2], [], []) // push "Vector2"
(call, [CallFunction], [], []) // call Vector2
(call, [PushStrVec], [], []) // push "vec"
(call, [SetLocal], [], []) // setlocal vec
// vec.x = vec.y * 2
(call, [LoadLocal], [], []) // loadlocal vec
(call, [PushStrY], [], []) // push "y"
(call, [LoadField], [], []) // call LoadField vec "y"
(call, [PushInt2], [], []) // push 2
(call, [Mul], [], []) // vec.y * 2
(call, [LoadLocal], [], []) // loadlocal vec
(call, [PushStrX], [], []) // push "x"
(call, [SetField], [], []) // call SetField vec "x"
// print(vec)
(call, [LoadLocal], [], []) // loadlocal vec
(call, [Print], [], []) // print vec
Главная проблема в том, что ни на этапе AST, ни на этапе байткода инструкции LoadField и SetField не получается распознать, как использование переменной. Конечно, на уровне AST невозможно распознать такую конструкцию, так как создаются новые типы нод. А вот с байткодом есть возможность подружиться, если продумать типы инструкций. Можно сказать, что тип инструкции байткода - это ее тег. Первая мысль - делать строгие инструкции, например SetLocal, LoadGlobal, LoadMethodReference и другие. Но проблема таких типов в том, что они слишком конкретны.
Постараемся развить абстракции. Оставим только инструкции Set и Get, которые будут оперировать интерфейсами (точнее классами/структурами, имплементирующими их) ISettable и IGettable. Чтобы вызвать эти методы, нужны экземпляры классов. Для этого перед каждой установкой или чтением значения будем класть на стек ссылку-обертку на значение. Ссылка, в данном контексте, это не всегда указатель на ячейку памяти. Это абстрактный указатель на само значение. Класс, имплементирующий ISettable и IGettable. Ссылка может быть регистром, реальным указателем или быть ячейкой словаря (map'ы). Рассмотрим пример:
x = 2
vec.x = 3
/// --- --- ///
// x = 2
(LoadRef, [LoadReferenceX], [], []) // &x
(LoadReadonlyRef, [PushInt2], [], []) // 2
(Set, [SetLocal], [], []) // *(&x) = 2
/// --- --- ///
void SetLocal<T>(ISettable<T> settableValue, T valueToSet) {
settableValue.Set(valueToSet);
}
Как видно, логика написана достаточно абстрактно, нет никакой конкретики. Как работает ISettable<T> - неясно, да и не нужно. Наша задача - написать относительно простой, максимально обобщенный код.
Инструкции байткода также могут возвращать значения. Первая мысль реализации: как и в обычных функциях, возвращаем значение через return. Но в байткоде встречаются такие инструкции, как swap. Они принимают два значения и возвращают два значения. Поэтому, для удобства, стоит возвращать значения через аргументы-ссылки, предназначенные только для записи (в C# - ключевое слово out). Например:
void Swap<T>(T a, T b, out T result1, out T result2) {
(result1, result2) = (b, a);
}
Возвращаясь к проблеме типизации и структур, теперь модуль типизации может вставлять проверки перед каждой инструкций типа Set/Get, а модуль структур будет генерировать код установки и получения значений именно через Set и Get. Тут нам и пригодятся три списка делегатов в инструкции. Проверка типов обязана исполнятся перед тем, как основная инструкция будет выполнена, поэтому проверку стоит поместить в нулевой список делегатов. Также плюсом будет то, что модули работают на уровне байткода, а не AST. Это позволит без боли менять фронтенд языка и не переписывать большинство модулей.
Теперь, понимая смысл трех списков, можем дать им названия: pre-ops, main-ops, post-ops - делегаты, исполняемые, соответственно, до, во время и после совершения основной операции.
В результате реализации байткода, придерживаясь таких идей, удастся убрать зависимость между модулями. Модуль типизации может ничего не знать о модуле структур. Аналогично модуль сборщика мусора ничего не будет знать о модуле синтаксического сахара.
Перфоманс байткода
Скорее всего, сложилось ощущение, что при компиляции такого байткода получится очень неэффективный ассемблерный код. В принципе, так и есть. При компиляции без некоторых оптимизаций итоговый код не уберет абстракции, не уберет виртуальные вызовы методов и в принципе не заметит некоторые паттерны, которые можно было бы упростить. Поэтому, если временем компиляции можно немного пожертвовать в пользу перфоманса, нужно в первую очередь (то есть перед всеми остальными оптимизациями) инлайнить все методы шитого кода. Уже после инлайнинга этих методов производить оптимизации. Снова рассмотрим пример:
x = 2
vec.x = 3
/// --- --- --- \\\
(LoadRef, [LoadReferenceX], [], [])
(LoadReadonlyRef, [PushInt2], [], [])
(Set, [SetLocal], [], [])
/// --- --- --- \\\
void LoadReferenceX(out VariableWrapper<int> result) {
result = new VariableWrapper<int>(0);
}
void PushInt2(out int result) {
result = 2;
}
void SetLocal<T>(ISettable<T> settableValue, T valueToSet) {
settableValue.Set(valueToSet);
}
class VariableWrapper<T>(int variableId) : IGettable<T>, ISettable<T> {
private static readonly Dictionary<int, T> _variables = [];
public T Get() => _variables[variableId];
public void Set(T value) => _variables[variableId] = value;
}
interface IGettable<T> {
public T Get();
}
interface ISettable<T> {
public void Set(T value);
}
/// --- --- --- \\\
LoadReferenceX(out var a);
PushInt2(out var b);
SetLocal(a, b);
/// --- --- --- \\\
var a = new VariableWrapper<int>(0);
var b = 2;
a.Set(b);
// тоже самое, что и
// new VariableWrapper<int>(0).Set(2);
/// --- --- --- \\\
VariableWrapper<int>._variables[0] = 2;
/// --- --- --- \\\
// возможный ассемблер, при нужных оптимизаторах
mov rax, 2
Как видно, при достаточно глубоком инлайнинге (2-3 шага) можно сильно упростить код. Уже после инлайнинга, оптимизатор паттернов может сильно упростить некоторые под-коды. Например можно научить оптимизатор распознавать обращение к классу VariableWrapper<T>
и заменять обращения на их быстрые аналоги (как было показано в примере, переменная была размещена в регистре rax, а не в словаре). Аналогично можно применить и другие оптимизации. А те инструкции, для которых оптимизатор не смог сгенерировать оптимизированный код, будут исполняться как обычно, в лоб. Если написано обращение к словарю, то просто вызываем переданную функцию. Такой подход позволяет писать как высокопроизводительные коды, благодаря оптимизаторам, так и универсальные, если оптимизаторы, например, не умеют компилировать код под какую-то платформу.
Конечно, качественная компиляция такого байткода - долгий процесс. Поэтому можно контролировать уровни оптимизации. Отключив инлайнинг, свертку констант и т.п., можно добиться достаточно глупой, но быстрой компиляции. Код просто будет представлять собой вызовы функций. И наоборот, задав глубину инлайнинга, включив свертку констант, аллоцируя структуры в регистрах или на стеке, векторизуя обработку данных, можно достичь высокой скорости исполнения.
Обсуждаемый байткод легко перевести в байткод виртуальной машины dotnet или JVM. Также не сложно перевести в LLVM IR, чтобы уже этот фреймворк применял сильные оптимизации.
Рассматриваем узкие места
Дебаггинг
Возможно отладка станет проблемой при фиксе ошибок компиляции байткода. Не плохой идеей кажется отслеживание, какие инструкции байткода были сгенерированы какими модулями, чтобы потом искать ошибку именно в них. Реализовать это можно достаточно просто через инкапсуляцию логики в классе коллекции байткода. Аналогичное замечание относится и к AST, ассемблерному листингу и другим промежуточным представлениям. Также хорошо добавить логирование почти каждого действия при изменении промежуточных представлений. Для совершенствования процесса отладки можно написать простенький интерпретатор этого байткода.
Зависимости между модулями
В худшем случае, количество зависимостей будет O(n^2), где n - количество модулей. Байткод специально был спроектирован так, чтобы зависимость между модулями была минимальной. Но даже с этим условием все еще стоит стараться делать модули абстрактными и подумать заранее о написании других модулях, которые смогут расширять его исходные возможности. Когда будет написан модуль с типизацией, нужно подумать, как улучшить имеющиеся абстракции для потенциальных будущих модулей с ее улучшением (вспомним лямбда-куб).
Общее API модулей
Также мне нравится идея с веб-сервисами тем, что модулям придется общаться через json'ы. Это сразу заставит продумать API создаваемого модуля. Мучаться с бинарной совместимостью не придется, так как обмен информацией достаточно высокоуровневый и многие проблемы решают такие протоколы как, например, http.
Заключение
Не думаю, что когда-нибудь будет создан один универсальный язык программирования, который подойдет для решения всех задач. Но, скорее всего, именно удобное окружение и продуманная архитектура поможет создавать языки как конструктор для нужных задач. Нельзя допустить роста множества DSL'ей, где один язык решает одну проблему, чтобы не пришлось каждый день учить новый инструмент, но база модулей помогла бы иметь гораздо более полезный эффект. Не хватает высокоуровневого программирования в системном языке? Подключите модуль с zero-cost абстракциями. Не хватает функционального программирования в ООП языке? Подключите модуль с синтаксическим сахаром, оператором |>, ленивыми вычислениями и unified calls.
Комментарии (13)
Apoheliy
29.08.2025 14:59Если кратко, то суть статьи можно выразить фразой:
Шуруп, забитый молотком, держит крепче, чем гвоздь, завёрнутый отвёрткой.
Но что-то мне подсказывает, что лучше или гвоздь забить молотком, или (наверное ещё лучше) шуруп завернуть отвёрткой.
---
Если переходить к деталям, то в семействе компиляторов gcc есть возможность подключать плагины, которые могут втащить новые свойства, добавить ключевые слова (атрибуты и т.д.). Т.е. с идеей "давайте добавим" автор слегка опоздал.
НО, по-моему, этим всем очень редко пользуются, т.к. это может быть сродни изучению нового языка.
Причём такого нового "языка", на который нет ни стандарта, ни внятной документации. Этот "язык" неизвестен никому, кроме пары-тройки человек. И конструкции этого "языка" могут поменяться одним днём. ОДНИМ ДНЁМ, КАРЛ! Кто захочет учить и использовать такой язык для продакшена? Вот для развлечения или хобби (или курсовой работы) - самое оно.
Причём проблема изменений языка - она очень тяжёлая. Например, если вспомнить довольно популярный язык программирования Python, то V2 и V3 это уже разница.
Поэтому, может следовать по принципу "мамы всякие нужны, мамы всякие важны"?: есть разные языки - пусть и будут. Да и новые появляются.
Lewigh
29.08.2025 14:59Главный вопрос - а зачем? Чтобы у группы не взаимосвязанных языков был похожий синтаксис. Ну так синтаксис вторичен, главное как раз семантика, которая будет отличаться. Причем отличается не от языка к языку а внутри любой комбинации любого варианта этого языка. По итогу получите бесконечное количество языков каждый со своими правилами но со сходными синтаксисом что есть проблема. И какой в этом смысл? Приходить в компанию и каждый раз учить новую вариацию одного и того же языка?
gybson_63
29.08.2025 14:59ЯП уже есть, какие есть. Какие выжили, такие и нужны. Не получится никакой генной инженерии и игры в Бога, только жестокая природа.
AuToMaton
29.08.2025 14:59Выше уже написали - Вы изобретаете Racket. Наверно, комментирующему казалось, что после этого Вы бегом поскачете разбираться что Racket такое. А я так не думаю, поэтому напишу что известно про Racket.
Идея самоизменяемого языка, она же идея DSL (Domain Specific Language) хороша до тех пор, пока над проектом не начинает работать группа людей. Когнитивная нагрузка от изучения языка и чтения элегантного текста на нём, о написании даже не говорим, оказывается выше, чем от чтения корявого текста на общеизвестном языке. Отсюда понятно, почему Racket и DSL вообще - нишевые решения.
Универсального языка на все случаи жизни нет и быть не может не только потому, что есть группа (не)товарищей стремящаяся наплодить языков поболе согласно максиме о разделении и властвовании, но и потому, что случаи жизни сильно разные. Когнитивная нагрузка от игнорирования ненужных особенностей языка, каковая неизбежно возникает в языке, универсальном не через построение DSL, оказывается выше, чем от необходимости при переходе к новым задачам изучать новый язык, и выше, чем от необходимости приспосабливать уже изученный язык к той задаче, для которой он подходит не зидеально.
Так оказалось давно и многократно - другой Природы у нас для Вас нет, об идее забываем. Кстати, практическая возможность решить задачу на каком-либо языке, кроме вырожденных случаев типа написания ОС реального времени для встраиваемой системы, определяется и не задачей, и не языком, а наличием подходящих библиотек. Поэтому на практике JavaScript и Python - языки поуниверсальнее всех остальных.
OlegZH
29.08.2025 14:59... практическая возможность решить задачу на каком-либо языке, кроме вырожденных случаев типа написания ОС реального времени для встраиваемой системы, определяется и не задачей, и не языком, а наличием подходящих библиотек.
Вы пропускаете, как мне кажется, очень важный аспект. Да, традиционный подход к созданию приложений основывается на библиотеках (содержащих функции и классы). Но язык программирования, как таковой, это операторы языка, это его синтаксис. Перекладывание ответственности на библиотеки приводит к тому, что все заняты написанием библиотек (на все случаи жизни), а сам язык развивается мало. Каждый язык характеризуется своими изобразительными средствами. Когда мы изучаем Python, то мы сталкиваемся с генераторами и итераторами и тем, как за кулисами всех процессов стоят замыкания и ООП. И это всё описывается вполне определённым синтаксисом. Однако, если мы хотим решать какие-то специфические задачи, то у нас должен быть и подходящий синтаксис. Из-за того, что все пишут библиотеки, множество языков программирования становятся мало отличимыми друг от друга универсальными монструозными конструкциями. Кому ,что ближе, тот на чём и пишет. Ну и, если библиотека подходящая есть. Но! Вы можете представить себе язык программирования, где нет числовых вычислений? А язык, предназначенный исключительно для описания компонентов? В этом смысле, управляемость синтаксиса (возможность изменять его под конкретную задачу) может оказаться хорошим подспорьем. Было бы. например, более понятно, если бы можно было бы ввести в язык оператор SORT, который по своему смыслу должен приводить к сортировке некоторого списка (массива). Подход, основанный на библиотеках заставляет нас выбирать конкретную реализацию алгоритма сортировки (то есть — конкретный алгоритм). А нам важнее указать только то, что определённый массив должен быть отсортирован. Выбор конкретного метода может зависеть от контекста. Более того, можно представить себе, что ОС, имея описание алгоритма обработки на некоем универсальном=обобщённом языке программирования, предоставит пользователю план выполнения этого обобщённого алгоритма, подставляя для каждого такого оператора свою реализацию. И всё это вместо плясок с шаблонами в разного рода языках программирования.
И ещё. В теория программирования описывается способ разработки ПО, когда сама программа, сначала проектируется на одном языке программирования, а реализуется, фактически, на другом языке программирования. Думаю, очень важно осознать тот факт, что многие проблемы проектирования и реализации ПО проистекают из-за того, что всё делается в рамках единственного языка программирования, когда в одном пространстве имён располагаются и классы исходных вычислительных объектов, и и классы компонентов (как визуальных, так и невизуальных), так и классы конечной реализации, относящиеся к предметной области. Всё-таки, правильное программирование основано на чётком разделении всех задач и применении, по сути, трёх различных языков программирования.
Yuvitch
29.08.2025 14:59Не-не, тут вопрос философский: почему до сих пор нет универсального языка программирования, потому что у каждого человека модель мира (всего сущего) в голове своя -- нет общей модели мира, известной каждому. Поэтому каждый автор языка строит его в соответствии со своим внутренним пониманием модели. Получаются языки каждый на свой манер. С другой стороны, космонавтика до недавнего времени не была частью модели мира. Сначала создали спутниковую космонавтику, и она стала частью модели мира. Потом создали пилотируемую космонавтику, потом станционную и т.д. Вывод: чем более универсальный язык будет создаваться, тем более полную модель мира можно описать с его помощью. Это процесс итерационный. Где-то в будущем они должный сойтись. Как говорилось в одном советском фильме: поживем -- увидим.
morulus
29.08.2025 14:59Один язык с известной, фиксированной структурой и синтаксисом порой приходится годами доводить до стабильного состояния. Подозреваю, что язык, в который каждый кому не лень будет закидывать новых возможностей, будет настолько полон багов, что окажется просто непригоден для продакшена.
MasterMentor
29.08.2025 14:59Самое интересное, что самое интересное начинается с первых строк статьи:
Языки можно поделить на две большие группы: предметно-ориентированные языки (aka DSL) и языки общего назначения (ЯОП). DSLи точно можно назвать инструментами, так как они по определению решают конкретную задачу. С языками общего назначения все несколько сложнее. ЯОПы имеют...
Если ввести в поисковик: https://yandex.ru/search/?text=что+такое+ЯОП
то выйдут десятки страниц с определением ЯОП как:
Языково-ориентированное программирование (ЯОП) — парадигма программирования, заключающаяся в разбиении процесса разработки программного обеспечения на стадии разработки предметно-ориентированных языков (DSL) и описания собственно решения задачи с их использованием.
https://ru.wikipedia.org/wiki/Языково-ориентированное_программированиеПервой будет ссылка ссылка на Википедию, второй - на эту статью на Хабре: Зачем ЯОП? Зачем Racket? https://habr.com/ru/articles/445822/
То есть ЯОПы это и есть DSL-и (по определению).
Поэтому возникают резонные вопросы:
насколько автор воообще осведомлён в вопросах, о которой он взялся писать?
что имеют в виду плюсующие статью?
Скриншот на память:
mckokos
29.08.2025 14:59Слышал на forth можно писать как угодно. Можно даже сэмулировать любой язык и даже есть forth процессоры)
sledov
Это вы Lisp изобретаете, или скорее даже Racket.
MasterMentor
Да нет, здесь "изобретение" по круче:
https://habr.com/ru/articles/942134/comments/#comment_28773432
:)