[소스코드 분석 시리즈] number-precision and bignumber.js

01_JS 정밀도

오래전에 회사에서 공유했던 글이 이제서야 공개되네요... 이 글은 왜 0.1 + 0.2 != 0.3인지 설명하고 number-precision과 bignumber.js의 풀이원리를 분석한 글입니다.

JS정확도 문제에 쩔쩔매고 있어서 시스템이 복습 하러 왔어요 ~

배경

실제 비즈니스 개발에서 다음과 같은 문제에 직면할 수 있습니다.

// 加法
0.1 + 0.2      // 0.30000000000000004

// 减法
1.5 - 1.2      // 0.30000000000000004

// 乘法
19.9 * 100     // 1989.9999999999998

// 除法
0.3 / 0.1      // 2.9999999999999996

toFixed()toPrecision()필요한 경우 반올림

때때로 우리는 이 문제를 해결하기 위해 사용 toFixed()하지만 실제로 이 방법은 때때로 원하지 않는 결과를 낳습니다.

2.54.toFixed(1)         // 2.5
2.56.toFixed(1)         // 2.6

2.55.toFixed(1)             // error: 2.5
2.55.toPrecision(1)         // error: 2.5

업계에서 고전적인 인터뷰 질문이 탄생했습니다. 왜 0.1 + 0.2가 0.3과 같지 않습니까?

JS는 IEEE 754배정밀도 버전(64비트)을 사용하기 때문에 IEEE 754사용하는 언어에 이 문제가 있는 한.

IEEE754

사전 지식

  • 컴퓨터 내부는 바이너리, 즉 0 1코드의 구성으로 표현됩니다.

  • 십진수를 이진수로 변환:

    • 양의 정수를 이진수로 변환: 양의 정수를 2로 나누고 얻은 몫을 몫이 0 또는 1이 될 때까지 다시 2로 나눈 다음 나머지를 거꾸로 연결한 다음 상위 비트를 0으로 채우고 8비트이면 앞에 0을 2개 더하여 최종 결과는 다음과 같습니다
      여기에 이미지 설명 삽입
      .

    00100110 0010 011000100110

    • 음수를 이진수로 변환: 먼저 양수를 이진수로 변환한 다음 이진수를 반전한 다음 결과에 1을 더합니다.

      -38예를 들어 38바이너리는 이고 0010 0110 반전 후 결과는 이고 1101 10011을 더한 후 결과는 입니다 1101 1010.

    • 소수점을 이진수로 변환: 소수점 이하 숫자에 2를 곱하고 정수 부분을 취한 다음 소수 부분에 2를 곱하고 소수 부분이 0이 되거나 자릿수가 OK가 될 때까지 차례로 누른 다음 정수 부분을 십진수의 이진 결과를 정렬하기 위해:

      0.125를 예로 들어 보겠습니다.

      0.125 * 2 = 0.25 --------------- 取整数 0,小数 0.25
      0.25 * 2 = 0.5 ----------------- 取整数 0,小数 0.5
      0.5 * 2 = 1 -------------------- 取整数 1
      
      

    따라서 결과는 0.001필요에 따라 낮은 비트를 0으로 채울 수 있다는 것입니다.

    • 소수의 정수 부분이 0보다 크면 정수와 소수 부분을 차례로 이진으로 변환한 다음 함께 더하면 OK입니다. 따라서 이진수로 38.125는 다음과 같습니다.0010 0110.001
  • 과학적 표기법, 먼저 십진수 과학적 표기법을 예로 들어 보겠습니다.

    • 23.32 => 0.2332 => 소수점을 왼쪽으로 2칸 이동하여 최종 결과는

      0.2332 * 102 0.2332 * 10^20.23321 02
      이진은 이진 과학적 표기법으로 저장됩니다. 이진 과학적 표기법인 경우:

    • 10111=> 1.0111=> 소수점을 왼쪽으로 4자리 이동하고 4를 2진수로 환산하면 100이므로 최종 결과는 1.0111 ∗ 2 ( 100
      ) 1.0111 * 2^(100)1.01112( 100)

