OverScript — язык программирования, написанный на C#

в 17:51, , рубрики: .net, C#, встраиваемые языки, интерпретатор, Программирование, скриптовые языки, языки программирования

Привет! Меня зовут Дмитрий, я написал на C# свой интерпретируемый язык программирования, который назвал — OverScript. Это си-подобный язык со статической типизацией. Сразу скажу, что это не прототип, а готовый проект. Весь код на 100% мой. Я подробно не интересовался, как написаны другие языки, поэтому вся реализация интерпретатора это моя чистая импровизация. Мой подход неконвенциональный, поэтому к техническим аспектам стоит относиться без ассоциаций с тем, что вы могли ранее видеть в других языках, несмотря на то, что некоторые вещи могут казаться знакомыми.

image

Самое важное:

  1. Интерпретатор является полностью независимым, т.е. не использует имеющихся в .NET средств компиляции кода. Он также не использует никаких сторонних библиотек. Это не транслятор, а хардкорный интерпретатор.
  2. OverScript — си-подобный язык с классическими принципами ООП. Он имеет статическую типизацию, поддерживает наследование, виртуальные методы, перегрузку операторов и многое другое. Моя задача была не придумывать новый синтаксис, а сделать язык, привычный для C#-программистов.
  3. Скорость работы сопоставима с Python и ClearScript (проверял на VBScript). OverScript пока чуть уступает, но ещё есть что оптимизировать. При этом он в разы быстрее других интерпретаторов, написанных на C#. Например, в 5 раз быстрее, чем MoonSharp (Lua). C IronPython не сравнивал, т.к. это транслятор компилирующего типа.
  4. OverScript, на мой взгляд, является идеальным языком для встраивания в .NET-программы. Он относительно быстрый и привычный. Да, это не V8, но более удобный язык, чем JS, за счёт, например, той же статической типизации, которая позволяет на стадии загрузки кода выявлять ошибки, связанные с несовместимостью типов.
  5. В OverScript нет проблемы с отсутствием библиотек. Он может использовать типы стандартных .NET-библиотек. Можно импортировать функции из самописных библиотек.

Для работы интерпретатора нужен .NET 6. Можно и под .NET Framework перекомпилировать с небольшими изменениями (не пробовал). Я использовал классический C# без таких нововведений как, например, индексы и диапазоны, которые появились только в версии 8.0.

Пример кода:

string s="Hello, world!"; //это переменная типа System.String
WriteLine(ToUpper(Substring(s, 0, 5))); //HELLO //вызывается базовые функции Substring и ToUpper
WriteLine(s.Substring(0, 5).ToUpper()); //HELLO //то же самое
s.Substring(0, 5).ToUpper().WriteLine(); //а можно и так
WriteLine(s->Substring(0, 5)->ToUpper()); //HELLO //через рефлекшн вызываются стандартные методы типа System.String
//WriteLine - это базовая функция-обёртка для Console.WriteLine(str) 
//В OverScript почти все базовые функции имеют имена .NET-методов, которые они используют

Из простого примера выше видно, что OverScript представляет собой слой абстракции над стандартными .NET-типами. Говоря по-простому, OverScript это программа, которая анализирует код, выстраивает из него определённую структуру из последовательностей отдельных операций, после чего рекурсивно выполняет их.

Ещё пример:

Point[] arr = new Point[]{new Point(25, 77), new Point(122, 219)}; //создание массива из двух экземпляров класса Point
int n; // имеет значение 0 по умолчанию
foreach(Point p in arr){ // перебор всех элементов массива
    n++;
    WriteLine($"{n}) {p.X}; {p.Y}"); // вывод значений с помощью интерполяции строк
}
//1) 25; 77
//2) 122; 219
ReadKey();

class Point{
    public int X, Y;
    New(int x, int y){ // конструктор
        X=x;
        Y=y;
    }
}

Ещё:

const string label = "  O  v  e  r  S  c  r  i  p  t  ";
const int a = '0', z = '9';
const int labelX = 9, labelY = 8, labelX2 = labelX+Length(label);
const object Console = typeof("System.Console, System.Console");
const object DarkGreen = "DarkGreen".ToEnum("System.ConsoleColor, System.Console");
const object Cyan = "Cyan".ToEnum("System.ConsoleColor, System.Console");
bool w, p;
int x, y;
char c;

ReadKey("Press any key to continue");
ClearConsole();
Console->ForegroundColor = Cyan;
string welcome="Welcome to OverScript!";
foreach(c in welcome){Write(c); Sleep(Rand(0, 200));}
Sleep(500);
SetCursorVisible(false);
Console->ForegroundColor = DarkGreen;
foreach(int i in Range(10000)){
    x = Rand(0, 50);
    y = Rand(0, 16);
    p = w;
    w = y == labelY && x >= labelX && x < labelX2;
    c = w ? label[x - labelX] : charRand(a, z);
    if(w != p) 
        Console->ForegroundColor = w ? Cyan : DarkGreen;
    SetCursorLeft(x);
    SetCursorTop(y);
    Write(c);
}
Sleep(5000);

Результат выполнения — GIF.

Примеры полезных приложений

  1. Парсер урлов изображений с сайта;
  2. Переводчик текстов через Yandex Translate;
  3. Игра Змейка с использованием GTK#.

Есть много разных языков...

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

  1. Обычно пишут трансляторы в Си, JS, или под LLVM-компиляторы. Чаще всего это JS-подобные языки (функции — объекты, отсутствие полноценного ООП и т.п.).
  2. Используют готовые лексеры, парсеры и прочие инструменты.
  3. Дальше прототипов дело редко идёт. Скорее всего, причина в том, что пишут по шаблону и упираются в фундаментальные ограничения, которые не знают как обойти.
  4. Пишут обычно на C/C++, а C# считается непригодным для таких задач, и проектов почти нет.
  5. Сами языки довольно минималистичны и не про ООП. Дело тут, скорее, не в моде, а в "и так пойдёт".

Тема создания языков с одной стороны популярная, но с другой — маргинальная. Есть известные языки с большими комьюнити, и в новых языках особой необходимости нет. Поэтому новыми проектами, как правило, занимаются любители со специфическим пониманием практической стороны вопроса и стремлением уйти от самостоятельной разработки ядра. Не то чтобы это было плохо, просто толку от этого мало. Почти весь материал (обсуждения, проекты), что я нашёл, по сути — переливание из пустого в порожнее. И как следствие, отношение людей к теме создания новых языков довольно скептическое. Я противник хейта, респект всем, кто пытается сделать что-то новое, но просто констатирую факт.
С новыми языками есть ещё такая проблема: одни ожидают от них привычных возможностей, а другие чего-то революционного. И, задавая вопрос "а зачем он нужен?", сразу готовы ответить "ничего не понятно" или "ничего нового". Это такая почти философская проблема прогресса вообще.
Если трезво смотреть на ситуацию с языками, то единственное, что может дать преимущество новому языку — это лучшая производительность, которая сейчас, на сколько я понимаю, упирается в фундаментальные факторы, связанные с ОС и процессорами. Интерпретаторы же про удобство, доступность и скорость разработки. Ruby, Python или Lua — дело вкуса и привычки. Для меня они непривычны из-за ярко выраженной скриптовости, которая, с одной стороны, сделала их популярными у начинающих, а с другой — обособила не в пользу широкого применения в программах со сложной логикой. OverScript — интерпретируемый язык, но, скажем так, более мейнстримный. Я не противопоставляю его другим, ведь главное в нём даже не статическая типизация, а платформа .NET.
У меня были большие сомнения, что с OverScript получится что-то более-менее приемлемое из-за мифа, что управляемый код не годится для создания интерпретаторов. Но я сразу решил, что цель-минимум — разобраться самому, как устроено программирование со стороны разработчика языка, ведь когда пишешь тривиальные программы на высокоуровневых языках, то кажется, что знаешь всё, но это как с айсбергом — видишь только верхушку. Поэтому я даже не думал о том, чтобы пойти по накатанной дорожке с использованием готовых инструментов. Сейчас уже я уверен, что всё сделал правильно, а C# показал себя отличным языком даже для такой нестандартной задачи, как написание интерпретатора.

