Казахстан, Алматы, Гимназия им. Ахмета Байтурсынова №139, 2008 |
Базовые понятия Action Script
Подробно о числовом типе Number
Без чего невозможна нормальная работа программиста - так это без четкого представления деталей работы числовых типов. Во Флэше числовой тип только один, это Number. Тем больше у нас оснований разобраться с ним как можно тщательнее.
Тип Number: точность
Итак, каково же внутреннее устройство типа Number во Флэше? В принципе, вы можете посмотреть в стандарт ECMAScript и сразу же обнаружить, что под типом Number скрывается. Однако мы поступим по-другому: мы сначала постараемся выяснить все что можно средствами самого Флэш МХ. А уж потом, когда все станет ясно, расставим последние точки. Почти наверняка те, на первый взгляд, хорошо знакомые вам вещи, которые мы обнаружим, засверкают новыми гранями.
Итак, в тип Number мы можем записать любые числовые значения: как целые, так и с плавающей точкой. Слишком большие целые константы (или константы с фиксированной точкой), встречающиеся в коде, преобразуются в значения с плавающей точкой (делает это, по-видимому, компилятор). На самом деле, внутреннее представление Number - это именно число с плавающей точкой. Но при вызове метода toString() (например, при выводе числа в консоль) производится проверка: если можно вывести число в форме целого или в форме числа с фиксированной точкой - это будет сделано. Возможность вывода в виде целого числа или числа с фиксированной точкой ограничивается точностью мантиссы во внутреннем представлении Number. Ведь число значащих цифр в любом представлении числа не должно превосходить число цифр в мантиссе. А еще точнее - число значащих битов не должно превосходить число битов в мантиссе.
Сейчас мы произведем ряд экспериментов, из которых выясним, что точность мантиссы - 53 бита. В 53 бита помещается 15 знаков мантиссы (14 знаков после запятой). Бывает, что число выводится с меньшим количеством знаков - но это лишь потому, что замыкающие нули не показываются для чисел в экспоненциальной записи. Так что на самом деле точность составляет 14 знаков после запятой всегда. Вообще-то мантисса с 14 знаками помещается (порой с хорошим запасом) не то что в 53, а даже и в 50 бит. И остается еще как минимум 3 дополнительных бита, которые позволяют увеличить точность расчетов и сделать последнюю цифру числа более надежной (при выводе числа мы все равно видим не более 14 цифр после запятой, так что "запасные" биты используются для округления). Итак, вот те примеры, которые демонстрируют сказанное выше.
// Сколько знаков дают 50 бит: все целые числа вплоть до 10^15 // (все 15-значные числа) помещаются в 50 бит // (числа вплоть до 2^50 - 1) trace("2^50 - 1 = " + (Math.pow(2, 50) - 1)); // Сколько знаков дают 53 бита: // НЕ ВСЕ целые числа вплоть до 10^16 помещаются в 53 бита // (числа вплоть до 2^53 - 1), так что 53 битам // все равно соответствует 15 знаков (14 после запятой). trace("2^53 - 1 = " + (Math.pow(2, 53) - 1) + "\n"); // Демонстрируем, что "лишние биты" присутствуют // и повышают точность вычислений for (i=0; i<=10; i++){ x = Math.pow(2, 52) - 2; y = Math.pow(2, 52) - 1 - i; trace("x = " + x + "; y = " + y + "; x - y = " + (x - y)); } // Просто печатает пустую строку для лучшего // форматирования выходных данных trace(""); // Демонстрируем, что внутреннее представление мантиссы // действительно записывается в 53 бита (не считая знак). for (i=0; i<=10; i++){ // Число 2^(52+i) помещается в 53+i бит, // так что последние i бит отрезаются a = Math.pow(2, 52+i); // Прибавляем двоичное число 10101010101 // (единицы чередуются с нулями, чтобы избежать // округления при обрезании последних битов b = a + parseInt("10101010101", 2); // Выводим разницу полученной суммой и а // в двоичном виде (аргумент функции Number.toString - // это основание системы счисления). trace("Bits: " + (53 + i) + "; b - a = " + (b - a).toString(2)); } // Проверяем, во скольких разрядах происходит преобразование // в двоичную систему trace( "\n(Math.pow(2, 31)).toString(2) = " + (Math.pow(2, 31)).toString(2) ); trace( "(Math.pow(2, 31) + 1).toString(2) = " + (Math.pow(2, 31) + 1).toString(2) ); // И в 36-ричную trace( "(Math.pow(2, 31)).toString(36) = " + (Math.pow(2, 31)).toString(36) ); trace( "(Math.pow(2, 31) + 1).toString(36) = " + (Math.pow(2, 31) + 1).toString(36) );
После запуска этого кода мы получим:
2^50 - 1 = 1.12589990684262e+015 2^53 - 1 = 9.00719925474099e+015 x = 4.50359962737049e+15; y = 4.5035996273705e+15; x - y = -1 x = 4.50359962737049e+15; y = 4.50359962737049e+15; x - y = 0 x = 4.50359962737049e+15; y = 4.50359962737049e+15; x - y = 1 x = 4.50359962737049e+15; y = 4.50359962737049e+15; x - y = 2 x = 4.50359962737049e+15; y = 4.50359962737049e+15; x - y = 3 x = 4.50359962737049e+15; y = 4.50359962737049e+15; x - y = 4 x = 4.50359962737049e+15; y = 4.50359962737049e+15; x - y = 5 x = 4.50359962737049e+15; y = 4.50359962737049e+15; x - y = 6 x = 4.50359962737049e+15; y = 4.50359962737049e+15; x - y = 7 x = 4.50359962737049e+15; y = 4.50359962737049e+15; x - y = 8 x = 4.50359962737049e+15; y = 4.50359962737048e+15; x - y = 9 Bits: 53; b - a = 10101010101 Bits: 54; b - a = 10101010100 Bits: 55; b - a = 10101010100 Bits: 56; b - a = 10101011000 Bits: 57; b - a = 10101010000 Bits: 58; b - a = 10101100000 Bits: 59; b - a = 10101000000 Bits: 60; b - a = 10110000000 Bits: 61; b - a = 10100000000 Bits: 62; b - a = 11000000000 Bits: 63; b - a = 10000000000 (Math.pow(2, 31)).toString(2) = - (Math.pow(2, 31) + 1).toString(2) = - 111111111111111111111111111111 (Math.pow(2, 31)).toString(36) = - (Math.pow(2, 31) + 1).toString(36) = -zik0zj
Давайте подробно разберем, что же означает весь этот вывод нашей программки. Две первых строки показывают, что 15 знаков помещаются в 50 бит, но 16 не поместятся и в 53. Затем идут строчки (вычисление значения выражения х - у для разных у ), которые демонстрируют, что дополнительные биты действительно есть. Во всех строчках этой серии (кроме первой и последней) выводимые значения х и у одинаковы. Но разность их отнюдь не нулевая; нужные нам биты мы не потеряли. Заметьте, что здесь есть и некоторый подвох: не всегда два числа, которые выглядят одинаково при выводе на печать, являются равными (ненулевая разность означает, что x == y дает false ). Впрочем, это обычное дело при сравнении чисел с плавающей точкой; достаточно лишь соблюдать осторожность и писать вместо х == у что-то вроде Math.abs(x - y) < (Math.abs(x)+Math.abs(y))*1e-13. Если же ваши данные являются целыми, можете смело их сравнивать с помощью оператора " == ".
Далее следует около десятка строчек, которые иллюстрируют разрядность внутреннего представления мантиссы (без учета знака). Если в двоичном представлении числа имеется 53 знака, то младшие биты не обрезаются. Легко видеть, что для 54-битного числа обрезается самый младший бит, для 55-значного - два бита и т.д. Наконец, четыре последние строки возникли, в частности, из попытки получить информацию о содержимом "запасных" битов напрямую. Вдруг вывод числа в двоичной системе (или другой системе счисления) поможет и эти биты станут видны? Оказалось, однако, что при преобразовании в другую систему счисления используется 32-битный регистр, и не то что о 53, но и о 50 битах не может быть и речи. Причем это обыкновенный дополнительный код, то есть 32-й бит кодирует знак. (Именно поэтому такой странный результат получается при попытке вывести 231 - воспринимаемое как дополнительный код, такое число не имеет смысла). То же касается и 36-разрядной системы (как и любой другой, кроме десятичной) - 231 + 1 воспринимается как отрицательное число. Почему для примера мы взяли именно 36-ричную систему? Потому что это система счисления с наибольшим основанием - из тех, в которые функция toString способна переводить числа. Почему именно 36? А просто это 10 (число цифр) плюс 26 (число букв латинского алфавита). Для еще больших оснований знаков просто не хватает. И если вы попытаетесь ввести большее число в качестве аргумента toString, такой аргумент будет попросту проигнорирован (и вы получите десятичную запись). Наконец, укажем еще на то, что при переводе в другую систему счисления само число и основание системы приводятся к целым (причем не округлением, а отбрасыванием дробной части).
Граничные значения и размеры в памяти
Итак, разрядность мантиссы мы определили. Неплохо было бы прикинуть теперь разрядность порядка. Чтобы это сделать, нам необходимо знать максимальное и минимальное по модулю значения, которые можно записать в типе Number.
Эти значения легко получить, поскольку они хранятся в специальных константах Number.MAX_VALUE и Number.MIN_VALUE. Так вот, Number.MAX_VALUE равно 1.79769313486231e+308, а Number.MIN_VALUE равно 4.94065645841247e-324. Почему минимальное значение имеет порядок больший по абсолютной величине (минус 324, а не минус 308)? Дело в том, что мы можем, сохраняя порядок, уменьшать мантиссу до весьма малых значений (гораздо меньше 1). В обычных случаях это не практикуется, но очень малые по абсолютной величине числа позволяет записывать.
Давайте подсчитаем, сколько битов нужно для хранения порядка числа. Используя 11 битов, мы сможем записать положительные и отрицательные значения величиной от 0 до 1023. Итак, на хранение порядка надо 11 бит, а мантисса занимает 53 бита, как мы уже выяснили. Еще один бит нужен на знак числа. Всего получается 65; еще бы чуть-чуть сэкономить - и выйдет удобные 64 бита. И действительно, сэкономить есть на чем. Поскольку число у нас представляется в виде , то мантисса может быть не от 1.0 до 10.0 (точнее, строго меньше 10.0), как в обычном десятичном представлении, а от 1.0 до 2.0 (строго меньше 2.0). В результате первый бит мантиссы - всегда единица, так что его можно не хранить. Итого на внутреннее представление Number должно отводиться 64 бита (о чем вы, наверняка, догадались с самого начала; но надо ведь было поддержать видимость сюжетной интриги).
А теперь, когда интрига закончилась, пора срывать покровы. Конечно, внутри Number на самом деле скрывается тип double. Самый настоящий, соответствующий стандарту IEEE 754. Впрочем, было бы странно, если бы разработчики Флэш пренебрегли такой удобной и стандартной вещью. И, оказывается, хранить в double целые числа тоже весьма удобно. Особенно удобно то, что часто после ряда арифметических операций, имевших в качестве промежуточных результатов нецелые числа, в окончательном ответе мы можем получить целое число, причем абсолютно точно. Лучше всего срабатывает округление после операций умножения или деления. Вот примеры
a = 1/3; trace("a = 1/3, то есть " + a); trace("(a*3 == 1) = " + (a*3 == 1)) trace("(a*3 - 1)*Math.pow(2, 52) = " + (a*3 - 1)*Math.pow(2, 52)); trace("(a - 0.333333333333333)*Math.pow(2, 52) = " + (a - 0.333333333333333)*Math.pow(2, 52)); trace("(a - 0.3333333333333333)*Math.pow(2, 52) = " + (a - 0.3333333333333333)*Math.pow(2, 52)); trace(""); // Пробуем разделить 1 на другое число b = 1/Math.pow(7, 45); trace("b = 1/Math.pow(7, 45), то есть " + b); trace("(b*Math.pow(7, 45) == 1) = " + (b*Math.pow(7, 45) == 1))
На выходе имеем:
a = 1/3, то есть 0.333333333333333 (a*3 == 1) = true (a*3 - 1)*Math.pow(2, 52) = 0 (a - 0.333333333333333)*Math.pow(2, 52) = 1.5 (a - 0.3333333333333333)*Math.pow(2, 52) = 0 b = 1/Math.pow(7, 45), то есть 9.34519137233795e-39 (b*Math.pow(7, 45) == 1) = true
А вот после других операций может не так повезти. Попробуем, скажем, вычислить (101/3)3. Должно получиться опять 10. А на деле получаем 9.99999999999999. А может быть еще хуже: (101/5)5 на печати выдает 10, но при попытке сравнить полученное число с десяткой, мы получаем false. Посмотрите сами: код
trace(Math.pow(Math.pow(10, 1/3), 3)); trace(Math.pow(Math.pow(10, 1/5), 5)); trace(Math.pow(Math.pow(10, 1/5), 5) == 10);
дает на выходе
9.99999999999999 10 false
Так что при сложных вычислениях с плавающей точкой никто не избавит нас от необходимости сравнивать числа приблизительно так: Math.abs(x - y) < (Math.abs(x)+ Math.abs(y))*1e-13. Мы уже говорили об этом чуть раньше, но привычка использовать такой прием при сравнении чисел с плавающей точкой настолько важна, что повториться не помешает.