Немного про «ПИ» и другие встроенные константы
Чтобы «Пи» число запомнить,
Надо правильно прочесть:
Три, четырнадцать, пятнадцать,
Девяносто два и шестьНародная мудрость
Нет-нет, я не собираюсь рассказывать все прибаутки о константах, вроде того, как связано число E и год рождения Льва Толстого. Речь о другом.
Как-то один мой коллега попросил меня «свежим взглядом» посмотреть его программу. Он проводил проверочный расчет, и в итоге должна была получиться единичная матрица. На месте нулевых элементов оказались величины, близкие к нулю – что-то около 10**-17, что можно объяснить погрешностью расчета и исходных данных. Но у трех элементов было значение 10**-7. Вопрос состоял в том, а, собственно, почему так? ведь все формулы «симметричны».
Анализ показал, что виноват «копипастный» фрагмент, в котором оказался оператор PI=3.1415.
Сразу вспомнилась цитата из отличной книги Штернберга:
Вторая типовая ошибка иллюстрируется примером
Z=3.14*COS(2Х);
в котором константа 3.14 призвана изображать математическую константу «пи». Но эта константа задает «пи» с огромной погрешностью 0.00159..., которую мы запускаем в наш расчет. Трудно предсказать, во что вырастет эта погрешность, пройдя через расчет.
Здесь также надо завести переменную, в которую записать значение константы с максимально возможной точностью, а затем вместо длинного значения использовать более короткий идентификатор.
Т.е. константа была задана с недостаточной точностью, что и вызвало неодинаковые результирующие значения элементов матрицы.
И тогда для исключения в дальнейшем подобных случаев, я решил ввести в компилятор и язык встроенную константу π. И действовать в духе анекдота про выпускника физтеха, который на вопрос, сколько будет дважды два, раздраженно заявил: я что, должен помнить все константы?
Не надо помнить значение π, оно должно появляться в программе «само собой».
Казалось бы, чего проще: берете чистую кастрюлю заголовочный файл и дописываете его инициализированной статической переменной. Один раз выписывая максимально точное значение π.
Ну не люблю я заголовочные файлы. Часто они привносят в программу очень много лишнего. И в программе коллеги не было заголовочных файлов. К тому же, я сопровождаю компилятор с языка PL/1, а значит, в отличие от большинства программистов, могу реализовывать нетривиальные решения, в том числе, изменять и дополнять сам язык через изменения компилятора.
Из далеких 60-х до нас дошли страшные сказки о невероятной сложности языка PL/1 (что смешно на фоне какого-нибудь C++). Одним из доказательств этой сложности являлось большое количество встроенных в язык функций, которое якобы усложняло компилятор. Однако большинство встроенных функций никак не усложняет компилятор, он просто имеет внутри себя список всех встроенных объектов (аналог заголовочного файла) и в начале своей работы переносит этот список в самый внешний блок программы, компилятором же и созданный. Блочная структура языка PL/1 здесь хорошо помогает.
Например, встроенная функция sin для компилятора ничем не отличается от любой описанной программистом функции, разве что ее не надо явно описывать.
А если в программе описан свой sin, то стандартный не будет вызываться. И только если всегда нужно вызывать стандартный, то только тогда нужно указать описание sin с атрибутом builtin. Во всяком случае, не нужно каждый раз писать что-то типа std::sin (или, скорее, math::sin), что, на мой взгляд, безобразно раздувает исходный текст.
Итак, все встроенные в язык функции находятся в самом внешнем и напрямую недоступном для программистов блоке программы. В нашем компиляторе даже специально оставлено пустое место в конце списка встроенных, и можно с помощью примитивной программы внести новые встроенные объекты, не перетранслируя сам компилятор.
Наряду со встроенными функциями имеются и встроенные переменные. Обычно это константы-переменные. Несмотря на очевидный идиотизм этого термина, имеются в виду константы, для которых компилятором выделена память, в отличии, например, от большинства литералов.
Например, встроенная константа ?FILE заполняется компилятором именем файла с исходным текстом, а константа ?LINE – текущим номером строки исходного текста. После этого отладочная печать в виде всегда одинакового оператора типа put skip list(?file, ?line,’ошибка’); будет выдавать разные значения в лог в разных местах исходных текстов.
Но есть в списке и не константы. Например, для ввода-вывода массивов компилятор «на лету» создает и тут же сам и компилирует циклы, где используются эти встроенные переменные, как переменные цикла.
Еще со времен Гарри Килдэла в компиляторе было принято неофициальное правило: идентификаторы всех служебных объектов должны начинаться с символа «?». Вообще-то этот знак разрешен стандартом языка в любых идентификаторах. Но очень удобно всего лишь категорически не рекомендовать пользователям начинать свои имена с этого знака и тогда никогда не будет путаницы между объектами программиста и служебными.
Таким образом, я просто дописываю в список встроенных объектов в компиляторе переменную ?PI типа double или, в терминах языка, external float(53). Осталось лишь заполнить ее значением.
Тут возникает смешная проблема. Если конкретная программа не использует новую встроенную константу ?PI, компилятор выбрасывает ее из объектного модуля. Казалось бы, тогда можно заполнить значением уже при запуске программы, например, в стандартном прологе. Там все равно и без этого много действий выполняется.
Но тогда эту константу нужно описать в исходном тексте самого пролога и получится, что ?PI всегда требуется, даже если в самой программе обращения к ней и нет.
Лучшим вариантом является заполнение при работе редактора связей. Ему все равно приходится заполнять некоторые константы, например, встроенную константу ?DATE, которая принимает значение текущей даты сборки. Эту константу компилятор не может заполнить в принципе, поскольку сборка может проходить гораздо позже компиляции отдельных модулей.
Получается очень изящно: если модули не обращались к ?PI, то редактор связей не найдет ее в своих таблицах и в собранной программе ничего не заполнит. Если же хотя бы один из отдельно компилируемых модулей обращался к ?PI, эта константа сохранится в собранном EXE-файле, и редактор заполнит ее значением π.
Ну, и, разумеется, компилятор должен следить, чтобы программа не пыталась ничего записать в ?PI, несмотря на то, что, как известно, в военное время значение синуса может достигать 4, а π – 10.
И, наконец, вишенкой на тортике появляется возможность простой «тактической оптимизации» кода в части константы ?PI.
Если, например, в программе имеется фрагмент:
dcl x float(53);
...
if x*?pi>1e0 then …
...
он компилируется в код типа:
BB00000000 mov q rbx,offset @?PI
BAE0000000 mov q rdx,offset X
F9 stc
DD0353DC0ADD1C24 call ?FM4_M
BBF0000000 mov q rbx,offset @000000F0h
E800000000 call ?FC44L
7E05 jle @1
Где ?FM_M – это «экстракод», т.е. служебный вызов, данном случае подставляемый in line.
В отладчике этот фрагмент выглядит так:
И можно применить простейшую оптимизацию, заключающуюся в том, что если компилятором сгенерирована команда FLD64 [RBX], то компилятор смотрит, не было ли чуть ранее команды MOV EBX,OFFSET ?PI.
Если есть, вместо команды FLD можно подставить команду FLDPI непосредственной загрузки π, т.е. всего лишь заменить два байта кодов DD03 на два байта D9EB:
Казалось бы, какая разница – ведь объем кода даже не изменился. Однако такая замена дает два преимущества:
а) Вместо загрузки 8 байт переменной ?PI в FPU загружается аппаратная константа π с максимально возможной точностью для FPU x86 – 80 разрядов (и опять-таки эту константу не нужно помнить самому). Между прочим, такое внутреннее значение π в FPU равно 4х0.C90FDAA22168C234Ch. Именно эта мешанина из нулей и единиц иногда используется как часть кода для RSA-ключей.
б) При выполнении команды FLDPI не происходит обращения к памяти, хранящей константу ?PI, что важно для современных процессоров и ускоряет их работу.
Конечно, в этом случае можно было бы и вообще выбросить всю команду MOV EBX,OFFSET ?PI, но поскольку оптимизация в этот момент уже идет, возможно, значение регистра RBX далее используется. Лучше не усложнять анализ лишними проверками и опасностью ошибки.
А моему коллеге остается теперь пожелать забыть про все представления π и использовать в расчетах только встроенную константу.