Вы находитесь на странице: 1из 7

Выражения

И в математике, и в программировании помимо текста – на естественном языке


или текста программ – читаемого более-менее последовательно, присутствуют
относительно небольшие конструкции, читаемые иным образом. Наиболее
известный пример – арифметические выражения. Арифметические выражения
читаются «снаружи внутрь». Для примера возьмём известное выражение для
механической энергии . Что мы видим? Мы видим, что энергия состоит
из двух частей. Затем мы можем заглянуть внутрь каждой части (если нужно),
увидеть, что первая часть содержит произведение массы на квадрат скорости и так
далее. Более сложные формулы могут содержать больше уровней вложенности, но
принцип тот же – от общей картины к детальным соотношениям.
А вычисляем, получаем число по выражению, мы, кстати, наоборот – изнутри
наружу, сначала, если говорить о формуле выше, считаем , потом и т.д.
Есть несколько типов подобных выражений относительно часто встречающихся
в программировании.
1. Упомянутые арифметические выражения,
2. Регулярные выражения, например, ^[a-z]{0,5},
3. Объявления типов в языках C/C++, например, int (*compar) (const
void*, const void *).
Структура разных выражений различна и методы работы, соответствующие
структуре, тоже. Разберём подробнее пример арифметических выражений.
Структура, описанная выше как «снаружи внутрь» представляет собой дерево.
Бинарные операции вроде сложения, умножения и т.д. «собирают» свои числа -
операнды – в один узел дерева, делая из них одно число – результат операции,
который далее войдёт в качестве операнда в следующий узел, Например, для
выражения

дерево выглядит следующим образом

Или, словами: результат сложения 1 и результата умножения 2 на результат


сложения 3 и 4.
А что если у нас будет подряд два сложения? Т.е. сумма трёх величин – как
такая сумма укладывается в бинарное дерево? Все в порядке, мы при вычислении в
любом случае сначала выполняем одно суммирование, а потом суммируем
результат со следующим слагаемым, так что бинарное дерево сохраняется.
Например, для выражения

дерево может выглядеть как

есть и другие варианты, разная структура дерева соответствует разной очерёдности


суммирования, но и там, и там мы выполняем три суммирования и результат,
конечно, тот же.
Арифметическое выражение, как бы мы его ни записывали в виде строки, по
своему устройству есть такое дерево.
Привычная нам запись выражения, инфиксная (in- - внутри, оператор стоит
внутри, между своими операндами) получится, если обойти такое дерево слева
направо. Т.е начинаем с самого левого узла, а затем каждый узел читаем в
следующей последовательности. Узел имеет вид

где A и B – конечные узлы или поддеревья, сначала читаем поддерево A по этому же


правилу, затем узел R (Root), затем поддерево B.
То есть, дерево из первого примера будет прочитано так:
1. Самый левый узел - 1
2. Его верхний, R, узел - +
3. Его правое поддерево, начинаем его читать с левого узла, там единственный
узел – 2
4. Верхний узел - ×
5. Его правое поддерево, начинаем читать с левого узла – 3
6. Верхний узел - +
7. Правое поддерево состоит из одного узла – 4,

итого прочли: , наше выражение только без скобок. Когда выражение


записано в виде дерева, скобки не нужны, последовательность видна, но когда мы
переводим дерево в запись в виде строки, скобки становятся нужны из-за разного
приоритета (какой первый выполняется) операторов.
Но мы привыкли читать дерево сверху вниз, от верхнего узла. Имеет ли смысл
такое прочтение дерева арифметического выражения? Давайте попробуем, только
чтобы были разные операторы, для наглядности, возьмём выражение

,
его дерево

делимое и уменьшаемое в левом узле, делитель и вычитаемое в правом


Обойдём его сверху вниз, т.е. будем читать дерево в последовательности R-A-
B, получим

На самом деле, в области программирования такая запись абсолютно


привычна, я перепишу то же самое в виде

plus(multiply(1,2), divide(3, minus(4,5)))

вложенный вызов функций, у каждой функции два аргумента.


