Некоторое время назад мне попался в Интернете вопрос о таком синтаксисе в Rust:

*pointer_of_some_kind = blah;

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

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

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

*pointer_of_some_kind = blah;

следует оперировать примерно теми же средствами, которые нужны для понимания «кода», написанного по-английски, например:

You can't judge a book by its cover // Нельзя судить о книге по её обложке

Да, читая следующие разделы, вы вполне можете задаться вопросом: «А почему процесс такой многоступенчатый?». Кратко ответить на этот вопрос можно по классике: дробя крупную проблему вроде «что это значит?» на мелкие шаги, мы добиваемся того, что каждый отдельный шаг получается сравнительно несложным. Сделать всё за один раз, естественно, гораздо тяжелее. Здесь я опишу классический способ работы с компилятором, но, если вы попробуете более современные подходы, то многие описанные здесь шаги зачастую будут сливаться или выполняться в непривычном порядке. Обработка ошибок — это огромная самостоятельная тема! Считайте этот пост отправной точкой, а не законченным путешествием.

Итак, приступим.

Лексический анализ (также "сканирование" или "токенизация")

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

Итак, возвращаясь к этому английскому выражению:

You can't judge a book by its cover.

выполним двухэтапный процесс, чтобы его токенизировать. Сначала «просканируем» фразу, чтобы разложить её на последовательность «лексем». Делая это, будем придерживаться правил. Я не буду вдаваться здесь в тонкости английской грамматики, поскольку пост и так получается слишком длинным. Но у вас может получиться нечто подобное:

You

can't

judge

a

book

by

its

cover

.

Обратите внимание: символ ' идёт у нас внутри can't, но . отделяется от cover. Правилам именно такого рода мы будем следовать: ' остаётся внутри, так как это сокращение, но . не входит в состав cover, а существует сама по себе.

Затем переходим ко второму этапу и интерпретируем каждую отдельную последовательность символов, превращая их в «токены». Токен — это самостоятельный тип данных в вашем компиляторе, поэтому в Rust мы можем поступить, например, вот так:

enum Token {
    Word(String),
    Punctuation(String),
}

А на выход наш токенизатор может дать массив примерно следующего содержания:

[
    Word("You"),
    Word("can't"),
    Word("judge"),
    Word("a"),
    Word("book"),
    Word("by"),
    Word("its"),
    Word("cover"),
    Punctuation("."),
]

На данном этапе у нас уже есть нечто вроде полусогласованных данных, но мы по-прежнему не уверены, что они полноценные. Пора переходить к следующему этапу!

Синтаксический анализ (он же "парсинг")

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

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

  1. Высказывание — это последовательность слов.

  2. В высказывании есть «тема», которой считается первое слово в этом высказывании, и тему нужно начинать с заглавной буквы.

  3. Высказывание оканчивается точкой.

Конечно же, это крошечное подмножество английского, но идею вы уловили. Цель синтаксического анализа — превратить нашу последовательность токенов в более информативную структуру данных, с которой проще работать. Иными словами, мы определили, что наше высказывание состоит из валидных последовательностей символов, но подчиняются ли они при этом грамматическим правилам нашего языка? Обратите внимание и на то, что нам не требуется сохранять всю информацию. Например, наша структура данных для представления английского языка может иметь вид:

struct Sentence {
    subject: String,
    words: Vec<String>,
}

Где точка? Откуда нам знать, что subject должна начинаться с заглавной буквы? Эти задачи решаются при синтаксическом анализе. Поскольку любое высказывание оканчивается точкой, нам не требуется отслеживать этот параметр в нашей структуре данных. Анализ позволяет убедиться, что это действительно так, поэтому мы не повторяем эту операцию. Аналогично, мы не обязаны сохранять нашу тему как строку, начинающуюся с заглавной буквы: мы определили, что поступившие на вход данные удовлетворяли этому правилу, но при этом можем преобразовывать их по мере необходимости. Итак, после синтаксического анализа наше значение может принять следующий вид:

Sentence {
    subject: "you",
    words: ["can't", "judge", "a", "book", "by", "its", "cover"],
}

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

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

Семантический анализ ("А что всё это значит?")

Допустим, нам пришлось работать не с выражением «You can’t judge a book by its cover», а вот с этим:

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

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

Sentence {
    subject: "Lorem",
    words: ["ipsum", "dolor", "sit", "amet", /* и далее */ ],
}

Но оно не информативно. Как нам это определить?

