Я занимаюсь изучением синтаксиса языков программирования и пытаюсь отделить фундаментальные закономерности от исторических случайностей. Одна из таких случайностей - почти повсеместное использование терминов выражение (expression) и инструкция (statement) при описании и классификации синтаксиса языков программирования.

Интуитивно кажется, что 2 + 2 и if (x) { foo(); } - это совершенно разные сущности, но при более глубоком анализе оказывается, что подобное разделение искусственное и возникло из-за архитектурных особенностей вычислительных машин почти полвека назад и с тех пор просто «переходит» из языка в язык.

Часть 1. Историческая

Чтобы понять, откуда взялось разделение синтаксиса на выражения (expression) и инструкции (statement), нужно проследить эволюцию синтаксических правил от первых языков высокого уровня.

FORTRAN -> ALGOL 60 -> C: три акта одной пьесы

  • FORTRAN (1957) - разделение «по железу». В языке, созданном для IBM 704, выражения (X + Y*Z) были строго отделены от управляющих инструкций (IF, DO, GOTO). Первые вычисляли значения, вторые управляли потоком исполнения и смешивать их между сбой было нельзя. Эта концепция синтаксиса напрямую отражала архитектуру вычислительных машин того времени: программа понималась как последовательность машинных команд, каждая из которых либо вычисляла операнд, либо изменяла счётчик команд, но не одновременно.

  • ALGOL 60 (1960). В ALGOL 60 ввели составной оператор begin ... end (прообраз блока), но важнее другое: язык разрешил условное выражение. Конструкция if B then E1 else E2 могла появляться в позиции любого выражения, а значит, допускала запись

    x := if a > b then a else b
    

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

  • C (1972) - фиксация различий Expression vs Statement. Деннис Ритчи (при участии Кена Томпсона, автора языка-предшественника B), проектируя C как «переносимый ассемблер» для PDP-11, закрепил в грамматике чёткое различие между этими понятиями. Такое решение диктовалось стремлением к максимально прямому отображению конструкций языка на машинные инструкции: условный переход jz BEQ процессора - это действие, а не способ вычислить значение.

Lisp и параллельная вселенная без statement’ов

Почти синхронно с FORTRAN, в 1958 году, Джон Маккарти создал Lisp, фундаментально основанный на лямбда-исчислении. Здесь нет понятия statement - вся программа состоит из S-выражений: будь то вызов функции, условная конструкция или блок.

В Лиспе конструкция (if (> a b) a b) - это просто выражение, значение которого можно присвоить, передать в функцию или использовать в более сложной композиции. А блок (let ((x 5)) (+ x 1)) возвращает 6, оставаясь обычным выражением.

Это не «расширение» возможностей, а фундаментальный иной принцип: вся программа - это вычисляемые значения, а не последовательность управляющих инструкций. С этой позиции разделение на expression и statement выглядит ненужным.

Часть 2. Современные языки

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

Классическая модель: C, C++, Java (и Python)

В этих языках if, for, while категорически не могут быть частью выражения. Код

int y = if (x > 0) { 1; } else { 2; }   // ошибка компиляции

недопустим. Разработчики вынуждены либо использовать тернарный оператор:

int y = (x > 0) ? 1 : 2;

либо (в C/C++) прибегать к нестандартным расширениям вроде GCC statement expressions:

int y = ({ if (x > 0) 1; else 2; });    // только с расширениями GCC

В Python ситуация аналогична: if - это инструкция, но начиная с версии 2.5 введено тернарное выражение:

y = 1 if x > 0 else 2

Эта конструкция - точный аналог тернарного оператора ?:, «заплатка» для грамматики, которая не решилась сделать if/else полноценным выражением.

JavaScript: эмуляция выражения через функцию

JavaScript формально сохраняет C-подобное разделение, однако в язык встроены обходные инструменты. Используя IIFE (немедленно вызываемые функциональные выражения), разработчик может «обернуть» statement-логику в контекст, где требуется значение:

let y = (function() {
    if (x > 0) return 1;
    else return 2;
})();

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

Ruby: всё есть выражение

