ИНФОРМАТИКА, ВЫЧИСЛИТЕЛЬНАЯ ТЕХНИКА И УПРАВЛЕНИЕ
УДК 004.9
РАЗРАБОТКА СИНТАКСИЧЕСКОГО АНАЛИЗАТОРА АРИФМЕТИЧЕСКИХ
ВЫРАЖЕНИЙ НА ЯЗЫКЕ C++
Т.О. Губаев, Н.К. Петрова
Казанский государственный энергетический университет, г. Казань, Россия
this_mail_for_social_web@mail.ru, nk_petrova@mail.ru
Резюме: Данная статья посвящена разработке упрощённой модели синтаксического анализатора на языке C++. Было разработано программное обеспечение, позволяющее ввести арифметическое выражение и проверить его корректность с точки зрения синтаксиса языка. Помимо этого, если выражение корректно, программа способна вычислить его числовое значение, определяя также и тип данных полученного результата (целочисленный или вещественный). Поскольку мы говорим только об арифметических операциях, выражение не может содержать переменные, циклы, вызовы функций и т.д. Оно может содержать только операции суммы, произведения, разности и частного. Помимо этого, выражение может содержать группирующие скобки, знак числа и числовые константы.
Синтаксический анализ разделён на две стадии. Первая стадия обнаруживает ошибки в потоке лексем. На второй стадии строится дерево синтаксического разбора. Это разделение позволило разработать довольно простой алгоритм построения дерева разбора, который полагается на гарантию правильности выражения с точки зрения синтаксиса.
Для решения задачи, помимо классического процедурного подхода к построению программ, была использована технология объектно-ориентированного программирования. Этот подход обеспечил современные способы проектирования программ и позволил создать более гибкую архитектуру приложения.
Ключевые слова: синтаксический анализ, арифметическое выражение, дерево синтаксического разбора, лексема, грамматика, операнд, оператор
THE DEVELOPMENT OF THE SYNTACTIC ANALYZER OF ARITHMETIC
EXPRESSIONS IN C++
T.O. Gubaev, N.K Petrova
Kazan State Power Engineering University, Kazan, Russia Engineering cybernetics Dpt.
this_mail_for_social_web@mail.ru, nk_petrova@mail.ru
Abstract: This work is dedicated to the development of a simplified version of the parser of arithmetic expressions in C++ programming language. During this research, there has been developed an application, allowing input arithmetic expression and check its contents for existing of any mistakes in the terms of C+ + syntax. Besides, the developed application is able to calculate numeric value of an expression and define its type (either integer, or real), if this expression has no mistakes. As we talking about arithmetical operations, input cannot include such things, like variables, loops, function calls, etc. It has only operations like sum, multiplication, subtraction and division. Also it can contain parentheses, operator of sign change (or simply sign) and numeric constants.
Parsing algorithm is divided into two stages. The first one detects mistakes in a stream of tokens. The second one builds the parse tree. This division helps us to develop a pretty simple algorithm of parse tree building, which relies on a guarantee that its syntax is correct.
For dealing the task of application development there was applied not only procedural concept, but also object-oriented programming. It has allowed us using modern methods of designing application and to create more flexible architecture.
Keywords: syntactic analysis, arithmetic expression, syntax tree, token, grammar, operand, operator.
Введение
При современной скорости внедрения информационных технологий в жизнь в виде смартфонов, планшетов, электронных книг появляются новые операционные системы (ОС) и новые языки программирования, на которых эффективно разрабатывать не только приложения для этих ОС, но и системные программы, такие, к примеру, как трансляторы исходных кодов на языках программирования. Этим, в значительной степени, обусловлена актуальность представленной работы, способствующей, кроме всего прочего, формированию навыков и умений, необходимых в профессиональной деятельности студента-программиста.
Где может пригодиться синтаксический анализ? Он нужен везде, где возможна работа с текстом на некотором языке, будь то формальном, естественном или искусственном. Например, текстовый процессор MS Word из пакета офисных программ MS Office выполняет выявление грамматических ошибок при написании текста. Современные программные калькуляторы имеют свои правила набора арифметического выражения. И, конечно, синтаксическая проверка необходима и в текстах программ, написанных на языках разметки (например, HTML, CSS), языках программирования высокого уровня (например, C++, C#, Java), языках технических расчётов (MATLAB) и т.д.
Для реализации поставленной задачи нами был использован язык программирования С++. Несмотря на постепенную утрату лидирующих позиций С++, как языка для системного программирования, его не стоит вычёркивать из инструментария современного программиста [1]. Он, как приемник языка C, включает в себя его стандартную библиотеку и имеет возможности по управлению памятью, то есть является языком программирования с управляемой памятью [2]. Помимо этого, он реализует основные принципы ООП, с помощью которых возможно создание приложений с гибкой архитектурой [3]. Всё это даёт возможность варьировать поведение программы во время выполнения, т.к. современные программы могут решать очень широкий спектр задач.
Теоретические основы
Процесс получения исполняемого файла. Чтобы конкретизировать нашу задачу, мы вкратце рассмотрим основные этапы получения исполняемого файла, при создании программы на языке C++ [4]:
• Препроцессинг - на этом этапе обрабатываются директивы препроцессора, находящиеся в файлах исходного кода. Самый простой пример - директива #include <iostream> заменяется содержимым файла «iostream».
• Компиляция - этот процесс направлен на генерацию машинного кода и создание, так называемых объектных модулей, которые, собственно, и содержат блоки машинного кода. Однако в них присутствуют ссылки с неопределёнными адресами на различные сущности в других объектных файлах.
• Компоновка - процесс объединения объектных файлов в единый исполняемый модуль или библиотеку. Компоновщик также называют редактором связей - он вычисляет и заполняет адреса перекрестных ссылок между модулями.
Из перечисленных этапов нас интересует компиляция, поскольку именно на этом шаге выполняется анализ исходного текста программы. Рассмотрим порядок действий, которые предпринимает компилятор [5]:
• В первую очередь применяется лексический анализ. Он преобразует поток символов на языке программирования в поток лексем, который, теоретически, может являться линейным массивом объектов-лексем. Его легко обрабатывать в рамках объектно-ориентированного программирования, применяя принцип инкапсуляции методов и данных.
• После того, как получен поток лексем, к нему можно применить синтаксический анализ или же синтаксический разбор - процесс сопоставления последовательности лексем (слов) на естественном или формальном языке с его формальной грамматикой.
• К полученной структуре применяется семантический анализ - процесс выявления «смысла» синтаксических конструкций. Это, например, может быть связывание идентификатора с его объявлением, типом данных, определение результирующего типа данных выражения и так далее. В результате, получается ещё более конкретная структура -промежуточное представление.
• На конечном этапе промежуточное представление обрабатывается генератором кода, который, исходя из своего названия, призван переводить это представление в набор инструкций, реализуемых средой выполнения.
Описание принципов решения задачи. В ходе исследования мы ввели в рассмотрение упрощенную модель транслятора, сущность которой будет рассмотрена далее, и разработали проект, который позволяет в тексте программы на С++ анализировать арифметические выражения на наличие ошибок с точки зрения синтаксиса языка С++. В первую очередь, обратим внимание на такие этапы, как лексический и синтаксический анализ. Их конечным результатом является дерево синтаксического разбора [6], являющееся ориентированным связным ациклическим графом, отображающим зависимости между лексемами. Зависимости, очевидно, следуют из грамматики рассматриваемого языка, которая в нашем случае является контекстно-свободной [7]. Сразу стоит заметить, что вместо того чтобы реализовывать семантический анализ и генерацию машинного кода, мы создадим алгоритм, который в процессе обхода дерева синтаксического разбора будет вычислять значение исходного арифметического выражения, а также определять его тип данных. Это позволит оценить корректность работы нашей реализации упрощённой модели транслятора.
Так как речь идёт лишь только об арифметическом выражении, то нужно понимать, что в нём не могут участвовать разного рода ветвления, циклы, операторы присваивания, вызовы функций, обращение к переменным. Иными словами, речь идёт о выражении, состоящем только из числовых констант (целочисленные константы, константы с фиксированной точкой и константы с плавающей точкой), основных арифметических операций (сумма, разность, произведение, частное, изменение знака) и группирующих скобок (круглая открывающая и круглая закрывающая, соответственно).
Должны поддерживаться положительные и отрицательные:
• целые числа;
• числа с фиксированной точкой:
• с целой и вещественной частью;
• с опущенной целой частью;
• с опущенной вещественной частью;
• числа с плавающей точкой (обязательная мантисса, подчиняющаяся правилам описания целого числа или числа с фиксированной точкой, символ экспоненты может быть в любом регистре, порядок может быть как положительным, так и отрицательным, пробелов быть не должно).
Все вышесказанное приводит нас к иерархии лексем, показанной на рис. 1. В целях упрощения алгоритма лексического анализа были введены лексемы начала, конца и ошибки, для которых грамматические правила описаны так же, как, например, для операндов или операторов.
Рис. 1. Иерархия лексем, распознаваемых лексическим анализатором
Разработка алгоритмов для решения задачи
Построение алгоритма обработки лексем. Каким же образом мы будем выделять лексемы из потока символов? Стандартная библиотека языка C включает в себя мощный инструмент в виде функции «scanf» [8], которая позволяет считывать данные определённого формата из стандартного потока ввода.
Помимо того, что мы можем считывать, например, числа и строки с помощью спецификаторов формата таких, как «%d», «%f» и «%s», мы также имеем возможность извлекать из потока последовательности из определённых символов, или последовательности, не включающие указанные символы. Например, после вызова «scanf("%[0-9]", buffer)» буфер будет содержать только цифры от нуля до десяти, а вызов «scanf"%[AA-Za-z]", buffer)» не будет считывать символы латинского алфавита. Стоит отметить, что считывание прекращается ровно тогда, когда встречаются символы, не входящие в описанное множество сканирования. Также можно указывать максимальное количество символов, которое нужно прочитать: вызов «scan/("%20[U]", buffer)» приведёт к чтению максимум двадцати символов табуляции. Помимо всего вышеперечисленного есть
ещё одна возможность - подавление присваивания. Предположим, мы имеем вызов «scanf("%*[ ]")», тогда из потока прочтутся пробелы до первого не пробельного символа и результат чтения не будет присвоен по указателю, который мы передаём в функцию. Чтобы продемонстрировать работу с этой функцией, приведём такой пример: в стандартном потоке ввода содержится одно целое число, но прямо перед ним находится неопределённое количество символов, как пробельных, так и буквенных (верхний и нижний регистр). Тогда для чтения этого числа можно применить вызов «scanA"%*[A-Za-z]%d", &number)». Таким образом, если содержимое стандартного потока ввода имеет вид «Some Boring Text Before Actual Number AABBCCDDEEFFGG100», то после вызова, описанного выше, в переменной «number» окажется значение «100».
Поскольку арифметическое выражение является, по сути, чередованием операндов, операторов и группирующих скобок, то синтаксический анализ можно свести к простой проверке соседних лексем. То есть, проверяя очередную лексему, мы определяем то, чем, с точки зрения языка C++, является предыдущее слово. Таким образом, для каждой группы лексем со сходными грамматическими правилами можно составить список из других групп, которые не могут им предшествовать (рис. 2).
Операнд
■ Другой операнд ■Закрывают ая скобка
Оператор
• Другой оператор
• Открываю щая скобка
•Начало выражения (леке ем а -начало)
Открывают ая скобка
■Операнд ■Закрывают ая скобка
Закрывают ая скобка
• Оператор
• Открываю тая скобка
•Начало выражения (леке ем а -начало)
Лекеема-конец
• Оператор
• Открываю тая скобка
•Начало выражения (леке ем а-начало)
Рис. 2. Сопоставление лексем по признаку их несовместимости
Оператор суммы и разности в некоторых ситуациях может интерпретироваться как знак числа, здесь требуется проверка с двух сторон: если перед знаком (лексемой суммы или разности) стоит оператор, открывающая скобка или лексема-начало, а после знака стоит операнд или открывающая скобка, значит эта лексема, действительно, является знаком числа, а не оператором. Если условие с правой стороны не выполняется, значит, допущена ошибка в написании знака (знак есть, а само число отсутствует). Однако, если лексема с правой стороны является концом выражения, то текущая лексема никак знаком являться не может и нужно интерпретировать её как оператор (а вот тогда ошибка об отсутствии правостороннего операнда будет выявлена на этапе проверки лексемы-конца).
Что касается группирующих скобок и вложенностей, которые они создают, то следует вести счётчики лишних открывающих и закрывающих скобок. Когда встречается открывающая скобка, нужно увеличить счётчик открывающих скобок, а когда встречается закрывающая - уменьшить счётчик открывающих скобок, если он больше нуля, или увеличить счётчик закрывающих скобок в противном случае. В конечном итоге, эти значения покажут, сколько осталось незакрытых скобок (счётчик открывающих скобок) и сколько осталось лишних закрывающих скобок (счётчик закрывающих скобок).
Построение алгоритма синтаксического анализа. В данной работе было решено отделить проверку выражения на ошибки от построения древа синтаксического разбора. Это позволило создать очень простой алгоритм получения иерархической структуры, реализация которого опирается на гарантию корректности выражения. Узлами этой иерархии являются операторы (сложение, вычитания и т.д.) и отрицательный знак (он может встречаться не только перед числом, но и перед выражением в скобках). Листьями
являются операнды, а группирующие скобки и положительный знак не включаются в конечное дерево: они оказывают лишь структурное влияние и не привносят в иерархию никакой семантики. На рис. 3 проиллюстрированы обе ситуации на примере выражения «3 * (1 + 2)».
Правила построения структуры таковы:
• Если встретился операнд, то его нужно сделать потомком текущего узла.
• Если встретился положительный знак, то он пропускается.
• Если встретился отрицательный знак, то для него создаётся новый узел, который становится потомком текущего. Текущим узлом становится узел, содержащий отрицательный знак.
• Если встретился оператор, то сравниваем его приоритет с приоритетом оператора в родительском узле. Если приоритет больше, то данный оператор становится потомком текущего узла. Если приоритет меньше или равен приоритету оператора в родительском узле, то оператор необходимо поместить между текущим узлом и его родителем. Во всех случаях, после изменения дерева, текущим узлом становится тот, который был только что добавлен.
• Если встретилась открывающая скобка, то текущий узел добавляем в стек. Создаём новый узел и делаем его потомком текущего узла. Теперь текущим узлом будет только что созданный.
• Если встретилась закрывающая скобка, то очередной узел вынимается из стека и становится текущим.
Хорошим критерием правильности синтаксического анализа является применение полученного дерева синтаксического разбора для решения некоторой задачи. Самой очевидной задачей может являться вычисление численного значения арифметического выражения. С этой целью в проект был включён алгоритм обхода этой древовидной структуры, который рекурсивным образом вычисляет численное значение этого узла по следующей схеме:
• Если встретился операнд, то выполнить преобразование его строкового значения в числовое.
• Если встретился оператор, то рекурсивным вызовом получить численное значение его потомков и, используя эти значения, выполнить требуемую арифметическую операцию.
Результаты
Структура проекта включает в себя такие классы, как «Token», «Expression», «ParseTreeNode», «ParseTree» и «Calculator» (рис. 4).
(1 + 2) J
3 * (1 + 2)
Рис. 3. Дерево синтаксического разбора для арифметического выражения.
Класс «Token» инкапсулирует данные связанные с лексемой (её тип и строковое значение) и позволяет выполнить её чтение с клавиатуры. Также класс содержит методы, которые упрощают работу синтаксического анализатора. Основной метод этого класса -«ReadToken». Данный метод возвращает логическое значение: истина, если лексема успешно прочитана и ложь, если был достигнут конец выражения.
<
- tree : ParseTreeNode
+ buildTree(Expression)
\/ 1
ParseTreeNod
Calculator
• result : CalculationResult
+ calculate(ParseTree)
- calculate(ParseTreeNode) : CalculationResult
- token:
- parent : ParseTreeNode
- leftChild : ParseTreeNode
- rightChild : ParseTreeNode
Token
- tokenType : TokenType
- tokenValue : String
+ readToken() : Boolean
CalculationResult <----------
isReal : Boolean
integerValue : Integer
doubleValue : Double
Expression
- tokenList : Token[2..*]
- tokenCount : Integer
+ analyzeExpression(Integer) + analyzeOperand(Token) + analyzeOperator(Token) + analyzeOpenBrace(Token) + analyzeCloseBrace(Token) + analyzeEnd(Token)
«перечисление» TokenType
Целая константа
Константа с фиксированной точкой Лексема-ошибка
+
v 2-
Рис. 4. Упрощенная диаграмма классов, используемых для решения задачи
Класс «Expression» создан для чтения, хранения и анализа потока лексем. Здесь под анализом подразумевается проверка выражения на лексические и синтаксические ошибки. Основные методы класса «Expression» - «ReadExpression» и «AnalyzeExpression». Первый метод выполняет чтение выражения с клавиатуры, его лексический анализ и подсчёт количества ошибок. Второй метод анализирует прочитанное выражение и также подсчитывает ошибки. Оба метода принимают параметр - ссылка на счётчик лексических и синтаксических ошибок соответственно.
Если проверка на синтаксические ошибки завершилась успешно, то можно применить методы класса «ParseTree», позволяющие выполнить построение дерева синтаксического разбора. Для этого необходимо вызвать метод «BuildTree». В качестве параметра он принимает ссылку на объект выражения. После этого можно вызвать метод «PrintTree» для того, чтобы увидеть содержимое дерева.
В свою очередь, иерархическая структура, представленная в «ParseTree», описывается самоотносимым [9] классом «ParseTreeNode», представляющим из себя описание одного узла двоичного древа. Содержит типичные методы, характерные для связных списков, иерархий и других структур данных.
Класс «Calculator» содержит функционал, который позволяет выполнить обход дерева синтаксического разбора и вычислить численное значение арифметического выражения. Результатом вычисления является структура данных, в которой содержится числовое значение выражения, а также флаг, указывающий на то, является ли результат целым или вещественным числом. Основной метод данного класса - «Calculate». В
качестве входного параметра он принимает ссылку на объект класса «ParseTree». Выходными данными является структура данных «CalculationResult». Она содержит объединение [10] из двух полей (целочисленного и вещественного) и флаг, который указывает на то, какое из полей следует использовать в качестве результата.
Заключение
В работе был программно реализован алгоритм синтаксического анализа арифметического выражения на языке программирования C++. Были получены представления о принципиальном функционировании процесса трансляции, о том, какие алгоритмы, абстракции и структуры данных в нём используются. Также были получены навыки разработки программ, реализующих основные принципы объектно-ориентированного программирования. Дальнейшим развитием проекта может оказаться расширение понятия арифметического выражения, определённого ранее, вплоть до любого выражения на языке C++. Также можно реализовать процесс генерации кода на другом языке программирования, промежуточного байт-кода, или машинного кода.
Литература
1. C++ в современном мире // habrahabr.ru URL: https://habrahabr.ru/company/pvs-studio/blog/259777/ (дата обращения: 04.11.2017).
2. Stroustrup B. The C++ Programming Language. 4 изд. Addison-Wesley, 2013.
3. Gamma E., Helm R., Johnson R., Vlissides J. Design Patterns: Elements of Reusable Object-Oriented Software. USA: Addison-Wesley, 1994. 395 с.
4. Павловская Т.А. С/С++. Программирование на языке высокого уровня. СПб.: Издательский дом "Питер", 2010. 460 с.
5. Aho A., Lam M., Sethi R., Ullman J. Compilers: Principles, Techniques, and Tools. 2 изд. London: Pearson Education, Inc, 2008. 1184 с.
6. Гавриков М.М., Иванченко А.М., Гринченко Д.В. Теоретические основы разработки и реализации языков программирования. М.: КноРус, 2010. 184 с.
7. Белоусов А. И., Ткачев С. Б. Дискретная математика. М.: МГТУ, 2006. 743 с.
8. Deitel P., Deitel H. C How To Program. М.: Бином, 2017. 1008 с.
9. Deitel P., Deitel H. Visual C# 2012 How to Program. 5 изд. СПб.: Питер, 2014. 1008 с.
10. Типы struct, union и enum в Modern C++ // habrahabr.ru URL: https://habrahabr.ru/post/334988/ (дата обращения: 22.04.2018).
Авторы публикации
Губаев Тимур Олегович - студент Казанского государственного энергетического университета (КГЭУ). E-mail: this_mail_for_social_web@mail.ru.
Петрова Наталья Константиновна - канд. физ.-мат. наук, доцент кафедры «Информатика и информационно-управляющие системы» (ИиУС) Казанского государственного энергетического университета (КГЭУ). E-mail: nk_petrova@mail.ru.
References
1. C++ in the modern world // viva64.com URL: https://www.viva64.com/en/bZ0329/ (date viewed: 04.11.2017).
2. Stroustrup B. The C++ Programming Language. 4 edition. Addison-Wesley, 2013.
3. Gamma E., Helm R., Johnson R., Vlissides J. Design Patterns: Elements of Reusable Object-Oriented Software. USA: Addison-Wesley, 1994. 395 p.
4. Pavlovskaya T. C/C ++. Programming in high-level language. St. Petersburg : Publishing House "Peter", 2010. 460 p.
5. Aho A., Lam M., Sethi R., Ullman J. Compilers: Principles, Techniques, and Tools. 2 edition. London: Pearson Education, Inc, 2008. 1184 p.
6. Gavrikov M., Ivanchenko A., Grinchenko D. Theoretical bases of development and implementation of programming languages. Moscow: KnoRus, 2010. 184 p.
7. Belousov A., Tkachev S., Discrete mathematics. Moscow: MSTU, 2006. 743 p.
8. Deitel P., Deitel H. C How To Program. Moscow: Binom, 2017. 1008 p.
9. Deitel P., Deitel H. Visual C# 2012 How to Program. 5 edition. St. Petersburg: Publishing House "Peter", 2014. 1008 p.
10. Types struct, union and enum in Modern C++ // habrahabr.ru URL: https://habrahabr.ru/post/334988/ (date viewed: 22.04.2018).
Authors of the publication
Gubaev T. Olegovich - student of the Kazan State Power Engineering University, Department «Engineering Cybernetics». E-mail: this_mail_for_social_web@mail.ru.
Natalia K. Petrova - PhD, assistant professor, Department «Computer Science And Information-Control Systems», Kazan State Power Engineering University. E-mail: nk_petrova@mail.ru.
Дата поступления 19.04.2018