Если вы думаете, что знаете, чем отличается «Expression» от «Statement», то, скорее всего, вы ошибаетесь

от автора

Я занимаюсь изучением синтаксиса языков программирования и пытаюсь отделить фундаментальные закономерности от исторических случайностей. Одна из таких случайностей — почти повсеместное использование терминов выражение (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 процессора — это действие, а не способ вычислить значение.

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  1else  2endz = case value    when 1 then "one"    when 2 then "two"    else "other"    endw = 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     := StatementExpression  := 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, а мост между двумя мирами, которых на самом деле никогда не было.

ссылка на оригинал статьи https://habr.com/ru/articles/1065722/