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


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


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


Ведение


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


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


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


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


Странные инкремент и декремент (++ и --)


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


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


Мне могут возразить, что операторы +=, -=, *= или \= тоже меняют значение переменной, но хочу заметить, что это только упрощенная запись комбинации двух операторов, один из которых как раз и предназначен для присваивания нового значения переменной, поэтому возражения не принимаются. :-)


А если еще вспомнить, что операторы инкремента и декремента могут быть префиксными и постфиксными, то в комбинациях с адресной арифметикой (*val++ или не дай бог какой нибудь ++*val++), взрыв мозга с возможными ошибками просто гарантирован.


Мало операторов присвоения значения


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


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


Если вспомнить Королевское, "критикуешь, предлагай", то мне кажется, что было бы правильным сделать два разных оператора: оператор присвоения значения и оператор создания переменной (в С/С++ логика создания переменной выполняется за счет указания типа переменой при её первом использовании).


Другими словами, вместо одного оператора "создания и/или присвоения значения" лучше использовать два или даже три оператора. Создания новой переменной (::=), только присвоения значения уже существующей переменной (=) и создания/присвоения не зависимо от наличия переменной (:=) — т.е. аналога текущего оператора =.


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


А еще, я бы добавил оператор "обмен значений", какой нибудь :=:. По сути, это аналог std::swap() в С++, только на уровне синтаксиса языка.


Передача владения в Rust:


fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{}, world!", s1);
}

Получаем ошибку, потому что Rust не позволяет вам использовать недействительную ссылку:


$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:28

А мог бы быть обмен значений (и владения) в явном виде, например s2 :=: s1.


Всегда лишний тип данных


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


Другое дело, булевый (логический) тип данных. В описании одного языка я встретил даже вот такое:


Bool        1 Byte truth value
(Bool16)    2 Byte truth value
(Bool32)    4 Byte truth value
(Bool64)    8 Byte truth value

А когда копаешь чуть глубже, все скатывается к одному единственному биту, которым можно представить два противоположных состояния ДА/НЕТ, true/false, 1/0...


Но позвольте, если это 1 или 0, то почему бы сразу не определить, что логический тип, это число с одним разрядом? (передаю пламенный привет LLVM!).


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


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

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


Нулевой указатель


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


Однако, наличие ссылочных типов данных, добавляет сразу несколько неопределенностей, таких как управление памятью и разделяемыми ресурсами (подробнее в статье Управление памятью и разделяемыми ресурсами без ошибок). Кроме этого, при наличии адресной арифметики (явной или не явной), сразу становится необходимым использование специального зарезервированного значения под названием "нулевой указатель", NULL, nil, nullptr и т.д. в зависимости от языка.


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


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


Результат выполнения последней операции


Бывают ситуации, когда не хватает системной переменной со значением результата выполнения последней операции. Какой нибудь аналог $? в bash скриптах, но на уровне исходного кода Python или C/C++.


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


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


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


Чистые функции


Еще, мне бы иногда хотелось иметь возможность создавать чистые функции на С/С++ или Python, чтобы сам компилятор контролировал запрет на обращение к глобальным переменным или не чистым функциям на уровне синтаксиса языка, и это бы проверялось во время компиляции.


Пустое имя переменной


И напоследок хочется сказать, что в С++ очень сильно не хватало пустой переменой "_" (как в Python). Но в последних предложениях стандарта её кажется завезли, поэтому начиная с C++26 будет нам счастье :-)


Итого


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


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