Отдельно скажу про вообще все языки. Языков много, но большинство из них либо узкоспециализированные, либо давно устарели. Есть новые языки общего назначения вроде Go и Julia, но я пока не вижу, чтобы они пользовались большой популярностью (в рейтингах цифры весьма скромные). Могу отметить только набирающий обороты Rust, который рассматривается как альтернатива C++, и объединяет сейчас вокруг самых дотошных кодеров, которые за абстракции с нулевой стоимостью и прочие штуки, до которых большинству программистов дела нет. Но Rust сложный, поэтому популярным у широких масс не будет. Так что, думаю, основными языками для любительских и промышленных целей ещё долго будут C# и Java. Можно, конечно, и Python ещё назвать, но мнения о его практическом использовании в сложных проектах слишком полярные (собственной оценки давать не буду, т.к. не писал ничего серьёзного на нём).

Я это всё пишу, чтобы у читателя было какое-то представление о том, как я вижу ситуацию, понимаю сложности, и почему решился написать свой язык. Если подытожить, то кажется, что языков много, но на деле выбор небольшой, особенно, если говорить о языках для встраивания в .NET-программы. Конечно, можно обходиться ClearScript, но JScript/VBScript не те языки, которые можно назвать удобными, если вы привыкли к C#. То же самое касается IronRuby и IronPython. Есть много вариантов скриптинга, но все со своими подводными камнями. Это целая отдельная тема, в которой ключевое значение имеет степень интеграции, и конструктивно обсуждать её здесь из-за принципиальных различий подходов, наверное, не имеет смысла. Это также тесно связано с темой "интерпретаторы vs компиляторы", которая для меня — всё равно что сравнивать устройство электромобилей и бензиновых машин.

Под капотом OverScript

Довольно сложно простыми словами объяснить, как работает интерпретатор OverScript, но я попробую:

  1. Код подготавливается (удаляются комментарии, отступы и лишние пробелы, находятся литералы и т.п.), после чего разбивается на классы и функции.
  2. Код функций разбивается на отдельные логические строки, определяется вид инструкций (if, goto, return и т.д.).
  3. Для управляющих конструкций высчитываются переходы. Например, для if ищется, куда переходить в случае выполнения условия и куда в противном случае.
  4. Из каждой строки рекурсивно выстраивается дерево операций. В итоге имеем набор отдельных элементов: присваивание, переменная, элемент массива, литерал, функция и т.д.
  5. Далее запускается цепочка рекурсивных вычислений. Начинается она от создания главного класса приложения (в других языках обычно вызывается main). Самым первым вызывается метод Instance(), потом конструктор New().

Итого имеем следующие ключевые элементы: класс, функция, логические строки, единицы вычисления (эвал-юниты).

