Иногда в голову приходят идеи, которые звучат ужасно, но требуют воплощения в жизнь. Несколько месяцев назад автор этой заметки, разработчик под ником ntietz*, обсуждала с другом идею языка, в котором единственным потоком команд является обработка ошибок. Эта мысль укоренилась в сознании автора и не давала ей покоя. Она продолжала приставать ко всем с этой идеей, пока в течение одной недели пара человек случайно не подтолкнули её реализации задумки.

К сожалению, ntietz решила воплотить этот язык в жизнь — и теперь заранее просит у читателей прощения. Если вы решите перейти под кат, знайте, что делаете это на свой страх и риск.

*Обращаем ваше внимание, что позиция автора может не всегда совпадать с мнением МойОфис.


Предпосылки создания Hurl

Вот основная идея этого языка.

Знаете ли вы, что в Python иногда используют исключения для контроля потока управления? Да, да, я в курсе, что это не основное их предназначение, но фактически так и происходит. Исключения имеют много общего с операторами goto, с помощью которых можно переходить в другое место программы. Но они менее гибкие, так как позволяют двигаться только вверх по стеку.

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

Оказывается, они могут покрыть практически все.

Ядро языка

Вот основные функции языка:

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

  • Определение анонимных функций

  • Обработка исключений

Давайте рассмотрим каждую из них по отдельности, поймём, как они работают, а затем увидим, как они объединяются в более полнофункциональную систему.

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

Выглядит и работает так, как и следовало ожидать. Вы используете ключевое слово let для привязки значения к имени (никаких неинициализированных переменных, извините!). Примерно так:

let x = 10;
let name = "Nicole";

Отсюда следует наше первое интересное решение: инструкции заканчиваются точкой с запятой. Я предпочитаю точки с запятой, поскольку лично для меня они упрощают восприятие грамматики.

В остальном это очень напоминает синтаксис JavaScript или Rust. По сути, я взяла готовое решение.

Язык имеет динамическую типизацию, поэтому не требуется указывать тип каждого элемента. Это помогает уменьшить объём грамматики. Посмотрим, как это скажется на реализации интерпретатора!

Определение анонимных функций

Следующий шаг — определение анонимных функций. Это делается с помощью ключевого слова func, как в Go или Swift. Каждая функция может иметь сколько угодно аргументов.

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

func(x, y) {
  hurl x + y;
};

Ах да, забыла сказать: мы не можем возвращать значения из функций. Если вы хотите вернуть значение, то должны сделать это в рамках броска исключения, и одно из двух ключевых слов для этого — hurl.

Кроме того, от анонимных функций мало толку, если вы никогда не сможете сослаться на них и вызвать. Для обхода этой проблемы мы просто объединяем анонимные функции с привязкой локальных переменных и даём им имя. Затем вызываем их с помощью привычного синтаксиса типа f(1,2).

let add = func(x, y) {
  hurl x +  y;
};

Ещё одна важная деталь — в силу динамической типизации Hurl можно передать два целых числа, или две строки, или одно целое число и строку. Одни комбинации будут работать, а другие могут вызвать проблемы, если для этих типов не определён +! Вот пример того, что делают некоторые комбинации:

// hurls 3
add(1, 2);

// hurls "1fish"
add(1, "fish");

// hurls "me2"
add("me", 2);

// hurls "blue fish"
add("blue", " fish");

Кстати, функция не может быть рекурсивной (вызывать саму себя), поскольку при её определении мы не будем привязывать её к имени в локальном контексте. Забавно, правда?

Прекрасно. У нас есть функции. Теперь нам нужна вишенка на торте.

Обработка исключений

Во-первых, мне очень жаль. Я не должна была этого делать, но сделала, и вот мы здесь.

Обработка исключений состоит из двух этапов: генерация исключения и его перехват.

Есть два способа сгенерировать исключение:

  • Использовать hurl, который работает так, как и ожидалось: раскручивает стек по мере продвижения, пока не достигнет блока catch, соответствующего значению, или пока не исчерпает стек.

  • Использовать toss, который работает немного иначе: обходит стек, пока не найдет соответствующий блок catch, но затем с помощью ключевого слова return можно вернуться (go back) к месту, где было сгенерировано исключение.

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