В Ruby любая конструкция - выражение, без исключений. Можно написать:

y = if x > 0
  1
else
  2
end

z = case value
    when 1 then "one"
    when 2 then "two"
    else "other"
    end

w = while false
  # тело не выполняется
end  # w равно nil, но сам while - валидное выражение

Даже определение класса или метода возвращает значение последнего выражения. Грамматика Ruby не вводит отдельного нетерминала «statement» - вся программа состоит только из выражений.

Rust: гибрид - ни нашим ни вашим

Rust предлагает своеобразный компромисс: формально в грамматике различие между statement и expression есть (let-декларации, объявления элементов - это не expression), но управляющие конструкции (if, match, loop) целиком отнесены к категории Expression - в отличие от C/C++, где это принципиально невозможно.

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

let x = while true { break 5; };  // ОШИБКА! while не умеет так

тогда как очень похожий loop - умеет:

let x = loop { break 5; };  // ОК! x == 5

Различие между loop и while/for в том, что компилятор не может гарантировать, что тело while/for вообще выполнится хоть раз (условие может быть ложным с самого начала), тогда как loop либо бесконечен, либо выходит через break с конкретным значением. Эта асимметрия поведения чисто техническая, но принципиально влияет на синтаксис этих конструкций.

Или вот if со скобками и без них ведёт себя неожиданно:

fn f() -> i32 {
    if x > 0 { 1 } else { 2 } - 1
}

Кажется, что это должно вычислить (if...) - 1. Но нет! Компилятор трактует if {...} else {...} как отдельный statement (потому что он стоит не в «хвостовой» позиции блока), а - 1 - как отдельное выражение - унарный минус. Функция вернёт ошибку типов, потому что последней строкой оказывается -1, а значение if-выражения будет просто отброшено.

Чтобы получить ожидаемое поведение, нужно явно обернуть if в скобки:

let y = (if x > 0 { 1 } else { 2 }) - 1;  // так работает как надо

Но самым странным является поведение точки с запятой, которая может менять тип функции:

fn f() -> i32 {
    5 + 3;   // с точкой с запятой!
    10
}

А если поставить ; после последней строки:

fn f() -> i32 {
    5 + 3;
    10;      // теперь тут ;
}
// ОШИБКА: функция должна вернуть i32, а вернула ()

; (точка с запятой) в Rust - это не просто «конец строки», а оператор, буквально меняющий тип выражения на () (пустой тип). Забытая или лишняя точка с запятой - самая частая причина неочевидных ошибок среди новичков.

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

Часть 3. Что на самом деле отличает Statement от Expression?

Если вернуться к изначальному вопросу, так чем же отличается statement от expression?

Отвергнутые критерии

  1. «Statement - выполнение, Expression - вычисление» - оба термина связаны с вычислительными действиями, разница не в природе операции.

  2. «Statement не возвращает значение» - опровергается Ruby, где всё возвращает значение.

  3. «Statement - отдельная категория грамматики» - это просто описание симптома (того, что в конкретном языке зафиксировано на уровне BNF), а не объяснение принципиальных различий между терминами.

Более продуктивным оказывается взгляд на пару statement/expression как на структурную композицию, в которой:

  • Expression - неделимая лексическая единица: литерал, идентификатор, вызов функции, математическая операция.

  • Точка с запятой ; (или её аналоги - перевод строки в Ruby) играет роль оператора-разделителя, который сообщает компилятору: нужно вычислить выражение слева, отбросить его значение и перейти к правой части. Цепочка из нескольких выражений, последовательно соединённых ; и образует statement.

  • Составной блок, ограниченный фигурными скобками {...} - это способ превратить цепочку из нескольких выражений в единое неделимое выражение. Значением блока становится значение последнего выражения в нём.

Таким образом, statement - это не альтернативная категория синтаксиса, а композиция одного или нескольких выражений, соединённых оператором ;, где значение всей цепочки равно значению последнего элемента. Запись:

expression1;
expression2;
...
expressionN

можно прочитать как (expression1 ; expression2 ; ... ; expressionN), и её значением является значение expressionN. А если нужно оформить такую цепочку как единое выражение, то её помещают в фигурные скобки:

{ expression1; expression2; ...; expressionN }

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

А как же оператор запятая в C/C++?

Оператор запятая в C/C++ очень похож на реализацию описанной формальной модели - композицию выражений:

int y = (foo(), bar(), 42);   // foo() и bar() вычислены ради побочного эффекта, y == 42

Но есть принципиальное отличие: оператор запятая - это всегда Expression и не может выйти за пределы одного expression-контекста.

int y = foo(), bar();   // Это НЕ comma-expression! Это два объявления (переменной и функции)!
int z = (foo(), bar());  // а вот это - настоящий comma operator, нужны скобки

Без явных скобок оператор , (запятая) в контексте объявления переменных или списка аргументов функции интерпретируется грамматикой иначе - как разделитель в списке (declarator-list, argument-list). И эта неоднозначность грамматики C/C++ разрешается только контекстом.

Кроме того, оператор ,(запятая) ограничен уровнем expression и не может содержать statement-конструкции:

int y = (if (x > 0) 1; else 2, 42);   // ОШИБКА - if не Expression, нельзя вставить внутрь ","

И напоследок, comma operator имеет самый низкий приоритет среди операторов C, и область его применения жёстко ограничена контекстами, где парсер может быть точно уверен, что это не разделитель списка:

f(a, b, c);   // это вызов с тремя аргументами, а не comma-expression!
f((a, b), c); // а вот это уже - comma-expression как первый аргумент

Часть 4. Формальная грамматика универсального синтаксиса

Если попробовать описать expression и statement в общем виде, получится красивая взаимная рекурсия между ними:

Program     := Statement

Expression  := atomic-expr
             | Expression binary-op Expression
             | unary-op Expression
             | Expression '(' arg-list ')'          // вызов функции
             | '{' Statement '}'                     // сгруппированный Statement - тоже Expression!

Statement   := Expression
             | Expression ';' Statement              // рекурсивная композиция через ;

arg-list    := Expression (',' Expression)*

Где:

  • Statement - это либо одно выражение, либо выражение, за которым следует ; и новый statement (рекурсивно).

  • Expression - это атомарное выражение (идентификатор, литерал, операция, вызов) либо { statement }, то есть блок, внутри которого находится statement, но который сам рассматривается как единое выражение.

Именно взаимная рекурсия, при которой блоки могут содержать statement, а statement строится из выражений, придаёт такой модели логичность и стройность, тогда как разные языки её по-разному ограничивают:

  • C-подобные языки разрешают переход { statement } -> expression только для простых выражений, но запрещают для управляющих конструкций. Поэтому if (x) { ... } нельзя использовать как выражение, хотя блок в фигурных скобках сам по себе мог бы быть выражением, если бы грамматика это допускала. Более того, GCC-расширение ({ ... }) буквально реализует такое правило: { statement } интерпретируется как expression, тогда как стандартный C так не умеет.

  • Lisp-подобные языки и Ruby вообще не нуждаются в отдельном нетерминале statement - в этих языках всё является выражением.

  • Rust оказался где-то посередине. В нём есть категория объявлений (let, fn, mod), которые являются инструкциями и не могут быть частью выражения. Тем не менее все управляющие конструкции (if, match, loop и т. д.) являются выражениями.

Заключение