Такая запись арифметических выражений называется префиксной (pre- -
перед), оператор стоит перед своими операндами – числами или результатом
действия следующего оператора. Ещё такая запись называется прямой польской
нотацией, возможно, вы с такой сталкивались.
Её главное преимущество, помимо понятности, если вы привыкли к вложенным
функциям, - не нужны скобки. Порядок вычислений задаёт не приоритет оператора, а
последовательность операторов и операндов в строке.
А что, если мы будем читать дерево сверху вниз, но первым будем читать не
левое, а правое поддерево – R-B-A? Ничего особенного – просто операнды
поменяются местами. Для сложения и умножения ничего не изменится, но для
вычитания и деления нам нужно будет учесть, что наши функции принимают теперь
аргументы в обратном по отношению к привычному порядке, будет означать
«раздели на ».
Заметьте, и правило чтения слева направо, и правило чтения сверху вниз мы
задавали рекурсивно, это общее правило, деревья и рекурсия всегда идут
вместе. Деревья – рекурсивные объекты, каждый узел дерева содержит вершину и
два поддерева, имеющие ту же структуру, что дерево в целом.
Ещё один способ чтения деревьев – снизу вверх. Т.е. начинаем снова с левого
узла, но читаем в последовательности A-B-R. Например, дерево последнего
примера будет прочитано так

Такой способ записи называется постфиксной (post- - после), оператор идёт


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

push a – протолкни число a в стек,


add – возьми два верхних числа из стека, сложи их и положи результат в стек,
sub – возьми два верхних числа из стека, вычти первое, то, что на вершине, из
второго, того, что было глубже, и положи результат в стек,
mul – возьми два верхних числа из стека, перемножь их и положи результат в
стек,
div – возьми два верхних числа из стека, раздели второе, то, что глубже, на
первое, то, что на вершине стека, и положи результат в стек,

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


внимание, у команд операций нам не нужно указывать, с какими регистрами или
ячейками памяти операцию выполнить – всё проще, операция всегда выполняется
над верхними числами в стеке.
Теперь я пройду по строке постфиксной записи и вместо каждого числа запишу
команду отправки его в стек, а вместо оператора – соответствующую команду
ассемблера, я получу
push 1
push 2
mul
push 3
push 4
push 5
sub
div
add

Программа найдёт результат , затем поместит в стек 3, 4 и 5, вычтет 5 из 4,


разделит 3 на результат и сложит с результатом – т.е. проделает именно то, что
мы бы сделали, вычисляя выражение – проделает в правильном порядке нужные
вычисления. На вершине стека окажется результат вычисления.
Выражение в обратной польской нотации представляет собой готовую
программу для вычисления выражения для стековой машины.
Несколько слов о стековых машинах. Наиболее популярные современные
процессоры работают со стеком, но во внешней памяти, внутри процессоры
работают с регистрами. Но один из первых процессоров Intel 4004, имел внутренний
стек. Стековые машины являются альтернативой регистровым, со своими
достоинствами, например, как вы видите в примере выше, арифметические команды
не требуют операндов, программа получается короче.
Собственно, первые калькуляторы, которые умели выполнять не только
отдельные операции, но и вычислять целые выражения, принимали выражения в
обратной польской нотации, как и некоторые языки программирования, например
Forth.
И сейчас современные компиляторы переводят выражения перед выполнением
из инфиксной записи в постфиксную.
Стековыми машинами являются виртуальные машины, исполняющие байт-код
языков программирования Java, Python.

Объявления типов в C/C++, другой пример выражений, вроде

char(*(*x[3])())[5];

тоже можно перевести в постфиксную запись для удобства чтения, Как было сказано
выше, в префиксной и постфиксной записи не нужны скобки и не важны приоритеты
операторов, т.к. последовательность операций задаётся последовательностью
операторов и операндов в записи. Например, объявление выше в постфиксной
записи будет иметь вид
x[3]*()*[5]char

то есть, читая слева направо – x есть массив из трёх элементов – указателей на