Рассмотрим пару примеров с анализом состояния стека для каждого случая.

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

 1 | let thrower = func(val) {
 2 |   hurl val + 1;
 3 | };
 4 |
 5 | let middle = func(val) {
 6 |   print("middle before thrower");
 7 |   thrower(val);
 8 |   print("middle after thrower");
 9 | };
10 |
11 | let first = func(val) {
12 |   try {
13 |     middle(val);
14 |   } catch as new_val {
15 |     print("caught: " + new_val);
16 |   };
17 | };
18 |
19 | first(2);

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

(file):19:
  stack: (empty)
  calls first

first:12:
  stack: [ (first, 2) ]
  enters try block

first:13:
  stack: [ (first, 2), (<try>) ]
  calls middle

middle:6:
  stack: [ (first, 2), (<try>), (middle, 2) ]
  prints "middle before thrower"

middle:7:
  stack: [ (first, 2), (<try>), (middle, 2) ]
  calls thrower

thrower:2:
  stack: [ (first, 2), (<try>), (middle, 2), (thrower, 2) ]
  resolves val as 2, adds 1, and stores this (3) as a temp

thrower:2:
  stack: [ (first, 2), (<try>), (middle, 2), (thrower, 2) ]
  hurls 3, pops current stack frame

middle:7:
  stack: [ (first, 2), (<try>), (middle, 2) ]
  status: hurling 3
  not in a try block, pops stack frame

first:13:
  stack: [ (first, 2), (<try>) ]
  status: hurling 3
  in a try block, try block matches, jump into matching block

first:15:
  stack: [ (first, 2), (<try>), (<catch>, 3) ]
  print "caught: 3"
  pop catch and try stack frames
  pop first stack frame

file:19:
  stack: []
  execution complete

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

Также появилась ещё одна конструкция, catch as, которая позволяет перехватывать все значения и сохранять их в новой локальной переменной. Можно также использовать что-то вроде catch (true) или catch ("hello"), чтобы перехватывать только определённые значения.

А второй вариант довольно забавный. Это toss. Мы можем изменить приведённый выше пример, чтобы использовать toss и return. На этот раз я просто покажу стек с момента, когда мы доходим до toss; до этого программа выполняется аналогичным образом (с незначительными изменениями номеров строк).

 1 | let thrower = func(val) {
 2 |   toss val + 1;
 3 | };
 4 |
 5 | let middle = func(val) {
 6 |   print("middle before thrower");
 7 |   thrower(val);
 8 |   print("middle after thrower");
 9 | };
10 |
11 | let first = func(val) {
12 |   try {
13 |     middle(val);
14 |   } catch as new_val {
15 |     print("caught: " + new_val)
16 |     return;
17 |   };
18 | };
19 |
20 | first(2);

Вот укороченная трассировка, начинающаяся прямо с инструкции toss. Заметьте, теперь у нас есть индекс, который указывает текущую позицию в стеке. Он начинается с нуля, что соответствует языку, на котором я напишу интерпретатор.

thrower:2:
  stack: [ (first, 2), (<try>), (middle, 2), (thrower, 2) ]
  stack index: 3
  tosses 3 from stack index 3, decrements stack index

middle:7:
  stack: [ (first, 2), (<try>), (middle, 2), (thrower, 2) ]
  stack index: 2
  status: tossing 3 from stack index 3
  not in a try block, decrements stack index

first:13:
  stack: [ (first, 2), (<try>), (middle, 2), (thrower, 2) ]
  stack index: 1
  status: tossing 3 from stack index 3
  in a try block, try block matches, jump into matching block creating a substack

first:15:
  stack: [ (first, 2), (<try>), (middle, 2), (thrower, 2) ]
  stack index: 1
  status: tossing 3 from stack index 3
  substack: [ (<catch>, 3) ]
  print "caught: 3"

first:16:
  stack: [ (first, 2), (<try>), (middle, 2), (thrower, 2) ]
  stack index: 1
  status: tossing 3 from stack index 3
  substack: [ (<catch>, 3) ]
  returning, pop the substack, set stack index to 3

