Привет, Хабр!
Сегодня мы рассмотрим одну из тем систем компиляции — генерацию абстрактного синтаксического дерева или просто AST на Rust. Создадим свое собственное AST, разберем, как структурировать синтаксическое дерево, и рассмотрим, как использовать возможности Rust для создания парсеров и обработки узлов дерева.
Процесс генерации AST
Итак, абстрактное синтаксическое дерево — это структура данных, которая описывает синтаксическую структуру исходного кода в виде дерева. Узлы дерева представляют синтаксические конструкции: операторы, переменные, выражения и т.д.
В компиляторах AST выполняет важную задачу: это промежуточное представление программы, которое можно легко обрабатывать, анализировать и преобразовывать на других этапах компиляции, таких как оптимизация и генерация кода.
Определение структуры AST в Rust
Для начала нужно создать структуру данных, которая будет представлять узлы дерева. В Rust удобнее всего использовать enum
для представления разных типов узлов дерева:
#[derive(Debug, Clone)]
enum Expr {
Number(i32),
Variable(String),
Add(Box<Expr>, Box<Expr>),
Subtract(Box<Expr>, Box<Expr>),
Multiply(Box<Expr>, Box<Expr>),
Divide(Box<Expr>, Box<Expr>),
FunctionCall(String, Vec<Expr>),
}
Здесь Expr
— это перечисление, которое описывает узлы дерева для выражений. Используем Box<Expr>
для рекурсивных структур, таких как арифметические операции, чтобы избежать проблем с определением размера структур на этапе компиляции. Почему используется Box
? В Rust для рекурсивных структур размер типа должен быть известен на этапе компиляции. Box
указывает на данные в куче и имеет фиксированный размер.
Создание простого парсера
Теперь займемся созданием простого парсера, который будет обрабатывать строки с арифметическими выражениями, переменными и вызовами функций и преобразовывать их в наше дерево. Для этого воспользуемся библиотекой nom
. Это мощная библиотека парсер-комбинаторов, которая позволяет строить парсеры на основе небольших компонентов. Парсер-комбинаторы — это функции, которые принимают строку на вход и возвращают результат парсинга или ошибку. Их можно комбинировать для построения сложных парсеров.
Добавим в проект зависимость:
[dependencies]
nom = "7.0"
Теперь начнем писать парсер:
use nom::{
IResult,
character::complete::{alpha1, digit1, char},
branch::alt,
combinator::map_res,
sequence::tuple,
multi::fold_many0,
multi::separated_list0,
bytes::complete::tag,
};
fn parse_number(input: &str) -> IResult<&str, Expr> {
let (input, num) = map_res(digit1, |s: &str| s.parse::<i32>())(input)?;
Ok((input, Expr::Number(num)))
}
fn parse_variable(input: &str) -> IResult<&str, Expr> {
let (input, var) = alpha1(input)?; // Переменные начинаются с букв
Ok((input, Expr::Variable(var.to_string())))
}
fn parse_function_call(input: &str) -> IResult<&str, Expr> {
let (input, (func_name, _, args, _)) = tuple((
alpha1, // имя функции
char('('), // открывающая скобка
separated_list0(char(','), parse_expr), // аргументы функции
char(')') // закрывающая скобка
))(input)?;
Ok((input, Expr::FunctionCall(func_name.to_string(), args)))
}
fn parse_factor(input: &str) -> IResult<&str, Expr> {
alt((
parse_number,
parse_variable,
parse_function_call,
))(input)
}
fn parse_term(input: &str) -> IResult<&str, Expr> {
let (input, initial) = parse_factor(input)?;
fold_many0(
tuple((alt((tag("*"), tag("/"))), parse_factor)),
initial,
|acc, (op, val)| match op {
"*" => Expr::Multiply(Box::new(acc), Box::new(val)),
"/" => Expr::Divide(Box::new(acc), Box::new(val)),
_ => unreachable!(),
},
)(input)
}
fn parse_expr(input: &str) -> IResult<&str, Expr> {
let (input, initial) = parse_term(input)?;
fold_many0(
tuple((alt((tag("+"), tag("-"))), parse_term)),
initial,
|acc, (op, val)| match op {
"+" => Expr::Add(Box::new(acc), Box::new(val)),
"-" => Expr::Subtract(Box::new(acc), Box::new(val)),
_ => unreachable!(),
},
)(input)
}
Что мы сделали:
Добавили
parse_variable
для парсинга переменных, состоящих из букв.Добавили
parse_function_call
, чтобы парсить вызовы функций с аргументами.Функция
parse_factor
теперь может парсить числа, переменные и вызовы функций.
Парсинг всегда может завершиться ошибкой, особенно когда на вход подаются некорректные данные, поэтому добави обработку ошибок:
fn parse_expr_with_errors(input: &str) -> Result<Expr, String> {
match parse_expr(input) {
Ok((_, expr)) => Ok(expr),
Err(nom::Err::Error(e)) | Err(nom::Err::Failure(e)) => Err(format!("Parsing error: {:?}", e)),
Err(nom::Err::Incomplete(_)) => Err("Input is incomplete".to_string()),
}
}
Теперь парсер обрабатывает ошибки и выводит их пользователю.
Визуализация AST
Теперь визуализируем AST, чтобы наглядно показать структуру дерева. Уже реализовали функцию pretty_print
, которая выводит дерево на экран:
impl Expr {
fn pretty_print(&self, indent: usize) {
let padding = " ".repeat(indent);
match self {
Expr::Number(n) => println!("{}Number({})", padding, n),
Expr::Variable(v) => println!("{}Variable({})", padding, v),
Expr::FunctionCall(name, args) => {
println!("{}FunctionCall: {}", padding, name);
for arg in args {
arg.pretty_print(indent + 2);
}
}
Expr::Add(lhs, rhs) => {
println!("{}Add:", padding);
lhs.pretty_print(indent + 2);
rhs.pretty_print(indent + 2);
}
Expr::Subtract(lhs, rhs) => {
println!("{}Subtract:", padding);
lhs.pretty_print(indent + 2);
rhs.pretty_print(indent + 2);
}
Expr::Multiply(lhs, rhs) => {
println!("{}Multiply:", padding);
lhs.pretty_print(indent + 2);
rhs.pretty_print(indent + 2);
}
Expr::Divide(lhs, rhs) => {
println!("{}Divide:", padding);
lhs.pretty_print(indent + 2);
rhs.pretty_print(indent + 2);
}
}
}
}
Вывод:
Add:
Number(5)
Multiply:
Variable(x)
Number(3)
Этот вывод демонстрирует следующее:
Корневой узел — это операция сложения
Add
.Левый операнд — это число 5
Number(5)
, оно отображается с отступом в 2 пробела.-
Правый операнд — это операция умножения
Multiply
, которая в свою очередь состоит из переменнойx
и числа3
.Переменная
x
выводится какVariable(x)
с отступом в 4 пробела.Число
3
выводится какNumber(3)
с тем же отступом в 4 пробела, так как это правая часть операции умножения.
Этот вывод показывает структуру AST, соответствующую выражению 5 + (x * 3)
.
Оптимизация: свертка констант и устранение мёртвого кода
На этапе компиляции можно выполнять оптимизации, такие как свёртка констант и устранение мёртвого кода:
impl Expr {
fn optimize(&self) -> Expr {
match self {
Expr::Add(lhs, rhs) => {
let left = lhs.optimize();
let right = rhs.optimize();
if let Expr::Number(l) = left {
if let Expr::Number(r) = right {
return Expr::Number(l + r); // Оптимизация констант
}
}
if let Expr::Number(0) = right {
return left; // x + 0 => x
}
if let Expr::Number(0) = left {
return right; // 0 + x => x
}
Expr::Add(Box::new(left
), Box::new(right))
}
Expr::Multiply(lhs, rhs) => {
let left = lhs.optimize();
let right = rhs.optimize();
if let Expr::Number(l) = left {
if let Expr::Number(r) = right {
return Expr::Number(l * r); // Оптимизация констант
}
}
Expr::Multiply(Box::new(left), Box::new(right))
}
Expr::FunctionCall(_, _) => self.clone(), // В будущем можно добавить интерпретацию вызовов функций
_ => self.clone(),
}
}
}
Функция optimize
автоматически упрощает выражения и вычисляет константы на этапе компиляции.
Заключение
AST делает процессы работы с кодом более удобными, предоставляя четкую структуру для дальнейших манипуляций и оптимизаций.
Archi против всех: как бесплатный архитектурный инструмент покоряет сердца миллионов? Обсудим на открытом уроке 23 сентября. На уроке мы:
Рассмотрим критерии для выбора архитектурных инструментов в компании
Сделаем обзор ведущих мировых и российских инструментов
Затронем основные фичи бесплатного инструмента Archi
Смоделируем простой кейс
Записаться на урок можно настранице курса «Archimate. Basic».
PrinceKorwin
Этот туториал не переводил уже только ленивый. Если всё таки кому-то хочется сделать свой скриптовой язык на Rust, то рекомендую посмотреть на следующий проект:
https://rhai.rs
Быстрый, фичастый, удобный в использовании, отличная документация.