Я занимаюсь изучением синтаксиса языков программирования и пытаюсь отделить фундаментальные закономерности от исторических случайностей. Одна из таких случайностей - почти повсеместное использование терминов выражение (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 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?
Отвергнутые критерии
«Statement - выполнение, Expression - вычисление»- оба термина связаны с вычислительными действиями, разница не в природе операции.«Statement не возвращает значение»- опровергается Ruby, где всё возвращает значение.«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, а мост между двумя мирами, которых на самом деле никогда не было.
mbait
Вместо тысячи слов хотелось бы увидеть пример дизайна языка, где нет разделения на statements и expressions, но и нет уродливого побочного синтаксиса. Скажем, решаем мы, что последнее выражение это значение блока while, но тут мы пишем последней строкой break. И приходится либо задавать break возвращаемый тип, либо... у нас снова разделение.
rsashka Автор
Ruby ?
mbait
То есть, вы просто посмотрели синтаксис языка и решили, что в компиляторе нет разделения на expr/stmt?
rsashka Автор
Что значит “посмотрел синтаксис”? Это фундаментальная особенность языка.
ImagineTables
Nemerle
rsashka Автор
Точно!