У тех, кто уже изучал устройство других интерпретаторов, наверняка возникнет много вопросов, почему я сделал что-то именно так, а не иначе. Но я не смотрел, как сделаны другие языки, поэтому ожидать какого-то соответствия принятым в них нормам не стоит. Я писал, придумывая на ходу. Возможно, это не лучший подход, но копирование других проектов мне не интересно. К тому же, даже частичное копирование не имеет смысла, если вы не хотите ограничиваться функционалом источника. Как показывает опыт, в какой-то момент придётся всё переписывать с нуля, чтобы иметь возможность добавлять свои нестандартные фичи, которые выходят за рамки заложенного потенциала. И я уж не говорю про то, что разобраться в чужом коде, обвешенном всевозможными молдингами, бывает сложнее, чем написать свой.
Подробно расписывать здесь технические детали не вижу смысла. Скажу только про то, что у каждой операции есть свой тип результата, и все вычисления происходят в обобщённых методах и классах с этим типом. Это про внутренние алгоритмы интерпретатора, а не про сам язык. Каждый раз на точках входа в обобщённые части приходится делать switch по типам, либо вызывать делегаты, что значительно снижает быстродействие. Но пока я не придумал, как обойтись без этого. Я перепробовал много решений (абстрактные классы, интерфейсы), но по сути всегда получаются непрямые вызовы методов, которые снижают быстродействие. Та архитектура, на которой я остановился, на первый взгляд, может показаться неоптимальной, но я пришёл к ней по результатам многих тестов.
И ещё важный момент: в интерпретаторе не используется unsafe-код.

О типах

С простыми типами (int, string, bool и т.д.) всё как в C#. Но, чтобы не было путаницы, в OverScript тип Single называется float (есть функция ToFloat(), а не ToSingle()). Ещё DateTime я назвал date. Нет sbyte, ushort, uint, ulong.
Теперь давайте посмотрим на оператор typeof:

WriteLine(typeof(float)); //System.Single //тут всё понятно
WriteLine(typeof("System.Drawing.Point, System.Drawing")); //System.Drawing.Point //а это, наверное, выглядит странно  

OverScript ничего не знает о .NET-типе System.Drawing.Point. Если написать typeof(System.Drawing.Point), то интерпретатор будет искать ваш собственный класс, а не тип в библиотеке .NET.
Работа с типами, которых нет в OverScript, возможна через рефлекшн:

object Point = typeof("System.Drawing.Point, System.Drawing");
object point = Create(Point, 150, 225); //можно так: Point.Create(150, 225)
WriteLine(point->X + "; " + point->Y); //150; 225

В этом примере, для создания объекта типа Point, используется базовая функция Create, которой передаются тип и аргументы для конструктора (150 и 225). Стрелка (->) в данном случае — это вызов базовой функции GetMemberValue, которая через рефлекшн получает значение свойства/поля. При загрузке кода последняя строка превращается в:

WriteLine(@GetMemberValue(point,"X")+"; "+@GetMemberValue(point,"Y")); 

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

Получаем курсы валют

Теперь разберём более сложный пример, в котором с сайта www.cbr-xml-daily.ru загружаются курсы валют в формате JSON, данные десериализируются и выводятся построчно:

object JsonDocument=typeof("System.Text.Json.JsonDocument, System.Text.Json"); //получаем тип JsonDocument, который будем использовать для парсинга JSON данных
WriteLine("Загрузка курсов валют..."); //выводим строку, что начинается загрузка данных с сайта
string json=Fetch("https://www.cbr-xml-daily.ru/daily_json.js"); //get-запросом получаем ответ сервера с курсами валют в JSON формате
int i=json.IndexOf("rnrn"); //ищем два переноса строки, чтобы удалить http-заголовки. json.IndexOf("rnrn") - это вызов базовой функции IndexOf(json, "rnrn")
if(i<0 || json.IndexOf(" 200 OK")<0){ //если переносы не найдены, либо в http-ответе нет кода 200 OK
    WriteLine("Не удалось загрузить данные!"); //выводим сообщение об ошибке
    ReadKey("Нажмите любую клавишу для выхода"); //выводим текст, и начитается ожидание нажатия  (а ReadKey() в C# не умеет выводить сообщение)
    return; //после нажатия любой клавиши произойдёт завершение работы программы
}
json=json.Substring(i+4); //из http-ответа берём только тело, без заголовка. json.Substring(i+4) - это Substring(json, i+4).
//далее нужно прочитать данные из JSON
object defaultJsonDocumentOptions=Create("System.Text.Json.JsonDocumentOptions, System.Text.Json"); //создаём объект JsonDocumentOptions, который дальше нужно будет передать методу Parse 
object docRoot=JsonDocument->Parse(json, defaultJsonDocumentOptions)->RootElement; //сначала статическим методом Parse класса JsonDocument получаем из JSON-строки объект со структурированными данными, а потом из этого объекта получаем корневой элемент
WriteLine("Курсы валют на: "+docRoot->GetProperty("Date")); //из корневого элемента получаем дату обновления данных. GetProperty - это метод структуры System.Text.Json.JsonElement.
/*
теперь взглянем на то, как в JSON хранятся курсы валют:

"AUD": {
    "ID": "R01010",
    ...
},
"AZN": {
    "ID": "R01020A",
    ...
},
"GBP": {
    "ID": "R01035",
    ...
}...

Это перечисление объектов. Далее нужно перебирать и десериализовывать их по одному.
*/
object rates=docRoot->GetProperty("Valute")->EnumerateObject(); //получаем перечислитель объектов c данными по каждой валюте. EnumerateObject - это метод структуры System.Text.Json.JsonElement.
foreach(object item in rates){ //перебираем объекты так же, как в C#.
    object val=item->Value; //получаем объект System.Text.Json.JsonElement. Это данные о конкретной валюте. Например:
    /*
    "ID": "R01010",
    "NumCode": "036",
    "CharCode": "AUD",
    "Nominal": 1,
    "Name": "Австралийский доллар",
    "Value": 54.0507,
    "Previous": 54.137
    */
    Valute v=FromJson(val.ToString(), Valute); //десериализуем в объект типа Valute, класс которого прописан в конце программы. FromJson - встроенная функция, которой передаётся json-текст и тип, в экземпляр которого нужно его превратить. val.ToString() - это базовая ToString(val).
    //теперь у нас есть экземпляр нашего класса Valute, в котором каждой переменной присвоено соответствующее значение из JSON-структуры, и мы можем просто вывести нужные нам данные
    WriteLine($"{v.Nominal} {v.Name.ToLowerFirst()} ({v.CharCode}): {v.Value}"); // выводим через интерполяцию строк номанал валюты, её название и сколько рублей она стоит. v.Name.ToLowerFirst() - это ToLowerFirst(v.Name).
}
/*Результат:
Курсы валют на: 2021-08-07T11:30:00+03:00
1 австралийский доллар (AUD): 54,0507
1 азербайджанский манат (AZN): 43,0432
1 фунт стерлингов Соединенного королевства (GBP): 101,7683
100 армянских драмов (AMD): 14,8383
...
*/
ReadKey(); //ожидает нажатия любой клавиши, после чего программа закрывается

class Valute{ //этот класс повторяет тип данных валюты в JSON-е
    public string ID;
    public string NumCode;
    public string CharCode;
    public int Nominal;
    public string Name;
    public decimal Value;
    public decimal Previous;
}

Как видим, структурно код такой же, как в C#. Понимаю, что object-переменные выглядят непривычно, но в целом, думаю, код понятен. Можно использовать подсказки типа, которые делаются из констант типа при помощи машинописного обратного апострофа:

const object Point=typeof("System.Drawing.Point, System.Drawing");
`Point p=Point.Create(10, 20); //интерпретатор знает, что в p должен находиться объект типа Point
WriteLine(p->X); //p->X - это @TGetValue(#Int32 X#,int,p), где #Int32 X# - объект MemberInfo 

В этом примере p — это object-переменная с подсказкой, что в ней находится экземпляр System.Drawing.Point. Это позволяет ускорить обращение к членам объекта, т.к. интерпретатор будет искать члены (не значения, а MemberInfo) не во время выполнения, а один раз при загрузке кода. Также вы не сможете обычным способом присвоить такой переменной значение неподходящего типа (по ошибке).

Покажу пару фич:
1) Кроме привычного try/catch есть очень простой способ перехвата ошибок при вызове функций:

string s="test";
WriteLine(s.Substring(9)("error message")); //error message
if(exception!=null) WriteLine("Error: "+exName+" ("+exMessage+")"); //Error: ArgumentOutOfRangeException (startIndex cannot be larger than length of string. (Parameter 'startIndex'))
//exception, exName и exMessage - специальные переменные, в которые записывается информация об исключении

Значение во вторых скобках возвращается в случае, если функция выбросила исключение.
Операторы — это функции, поэтому с ними тоже так можно:

WriteLine((5/0)(123)); //123

2) Выражения можно передавать и выполнять как объекты.

Go();

Go(){
    int x, y;
    object e=Expr(x+y);
    Test(e);
}

Test(object e){
    int x=2, y=3;
    WriteLine(e.Eval(int)); //5
}

Ремонт невозможно закончить — его можно только прекратить

Начал я этот проект в середине декабря 2020-го. Более-менее рабочий прототип был готов, как мне казалось, уже через месяц. Дальше я доделывал и переделывал, и по мере добавления функционала, становилось понятно, что нужно усложнять общую архитектуру. Правка старого кода приводила к его полному переписыванию. У меня было желание выложить всё уже через 2-3 месяца, но постоянно находилось что-то, что требует обязательной доработки.
Надо сказать, что психологически довольно непросто работать долго без фидбэка, особенно если ты пишешь что-то необычное, и непонятно, будет ли это вообще кому-то интересно. Несмотря на то, что ещё много чего нужно доделывать, я решил, что пора уже выложить то, что есть, чтобы понять по отзывам, что нужно сделать/переделать в первую очередь. Я отложил некоторые решения потому, что не уверен в их уместности. Например, нужны мнения о том, какие нестандартные перегрузки операторов добавить. В Python можно "abc"*3 и получить "abcabcabc". Удобно, но вдруг программист по ошибке пытается умножить строку на число?.. Где грань между допустимыми поблажками и фичами, от которых больше вреда, чем пользы?.. Чтобы избежать холивара, предлагаю обсуждать это исключительно в контексте интерпретаторов, для производительности которых важна лаконичность кода (максимальная автоматизация).

Сейчас в OverScript есть только самое основное. Нет многомерных массивов, интерфейсов, дженериков, лямбд, struct-ов, checked/unchecked и много чего ещё. Что-то добавить легко, что-то сложно, а что-то просто не нужно, ведь OverScript простой интерпретируемый язык, и нецелесообразно добавлять в него всё, что есть в C#.
OverScript ещё тестировать и тестировать. Я постоянно нахожу новые ошибки и код, который можно улучшить. И я практически не оптимизировал загрузку кода, только выполнение. В будущем я планирую сделать кэширование загрузки скрипта, чтобы ускорить повторные запуски.

Итог

В целом, я доволен результатом, но чувства неоднозначные. Во-первых, получилось лучше, чем ожидал, но многие задуманные фичи пока не реализованы. А во-вторых, у меня сейчас что-то вроде синдрома самозванца. Мой код, мягко говоря, неидеален, местами сумбурный, есть временные решения. И кто-то может возмутиться, что вот с этим я чуть ли не на лавры великого Питона покусился. Но, как я уже писал, ниша OverScript — это .NET. Есть Iron-языки, но это вторичные решения со своими легаси-особенностями. OverScript же свободен и лёгок на подъём! И как гласит китайская поговорка (тут представляем известный мем с китайским мудрецом): Увидеть лучше, чем услышать, познать лучше, чем увидеть, сделать лучше, чем познать.

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

Если у вас есть вопросы, интересные идеи или даже коммерческие предложения — пишите на support@overscript.org.

Спасибо за внимание, и с нетерпением жду ваших комментариев. Фух… Хей, хоу, летс гоу!

Автор:
FuncBall

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js