자바스크립트와 근삿값
인터넷에 흔히 돌아다니는 괴이한 자바스크립트 문법에 대한 사진이다. 실제로 저렇게 사용할 일은 거의 없지만 제대로 알고 쓰지 않으면 이상한데서 문제가 발생하는데 이와 관련하여 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로 처리한다. 정수로 나타낼 수 있는 최댓값과 최솟값의 범위는 에서 이며 이를 위해 Number.MAX_SAFE_INTEGER와 Number.MIN_SAFE_INTEGER 값을 제공한다.
비트 연산자
자바스크립트에서 비트 연산자(bitwise operators)는 개념적으로 다음과 같이 동작한다.
- 피연산자들을 32비트 정수로 변환하며 숫자가 32비트를 초과할 경우 가장 큰 비트부터 버린다.
- 각 비트 자리에 대응하는 수 끼리 연산을 진행한다. 첫 번째 비트는 첫 번째 비트끼리 두 번째 비트는 두 번째 비트 끼리 그리고 32번째 비트는 32번째 비트와 연산을 한다.
- 연산의 결과를 비트단위로 구축한다.
비트 시프트 연산자
비트 연산자 중에서 시프트 연산은 비트를 움직일 수 있는데 자바스크립트는 << 좌측 시프트, >> 우측 시프트 그리고 >>> 우측 논리 시프트(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이므로 표현 가능한 범위는 에서 이 된다.
NOT연산을 적용하면 1의 보수가 된다.- 입력으로 주어진 값의 숫자형은 32비트 이상 표현 가능하지만 32비트 정수형으로 바뀌었으므로 모든 비트를 반전하는 과정에서 표현 범위를 초과해버리기 때문에 발생하는 오버플로우 현상이다.
- 따라서 이중 물결표를 이하 양수에 사용하는 것은
Math.floor(), 이상의 음수에서는Math.ceil()과 같이 동작한다고 할 수 있다. 그러나 예기치 못한 상황을 발생시킬 수 있기 때문에 근삿값을 구할 때는 적절한 함수를 사용하는 것이 좋다.
참조(Reference)
- “숫자형”, Javascript Info, https://ko.javascript.info/number.
- “IEEE 754”, Wikipedia, https://ko.wikipedia.org/wiki/IEEE_754.
- “1의 보수”, Wikipedia, https://ko.wikipedia.org/wiki/1%EC%9D%98_%EB%B3%B4%EC%88%98.
- “논리 시프트”, Wikipedia, https://ko.wikipedia.org/wiki/%EB%85%BC%EB%A6%AC_%EC%8B%9C%ED%94%84%ED%8A%B8.
- “Negative numbers to binary string in JavaScript”, Stack overflow, https://stackoverflow.com/questions/16155592/negative-numbers-to-binary-string-in-javascript.