이진법 과학기술법에 따르면 소수점 앞에 0이 아닌 숫자가 있어야 합니다.

IEEE754란?

IEEE754 표준은 다음을 규정합니다.

  • float단정밀도 부동 소수점 숫자는 기계에서 1비트로 숫자의 기호를 나타내고, 지수는 8비트로, 가수는 23비트, 즉 소수부로 나타냅니다.
  • double배정밀도 부동 소수점 숫자의 경우 부호를 나타내는 데 1비트, 지수를 나타내는 데 11비트, 가수를 나타내는 데 52비트가 사용되며 지수 필드를 지수 코드라고 합니다 . 모든 수치 계산 및 비교는 64비트 형식으로 수행됩니다 .

여기에 이미지 설명 삽입

에서는 JS모든 것이 배정밀도 부동 소수점 숫자로 저장됩니다 Number.64bit

기호 S

컴퓨터의 모든 것은 2진법으로 표현되기 때문에 기호를 이해하기 위해서는 일반적으로 최상위 비트를 부호 비트, 0은 +, 1은 -로 이해한다.

지수 E

11비트를 차지하므로 값의 범위는 0~2의 11승, 즉 0~1024비트로 1024개의 숫자를 표현할 수 있습니다. 그러나 IEEE 754 표준에서는 지수 오프셋의 고정 값을 2e − 1 − 1 2^{e-1}-1 로 규정하고 있습니다.2e - 1-1 , 배정밀도 부동 소수점 숫자를 예로 들면:2 11 − 1 − 1 = 1023 2^{11-1}-1=1023211 - 1-1=1023

IEE754 부동 소수점 숫자 표준에서 64비트 부동 소수점 숫자의 지수 오프셋이 1023인 이유는 무엇입니까?

여기에 이미지 설명 삽입

32비트 부동 소수점 숫자를 예로 들면 지수는 8비트, 즉 0-2의 8승인 256을 차지합니다. 지수도 양수와 음수를 가지므로 가운데에서 -128~+128로 나누는데 중간에 0이 있으므로 -128~127까지 256개의 숫자를 나타낸다.

긍정과 부정을 기록하는 방법? 한 가지 방법은 높은 위치를 1로 설정하여 높은 위치가 1이라고 보는 한 음수임을 알 수 있도록 하는 것입니다. 소위 높은 위치 1은 0에서 255까지의 숫자를 반으로 나누고, 0에서 127은 양수를 나타내고, 128에서 255는 음수를 나타냅니다. 그러나 이러한 접근 방식은 문제를 야기합니다. 130과 30과 같은 두 숫자를 비교할 때 누가 더 큽니까? 기계는 130이 더 크다고 생각할 것이지만 사실 130은 음수이므로 30보다 작아야 합니다.

그래서 나중에 어떤 사람이 모든 수에 128을 더하여 -128 + 128 = 0, 127 + 128 = 255가 되도록 하자고 제안했습니다. 이렇게 비교하면 음수가 양수보다 큰 경우는 없습니다.

따라서 0을 읽고 128을 빼면 음의 지수 -128이 되고, 255를 읽고 128을 빼면 127이 됩니다.

그렇다면 최종 지수 오프셋이 128이 아닌 127인 이유는 두 숫자 0과 255가 지수를 나타낼 수 없기 때문입니다. 2개의 숫자가 누락되어 127만 사용할 수 있습니다.

마찬가지로 64비트, 지수 11비트, 즉 2^11 = 2048, 절반 1024, 0과 2048을 제거하므로 오프셋은 1023입니다.

가수 M

가수 M의 경우 다음 소수점 부분만 저장됩니다. 이는 1≤M<2이기 때문에 M이 컴퓨터 내부에 저장될 때 이 숫자의 첫 자리는 기본적으로 항상 1이므로 반올림이 가능하고 이렇게 하면 유효숫자 하나를 저장할 수 있다는 장점이 있다. 배정밀도 64비트 부동소수점 수의 경우 M은 52비트이고 처음 1은 반올림되며 저장할 수 있는 유효 수는 52 + 1 = 53비트입니다.