Вообще, в контексте английского языка слова «Lorem» не существует. Поэтому, если мы попытаемся проверить, является ли тема полноценным словом, то успешно отбракуем это высказывание. В языках программирования подобные операции выполняются, например, через проверку типов. Последовательность 5 + "hello" может вполне успешно пройти как лексический, так и синтаксический анализ, но, если мы попытаемся определить, что это означает, то убедимся, что перед нами бессмыслица. Если, конечно, в вашем языке не разрешено суммировать числа и слова!

После семантического анализа мы определили, что наша программа получилась хорошей, то есть «хорошо оформленной». При работе с компилятором мы после этого переходим к генерации машинного кода или байт-кода, в котором будет представлена наша программа. Но пусть этот материал и невероятно интересен, мы отвлеклись от исходной темы — кстати, помните, чего мы изначально пытались добиться? Мы хотели понять следующий код:

*pointer_of_some_kind = blah;

Это семантика. Поэтому теперь, обрисовав контекст, давайте обсудим, как понять этот код.

Итак... как же понять этот код?

Что ж, чтобы понять код, нужно первым делом уяснить, как он поддаётся лексическому и синтаксическому анализу. Иными словами, какова грамматика рассматриваемого языка. Каким образом наш язык реализует лексику, токенизацию, а затем синтаксический разбор кода. В данном случае речь о языке Rust. Грамматика Rust большая и сложная, поэтому здесь мы рассмотрим её лишь частично. Разберёмся, чем отличаются операторы (statement) и выражения (expression).

Возможно, вам уже доводилось слышать, что Rust – это «язык, основанный на выражениях». Именно об этом мы и поговорим. Как видите, большинство вещей, артикулируемых вами в программе, зачастую относятся к одной из этих двух категорий. Выражение производит то или иное значение, а операторы используются для выстраивания последовательностей, в которых выражения будут вычисляться. Формулировка немного абстрактная, давайте её конкретизируем.

Операторы

В Rust есть несколько видов операторов. Основные разновидности — это «объявляющие» (declaration statements) и «выражающие» (expression statements), и у каждого из них есть свои подвиды.

Объявляющие операторы бывают двух видов: объявления элементов (item declarations) и let-операторы (let statements). К объявлениям элементов относятся такие вещи как modstruct или fn: в них постулируется, что определённые вещи существуют. Операторы let — это, вероятно, наиболее известная разновидность операторов в Rust, они имеют следующий вид:

OuterAttribute* let PatternNoTopAlt ( : Type )? (= Expression † ( else BlockExpression) ? ) ? ;

Это… многословно. Мы пока не обсудили * или ?, и прямо сейчас я не стал бы разбирать относительно экзотические элементы Rust. Поэтому давайте сначала попытаемся поговорить об этом на уровне сравнительно простой грамматики:

let Variable = Expression;

Вот как в Rust создаются новые переменные: пишем let, затем имя, далее =, после чего, наконец, идёт выражение. Результат вычисления данного выражения становится значением переменной.

Здесь я многое опускаю: имя – это не просто имя, а паттерн, что само по себе очень круто. Сейчас в Rust существует let else, это тоже круто. Здесь мы игнорируем типы. Но основы можно понять уже на материале этой простой версии.

Операторы выражений гораздо проще:

ExpressionWithoutBlock ; | ExpressionWithBlock ;?

Знак | здесь означает «или» то есть, можно иметь либо одиночное выражение, за которым следует;, либо блок (заключённый в {}, за которыми опционально может следовать ; (символ ? здесь означает «может существовать или не существовать» .)

Итак, чтобы мыслить как компилятор, давайте научимся сочетать эти правила. Например:

let x = {
    5 + 6
};

Здесь у нас let-оператор, но с правой стороны от = находится выражение ExpressionWithBlock. Вопрос на засыпку: к чему относится ; — к выражению let или к выражению, находящемуся в правой части?

Правильный ответ — к let. В выражении let обязательно присутствует ;, а блок нет, таким образом:

let x = ExpressionWithBlock;

Если у нас есть точка с запятой, входящая в состав блока, то нам нужна ещё одна для let, поэтому у нас и получается };;. Компилятор такое принимает, но выдаёт предупреждение.

Возвращаемся к нашему оригинальному коду:

*pointer_of_some_kind = blah;

У нас нет let, поэтому здесь мы имеем дело не с объявлением элемента, а с оператором выражения. У нас есть ExpressionWithoutBlock, за которым следует ;. Теперь давайте поговорим о выражениях.

Выражения

В Rust есть много типов выражений. В разделе 8.2 Справки по Rust 19 подразделов. Уф! В данном случае код является операционным выражением, точнее, выражением присваивания:

Expression = Expression

Достаточно просто! Таким образом, слева от = находится выражение с *pointer_of_some_kind, а справа — blah. Ну ведь просто!

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

Выражение присваивания перемещает значение в указанную позицию.

Что такое позиции и значения?

Позиции и значения

В языке C и ранних версиях C++ две этих вещи назывались «lvalue» и «rvalue», где «l» и «r» означает, соответственно, «слева» и «справа» от =. В более свежих версиях стандарта C++ категорий уже больше. В Rust всё совсем иначе: здесь всего две категории, как и в C, но они более чётко отображаются на две из категорий C++. Значения lvalue в Rust (находящиеся слева) называются «позициями» (place), а rvalue (находящиеся справа) называются «значениями» (value). Вот два более чётких определения, взятых из «руководства по небезопасному коду»:

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

  • Значение — это то, что хранится на позиции. У значения есть тип.

Обе эти сущности бывают в форме выражений. Так что выражение позиции выдаёт место, где происходит вычисление, а выражение значения производит значение. Именно так работает =: слева у нас выражение позиции, а справа — выражение значения, и именно в назначенное место мы ставим полученное значение. Достаточно просто!

Опять же, вернёмся к коду, который разбираем:

*pointer_of_some_kind = blah;

Так *, оператор разыменования, принимает указатель и вычисляет позицию, в которую он направлен: это его адрес. А blah даёт нам значение, которое нужно туда поместить.

Что ж, расходимся? Ещё нет!

Разыменование

В Rust есть типаж Deref, позволяющий переопределить оператор *. Чтобы было проще, давайте разберём следующий пример:

use std::ops::{Deref, DerefMut};

struct DerefMutExample<T> {
    value: T
}

impl<T> Deref for DerefMutExample<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.value
    }
}

impl<T> DerefMut for DerefMutExample<T> {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.value
    }
}

fn main () {
    let mut x = DerefMutExample { value: 'a' };
    *x = 'b';
    assert_eq!('b', x.value);

Поэкспериментировать с ним можно здесь.

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

Выражения пути, которые разрешаются в локальные или статические переменные — это выражения позиций, все прочие — это выражения значений.

Ранее мы говорили о let: в let mut x = DerefMutExample { value: 'a' }; выше x — это выражение пути, и, поскольку оно разрешается в нашу новую переменную, это может означать лишь то, что перед нами выражение позиции. DerefMutExample { value: 'a' } — это выражение значения, поскольку оно не разрешается в переменную.

Теперь поговорим о *x = 'b';. Напомню, как выглядит наше выражение присваивания:

expression = expression;

Вот что оно делает: перемещает значение в указанную позицию.

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

*expression

Его семантика самоочевидна:

  • Если выражение имеет тип &T&mut Tconst T или mut T, то результирует в ту позицию, куда по указателю должно попасть значение. Получаем такую же изменяемость.

  • Если выражение не относится ни к одному из вышеперечисленных типов, то оно эквивалентно либо std::ops::Deref::deref(&x), если оно неизменяемое, либо std::ops::DerefMut::deref_mut(&mut x).

Вот и всё. Теперь мы знаем достаточно, чтобы полностью понять *x = 'b':

  • 'b' — это выражение значения

  • x — это не тип указателя, поэтому расширим его до std::ops::DerefMut::deref_mut(&mut x) и попробуем снова

  • std::ops::DerefMut::deref_mut(&mut x) в данном случае возвращает тип &mut char и указывает на позицию self.value (которую я для краткости назову <that place>), где в настоящий момент сохранено значение 'a'. Теперь имеем *&mut <that place>.

  • Далее воспользуемся другим правилом, применимым с оператором разыменования: мы работаем над &mut T, так что *&mut <that place> ссылается на <that place>.

  • Теперь у нас <that place> = 'b', так что мы перемещаем 'b' именно в эту позицию.

Уф! Вот теперь всё.

Заключение

Думать как компилятор порой очень интересно! Усвоив, что такое грамматики, и научившись правильной подстановке вещей, можно раскопать массу интересных вещей. В данном конкретном случае иногда задаёшься вопросом, почему Deref возвращает ссылку, если он нужен только чтобы направить куда следует конкретный указатель… а если вы не знали, что выражения разыменования могут переходить в развёрнутую форму благодаря содержащемуся в них *, то тут есть где запутаться! Но теперь вы всё знаете. Надеюсь, выяснили для себя что-то интересное о значениях, местах и вообще о том, как именно компиляторы трактуют код.

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