Всегда интересно узнать, что же ты пропустил, забыл или не учел.

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


  1. qw1
    12.01.2024 07:53
    +2

    Результат выполнения последней операции - это как? Вот с точки зрения архитектуры?

    Это регистр флагов ))

    CMP EAX, EBX
    JZ EQUAL
    

    На современных архитектурах регистр флагов - жёсткий антипаттерн, потому что является узким местом, разделяемым ресурсом. Но на x86-64 нам с ним жить и жить.


  1. diakin
    12.01.2024 07:53
    +2

    if .. then...goto хватит для всего.


    1. rsashka Автор
      12.01.2024 07:53
      +1

      Да, я про это сразу написал (машина Тьюринга). Вот только работать с таким кодом будет очень тяжело.


      1. diakin
        12.01.2024 07:53

        Дело привычки )))


        1. rsashka Автор
          12.01.2024 07:53
          +2

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


  1. LAutour
    12.01.2024 07:53
    +2

    *val++ - это от ориентированности С на систему адресации PDP-11. Просто ++ и -- видимо вошли в ЯП уже автоматом в нагрузку.


  1. pda0
    12.01.2024 07:53
    +2

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

    Добро пожаловать в Go. :)

    "оператор присвоения значения" =

    "оператор создания переменной" :=


  1. kipar
    12.01.2024 07:53
    +5

    По пунктам:

    декремент - да, не нужен (и во многих новых языках его нет)

    обмен значений - a, b = b, a. В таком реализован много где, да и читается легко.

    нулевой указатель - ситуация не так проста. С одной стороны да, это "ошибка на миллиард долларов". А с другой - нужен способ выразить что в ячейке "еще" или "уже" нет никакого значения. Если просто убрать null, то вместо него будут использовать пустой объект или еще какую-то непредсказуемую заглушку и станет только хуже (с null, по крайней мере, значение можно сравнить). Современный подход к решению - nullable типы у которых надо явно запросить перед использованием - null там или не null.

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

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

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


    1. rsashka Автор
      12.01.2024 07:53

      Большое спасибо за развернутый комментарий!

      обмен значений - a, b = b, a. В таком реализован много где, да и читается легко.

      Точно, я на самом деле забыл про эту конструкцию. Спасибо, что напомнили.

      нулевой указатель - ситуация не так проста.

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

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

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


      1. Kyoki
        12.01.2024 07:53

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

        Причем тут действительность ссылок? Вы дальше абзац тупо проигнорировали?


        1. rsashka Автор
          12.01.2024 07:53

          Нет не проигнорировал. Это вы не поняли что означает "компилятор не контролирует работу с ссылками". Это вовсе не "nullable типы у которых надо явно запросить перед использованием - null там или не null", а полный контроль компилятора над ссылками без какой либо адресной арифметики на уровне синтаксиса языка, в том числе и с управлением владения объектами как в Rust Управление памятью и разделяемыми ресурсами без ошибок


          1. Kyoki
            12.01.2024 07:53

            Так и ззнал, что раст всплывет... Где в том абзаце про адресную арифметику? По секрету: там про пустой объект и заглушку.


            1. rsashka Автор
              12.01.2024 07:53

              Я не являюсь апологетом Rust и не пишу на нем, но мне нравится его концепция владения объектами.

              А адресная арифметика, это не только арифметические операции с адресами, но и операции сравнения. Поэтому, сравнение адреса с null, это тоже часть адресной арифметики.


  1. huder
    12.01.2024 07:53
    +2

    Есть subleq, например – язык программирования из одной команды и собственно название этой команды


  1. itGuevara
    12.01.2024 07:53
    +1

    Полагаю, что тема близка к UTP:

    Вообще есть такая тема, как Unifying Theories of Programming (UTP), где пытаются собрать все абстракции, которые нужны для описания любых программ. В принципе это близко к Лексикону программирования.

    https://forum.oberoncore.ru/viewtopic.php?p=104352

    Если еще "глубже копнуть", то должно быть триединство: код (скрипт) - алгебраическая запись - схема. Т.е. программа должна иметь возможность трансляции в какое-либо мат. исчисление: CCS\ pi-исчисление, только более совершенную алгебру процессов.

    "Левое" движение к этому (код.скрипт - алгебраическая запись): функциональное программирование http://intsys.msu.ru/staff/mironov/thfunprog.pdf

    "Правое" движение (алгебраическая запись - схема): BPMN (pi-исчисление), s-BPM (CCS) http://workflowpatterns.com/documentation/documents/pi-hype.pdf


  1. DenSigma
    12.01.2024 07:53

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


  1. saboteur_kiev
    12.01.2024 07:53
    +3

    Прочитал статью.

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

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

    Результат выполнения последней операции - это как? Вот с точки зрения архитектуры?
    Я выполнил ++, какой должен быть результат? Я положил значение в стек - какой должен быть результат?
    Если я завершил цикл, в котором что-то считал, результат должен быть то что я считал, или счетчик цикла? А если цикл не for а while? а если вышел через break, должен ли результат быть отличным от выхода через завершение цикла по done?

    И какие затраты будут, если на КАЖДУЮ ОПЕРАЦИЮ нужно хранить результата?

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


    1. rsashka Автор
      12.01.2024 07:53
      +1

      Спасибо за развернутый комментарий!

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

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

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

      Результат выполнения последней операции - это как? Вот с точки зрения архитектуры?

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


      1. saboteur_kiev
        12.01.2024 07:53

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

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

        Почему?

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

        Ничего не понял. Компилятор не выполняет код, он его компилирует. Зачем вам переменная, которая есть только во время компиляции.

        Или вам отладчик нужен? Так они есть.


        1. rsashka Автор
          12.01.2024 07:53

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

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

          Почему?

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

          Ничего не понял. Компилятор не выполняет код, он его компилирует. Зачем вам переменная, которая есть только во время компиляции.
          Или вам отладчик нужен? Так они есть.

          Генерация машинного кода компилятором, это самое последние действие, которое он делает при компиляции исходного текст программа. А до этого происходит очень много предварительных этапов обработки исходных текстов. Чтобы лучше понять, почитайте теорию.


          1. saboteur_kiev
            12.01.2024 07:53
            -1

            Скорость работы программы бывает важна только в том случае, если программа работает правильно.

            Скорость работы важна тогда, когда код должен выполняться быстро и эффективно. Сжатие, шифрование, рендеринг.
            Правильно и быстро это вообще не рядом.

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

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

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

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

            Почему?

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

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

            Генерация машинного кода компилятором, это самое последние действие, которое он делает при компиляции исходного текст программа. А до этого происходит очень много предварительных этапов обработки исходных текстов. Чтобы лучше понять, почитайте теорию.

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


    1. qw1
      12.01.2024 07:53
      +2

      Результат выполнения последней операции - это как? Вот с точки зрения архитектуры?

      Это регистр флагов ))

      CMP EAX, EBX
      JZ EQUAL
      

      На современных архитектурах регистр флагов - жёсткий антипаттерн, потому что является узким местом, разделяемым ресурсом. Но на x86-64 нам с ним жить и жить.