이진법 과학기술법에 따르면 소수점 앞에 0이 아닌 것이 있어야 하고 유효영역은 1.xxxx이고 소수점 앞의 1은 기본적으로 존재하지만 기본적으로 피트를 차지하지 않으며 가수 부분은 소수점 뒤에 있는 부분을 저장한다 .

10진수를 IEEE754로 변환

그것을 사용할 때 Number컴퓨터의 맨 아래 계층은 입력한 십진수를 IEEE754 표준 부동 소수점 숫자로 자동 변환합니다.

예를 들어 0.1을 이진 과학 표기법으로 변환하면 다음과 같습니다.

0.1001100110011001100110011001100110011001100110011001 * 2 − 4 0.1001100110011001100110011001100110011001100110011001*2^{ -4}0.100110011001100110011001100110011001100110011001100124

  • 0.1은 양수이므로 부호 비트는 0입니다.
  • 지수는 -4, -4 +1023 = 1019, 이진수로 변환하면 1111111011, 총 10비트입니다. 인덱스 E가 11비트이므로 상위 비트는 0으로 채워집니다. 마지막으로 01111111011이 얻어집니다.
  • 가수는 최대 52비트를 저장할 수 있으므로 0으로 반올림됩니다.
    11001100110011001100110011001100110011001100110011001 // M 舍去首位的 1,得到如下
    1001100110011001100110011001100110011001100110011001  // 01 入,得到如下:
    1001100110011001100110011001100110011001100110011010  // 最终存储
    

0 반올림 방식: 가수를 오른쪽으로 이동하면 제거된 최상위 자리를 0으로 반올림하고 제거된 최상위 자리를 1로 하고 마지막 자리에 1을 더함

따라서 0.1의 최종 변환 결과는 다음과 같습니다.

S  E            M
0  01111111011  1001100110011001100110011001100110011001100110011010 

그런 다음 동일한 방식으로 0.2의 최종 변환 결과는 다음과 같습니다.

S  E            M
0  01111111100  1001100110011001100110011001100110011001100110011010 // 0.2

부동 소수점 숫자 연산

반대 순서

정산하기 전에 두 숫자의 지수가 같은지, 즉 소수점 위치가 일치하는지 판단해야 한다. 0.1의 차수 코드는 -4이고 0.2의 차수 코드는 -3입니다. 작은 차수와 큰 차수를 정렬하는 원리에 따라 0.1을 이동해야 합니다. 가수는 오른쪽으로 1비트 이동하고 지수는 +1입니다.

// 0.1 移动之前
0  01111111011  1001100110011001100110011001100110011001100110011010 

// 0.1 右移 1 位之后尾数最高位空出一位,(0 舍 1 入,此处舍去末尾 00  01111111100   100110011001100110011001100110011001100110011001101(0) 

// 0.1 右移 1 位完成
0  01111111100  1100110011001100110011001100110011001100110011001101

ps는 가장 높은 비트 값을 변경하지 않으며 1은 1의 보수, 0은 0의 보수입니다. 가수 부분에서 가장 높은 비트는 1을 숨겼습니다.

가수 합계

  0  01111111100   1100110011001100110011001100110011001100110011001101 // 0.1 
+ 0  01111111100   1001100110011001100110011001100110011001100110011010 // 0.2
= 0  01111111100 100110011001100110011001100110011001100110011001100111 // 产生进位,待处理

정규화 및 반올림

캐리 생성으로 인해 지수 코드는 +1이 되어야 하므로 01111111101이고 해당 십진수는 1021, 1021 - 1023 = -2이므로:

  S  E
= 0  01111111101

마지막에 2비트를 수행하고 가장 높은 비트의 기본값 1을 제거합니다. 가장 낮은 비트는 1이므로 반올림해야 합니다(이진법에서는 0으로 끝남). 반올림 방법은 최하위 비트에 1을 더하는 것입니다. 0이면 바로 버리고 1이면 계속 1을 더합니다.

  100110011001100110011001100110011001100110011001100111 // + 1
