Первая публикация 26.03.2015
Введение
Как-то на одном из компьютерных форумов участники делились соображениями, чего им не хватает в языках, так сказать, в повседневной деятельности. Один заявил, что ему очень не хватает оператора типа put data. Для тех, кто не слышал о таком, поясню, что в языке PL/1 был предусмотрен оператор вывода (или ввода) значений переменных вместе с их именами, т.е. вместе с идентификаторами.
Например, если обычный вывод put list(X,Y); давал что-нибудь вроде:
1280 1024
то оператор put data(X,Y); печатал:
X= 1280 Y= 1024
не заставляя писать для этого вывод в виде оператора:
put list(’X=’,X,’Y=’,Y);
Чаще всего это использовалось в отладочных целях, но не обязательно. Например, используя вывод в текстовый файл с помощью put data, а затем ввод этих же данных как исходных в другой программе с помощью «зеркального» оператора get data можно было получить удобную для чтения и редактирования форму представления исходных данных в текстовом виде.
Можно возразить, что современные «оболочки» систем программирования легко позволяют вывести значения (и имена) любых объектов программы в интерактивном режиме безо всяких put data и поэтому в нем больше нет необходимости.
Я тоже использую встроенный интерактивный отладчик, с помощью которого можно посмотреть любые, даже сложные (составные) данные. Однако потребность в возможностях put data не исчезла. Иногда нужен и анализ данных на распечатке без необходимости сидеть за компьютером, и быстро вставляемый вывод для отладки. К тому же для работы отладочных интерактивных систем используется дополнительная информация, которая обычно в выполняемом exe-файле отсутствует. В этом же случае все имена переменных «зашиты» в самой программе. Таким образом, удобства, предоставляемые языком в виде оператора, подобного put data, не заменяют, а дополняют возможности отладчиков. Кстати, в стародавние времена так называемая «посмертная» выдача на печать после ошибки иногда включала в себя и вывод полного списка всех имен переменных вместе с их текущими значениями. Некоторым программистам так нравился такой вывод, что они специально в конце своей программы ставили деление на ноль.
Реализации ввода-вывода с именами
В исходном варианте компилятора, которым я пользуюсь и который по мере сил развиваю [1], оператора put data не было. И мне его тоже очень не хватало в процессе отладки. Требовалось его ввести, причем хотелось сделать все как можно проще, компактнее и не внося больших изменений в компилятор. Поэтому была принята следующая схема реализации:
1. Сначала компилятором выполняется обычный разбор очередного объекта, перечисленного в списке ввода-вывода. Но при этом все лексемы, которые выдал лексический анализатор по запросу компилятора, еще и запоминаются в специальном буфере. По окончании разбора результат (т.е. все получившиеся операции внутреннего представления программы) отбрасывается, но характеристики объекта, например, его имя в программе, размерность и т.п., становятся известны.
2. Затем разбор повторяется заново, теперь полученные операции внутреннего представления программы будут использованы для генерации кода. Однако при разборе вместо лексического анализатора подключается его эмулятор, который уже не проводит лексический анализ исходного текста, а просто достает и выдает компилятору разобранные ранее и запомненные лексемы. Кроме них эмулятор может выдать и новые, сгенерированные им самим, лексемы.
Получается, что компилятор как бы сам для себя на ходу изменяет и дописывает исходный текст программы и тут же исправленный текст обрабатывает штатным образом. В чем-то эти действия немного похожи на макроподстановку.
Для реализации put data достаточно проанализировать запомненные в буфере лексемы, найти конец разбираемого элемента (это запятая вне скобок или внешняя закрывающая скобка) и все предыдущие лексемы объединить в одну строку, которую в начале работы эмулятора и выдать с признаком «текстовая константа».
В конце этой автоматически сформированной строки для наглядности дописывается знак равенства. После выдачи лексемы-строки эмулятор еще выдает лексему «запятая», а затем начинает выдавать все лексемы из буфера. По исчерпанию лексем в буфере эмулятор вместо себя опять подключает «настоящий» лексический анализатор, т.е. тот, который продолжит чтение исходного текста, и компилятор становится готов к разбору следующего объекта ввода-вывода.
Таким образом, например, текст:
put data(x(i+1,j-1));
внутри компилятора будет восприниматься как:
put list(’x(i+1,j-1)=’,x(i+1,j-1));
Разумеется, так будут обрабатываться не только объекты-переменные, но и любые выражения при выводе. Исключение составляет лишь вывод текстовых констант. Для них дополнительные лексемы, конечно же, не формируются во избежание бессмысленного вывода одного и того же текста два раза подряд со знаком равенства посередине.
Ввод-вывод не скалярных объектов
Идея исправлять исходный текст программы «на лету» позволила легко решить поставленную задачу. Однако полученный механизм жаль было использовать только для такой мелочи как put data, поскольку этот же подход можно было применить и для более объемной задачи – ввода-вывода не скалярных объектов. Автоматический ввод-вывод всех элементов не скалярного объекта также был предусмотрен в языке и тоже отсутствовал в исходной версии компилятора, хотя на практике часто требовался.
Здесь ситуация сложнее, чем с put data для скаляра, так как для вывода массива вместо текста:
put data(x);
требовалось «на лету» сформировать уже что-нибудь вроде:
do i=lbound(x) to hbound(x); put data(x(i)); end;
Но и это еще не все. Например, если x – двумерный массив целых (например, матрица 3х3), тот же самый оператор put data(x); уже должен осуществлять вывод по двум циклам, чтобы дать в результате что-то такое:
X(1,1)= 1 X(1,2)= 2 X(1,3)= 3 X(2,1)= 4 X(2,2)= 5 X(2,3)= 6 X(3,1)= 7 X(3,2)= 8 X(3,3)= 9
Но при этом оператор put data(x(2)); должен вывести лишь одну строку матрицы:
X(2,1)= 4 X(2,2)= 5 X(2,3)= 6
А если x – это набор разнотипных элементов (в PL/1 такой набор называется структурой), то нужно еще выводить и имена отдельных полей этого набора, например:
declare
1 a,
2 a1 fixed,
2 a2 fixed,
2 b,
3 b1 fixed,
3 b2 float;
put data(a);
A.A1= 0 A.A2= 0 A.B.B1= 0 A.B.B2= 0.000000E+00
Учитывая, что в структуре могут быть подструктуры, в свою очередь включающие массивы (т.е. могут быть и структуры массивов и массивы структур), то задача автоматического вывода элементов становится довольно громоздкой:
declare
1 a(1:2),
2 a1 fixed,
2 a2 fixed,
2 b,
3 b1 (-5:3) fixed,
3 b2 float;
put data(b);
B(1).B1(-5)= 0 B(1).B1(-4)= 0 B(1).B1(-3)= 0 B(1).B1(-2)= 0 B(1).B1(-1)= 0 B(1).B1(0)= 0 B(1).B1(1)= 0 B(1).B1(2)= 0 B(1).B1(3)= 0 B(1).B2= 0.000000E+00 B(2).B1(-5)= 0 B(2).B1(-4)= 0 B(2).B1(-3)= 0 B(2).B1(-2)= 0 B(2).B1(-1)= 0 B(2).B1(0)= 0 B(2).B1(1)= 0 B(2).B1(2)= 0 B(2).B1(3)= 0 B(2).B2= 0.000000E+00
Однако в языке PL/1 есть хорошая основа для доработок в виде возможности записать оператор цикла прямо внутри оператора ввода-вывода. В свое время разработчикам языка вывод массивов казался очень важным. Поэтому они разрешили писать цикл ввода-вывода в виде:
put list((x(i) do i=lbound(x) to hbound(x)));
вместо обычного цикла:
do i=lbound(x) to hbound(x); put list(x(i)); end;
Признаком начала цикла внутри оператора ввода-вывода является открывающая скобка. Кстати, разработчики PL/1 здесь немного попали впросак, поскольку при выводе выражения, начинающегося со скобки, большинство компиляторов воспримет эту скобку за начало цикла. Программистам приходилось писать выражения в экзотическом виде, только чтобы они не начинались со скобки:
put list(1e0*(x1+x2)/2e0));
Тем не менее, это сильно упрощает доработку компилятора, поскольку для вывода всех элементов массива достаточно при разборе очередного объекта ввода-вывода поставить его в скобки, дописать индексы и написать в этих же скобках заголовок цикла. В заголовке цикла начальное и конечное значение переменной цикла – это константы, которые берутся из описания не скалярного объекта. В данной версии компилятора разрешено только статическое описание границ массивов и это также сильно упрощает доработку.
Легко заметить, что вывод с именами (put data) и вывод не скалярных объектов – это дополняющие друг друга возможности. Объединяет их то, что в обоих случаях разбор объекта при трансляции выполняется два раза. Сначала все лексемы при лексическом разборе объекта запоминаются, а при повторном разборе выдаются из буфера так, как если бы из исходного текста. Но при этом в нужный момент выдаются и новые лексемы. В обоих случаях вместо лексического анализатора работает его эмулятор. Когда нужны обе возможности одновременно – работают оба эмулятора, один на более низком уровне выдает лексемы для индексов, названий полей структур и заголовков циклов, а второй добавляет к ним элементы с текстовыми константами-именами.
Причем эмулятор верхнего уровня, создающий текстовые константы-имена, даже «не подозревает», что лексический анализатор уже заменен на эмулятор для выдачи элементов массива и имен структур. Обе доработки компилятора не мешают друг другу и действуют независимо.
При этом работа эмулятора нижнего уровня гораздо сложнее, чем в случае put data, здесь применяется использование такого алгоритма, как конечный автомат. Т.е. эмулятор имеет несколько состояний, запоминаемых между вызовами, при разборе переходя от одного к другому и выдавая в каждом состоянии лексемы разного типа. Это позволяет сначала выдать одну или несколько лексем — открывающих скобок, затем все имена промежуточных уровней структуры и все индексы, как переменные циклов, а затем набор лексем, обозначающих заголовки циклов по каждой размерности и, наконец, лексемы — закрывающие скобки.
Естественно, что если в исходном тексте старшие индексы объекта были явно указаны, то эмулятор дописывает только недостающие младшие индексы. Поэтому, например, для трехмерного массива x(1:10,1:10,1:10) оператор вывода:
put list(x); выдаст 1000 значений
put list(x(5)); выдаст 100 значений
put list(x(5,6)) выдаст 10 значений.
А для оператора put list(x(5,6,8)); эмулятор вообще не сгенерирует дополнительных лексем-индексов и будет напечатано единственное значение.
Недостатком описанной реализации оказалось то, что при применении оператора put data к не скалярным объектам на печати вместо всех значений индексов печатались идентификаторы переменных циклов, которые подставлялись в исходный текст в момент трансляции. А поскольку для этой цели использовались служебные целые переменные ?A, ?B, ?С,…?O (всего 15 штук для максимально возможной размерности массива 15) выдача выглядела странно и во многом теряла ценность:
X(?A,?B)= 1 X(?A,?B)= 2 X(?A,?B)= 3 X(?A,?B)= 4 X(?A,?B)= 5 X(?A,?B)= 6 X(?A,?B)= 7 X(?A,?B)= 8 X(?A,?B)= 9
Для исправления этого недостатка потребовалось доработать системную библиотеку. В случае оператора put data с индексами компилятор не просто формирует текстовую константу – имя переменной с подставленными индексами ?A, ?B, ?С,…?O, но и дописывает перед каждым индексом специальный не выводимый символ 07FH.
При выполнении программы служебная процедура чтения очередного байта внутри системного вызова, с помощью которого обеспечивается весь вывод, обнаружив этот символ, в свою очередь, превращается в конечный автомат, который распознает идущую следом букву и находит текущее значение соответствующей переменной. Затем он переводит это значение в текст и посимвольно начинает выдавать этот текст вместо служебного символа и названия индекса внутри текстовой строки. В результате, наконец, получается правильное перечисление всех индексов элементов массива.
Переменные с именами при вводе
Естественно, имеется парный оператор к put data — это get data, который должен вводить значения переменных, заданные вместе с их именами. Но здесь, на мой взгляд, разработчики PL/1 переусложнили данный оператор. В соответствии со стандартом языка [2] оператор ввода должен не просто пропускать имена, но распознавать их и записывать следующее далее значение по нужному адресу. Например, если перетасовать значения, выданные put data(x), в другом порядке:
X(1,2)= 2 X(1,1)= 1 X(2,3)= 6 X(2,1)= 4 X(2,2)= 5 X(3,1)= 7 X(1,3)= 3 X(3,2)= 8 X(3,3)= 9
То оператор get data(x) все равно должен ввести значения в правильном порядке, распознав каждое имя и индексы перед знаком равенства. И хотя такая обработка дает дополнительные удобства, например, можно пропускать данные, которые не меняются при вводе, все-таки это слишком хлопотно и сложно для реализации. А, главное, для такой возможности нужно иметь таблицу имен переменных и их адресов в каждом exe-файле, использующем get data. Поэтому я не стал реализовывать распознавание имен при вводе, а ограничился при чтении простым пропуском всех символов до знака равенства при разборе очередного элемента. Самое важное для меня – операторы put data и get data остались «зеркальными» и выдача данных оператором put позволяет потом безошибочно читать их оператором get с таким же списком объектов.
Заключение
Как показывает практика, большую часть времени программист тратит все-таки на отладку. Поэтому любые средства и языка, и системы программирования в целом, хоть как-то облегчающие процесс отладки, должны только приветствоваться. Одним из таких простых средств является встроенный ввод-вывод объектов программы вместе с их именами, реализация которого, как показано выше, не требует значительных затрат в компиляторе.
Мало этого, в данном случае удалось, не затрагивая работу основных частей компилятора, добавить дополняющие друг друга возможности и автоматического формирования имен, и автоматического вывода всех элементов массива. Объем кода доработок составил менее процента от общего объема компилятора, однако они оказывают существенную помощь, особенно при отладке, экономя и время, и внимание программиста при написании операторов ввода-вывода.
Литература
- Караваев Д.Ю. К вопросу о совершенствовании языка программирования. RSDN Magazine #4, 2011
- American National Standard: programming language PL/I. New York, NY, American National Standards Institute, 1979 (Rev 1998); 403p. ANSI Standard X3.53-1976.