자바스크립트와 근삿값

Weird Javascript

인터넷에 흔히 돌아다니는 괴이한 자바스크립트 문법에 대한 사진이다. 실제로 저렇게 사용할 일은 거의 없지만 제대로 알고 쓰지 않으면 이상한데서 문제가 발생하는데 이와 관련하여 jsisweird.com라는 사이트에서 자바스크립트를 얼마나 잘 알고 있는가 테스트할 수 있다. 본인은 14개밖에 못 맞췄다. 어쨌든 이와 관련된 문제를 해결하느라 시간을 허비했기 때문에 글을 작성한다.

자바스크립트에서는 근삿값을 구하기 위해 Math.floor(), Math.ceil(), Math.round(), Math.fround()등 다양한 함수들을 제공한다. 그 중에서도 인자로 주어진 수를 내림하여 정수 근삿값으로 반환하는 Math.floor()가 있다. 가끔씩 몇 글자 되지도 않는 이 함수를 쓰기 귀찮아서 NOT을 의미하는 논리 연산자인 ~(물결표, tilde)을 두 번 사용하여 처리하고는 했다. 나는 둘이 같은 값을 반환하는 줄 알았는데 그게 아니었다!🤦‍♂️🤦‍♂️🤦‍♂️ 그래서 호되게 당했고 왜 그런지 알아보았다.

const realNumber = 3.141592;
const bigRealNumber = 4294967296.123;

console.log(Math.floor(realNumber)); // 3
console.log(~~realNumber); // 3

console.log(Math.floor(bigRealNumber)); // 4294967296
console.log(~~(bigRealNumber)); // 0 🤔

숫자형

자바스크립트는 숫자형을 배정밀도 부동소수점 숫자로 알려진 64비트 형식의 IEEE 754를 사용한다. 64비트 중 54비트는 숫자를 저장하는데에 사용하고 11비트는 소수점 위치를, 나머지 1비트는 부호를 저장하는데에 사용한다.

만약 64비트를 초과하는 경우라면 Infinity로 처리한다. 정수로 나타낼 수 있는 최댓값과 최솟값의 범위는 (2541)-(2^{54}-1)에서 25412^{54}-1이며 이를 위해 Number.MAX_SAFE_INTEGERNumber.MIN_SAFE_INTEGER 값을 제공한다.

비트 연산자

자바스크립트에서 비트 연산자(bitwise operators)는 개념적으로 다음과 같이 동작한다.

  1. 피연산자들을 32비트 정수로 변환하며 숫자가 32비트를 초과할 경우 가장 큰 비트부터 버린다.
  2. 각 비트 자리에 대응하는 수 끼리 연산을 진행한다. 첫 번째 비트는 첫 번째 비트끼리 두 번째 비트는 두 번째 비트 끼리 그리고 32번째 비트는 32번째 비트와 연산을 한다.
  3. 연산의 결과를 비트단위로 구축한다.

비트 시프트 연산자

비트 연산자 중에서 시프트 연산은 비트를 움직일 수 있는데 자바스크립트는 << 좌측 시프트, >> 우측 시프트 그리고 >>> 우측 논리 시프트(logical shift)을 지원한다.

연산자 사용법 내용
왼쪽 시프트(좌측 시프트) a >> b 왼쪽 피연산자를 오른쪽 피연산자만큼 왼쪽으로 시프트합니다. 왼쪽으로 넘치는 비트는 버리고, 오른쪽을 0으로 채웁니다.
오른쪽 시프트(우측 시프트) a << b 왼쪽 피연산자를 오른쪽 피연산자만큼 오른쪽으로 시프트합니다. 오른쪽으로 넘치는 비트는 버리고, 왼쪽은 제일 큰 비트의 값으로 채웁니다.
부호 없는 오른쪽 시프트(우측 논리 시프트) a >>> b 왼쪽 피연산자를 오른쪽 피연산자만큼 오른쪽으로 시프트합니다. 오른쪽으로 넘치는 비트는 버리고, 왼쪽은 0으로 채웁니다.

NOT 논리 연산자와 보수

function to32Bit(n) {
    console.log(n, (n >>> 0).toString(2).padStart(32, '0'));
}

const n1 = 4294967295.123;  //  (2 ** 32 - 1) + 0.123
const n2 = 2147483647.123;  //  (2 ** 31 - 1) + 0.123
const n3 = -2147483648.123; // -(2 ** 31) - 0.123

to32Bit(n1);                //  4294967295.123  11111111111111111111111111111111
to32Bit(-(n1));             // -4294967295.123  00000000000000000000000000000001

to32Bit(Math.floor(n1));    //  4294967295 11111111111111111111111111111111
to32Bit(~~(n1));            // -1          11111111111111111111111111111111

to32Bit(Math.ceil(-(n1)));  // -4294967295 00000000000000000000000000000001
to32Bit(~~(-(n1)));         //  1          00000000000000000000000000000001

to32Bit(Math.floor(n2));    //  2147483647 01111111111111111111111111111111
to32Bit(~~(n2));            //  2147483647 01111111111111111111111111111111

to32Bit(Math.ceil(n3));     // -2147483648 10000000000000000000000000000000
to32Bit(~~(n3));            // -2147483648 10000000000000000000000000000000

자바스크립트에서 NOT 연산자인 ~는 32개의 비트를 모두 반전하는데, 1의 보수(ones’ complement)가 된다. 가장 큰 (맨 왼쪽) 비트가 1이면 음수를 나타내는 것이다. ~x-x - 1과 같은 값으로 평가된다. 또, 주어진 수의 비트를 모두 반전 시킨뒤 1만큼 더하면 2의 보수(two’s complement)를 얻을 수 있는데 대부분의 산술 연산에서는 이를 원래 수의 음수로 취급한다. 비트 연산자의 규칙에 의해 위와 같이 적용된다.

결론

to32Bit(2 ** 31);      //  일반 숫자형    2147483648 10000000000000000000000000000000
to32Bit(~(2 ** 31));   //  32비트 정수형  2147483647 01111111111111111111111111111111
to32Bit(~~(2 ** 31));  //  32비트 정수형 -2147483648 10000000000000000000000000000000
  • 위의 내용을 종합해보면 비트 연산자를 사용하는 경우 32비트보다 큰 비트와 가수(fraction)부분의 비트들을 버리게 되므로 32비트 정수가되고 가장 큰 비트는 sign bit이므로 표현 가능한 범위는 2311-2^{31}-1에서 2311{2^{31} - 1}이 된다.
  • NOT 연산을 적용하면 1의 보수가 된다.
  • 입력으로 주어진 값의 숫자형은 32비트 이상 표현 가능하지만 32비트 정수형으로 바뀌었으므로 모든 비트를 반전하는 과정에서 표현 범위를 초과해버리기 때문에 발생하는 오버플로우 현상이다.
  • 따라서 이중 물결표를 23112^{31} - 1 이하 양수에 사용하는 것은 Math.floor(), 231-2^{31} 이상의 음수에서는 Math.ceil()과 같이 동작한다고 할 수 있다. 그러나 예기치 못한 상황을 발생시킬 수 있기 때문에 근삿값을 구할 때는 적절한 함수를 사용하는 것이 좋다.

참조(Reference)