функцию, не принимающую никаких аргументов и возвращающую указатель на
массив из пяти элементов типа char, разница состоит в том, что арифметические
операторы бинарные, хотя есть и унарные – факториал, унарный минус, а
операторы объявления типа унарные. Объявления типа – вопрос специальный,
подробнее мы его здесь разбирать не будем, вы можете ознакомиться с другими
примерами постфиксной записи объявлений в статье «Complicated declarations in C»
автора Narendra Kangralkar, а с правилами их чтения «для человека» в заметке
«Reading C type declarations» Steve Friedl, есть перевод, «Как правильно читать
объявления в Си».
При разборе регулярного выражения строится не дерево, а знакомый вам
конечный автомат – детерминированный и недетерминированный, т.е. регулярные
выражения – пример выражений с другим смыслом и другими алгоритмами
обработки.

Деревья арифметических выражений выше мы строили интуитивно, соединяли


операнды операторами в той последовательности, в которой проводили бы
вычисление. Но языки программирования разбирают выражения автоматически.
Полный алгоритм с учётом вызова функций из выражения, правосторонних
операторов вроде факториала, унарного минуса – выражений вроде ,
требующих отличать минус в вычитании от унарного, известен и опубликован, но мы
разберём упрощённый вариант.
Будем считать, что в нашем выражении могут встречаться только числа и
четыре арифметических оператора, кроме того, будем считать, что в выражении нет
скобок.
Также при разборе выражения нам нужно не само дерево, а преобразование
выражения в программу для вычисления. Как мы показали выше, таким
преобразованием является перевод арифметического выражения в постфиксную
форму. Поэтому опишем алгоритм преобразования инфиксного выражения в
постфиксную форму.
По сути, нам нужно просто поставить каждый оператор после обоих его
операндов, вопрос в том, как определить, где заканчивается правый операнд.
Например, в выражении правый операнд плюса заканчивается после двойки,
т.к. приоритет умножения выше и оно выполняется первым, а в правый
операнд плюса – тройка (или , помните, мы по одному выражению могли
построить разные деревья с одним результатом, но мы будем считать, что равный
по приоритету оператор заканчивает правый операнд предыдущего).
Алгоритм следующий:
1. Прочитай следующий слева направо элемент выражения – число или
оператор,
2. Если прочитано число – сразу отправь его в выходную строку,
3. Если прочитан оператор, запомни его и отправь в выходную строку, когда
закончится его правый операнд,
Сделаем отступление – где запомни? Посмотрите на выражение , наш
алгоритм сначала запомнит +, затем ×, после двойки напечатает умножение, а затем
+, в обратном порядке. Такое запоминание/извлечение в обратном порядке –
свойство стека, операторы будем запоминать в стеке.
4. Правый операнд данного оператора заканчивается, когда встречается
оператор такого же или более низкого приоритета, либо конец строки.
Посмотрим, как алгоритм обработает строку

Прочитано сте Выходная


к строка
1 1
+ + 1
2 + 12
× +× 12
3 +× 123
- - 123×+
4 - 123×+4
× -× 123×+4
5 -× 123×+45
конец строки 123×+45×-

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


по данному выражению.
1. Положить в стек 1, 2, 3
2. Перемножить 2 и 3, результат положить в стек
3. Сложить 1 с - на вершине стека лежит
4. Положить в стек 4 и 5
5. Перемножить 4 и 5, результат положить в стек
6. Вычесть из более глубокого вершину стека , результат
положить в стек.
Результат сошёлся с заданным выражением.

Выражения – способ ёмко и коротко задать взаимосвязи или операции между


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

Задачи

1. Нарисовать дерево выражения

2. Записать выражение в инфиксной (со скобочками в нужных местах),


префиксной и постфиксной форме выражения для дерева

3. С помощью команд ассемблера стековой машины, приведенных в тексте,


написать вычисляющую выражение программу для этого выражения.

4. Применить к выражению из первой задачи алгоритм перевода


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

5. В неком языке программирования можно перегружать операторы суммы и


произведения, я перегрузил их для строк так что оператор + означает конкатенацию,
объединение строк, т.е. "aaa" + "bbb" равно "aaabbb", а опратор * строки и
числа означает повторение строки, т.е. "aaa "*3 равно "aaa aaa aaa ". Чему
будет равно "aaa" + "bbb"*3, если приоритет операторов остался тот же, что был
для арифметических выражений? А если я сделал приоритеты этих операторов
равными?

Вам также может понравиться