Чтобы «Пи» число запомнить,
Надо правильно прочесть:
Три, четырнадцать, пятнадцать,
Девяносто два и шесть

Народная мудрость

Нет-нет, я не собираюсь рассказывать все прибаутки о константах, вроде того, как связано число 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 далее используется. Лучше не усложнять анализ лишними проверками и опасностью ошибки.

А моему коллеге остается теперь пожелать забыть про все представления π и использовать в расчетах только встроенную константу.

Комментарии (21)


  1. Goron_Dekar
    09.07.2023 05:31
    +7

    Ну не люблю я заголовочные файлы. Часто они привносят в программу очень много лишнего.

    Поэтому давайте натащим всё это лишнее прямо в компилятор?

    У меня по соседству сидят девчёнки и пишут софт для решения структур белков. И им константа Больцмана нужна почти так же часто, как π. Надо встроить ?k?

    На прошлом месте человек делал точный калькулятор масс для пептидов. Ему в компилятор тащить все константы из таблицы менделеева?


    1. Dukarav Автор
      09.07.2023 05:31

      "То бензин, а то - дети" (с) х/ф "Джентельмены удачи"

      Констант много, а такая одна)) Если нужна таблица Менделеева, то заголовочный файл - к месту. А ради одной константы создавать его не хочется. Кроме этого, в FPU есть аппаратная команда загрузки ПИ, а команды загрузки Больцмана нет )). Компилятор должен стараться использовать аппаратные возможности. По такой логике надо было бы добавить в компилятор еще и 4 аппаратные константы логарифмов, но лень - не используем мы их.


      1. sargon5000
        09.07.2023 05:31

        Джентльмены, камрад. Не джентельмены.

        .


        1. Dukarav Автор
          09.07.2023 05:31

          Согласен, но главный герой произносил именно так ))


  1. vadimr
    09.07.2023 05:31
    -2

    Вообще, если Пи где-то не определено, часто используют 4e0*atan(1e0).

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


    1. DrSavinkov
      09.07.2023 05:31

      acos(-1.) уже не в моде, нафига atan(1.) с коэффициентами тащить?


      1. vadimr
        09.07.2023 05:31

        Я думаю, изначально это пошло оттого, что acos вычисляется через asin (кстати, в свою очередь с использованием значения Пи), a asin - через atan. Но фактически разницы для языка PL/I нет, вызов встроенной функции с константным аргументом и производное от него выражение вычисляется на этапе компиляции.


  1. NeoCode
    09.07.2023 05:31
    +2

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

    В С/С++ математические константы определены в math.h (что конечно не совсем хорошо, учитывая, что во многих процессорах есть встроенные константы). И даже если использовать math.h - еще есть некий костыль в виде _USE_MATH_DEFINES, который тоже нужно писать каждый раз.

    Хорошо бы сделать их просто ключевыми словами языка. И если с Pi это еще прокатит, то вот число E... ну нехорошо делать одну букву ключевым словом. Чисто эстетически нехорошо.

    Использовать префиксы как в С++ (M_PI, M_2PI, M_E)? Вариант, но выглядит кривовато, не слишком эстетично.

    Использовать пространства имен (Math.Pi, Math.E)? Тоже вариант, но по идее в том же Math должны содержаться и всякие синусы с косинусами; и если в некотором файле открыть это пространство имен (не писать же каждый раз Math.sin(x) и Math.cos(x)), то опять же однобуквенные имена типа E окажутся в области видимости.


    1. Dukarav Автор
      09.07.2023 05:31
      +2

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

      Но и он тоже не спасет ((


      1. Aleshonne
        09.07.2023 05:31
        +1

        Разве, что разрешить в языке греческий алфавит.

        Некоторые таким путём и пошли, Julia, например.

        Hidden text


        1. Dukarav Автор
          09.07.2023 05:31
          +2

          Да, конечно. Но наука наплодила много других значков типа "аш с планкой". Прямо хоть вводи в юникод отдельное семейство "знаки научных формул" ((


          1. Aleshonne
            09.07.2023 05:31
            +1

            Зачем вводить, там уже 99% нужных символов уже есть.

            Hidden text


    1. vadimr
      09.07.2023 05:31
      +2

      Главная проблема с math.h в C заключается в том, что тамошние константы имеют тип double. Использование вычислений большей точности всё порушит. Эта проблема не имеет удовлетворительного решения в рамках такой концепции.


      1. NeoCode
        09.07.2023 05:31
        +2

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

        То есть, допустим, есть у нас "длинная арифметика" типа GMP - ОК, константа Pi и там будет константой той длины, которая используется в данный момент. Хоть килобайтной длины:)

        Причем эту концепцию можно распространить и на все остальные литералы. Допустим, число 42: что это - int32, int64, uint8, float, double? Может вообще какой нибудь экзотический fixed point формат? Это должен решать компилятор с помощью автовывода типа. У литерала нет специальных суффиксов типа, но в большинстве существующих языков тип все равно прибит к литералу.

        Аналогично, строковый литерал может быть однобайтовым (в одной из множества кодировок), utf8, utf16. utf32 и т.д. Конкретный тип пусть выводит компилятор.

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


        1. vadimr
          09.07.2023 05:31
          +1

          На это можно посмотреть и под немного другим углом – в обсуждаемом в статье языке PL/I число 42 имеет вполне определённый тип decimail fixed (2, 0), то есть целое из двух десятичных цифр.

          В большинстве же случаев тип можно извлечь из контекста и опций компиляции.

          Да. Но это противоречит модному в наши дни веянию сильной типизации.


          1. NeoCode
            09.07.2023 05:31

            Да. Но это противоречит модному в наши дни веянию сильной типизации.

            Зато не противоречит модному в наши дни веянию автоматического вывода типов :)


    1. vadimr
      09.07.2023 05:31
      +1

      from math import pi, e


  1. Darkhon
    09.07.2023 05:31

    Просто вспоминается, что встроенная PI была ещё в Бейсике. Неожиданно узнать, что сейчас не во всех языках она есть по умолчанию.


  1. Dolios
    09.07.2023 05:31
    +2

    Тот, кто хочет «Пи» запомнить,
    Должен часто повторять:
    Три, четырнадцать, пятнадцать,
    Девяносто два, шесть, пять..

    У нас такая "мудрость" была. На 1 знак больше )


    1. Dukarav Автор
      09.07.2023 05:31
      +1

      Причем, чем глупее стишок, тем лучше запоминается. Со школы помню рад металлов, замещающих водород в кислотах. Только из-за стихотворного размера.

      Натрий, калий, кальций, магний

      Алюминий, цинк, железо,

      Никель, олово, свинец.

      Почти пирожок получился ))


    1. voldemar_d
      09.07.2023 05:31
      +1

      Это я знаю и помню прекрасно:

      Пи многие знаки мне лишни, напрасны

      (ещё 3 знака)