=  00110011001100110011001100110011001100110011001101000 // 去除最高位默认的 1
=  00110011001100110011001100110011001100110011001101000 // 最后一位 0 舍去
=  0011001100110011001100110011001100110011001100110100  // 尾数最后结果

IEEE 754의 최종 스토리지는 다음과 같습니다.

S  E           M
0  01111111101 0011001100110011001100110011001100110011001100110100

IEEE754를 10진수로 변환

공식에 따르면:

n = ( − 1 ) s ∗ 2 ( e − 1023 ) ∗ ( 1 + f ) n = (-1)^s * 2^(e-1023)*(1+f)N=( 1 )에스2( 전자-1023 )( 1+에프 )

( − 1 ) 0 ∗ 2 ( − 2 ) ∗ ( 1 + 0011001100110011001100110011001100110011001100110100 ) (-1)^0 * 2(-2) * (1 + 00110011001100110011001 10011001100110011001100110100)( 1 )02 ( - 2 )( 1+0011001100110011001100110011001100110011001100110100 )

최종 답변은 다음과 같습니다.

0.30000000000000004

인쇄하면 바이너리가 10진수로 변환되고 10진수가 문자열로 변환되어 최종 출력됩니다. 10진수에서 2진수로 변환하면 근사치가 발생하고 2진수에서 10진수로 변환해도 근사치가 발생합니다.인쇄된 값은 실제로 대략적인 값이며 부동 소수점 숫자의 저장 내용을 정확하게 반영하지 않습니다.

javascript는 어떻게 그러한 정확도로 0.1을 인쇄합니까?

정밀도 손실

  • 10진수에서 2진수로, 10진수가 무한 루프인 경우 52비트를 초과하면 반올림됩니다.
  • 부동 소수점 숫자가 계산에 포함될 때 순서를 수정해야 합니다 . 더하기를 예로 들면 작은 지수 필드를 큰 지수 필드로 변환해야 합니다. 즉, 작은 지수 부동 소수점 숫자의 소수점을 왼쪽으로 이동합니다. 소수점을 왼쪽으로 이동하면 52비트 유효 필드의 맨 오른쪽 비트가 필연적으로 압착됩니다. 이때 압착된 부분도 "반올림"됩니다. 여기서 다시 정밀도 손실이 발생합니다.

솔루션

  • parseFloat결과를 지정된 정밀도로 반올림합니다 .
    210000 * 10000  * 1000 * 8.2                   // 17219999999999.998
    parseFloat(17219999999999.998.toFixed(12));    // 17219999999999.998
    parseFloat(17219999999999.998.toFixed(2));     // 而正确结果为 17220000000000
    
    
  • 부동 소수점 숫자를 정수 연산으로 변환한 다음 결과를 나눕니다. 현재 대부분의 시나리오에서 충분한 아이디어는 소수를 정수로 변환하고 정수 범위 내에서 결과를 계산한 다음 결과를 소수로 변환하는 것입니다. 범위가 있기 때문에 이 범위의 정수는 IEEE754 부동 소수점 형식으로 정확하게 표현할 수 있습니다 .
    0.1 + 0.2                        // 0.30000000000000004
    (0.1 * 100 + 0.2 * 100) / 100    // 0.3
    
  • 실제 작업 프로세스를 시뮬레이션하기 위해 부동 소수점 숫자를 문자열로 변환합니다.

일반 바퀴

숫자 정밀도

https://github.com/nefe/number-precision

용법

import NP from 'number-precision'
NP.strip(0.09999999999999998); // = 0.1
NP.plus(0.1, 0.2);             // = 0.3, not 0.30000000000000004
NP.plus(2.3, 2.4);             // = 4.7, not 4.699999999999999
NP.minus(1.0, 0.9);            // = 0.1, not 0.09999999999999998
NP.times(3, 0.3);              // = 0.9, not 0.8999999999999999
NP.times(0.362, 100);          // = 36.2, not 36.199999999999996
NP.divide(1.21, 1.1);          // = 1.1, not 1.0999999999999999
NP.round(0.105, 2);            // = 0.11, not 0.1

원칙