Разделение на expression и statement, привычное для многих поколений разработчиков C-подобных языков - не логическая необходимость, а историческая случайность, закреплённая архитектурой машин фон Неймана, которую перенесли из FORTRAN в ALGOL, а затем в C. Оно зафиксировалось в грамматиках как «удобное» решение на заре компиляторостроения, но не имеет фундаментальных причин на уровне синтаксиса.

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

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

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


  1. mbait
    02.08.2026 09:35

    Вместо тысячи слов хотелось бы увидеть пример дизайна языка, где нет разделения на statements и expressions, но и нет уродливого побочного синтаксиса. Скажем, решаем мы, что последнее выражение это значение блока while, но тут мы пишем последней строкой break. И приходится либо задавать break возвращаемый тип, либо... у нас снова разделение.


    1. rsashka Автор
      02.08.2026 09:35

      Ruby ?


      1. mbait
        02.08.2026 09:35

        То есть, вы просто посмотрели синтаксис языка и решили, что в компиляторе нет разделения на expr/stmt?


        1. rsashka Автор
          02.08.2026 09:35

          Что значит “посмотрел синтаксис”? Это фундаментальная особенность языка.


    1. ImagineTables
      02.08.2026 09:35

      Nemerle


      1. rsashka Автор
        02.08.2026 09:35

        Точно!


    1. NeoCode2
      02.08.2026 09:35

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

      Для if(x>0) y; else z; возвращаемый тип это тип-сумма typeof(y) + typeof(z)

      То есть если y это string а z это float то на выходе получаем "вариант" (дискриминированное объединение) с двумя полями string и float.

      Если у нас цикл с предусловием while(x>0) y; или условие без альтернативной ветки if(x>0) y; то очевидно что результат - тип-сумма typeof(y) + never, что эквивалентно опционалу optional(typeof(y))

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

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


      1. rsashka Автор
        02.08.2026 09:35

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

        Подобное ограничение не обязательно (точнее, оно определяется системой типов языка).


        1. NeoCode2
          02.08.2026 09:35

          Это не ограничение, а скорее удобство. Компилятор генерирует "нетерминальный" составной тип, и логично чтобы он вел себя так же, как литерал вида {1,2,3} в обычном Си, которым можно инициализировать объект любой структуры из трех числовых полей (увы, в Си эта тема неразвита, и просто присвоить или передать в функцию литерал не получится - только инициализировать, но как пример подойдет и инициализация).


          1. rsashka Автор
            02.08.2026 09:35

            Так я и говорю, чтоб подобное удобство зависит от системы типов языка. Если он статически типизируемый как С/С++ , тогда ему по любому нужен какой-то тип, чтобы было что проверять при компиляции. А, например, для динамически типизируемых языков конкретный тип выводить не обязательно. Для них без анализа будет достаточно какого нибудь Any, который все равно будет проверяться в рантайме.


  1. SIISII
    02.08.2026 09:35

    В языке, созданном для IBM 704, выражения (X + Y*Z) были строго отделены от управляющих инструкций (IF, DO, GOTO). Первые вычисляли значения, вторые управляли потоком исполнения и смешивать их между сбой было нельзя. Эта концепция синтаксиса напрямую отражала архитектуру вычислительных машин того времени: программа понималась как последовательность машинных команд, каждая из которых либо вычисляла операнд, либо изменяла счётчик команд, но не одновременно.

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

    Это был прямой предшественник тернарного оператора

    Во-первых, это, по свой сути, оно и есть, а не "предшественник". А во-вторых, в русском языке для действий в выражениях принято слово "операция" (арифметические операции и т.п. -- не операторы; в английском -- да, operator).

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

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

    Ну а во-вторых, у PDP-11 нет команды JZ, хотя идея, высказанная автором, понятна.

    архитектурой машин фон Неймана

    Архитектура фон Неймана, как и гарвардская архитектура -- это не про то, какие команды есть и что они делают, а про то, используются ли для памяти единые адреса независимо от того, команды это или данные (фон Нейман), или же адресные пространства кода и данных строго отделены друг от друга и не пересекаются, а численно один и тот же адрес указывает разные ячейки памяти в зависимости от того, является ли он адресом команды или данных (Гарвард).

    В общем, как по мне, слов в публикации много, а толку -- не очень...


    1. rsashka Автор
      02.08.2026 09:35

      Спасибо, вы поняли суть, но упустили из виду важный момент. Я старался делать акцент не на абстрактных командах микропроцессора (ассемблера), а на синтаксисе языка, как отображения этих команд.

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


  1. Siemargl
    02.08.2026 09:35

    int y = foo(), bar(); // Это НЕ comma-expression! Это два отдельных объявления переменных!

    Здесь объявление функции bar(), а не второй переменной.


    1. rsashka Автор
      02.08.2026 09:35

      Спасибо, исправил :-)