Простой синтаксический анализ. Или выражение со скобками

Раз уже подняли тему парсинга чего-бы-то-ни-было. И ЕП опубликовал табличный автомат, я поддержу бесплатное обучение и расскажу про один из самых простых способов синтаксического анализа - Метод Рекурсивного Спуска.
Синтаксический анализ рекурсивным спуском – это один из самых простых и интуитивно понятных методов разбора выражений. Он основан на непосредственном сопоставлении кода с грамматикой языка, где каждая функция соответствует определенному правилу. Это подходит только для LL-грамматик (левая рекурсия запрещена, но возможен разбор слева направо). Удобно для простых арифметических выражений, конфигурационных файлов и интерпретируемых языков.

Вот грамматика в БНФ для простых скобочных выражений, без унарного минуса и без экспоненциальной (“научной”) записи чисел.

<Выражение> ::= <Терм> { ("+" | "-") <Выражение> }  
<Терм> ::= <Фактор> { ("*" | "/") <Терм> }  
<Фактор> ::= <Число> | "(" <Выражение> ")"  
<Число> ::= <Целая_часть> [ "." <Дробная_часть> ]  
<Целая_часть> ::= <Цифра> { <Цифра> }  
<Дробная_часть> ::= <Цифра> { <Цифра> }  
<Цифра> ::= "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"  

Поехали разбирать!

Спойлер
void wSpace (char *&in)
{
    while (isspace(*in))
    { // Пропуск пробелов
        in++;
    }
}

bool wEOL (char *in)
{
    if (*in == '\0')
    { // Проверка на конец строки
        return true;
    }
    return false;
}
int wInt (char *&in)
{
    int num = 0;
    while (isdigit(*in))
    { // Преобразование строки в число
        num = num * 10 + (*in - '0');
        in++;
    }
    return num;
}

double wDouble (char *&in)
{
    double num = 0;
    while (isdigit(*in))
    { // Преобразование строки в число
        num = num * 10 + (*in - '0');
        in++;
    }
    if (*in == '.')
    {
        in++;
        double decimal = 0.1;
        while (isdigit(*in))
        { // Преобразование дробной части
            num += (*in - '0') * decimal;
            decimal *= 0.1;
            in++;
        }
    }
    return num;
}

Все функции я называю с первой буквой “w” потому, что я Влад. Никакой тайны. :wink:
Вся реализация “в лоб”. Это настолько общеизвестные строки, что генерятся автоматически.

далее пишем собственно функции под правила грамматики:

Спойлер
double wExpr (char *&in)
{
    if (wEOL(in))
    { // Проверка на конец строки
        return 0;
    }
    double result = wTerm(in); // Получение первого терма
    while (true)
    {
        wSpace(in); // Пропуск пробелов
        if (*in == '+')
        {
            in++;
            wSpace(in); // Пропуск пробелов
            result += wExpr(in); // Сложение
        }
        else if (*in == '-')
        {
            in++;
            wSpace(in); // Пропуск пробелов
            result -= wExpr(in); // Вычитание
        }
        else
        {
            break; // Выход из цикла, если нет операций сложения/вычитания
        }
    }
    return result;
}

double wTerm (char *&in)
{
    double result = wFactor(in); // Получение первого фактора
    while (true)
    {
        wSpace(in); // Пропуск пробелов
        if (*in == '*')
        {
            in++;
            wSpace(in); // Пропуск пробелов
            result *= wTerm(in); // Умножение
        }
        else if (*in == '/')
        {
            in++;
            wSpace(in); // Пропуск пробелов
            result /= wTerm(in); // Деление
        }
        else
        {
            break; // Выход из цикла, если нет операций умножения/деления
        }
    }
    return result;
}
double wFactor (char *&in)
{
    double result = 0;
    if (*in == '(')
    {
        in++;
        wSpace(in); // Пропуск пробелов
        result = wExpr(in); // Рекурсивный вызов для выражения в скобках
        if (*in == ')')
        {
            in++;
        }
    }
    else
    {
        result = wDouble(in); // Получение числа
    }
    return result;
}

Сравните правило для Выражения и функцию Wexpr().

Функция wExpr() полностью следует структуре грамматики:

  • Она сначала вычисляет Терм (wTerm(in)).
  • Затем в цикле проверяет, есть ли в строке + или -, и, если да, рекурсивно вызывает wExpr(), выполняя сложение или вычитание.

Подобное соответствие можно увидеть и в других частях кода:

  • wTerm() реализует правило <Терм>, обрабатывая * и /.
  • wFactor() соответствует <Фактор>, отличая числа от выражений в скобках.
  • wDouble() реализует <Число>.

Такой способ написания парсера делает код структурированным и интуитивно понятным: каждая функция соответствует одному правилу грамматики, а сам разбор следует естественной рекурсивной структуре выражения.

Обратите внимание на неточность в моем коде. Он рабочий, конечно, но цикл while остался в wExpr от НЕрекурсивной реализации . То есть можно и так и этак писать. В скобочных выражениях реально рекурсия нужна только при анализе скобок, но тут я её два раза написал, просто для примера.