가장 중요한 것은 parseFloat()십진수를 정수로 변환하는 것입니다. 예를 들어 추가하십시오.

function plus(...nums: numType[]): number {
    
    
  // 如果是多个参数,则递归相加
  if (nums.length > 2) {
    
    
    return iteratorOperation(nums, plus);
  }

  const [num1, num2] = nums;
  // 取两个数当中,小数位长度最大的值的长度
  const baseNum = Math.pow(10, Math.max(digitLength(num1), digitLength(num2)));
  // 把小数都转为整数然后再计算
  return (times(num1, baseNum) + times(num2, baseNum)) / baseNum;
}
  • 두 숫자 중 가장 큰 십진수 길이를 밑수로 사용합니다.
  • 두 숫자를 정수로 변환하고 더한 다음 밑으로 나눕니다.

안에:

function times(...nums: numType[]): number {
    
    
  // 如果是多个参数,则递归相乘
  if (nums.length > 2) {
    
    
    return iteratorOperation(nums, times);
  }
  
  // 将每个变量转为整数并相乘
  const [num1, num2] = nums;
  const num1Changed = float2Fixed(num1);
  const num2Changed = float2Fixed(num2);
  const leftValue = num1Changed * num2Changed;
  
  // 检查是否越界,如果越界就报错
  checkBoundary(leftValue);
  
  // 获得分母,即Math.pow(10,小数长度的数量)
  const baseNum = digitLength(num1) + digitLength(num2);
  return leftValue / Math.pow(10, baseNum);
}

float2Fixed소수를 정수로 변환:

function float2Fixed(num: numType): number {
    
    
  // 如果不是科学计数法,直接去掉小数点
  if (num.toString().indexOf('e') === -1) {
    
    
    return Number(num.toString().replace('.', ''));
  }
  
  // 如果是科学计数法,获得小数的长度
  const dLen = digitLength(num);
  return dLen > 0 ? strip(Number(num) * Math.pow(10, dLen)) : Number(num);
}

digitLength소수점 길이 계산:

// 常见的数字:1、0.1、2.2e-7
// 其中 2.2e-7 实际上就是指 0.00000022
            
function digitLength(num: numType): number {
  // 获取指数前后的数字
  const eSplit = num.toString().split(/[eE]/);
  // 如果 e 之前是小数,获取小数的数量 + e之后的数量
  const len = (eSplit[0].split('.')[1] || '').length - +(eSplit[1] || 0);
  // 返回小数的长度
  return len > 0 ? len : 0;
}

다음의 도움으로 parseFloat:

function strip(num: numType, precision = 15): number {
    
    
  return +parseFloat(Number(num).toPrecision(precision));
} 

console.log(strip(0.1 + 0.2));    // 0.3

bignumber.js

https://github.com/MikeMcl/bignumber.js

동시에 이 거물은 , big.js컴퓨팅 decimal.js과 관련된 다른 라이브러리도 작성했습니다.

여기에 이미지 설명 삽입

첫 느낌: 왜 그렇게 많은가요? ? ?

여기에 이미지 설명 삽입

용법

0.3 - 0.1                           // 0.19999999999999998
x = new BigNumber(0.3)
x.minus(0.1)                        // "0.2"
x                                   // "0.3"

원칙

먼저 생성자를 보세요. 음... 소스 코드를 보면 실제로 다음과 같습니다.

여기에 이미지 설명 삽입

여기에 이미지 설명 삽입

let x = new BigNumber(123.4567);
console.log(x);
// { c: (2) [123, 45670000000000], e: 2, s: 1 }

let y = BigNumber('123456.7e-3');
console.log(y);
// { c: (2) [123, 45670000000000], e: 2, s: 1 }