thrower:2:
  stack: [ (first, 2), (<try>), (middle, 2), (thrower, 2) ]
  stack index: 3
  finish this function, pops current stack frame

middle:8:
  stack: [ (first, 2), (<try>), (middle, 2) ]
  stack index: 2
  prints "middle after thrower"
  finish this function, pops current stack frame

first:13:
  stack: [ (first, 2), (<try>) ]
  stack index: 1
  finishes the try block, pops current stack frame
  finish this function, pops current stack frame

file:20:
  stack: []
  stack index: 0
  execution complete

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

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

Пришло время собрать всё воедино и сделать что-то «полезное».

Реализация потока управления посредством обработки исключений

Условные операторы и циклы — это основы программирования. Как их выразить в моей парадигме?

Условные операторы довольно просты, начнём с них. Мы можем просто вбросить (hurl) значение в блок try и использовать блоки catch для сравнения значений.

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

let val = 10;

try {
  hurl val > 0;
} catch (true) {
  print("over 0");
} catch (false) {
  print("not over 0");
};

В результате будет выведено «over 0». Здесь оценивается условие, вбрасывается результат true, и значение сразу же перехватывается. Если вдруг будет вброшено что-то отличное от true или false, продолжится дальнейшее раскручивание стека, поэтому будьте внимательны. Подумайте о возможности добавления catch как универсального перехватчика ошибок.

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

Тело цикла должно удовлетворять условию:

  • Оно должно отбросить (toss) локальные переменные следующей итерации перед окончанием тела цикла.

  • Через некоторое время после этого оно должно hurl либо true (для запуска очередной итерации), либо false (для завершения итерации).

Выглядит примерно так:

let loop = func(loop_, body, locals) {
    try {
        body(locals);
    } catch as new_locals {
        try {
            // `return` goes back to where the locals were tossed from.
            // This has to be inside a new `try` block since the next things
            // the body function does is hurl true or false.
            return;
        } catch (true) {
            loop_(loop_, body, new_locals);
        } catch (false) {
            hurl new_locals;
        }
    };
};

А затем для его использования нужно определить тело.

let count = func(args) {
  let iter = args[1];
  let limit = args[2];
  print("round " + iter);

  toss [iter + 1, limit];
  hurl iter < limit;
}

И если мы это вызовем, то сможем увидеть, что происходит!

loop(loop, count, [1, 3]);

В результате должно получиться следующее:

round 1
round 2
round 3

И это, в общем-то, всё, что нам нужно.

Пример программы

Ещё один интересный пример: программа fizzbuzz. Если на языке программирования нельзя реализовать задачу fizzbuzz, то он бесполезен для тестирования. Поэтому мы должны удостовериться, что на нём можно написать решение этой задачи.

Вот реализация с использованием ранее определённой функции loop.

let fizzbuzz = func(locals) {
    let x = locals[1];
    let max = locals[2];

    try {
        hurl x == max;
    } catch (true) {
        toss locals;
        hurl false;
    } catch (false) {};

    let printed = false;

    try {
        hurl ((x % 3) == 0);
    } catch (true) {
        print("fizz");
        printed = true;
    } catch (false) {};

    try {
        hurl ((x % 5) == 0);
    } catch (true) {
        print("buzz");
        printed = true;
    } catch (false) {};

    try {
        hurl printed;
    } catch (false) {
        print(x);
    } catch (true) {};

    toss [x+1, max];
    hurl true;
};

loop(loop, fizzbuzz, [0, 100]);

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

Дальнейший план

Итак, каковы следующие шаги для Hurl?

Здесь можно было бы остановиться: это отличная шутка, я написала примеры кода, и мы повеселились. Но я не собираюсь останавливаться. Это хороший компактный язык, который, по моему мнению, подходит для пересмотра некоторых идей из книги Crafting Interpreters («Создание интерпретаторов»). И это мой первый опыт разработки языка! Риски минимальны, поэтому я могу спокойно двигаться дальше, не привязываясь к чему-то конкретному.

