Pull to refresh

Пишем простой транслятор на Лиспе — III

Reading time12 min
Views2.8K
Предыдущая статья

Ошибки, Ошибки, Ошибки…


Хорошая программа должна быть защищена от ошибок пользователя. Это совершенно бесспорно. Ошибки нужно обрабатывать, а еще лучше – предупреждать (профилактика всегда лучше лечения!). Высший пилотаж – так выстроить диалог с пользователем, чтобы последний просто не мог совершить ошибку.

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

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

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

Начнем с первой функции start. Что она делает? Она берет имя входного файла, открывает его и обрабатывает строку за строкой. Для таких программ сценарий взаимодействия с пользователем уже “устоялся” – его можно считать каноническим:

  • Если имя файла не задано – вызвать стандартный диалог “Оpen”;
  • Если пользователь нажал кнопку “отказ” в диалоге “Open” – завершить работу;
  • Проверить, существует ли файл с заданным/введенным именем. Если не существует – выдать сообщение и завершить работу;
  • Если заданный файл существует – обработать его.

Наша версия процедуры start этому сценарию не удовлетворяет. В самом деле, посмотрим на приведенный код:

(defun start (&optional (fname ""))
   (setq *numline* 0)
   (setq *flagerr* nil)
   (setq *oplist* …) ;; список опущен для экономии места
   (when (zerop (strLen fname))
         (setq fname (sysGetOpenName (sysHome) "Мини-бэйсик|*.mbs")))
   (let ((fi (gensym 'fi)))
        (filOpen fi fname _INPUT)
        (loop
            (let ((curr-proc (action-proc fi)))
                 (when *flagerr* (return t))
                 (when (filEOF fi) (return t))
                 (eval curr-proc)))
        (filClose fi))
        (when *flagerr* (printsline "**** Были найдены ошибки")))

Отрицательный ответ пользователя не анализируется, поэтому если будет нажата кнопка «отказ» – программа “упадет”. Существование файла тоже не анализируется. К сожалению, этой недоработкой недостатки не исчерпываются. Очевидно, что если процедура мини-бэйсика — последняя во входном файле, то анализ конца файла приведет к тому, что цикл разорвется прежде, чем сгенерированная функция будет загружена в среду Лиспа.

Исправим эти недоработки:

(defun start (&optional (fname ""))
   (setq *numline* 0)
   (setq *flagerr* nil)
   (setq *oplist* … )
   (when (zerop (strLen fname))
         (setq fname (sysGetOpenName (sysHome) "Мини-бэйсик|*.mbs"))) 
   (if (and fname (filExistp fname))     
        (let ((fi (gensym 'fi)))
              (filOpen fi fname _INPUT)
              (loop
                 (let ((curr-proc (action-proc fi)))
                      (when *flagerr* (return t))
                      (when curr-proc (eval curr-proc))
                      (when (filEOF fi) (return t))))
              (filClose fi)
              (when *flagerr* (printsline "**** Были найдены ошибки")))
        (printsline (if fname 
                        (strCat "**** Файл " fname " не существует") 
                        "**** Опущено имя файла")))
    (unset '*numline*)         
    (unset '*flagerr*)
    (unset '*oplist*))

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

  • Отрабатывает функция action-proc. Результат ее работы сохраняется в локальной переменной curr-proc;
  • Если флаг *flagerr* поднят, цикл разрывается;
  • Если функция action-proc вернула непустой результат – происходит загрузка сформированной функции в среду Лиспа;
  • Если достигнут конец файла, цикл также разрывается.

Код стал, как будто, лучше… Но еще один серьезный недостаток остался неустраненным – после завершения обработки процедуры, содержащей одну или более ошибок, главный цикл будет разорван и программа завершится, не просмотрев часть исходного года, расположенного за процедурой с ошибками. Это плохо – хотелось бы, чтобы транслятор при каждом запуске выдавал бы все ошибки, которые можно выявить.

Чтобы исправить этот недочёт, давайте введем глобальную переменную “счетчик ошибок”, при обработке процедуры с ошибками будем этот счетчик увеличивать. А флаг ошибки будем сбрасывать после обработки каждой процедуры:

(defun start (&optional (fname ""))
   (setq *numline* 0)
   (setq *flagerr* nil)
   (setq *errcount* 0)
   (setq *oplist* …)
    (when (zerop (strLen fname))
         (setq fname (sysGetOpenName (sysHome) "Мини-бэйсик|*.mbs"))) 
   (if  (and fname (filExistp fname))     
        (let ((fi (gensym 'fi)))
              (filCloseAll)    
              (filOpen fi fname _INPUT)
              (loop
                 (let ((curr-proc (action-proc fi)))
                      (when *flagerr* (setq *errcount* (add1 *errcount*)))
                      (when (and curr-proc (not *flagerr*)) (eval curr-proc))
                      (setq *flagerr* nil)
                      (when (filEOF fi) (return t))))
              (filClose fi)
              (when (> *errcount* 0) (printsline "**** Были найдены ошибки")))
        (printsline (if fname (strCat "**** Файл " fname " не существует") "**** Опущено имя файла")))
    (unset '*numline*)         
    (unset '*flagerr*)
    (unset '*oplist*)
    (unset '*errcount*))

Вот теперь функция start будет работать приемлемо. Давайте в этом убедимся. Создадим следующий исходный файл:

*
* функция с ошибкой
*
proc test1(x)
 local y
 y=x^2
 bla-bla
end_proc
*
* функция без ошибки
*
proc test2()
  local x,y
  input x
  y=test1(x)
  print y
end_proc
*
* функция с ошибкой
*
proc test3(x)
  bla-bla-bla
  print x
end_proc

И “пропустим его” через наш транслятор. Получим:

0001 *
0002 * функция с ошибкой
0003 *
0004 proc test1(x)
0005  local y
0006  y=x^2
0007  bla-bla
**** Оператор (BLA - BLA) не распознан
0008 end_proc
0009 *
0010 * функция без ошибки
0011 *
0012 proc test2()
0013   local x,y
0014   input x
0015   y=test1(x)
0016   print y
0017 end_proc
0018 *
0019 * функция с ошибкой
0020 *
0021 proc test3(x)
0022   bla-bla-bla
**** Оператор (BLA - BLA - BLA) не распознан
0023   print x
0024 end_proc
0025 
**** Были найдены ошибки

Будем считать, что с функцией start мы справились. Но “работа над ошибками” только началась. Приглядимся к синтаксису той части языка, которую мы уже реализовали.

Вероятно, самая распространенная синтаксическая ошибка, которую люди чаще всего допускают – это неверная скобочная структура (несбалансированные или стоящие в неправильном порядке круглые скобки). Вспомним, что происходит со строкой исходного текста программы на мини-бэйсике после того, как она читается. Строка парсится (разбивается на лексемы), а затем список лексем переводится во внутреннюю списковую форму. В списке лексем круглые скобки являются отдельными лексемами и их сбалансированность мы не проверяем. Это можно было бы сделать отдельной функцией, но ведь список лексем передается на вход функции input, которая осуществляет перевод списка строк в список Лиспа. Если на вход функции input передается некорректное строковое выражение, функция вернет ошибку.

Давайте эту ошибку обработаем.

В HomeLisp для обработки ошибок служит конструкция (try Выражение-1 except Выражение-1). Работает она следующим образом:

  • Делается попытка вычислить Выражение-1. Если попытка успешна – результат вычисления возвращается, как результат всей формы try;
  • Если возникла ошибка, то вычисляется Выражение-2. При этом доступна системная функция без параметров (errormessage), которая возвращает текст сообщения об ошибке.

С учетом сказанного, перевод в списковую форму можно оформить так:

(defun mk-intf (txt)
  (let ((lex (parser txt " ," "()+-*/\^=<>%"))
        (intf ""))
   (iter (for a in lex) (setq intf (strCat intf a " ")))
   (try 
        (input (strCat "(" intf ")"))
    except
        (progn 
           (printsline (strCat "**** " (errormessage)))
           `(,txt) ))))

В случае возникновения ошибки преобразования будет выдано системное сообщение, а в качестве результата будет возвращен список из одного элемента – исходной строки кода. Далее этот список попадет (как очередной оператор) в процедуру action-proc. И, естественно, не будет распознан. Это породит еще одно сообщение об ошибке, а транслятор продолжит работу. Подготовим следующий исходный текст, и попробуем его протранслировать:

*
* функция с ошибкой
*
proc test1(x)
 local y
 y=(x^2))
end_proc
*
* функция без ошибки
*
proc test2()
  local x,y
  input x
  y=test1(x)
  print y
end_proc
*
* функция с ошибкой
*
proc test3(x)
  x=3+)x^2 
  print x
end_proc

Получим вполне ожидаемый результат:

0001 *
0002 * функция с ошибкой
0003 *
0004 proc test1(x)
0005  local y
0006  y=(x^2))
**** Нарушен баланс скобок или неверна скобочная структура
**** Оператор ("y=(x^2))") не распознан
0007 end_proc
0008 *
0009 * функция без ошибки
0010 *
0011 proc test2()
0012   local x,y
0013   input x
0014   y=test1(x)
0015   print y
0016 end_proc
0017 *
0018 * функция с ошибкой
0019 *
0020 proc test3(x)
0021   x=3+)x^2
**** Нарушен баланс скобок или неверна скобочная структура
**** Оператор ("x=3+)x^2") не распознан
0022   print x
0023 end_proc
**** Были найдены ошибки

А теперь посмотрим критическим взглядом на код, который преобразует арифметические выражения в префиксную запись. Этот код не содержит никаких средств, фиксирующих ошибки пользователя. К сожалению, этих ошибок может быть достаточно много. Давайте исправим эту оплошность. Для начала, попробуем протранслировать совершенно невинный (с виду) код:

proc test()
  local x,y
  x=6
  y=-x
  print y
end_proc

Трансляция завершится “падением” транслятора! Падение вызовет оператор y=-x. В чем же дело? В унарном минусе! Преобразовывая формулу из инфиксной формы в префиксную, мы как-то не подумали о том, что минус “двулик” – бывает бинарный минус (знак операции), а бывает унарный минус (знак числа). Наш парсер не знает этой разницы – он все минусы считает бинарными… Что же теперь делать? Чтобы не рушить уже работающий код, давайте превратим все унарные минусы в бинарные. Как? А очень просто. Ведь вполне очевидно, что унарный минус “живет” только в таких конструкциях:

“(-нечто”
“>-нечто”
“<-нечто”
“=-нечто”
ну, и в самом начале формулы он тоже может встретиться. Поэтому если перед разбивкой на лексемы мы выполним вот такие замены:

“(-нечто” => “(0-нечто”
“>-нечто” => “>0-нечто”
“<-нечто” => “<0-нечто”
“=-нечто” => “=0-нечто”

а если формула начинается с минуса, припишем в начало формулы нуль, то все минусы станут бинарными и ошибка будет устранена радикально. Давайте назовем функцию, которая будет делать приведенное выше преобразование, именем “prepro”. Вот как она может выглядеть:

(defun prepro (s)
  (let* ((s0 (if (eq "-" (strLeft s 1)) (strCat "0" s) s))
         (s1 (strRep s0 "(-" "(0-"))
         (s2 (strRep s1 "=-" "=0-"))
         (s3 (strRep s2 ">-" ">0-"))
         (s4 (strRep s3 "<-" "<0-"))) 
   s4))

Особые комментарии здесь не требуются. Но у нашего простого парсера есть еще одна не вполне очевидная на первый взгляд беда – двойные знаки операций. При работе с формулами знаки “>” и “=” стоящие рядом, означают одну операцию “>=” (и должны составлять одну лексему!). Парсер этого знать не желает – он каждый из знаков сделает отдельной лексемой. Справиться с этой проблемой можно, просмотрев список полученных лексем, и если соответствующие знаки стоят рядом – выполнив объединение. Назовем функцию, которая будет выполнять объединение именем “postpro”. Вот код возможной реализации:

(defun postpro (lex-list)
  (cond ((null (cdr lex-list)) lex-list)
    (t (let ((c1 (car lex-list))
             (c2 (cadr lex-list)))
         (cond ((and (eq c1 ">") (eq c2 "=")) (cons ">=" (postpro (cddr lex-list))))
               ((and (eq c1 "<") (eq c2 "=")) (cons "<=" (postpro (cddr lex-list))))
               ((and (eq c1 "=") (eq c2 "=")) (cons "==" (postpro (cddr lex-list))))
               ((and (eq c1 "<") (eq c2 ">")) (cons "<>" (postpro (cddr lex-list))))
               ((and (eq c1 ">") (eq c2 "<")) (cons "<>" (postpro (cddr lex-list))))
               ((and (eq c1 "!") (eq c2 "=")) (cons "/=" (postpro (cddr lex-list))))
               ((and (eq c1 "/") (eq c2 "=")) (cons "/=" (postpro (cddr lex-list))))
               (t (cons c1 (postpro (cdr lex-list)))))))))

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

(defun mk-intf (txt)
  (let ((lex (postpro (parser (prepro txt) " ," "()+-*/\^=<>%")))
        (intf ""))
   (iter (for a in lex) (setq intf (strCat intf a " ")))
   (try 
        (input (strCat "(" intf ")"))
    except
        (progn 
           (printsline (strCat "**** " (errormessage)))
           `(,txt) ))))

А сейчас давайте взглянем критически на функцию inf2ipn. Какие ошибки пользователя могут её “свалить”? Несбалансированность скобок мы уже отсекли выше. Что может быть еще? Два знака операции или два операнда, стоящие подряд. Можно было бы проанализировать это в коде inf2ipn (и желающие могут это проделать самостоятельно). Мы же “отловим” эти ошибки на этапе преобразования формулы из ОПЗ в префиксную. И давайте (на всякий случай) будем перехватывать все ошибки, которые могут возникнуть в процессе преобразования формулы из инфиксной формы в префиксную. Самое лучшее место для этого – функция-оболочка i2p. Теперь она может выглядеть так:

(defun i2p (f)
  (try    (ipn2pref (inf2ipn f))
   except (progn (printsline "**** Ошибка в записи выражения") 
                 (printsline (strCat "**** " (errormessage)))
                 (setq *flagerr* t) nil)))        

А теперь предотвратим появление в формулах двух знаков операции или двух операндов подряд. В предыдущей статье описан алгоритм перевода формулы из ОПЗ в префиксную форму. Признаком корректности завершения этого алгоритма является то, что на последнем шаге в стеке должно содержаться единственное значение. Если это не так – значит была допущена ошибка. И еще одна ошибочная ситуация возникает в том случае, когда функция вызывается с неверным (большим или меньшим) числом параметров. Эти ситуации следует “отловить”:

(defun ipn2pref (f &optional (s nil))
  (cond ((null f) (if (null (cdr s)) (car s) 
        (progn (printsline "**** Ошибка в записи выражения") 
               (setq *flagerr* t) nil)))
        ((numberp (car f)) (ipn2pref (cdr f) (cons (car f) s)))
        ((is-op (car f))
         (let ((ar (arity (car f))))
           (if (< (length s) ar)           
               (progn (setq *flagerr* t) 
                      (printsline "**** Ошибка в записи выражения") nil)
               (ipn2pref (cdr f) 
                (cons (cons (car f) (reverse (subseq s 0 ar))) 
                     (subseq s ar))))))
        ((atom (car f)) (ipn2pref (cdr f) (cons (car f) s)))
        (t (ipn2pref (cdr f) (cons (list (car f) (car s)) (cdr s)))))) 

Теперь посмотрим “критическим взглядом” на обработчик оператора proc. Мы явно упустил два момента. Первое, что следует сделать, это не забыть при обработке процедуры вычислить её арность (количество аргументов) и соответствующим образом модифицировать глобальную переменную *oplist*. А второе заключается в том, что генерируемые нами функции не возвращают правильного значения! Точнее, в качестве результата сгенерированных нашим транслятором функций будет возвращаться значение последней формы, вычисленной перед возвратом. Чтобы гарантировано возвращать нужное значение, предлагаю перенести из Паскаля переменную result. Теперь, при необходимости вернуть нужное значение, пользователю достаточно перед выходом из функции присвоить этой переменной нужное значение, а нам необходимо при генерации тела функции вставить имя result в тело функции последним выражением. Все это приводит функцию action-proc к виду:

(defun action-proc (fi)
 (let ((stmt nil)
       (proc-name nil)
       (proc-parm nil)
       (loc-var   nil)
       (lv        '((result 0)))
       (body      nil))
  (loop
     (setq stmt (mk-intf (getLine fi))) 
     (when (null stmt) (return t))
     (cond ((eq (car stmt) 'proc) 
                (setq proc-name (nth 1 stmt))
                (setq proc-parm (nth 2 stmt))
                (setq *oplist* (cons (list proc-name (length proc-parm)) *oplist*)))                      
            ((eq (car stmt) 'end_proc) (return t))
            ((eq (car stmt) 'print) 
                 (setq body (append body (list (cons 'printline (cdr stmt))))))
            ((eq (car stmt) 'input) 
                 (setq body (append body (list (list 'setq (cadr stmt) 
                  (list 'read) )))))
            ((eq (car stmt) 'local) 
                 (setq loc-var (append loc-var (cdr stmt))))
            ((eq (cadr stmt) '=) 
                    (setq body (append body (list (action-set stmt)))))
            (t (printsline (strCat "**** Оператор " (output stmt) " не распознан"))  
               (setq *flagerr* t))))
    (iter (for a in (setof loc-var)) (collecting (list a 0) into lv)) 
    (if proc-name `(defun ,proc-name ,proc-parm (let ,lv ,@body result)) nil)))

На этом мы пока остановимся (хотя проблемы нам еще встретятся, и код придется дорабатывать; но таков уж удел программиста…) А сейчас рассмотрим два улучшения нашего языка, которые уместно внести именно сейчас.

Мелкие улучшения…


В предыдущей статье я писал, что программисту неудобно, если в языке один оператор занимает ровно одну строку. Нужно обеспечить возможность записывать громоздкие операторы на нескольких строках. Давайте это реализуем. Сделать это совсем нетрудно. В процедуре getLine заведем локальную переменную, в которой будем накапливать прочитанный текст (при условии, что это не комментарий и оканчивается на пару символов “ _”. Как только зафиксирована значимая строка с другим окончанием – возвращаем накопленное в качестве значения. Вот код:

(defun getLine (fil)
  (let ((stri "") (res ""))
    (loop
      (when (filEof fil) (return ""))
      (setq *numline* (add1 *numline*)) 
      (setq stri (filGetline fil))
      (printsline (strCat (format *numline* "0000") " " (strRTrim stri)))
      (unless (or (eq "" stri) (eq "*" (strLeft stri 1)))
         (setq stri (strATrim stri))
         (if (eq " _"(strRight stri 2)) 
             (setq res (strCat res (strLeft stri (- (strLen stri) 2))))
             (setq res (strCat res stri)))
         (unless (eq " _"(strRight stri 2)) (return res))))))

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

z=(x>y)*5+(x<=y)*10

вызовет ошибку времени выполнения. И это понятно: в Лиспе выражение (> x y) вычисляется до Nil или T. А Nil/T нельзя умножить на 5… Однако, этой беде легко помочь. Давайте напишем несколько простых макро, которые заменят результат выражений сравнения на 0/1 (вместо Nil/T):

(defmacro $= (x y)  `(if (= ,x ,y) 1 0))
(defmacro $== (x y) `(if (= ,x ,y) 1 0))
(defmacro $> (x y)  `(if (> ,x ,y) 1 0))
(defmacro $< (x y)  `(if (< ,x ,y) 1 0))
(defmacro $/= (x y) `(if (/= ,x ,y) 1 0))
(defmacro $<> (x y) `(if (/= ,x ,y) 1 0))
(defmacro $<= (x y) `(if (<= ,x ,y) 1 0))
(defmacro $>= (x y) `(if (>= ,x ,y) 1 0))

А теперь взглянем на строку в функции ipn2pref, которая выполняет обработку операции. Вот эта строка:

(ipn2pref (cdr f) (cons (cons (car f) (reverse (subseq s 0 ar))) (subseq s ar)))

Здесь (car f) – имя операции. Напишем крохотную функцию замены кодов сравнения:

(defun chng-comp (op)
  (if (member op '(= == /= <> > < >= <=)) (implode (cons '$ (explode op))) op))

Функция проверяет, является ли ее аргумент операцией сравнения, и, при необходимости, добавляет в начало символ “$”. А теперь вызовем ее в нужном месте функции ipn2pref:

(ipn2pref (cdr f) (cons (cons (chng-comp (car f)) (reverse (subseq s 0 ar))) (subseq s ar)))

Что получится в результате? Операции сравнения будут заменены на вызовы соответствующих макро, а все прочие операции не изменятся. Если протранслировать вот такую функцию:

proc test()
 local x,y
 x=1
 y=2
 result=(x>y)*5+(x<=y)*10
end_proc

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

На сегодня – всё.

Код к этой статье расположен здесь
Продолжение следует.
Tags:
Hubs:
Total votes 24: ↑21 and ↓3+18
Comments5

Articles