ну и main для всего кода, чтобы проверять.

Спойлер

int main ()
{
    char input[256]; // Массив для хранения входной строки
    std::cout << "Введите выражение: ";
    std::cin.getline(input, 256); // Чтение строки с ограничением длины

    char *in = input; // Указатель на текущую позицию в строке
    double result = wExpr(in); // Вычисление выражения

    std::cout << "Результат: " << result << std::endl; // Вывод результата
}

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

Там же какая грамматика:

<Список_страниц> ::= <Диапазон> { "," <Диапазон> }  
<Диапазон> ::= <Число> | <Число> "-" <Число>  
<Число> ::= <Цифра> { <Цифра> }  
<Цифра> ::= "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"  

В рекурсивной форме первое правило:

<Список_страниц> ::= <Диапазон> [ "," <Список_страниц> ]  

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

Ну например, в скобочном выражении мы в функции wFactor можем обнаружить отсутствие закрывающей скобки.
Или неизвестный знак аддитивной операции в wExpt, или мультипликативной в wTerm.

Если найдутся интересующиеся люди, то попробуйте добавить унарную операцию. Хотя бы только унарный минус к этому коду. При этом возникнут вопросы, честное слово! Попробуйте самостоятельно!

в конце приведу весь код. (wExpr и wTerm без “следов” нерукурсивной реализации, моя цель была показать именно рекурсивную)

Спойлер

#include <iostream>
#include <cctype> // Для работы с isdigit и isspace

double wTerm(char *&in);
double wFactor(char *&in);

void wSpace(char *&in)
{
    while (isspace(*in))
    { // Пропуск пробелов
        in++;
    }
}

bool wEOL(char *in)
{
    if (*in == '\0')
    { // Проверка на конец строки
        return true;
    }
    return false;
}
int wInt(char *&in)
{
    int num = 0;
    while (isdigit(*in))
    { // Преобразование строки в число
        num = num * 10 + (*in - '0');
        in++;
    }
    return num;
}

double wDouble(char *&in)
{
    double num = 0;
    while (isdigit(*in))
    { // Преобразование строки в число
        num = num * 10 + (*in - '0');
        in++;
    }
    if (*in == '.')
    {
        in++;
        double decimal = 0.1;
        while (isdigit(*in))
        { // Преобразование дробной части
            num += (*in - '0') * decimal;
            decimal *= 0.1;
            in++;
        }
    }
    return num;
}

double wExpr(char *&in)
{
    if (wEOL(in))
    { // Проверка на конец строки
        return 0;
    }
    double result = wTerm(in); // Получение первого терма

    wSpace(in); // Пропуск пробелов
    if (*in == '+')
    {
        in++;
        wSpace(in);          // Пропуск пробелов
        result += wExpr(in); // Сложение
    }
    else if (*in == '-')
    {
        in++;
        wSpace(in);          // Пропуск пробелов
        result -= wExpr(in); // Вычитание
    }

    return result;
}

double wTerm(char *&in)
{
    double result = wFactor(in); // Получение первого фактора

    wSpace(in); // Пропуск пробелов
    if (*in == '*')
    {
        in++;
        wSpace(in);          // Пропуск пробелов
        result *= wTerm(in); // Умножение
    }
    else if (*in == '/')
    {
        in++;
        wSpace(in);          // Пропуск пробелов
        result /= wTerm(in); // Деление
    }

    return result;
}
double wFactor(char *&in)
{
    double result = 0;
    if (*in == '(')
    {
        in++;
        wSpace(in);         // Пропуск пробелов
        result = wExpr(in); // Рекурсивный вызов для выражения в скобках
        if (*in == ')')
        {
            in++;
        }
    }
    else
    {
        result = wDouble(in); // Получение числа
    }
    return result;
}

int main()
{
    char input[256]; // Массив для хранения входной строки
    std::cout << "Введите выражение: ";
    std::cin.getline(input, 256); // Чтение строки с ограничением длины

    char *in = input;          // Указатель на текущую позицию в строке
    double result = wExpr(in); // Вычисление выражения

    std::cout << "Результат: " << result << std::endl; // Вывод результата
}

Запустить можно в любой онлайн С++ среде.
Я проверил, что вот эта https://www.online-cpp.com/ открывается из РФ.

Подумал о конкурсе. Но без деанонимизации это трудно.

Я могу в меру своих возможностей заинтересовать только так, если без деанона:
Призовой фонд 20 долларов в крипте. Для простоты пусть в Биткоине. Это почти 2000 рублей по текущему курсу.

Задачи улучшения парсера максимально интересно и минимальный объем изменений.

Базовые задачи:

  1. Унарный минус.
  2. Операция возведения в степень.
  3. Функции, например синус, для демонстрации достаточно одной.
  4. переменные, тогда нужно переделать main() на работу в цикле.
  5. Пользовательские функции. (это уже почти Бейсик получится :wink: )

1 место $10, второе $5, третье $3, и два поощрения по $1!

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