추가 구현:

  • 먼저 두 숫자를 BigNumber유형으로 변환합니다(예: 0.1 및 1.1).
    {
          
           c: [10000000000000], e: -1, 1 }     // 0.1
    {
          
           c: [1, 25000000000000], e: 0, 1 }   // 1.1
    
  • 두 숫자 중 하나가 예인지 판단하고 NaN있으면 직접 반환합니다 new BigNumber(NaN).
  • 당사자 중 하나가 음수이면 빼기 계산 결과를 호출하십시오.
  • 기록 x.e、y.e、x.c、y.c:
    var xe = x.e / LOG_BASE,
            ye = y.e / LOG_BASE,
            xc = x.c,
            yc = y.c;
    
    console.log(xe, ye, xc, yc);      
    // -0.07142857142857142 0 [10000000000000] (2) [1, 25000000000000]
    // 其中LOG_BASE = 14;
    
  • xe와 ye 중 하나가 0인지 판단할 때 조건에 따라 다른 값을 반환합니다.
    if (!xe || !ye) {
          
          
    
      // ±Infinity
      if (!xc || !yc) return new BigNumber(a / 0);
    
      // Either zero?
      // Return y if y is non-zero, x if x is non-zero, or zero if both are zero.
      if (!xc[0] || !yc[0]) return yc[0] ? y : new BigNumber(xc[0] ? x : a * 0);
    }
    
  • xe합계 ye, 얕은 사본을 관리하십시오 xc.
    xe = bitFloor(xe);    // -0.07142857142857142 => -1
    ye = bitFloor(ye);    // 0 => 0
    xc = xc.slice();
    
    // n | 0 有省略小数的作用
    function bitFloor(n) {
          
          
        var i = n | 0;
        return n > 0 || n === i ? i : i - 1;
     }
     
    console.log( 104.249834 | 0 ); //104
    console.log( 9.999999 | 0 );   // 9
    
  • yeand xe따라 xcand yc의 짧은 쪽에서 0 채우기 작업을 수행하므로 이 때 다음과 같이 됩니다.
    // [10000000000000]
    xc: [0, 10000000000000] 
    // [1, 25000000000000]
    yc: [1, 25000000000000]
    
  • xc와 의 길이를 비교하여 yc길이가 긴 값이 에 위치하는지 확인하십시오 xc.
  • 트래버스 추가:
    // Only start adding at yc.length - 1 as the further digits of xc can be ignored.
    for (a = 0; b;) {
          
          
       a = (xc[--b] = xc[b] + yc[b] + a) / BASE | 0;
       xc[b] = BASE === xc[b] ? 0 : xc[b] % BASE;
    }
    
  • 마지막으로 normalise최종 결과를 통합하여 새 BigNumber 개체가 반환됩니다.

내가 배운 몇 줄의 코드는 매우 유용하다고 생각합니다.

// 将v转为整数比较的最快方法(当v < 2**31 时,比较是否是整数)
v === ~~v

// |0 直接取整数部分
function bitFloor(n) {
    
    
  var i = n | 0;
  return n > 0 || n === i ? i : i - 1;
}
console.log(0.6 | 0);     // 0
console.log(1.1 | 0);     // 1
console.log(3.6555 | 0);   // 3
console.log(-3.6555 | 0);   // -3

이 라이브러리와 저 라이브러리의 차이점 big.js은 후자의 API가 전자만큼 많지 않고 10진수 이외의 계산을 지원하지 않는다는 것입니다.

요약하다

  • 유형을 사용할 때 Number컴퓨터의 맨 아래 계층은 입력한 십진수를 IEEE754 표준 부동 소수점 숫자로 자동 변환합니다.
  • 일반적으로 10진수를 2진수로 변환할 때 또는 부동 소수점이 계산에 참여하고 정확해야 할 때 발생하는 부동 소수점 숫자를 변환할 때 정밀도 손실이 있습니다.
  • 이 문제를 해결하기 위해 parseFloat, 부동 소수점을 정수로, 부동 소수점을 문자열로 사용하는 것을 고려할 수 있습니다.
  • 업계에서 유명한 휠로는 number-precision, bignumber.js 등이 있습니다. 전자는 주로 parseFloat 및 부동 소수점을 정수로 변환하는 아이디어를 사용하는 반면 후자는 먼저 값을 특정 개체로 변환한 다음 정수 계산을 수행합니다.

참고


틀린 부분은 지적해주시면 감사하겠습니다~

추천

출처blog.csdn.net/qq_34086980/article/details/131681840