План заключается в итеративной работе над интерпретатором. Мои дальнейшие действия:

  1. Определить грамматику

  2. Разработать лексический анализатор

  3. Разработать парсер (демонстрация: проверка парсинга программ)

  4. Разработать форматтер (демо: переформатирование программ)

  5. Разработать интерпретатор

  6. Написать несколько программ для души (Advent of Code 2022?) и создать стандартную библиотеку

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

Комментарии (22)


  1. egormalyutin
    12.10.2023 14:57
    +3

    Автор изобрела алгебраические эффекты ????


  1. Jianke
    12.10.2023 14:57
    +2

    Разработать интерпретатор

    А компилятора, с возможностью отключить в настройках нафиг сборщик мусора, разве не будет? :-(


  1. Panzerschrek
    12.10.2023 14:57
    +2

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


    1. TSobolev
      12.10.2023 14:57
      +5

      Или результат эффекта гиперкомпенсации у тех, кто слишком долго программировал на Go.


  1. domix32
    12.10.2023 14:57
    +1

    DreamBerd хотя бы имел весёлую спеку, а тут почти JS получился.


  1. acsent1
    12.10.2023 14:57
    +2

    Берем js, но не до конца. Вот не нравится слово function, будем писать func ...
    Почему всегда так?


  1. titan_pc
    12.10.2023 14:57
    -6

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


    1. kAIST
      12.10.2023 14:57
      +2

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


      1. titan_pc
        12.10.2023 14:57
        -1

        https://hi-tech.mail.ru/news/63098-v-rossii-nachali-prodavat-kvantovye-kompyutery-spinq/


        1. playermet
          12.10.2023 14:57

          Количество квантовых битов: 3 Кубита;

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


    1. playermet
      12.10.2023 14:57
      +1

      Кубиты это не про general-purpose программирование вообще.


      1. Opaspap
        12.10.2023 14:57

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


  1. vadimr
    12.10.2023 14:57

    Почитайте про язык Снобол.


  1. tminnigaliev
    12.10.2023 14:57

    Немного режет глаз периодическая смена гендера в статье. Т.е. иногда автор(ка) говорит сделал, иногда сделала. Но подписывается мужским именем. Например:

    "Ах да, забыл сказать: мы не можем возвращать значения из функций."

    и вот

    "если вы знаете, как описать её лучше, поделитесь, чтобы я могла обновить публикацию и будущую документацию"

    Просто подметил, так-то я толерантен в принципе. А вообще, мне нравятся такие изыскания.


    1. Survtur
      12.10.2023 14:57
      -2

      Модный нынче гендер-флюид нонбайнери they


    1. diakin
      12.10.2023 14:57

      А что, в английском "сказал" отличается от "сказала"?


      1. tminnigaliev
        12.10.2023 14:57

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


  1. diakin
    12.10.2023 14:57

    let loop = func(loop_, body, locals) {
        try {
            body(locals);
        } catch as new_locals {
            try {
                // `return` goes back to where the locals were tossed from.
                // This has to be inside a new `try` block since the next things
                // the body function does is hurl true or false.
                return;
            } catch (true) {
                loop_(loop_, body, new_locals);
            } catch (false) {
                hurl new_locals;
            }
        };
    };

    О, и подсветка работает!


  1. Dovgaluk
    12.10.2023 14:57

    Мои дальнейшие действия:

    1. Написать несколько программ для души (Advent of Code 2022?) и создать стандартную библиотеку

    Стоило пойти немного дальше, чем просто перевод старой статьи.


    1. mrlobaker
      12.10.2023 14:57

      Таки оригиналу всего 3 месяца. Разве это считается "старой" статьёй?


      1. Dovgaluk
        12.10.2023 14:57

        Ну ссылка на будущий advent of code 2022 выглядит странно.


  1. nameisBegemot
    12.10.2023 14:57
    +1

    Имхо, для языка важнее не сам синтаксис, а его ядро - на чем он выполняется, как работает с памятью, стеком. Что с многопоточностью и асинхронностью и тд.

    И как раз интерпритатор во всем этом самая интересная часть. Ведь тягаться с питоном по синтаксису - дело такое:

    говорилка = print

    говорилка('hello world')