글자는 저장되거나 네트워크로 갈 때 UTF-8이라는 규칙에 따라 바이트로 바뀐다. 그 변환 코드를 보자(『한입 CS』 1장에서 한 줄씩 따라가는 코드다). 유니코드가 글자마다 붙인 번호인 코드 포인트(code point)를 2진수로 놓고, 오른쪽에서 6비트씩 떼어 바이트를 하나씩 만드는 코드다. rest는 아직 바이트로 옮기지 않은 코드 포인트의 남은 비트, encoded는 만든 바이트를 앞에 끼워 모으는 목록이다. 그 코드 가운데 이런 줄이 있다.
encoded.unshift(0x80 | (rest & 0x3F))
rest = rest >> 6
CSS에서 늘 보던 #ff8800도, 해시 값 2cf24dba…도 같은 종류의 글자로 적혀 있다. 0x80은 얼마이고, &와 |는 &&·||와 무엇이 다르며, 왜 하필 6만큼 미는 걸까? 이 줄을 읽으려면 세 가지가 필요하다. 수를 2진수(binary)와 16진수(hexadecimal)로 읽는 법, 2를 거듭 곱한 수의 크기 감각, 그리고 수를 2진수 자리 단위로 계산하는 비트 연산(bitwise operation)이다. 이 Unit은 이 세 가지를 차례로 푼다.
자릿값: 10진수와 2진수
먼저 말 두 개를 정한다. 같은 수를 여러 번 곱한 것을 거듭제곱(power)이라 하고, 몇 번 곱했는지를 위에 작게 쓴 수를 지수(exponent)라 한다. . "2의 5제곱은 32"라고 읽고, 2를 다섯 번 곱했다는 뜻이다. 위의 5가 지수다. 도 기억해 두자. 2를 한 번도 곱하지 않았으니 처음 값 1 그대로다. 에서 지수를 하나 줄일 때마다 2로 나누면 4, 2, 1이 되는 것과 같다.
우리가 쓰는 10진수(decimal)는 한 자리에 0~9 열 개의 숫자를 쓴다. 자리마다 정해진 값을 자릿값(place value)이라 한다. 오른쪽부터 1, 10, 100, 곧 10의 거듭제곱이다. . 305는 100이 3개, 10이 0개, 1이 5개라는 뜻이다.
2진수는 한 자리에 0과 1 두 개만 쓴다. 2진수 한 자리, 곧 0 또는 1 하나를 비트(bit)라 한다. 규칙은 같고 자릿값만 2의 거듭제곱이 된다. 오른쪽부터 1, 2, 4, 8, 16이다. 한 자리 왼쪽으로 갈 때마다 자릿값이 두 배가 된다.
| 자리(왼쪽 → 오른쪽) | 넷째 | 셋째 | 둘째 | 첫째 |
|---|---|---|---|---|
| 자릿값 | 2³ = 8 | 2² = 4 | 2¹ = 2 | 2⁰ = 1 |
1101의 숫자 | 1 | 1 | 0 | 1 |
2진수 1101은 10진수로 얼마일까? (오른쪽 자리부터 자릿값은 1, 2, 4, 8)
선택지와 해설 보기
실험 2진수를 10진수로: 자리마다 곱해 더하기
맨 오른쪽 자리부터 자릿값(place)을 곱해 total에 더합니다. 한 자리 왼쪽으로 갈 때마다 자릿값이 두 배가 됩니다.
const s = '1101'let total = 0let place = 1for (let i = s.length - 1; i >= 0; i--) { const digit = Number(s[i]) total += digit * place place *= 2}console.log(total)
- s
- '1101'
- 1
- 1
- 0
- 1
- 8
- 4
- 2
- 1
- 해볼 것
1101을 끝까지 실행해 본다11111111(비트 8개가 모두 1)을 끝까지 실행해 본다
1101을 끝까지 돌리면 total이 13이다.
0인 자리는 0을 곱하니 더하는 값이 없고, 1인 자리만 그 자릿값을 더한다. 식으로 쓰면 이다. 아래 작게 붙은 2는 "2진수로 적은 수"라는 표시다. 8 + 4 + 0 + 1을 더하면 13이라고 읽는다. 같은 방법으로 11111111은 128 + 64 + 32 + 16 + 8 + 4 + 2 + 1 = 255다. 이 255가 이 Unit 제목의 답이다. 4절에서 16진수로 다시 만난다.
거꾸로 10진수를 2진수로 바꿀 때는 2로 나눈 나머지를 모은다. 10진수에서 305를 10으로 나누면 나머지가 5, 곧 맨 끝 자리이고, 몫 30은 끝 자리를 떼어 낸 수다. 2진수도 같다. 2로 나눈 나머지(0 또는 1)가 맨 끝 자리이고, 몫은 끝 자리를 떼어 낸 수다. 몫이 0이 될 때까지 되풀이하고, 나온 나머지를 아래에서 위로 읽는다.
| 나눌 수 | ÷ 2의 몫 | 나머지(이번에 얻은 자리) |
|---|---|---|
| 13 | 6 | 1 (첫째 자리) |
| 6 | 3 | 0 (둘째 자리) |
| 3 | 1 | 1 (셋째 자리) |
| 1 | 0 | 1 (넷째 자리) |
나머지를 아래에서 위로 읽으면 1101이다. 앞에서 1101이 13이었으니 맞게 돌아왔다. 코드에서는 직접 나누지 않고 내장 함수를 쓴다.
(13).toString(2) // '1101'
parseInt('1101', 2) // 13
0b1101 // 13 (앞에 0b를 붙이면 2진수로 적은 수)
(5).toString(2).padStart(8, '0') // '00000101'
bin(13) # '0b1101'
int('1101', 2) # 13
0b1101 # 13
format(5, '08b') # '00000101'
toString(2)는 앞에 0b를 붙이지 않고, Python의 bin은 붙인다(MDN: Number.prototype.toString, MDN: parseInt, Python 문서: bin, Python 문서: int). 자리 수를 맞춰 보여 주려면 padStart나 format으로 앞을 0으로 채운다.
2의 거듭제곱 감각표: 비트 n개로 2ⁿ가지
비트 8개를 묶은 것을 바이트(byte)라 한다. 비트 n개로는 값을 가지 나타낼 수 있다. "2의 n제곱 가지"라고 읽는다. 비트 하나는 0, 1 두 가지다. 비트를 하나 더 붙이면 기존 경우마다 0을 붙인 것과 1을 붙인 것이 생기니 가짓수가 두 배가 된다. 비트 2개면 00, 01, 10, 11 네 가지, 3개면 여덟 가지다.
바이트 하나(비트 8개)에 담을 수 있는 가장 큰 수는? (0부터 센다)
선택지와 해설 보기
비트 8개가 모두 1인 11111111이 가장 크고, 1절에서 셈한 대로 255다. 가짓수는 256인데 가장 큰 수가 255인 까닭은 0도 한 가지로 세기 때문이다. 일반적으로 비트 n개가 담는 가장 큰 수는 이다. 비트 8개면 256 − 1 = 255다.
자주 나오는 2의 거듭제곱은 값만 외우기보다 어디서 보는지와 묶어 기억해 두면 오래 남는다.
| 거듭제곱 | 값 | 어디서 보나 |
|---|---|---|
| 2⁴ | 16 | 16진수 한 자리(4절) |
| 2⁸ | 256 | 바이트 하나가 담는 가짓수, 0~255 |
| 2¹⁰ | 1,024 | 1,024 ≈ 1,000. 1,024바이트를 흔히 1KB라 부른다 |
| 2¹⁶ | 65,536 | 바이트 2개. 『한입 CS』 1장의 코드 포인트(code point, 유니코드가 글자마다 붙인 번호) U+FFFF가 2¹⁶ − 1 |
| 2²⁰ | 1,048,576 | 약 100만(2¹⁰을 두 번 곱한 수) |
| 2³⁰ | 1,073,741,824 | 약 10억. Unit 2의 "10억 개에서 30번"이 여기서 나온다 |
| 2³² | 4,294,967,296 | 약 43억. JavaScript 비트 연산이 다루는 32비트(6절) |
| 2⁵³ | 9,007,199,254,740,992 | 약 9천조. JavaScript number가 정수를 빈틈없이 담는 한계(표 아래, 『한입 CS』 0장) |
JavaScript number는 정수를 2⁵³까지는 정확히 담지만 2⁵³ + 1은 담을 수 없어 2⁵³으로 반올림된다. 그래서 바로 옆 정수와 구별된다고 보장되는 안전한 정수(safe integer, Number.MAX_SAFE_INTEGER)는 2⁵³ − 1까지다. 2⁵³을 넘는다고 모든 수가 근삿값이 되는 것은 아니다. 2⁵³ 위에서는 담을 수 있는 수 사이 간격이 2, 4, …로 벌어질 뿐, 그 간격 위에 놓인 수(2⁵³ + 2처럼)는 정확히 저장된다.
표준 단위에서 킬로(k)는 1,000이라, 1,024바이트를 구별해 적을 때는 IEC가 정한 1KiB(키비바이트)를 쓴다(NIST: 이진 접두어).
표를 읽는 요령이 하나 있다. 이므로 지수가 10 늘 때마다 값이 약 1,000배가 된다. ≈는 "거의 같다"는 뜻이다. 그래서 2²⁰은 약 100만, 2³⁰은 약 10억이고, 2³²는 2³⁰에 4를 곱해 약 43억이다.
음수 지수와 2진 소수
1절에서 2³ = 8의 지수를 하나 줄일 때마다 2로 나누면 4, 2, 1이 됐다. 거기서 멈추지 않고 계속 줄이면 어떻게 될까? 1을 2로 또 나누면 1/2, 다시 나누면 1/4이다. 지수가 음수라는 것은 바로 이것, "곱하는 대신 나눈다"는 뜻이다.
"2의 −4제곱은 1을 2로 네 번 나눈 값, 곧 1/16"이라고 읽는다. 2⁻¹ = 1/2, 2⁻² = 1/4, 2⁻³ = 1/8이다. 10진수도 같아서 10⁻² = 1/100 = 0.01이다.
소수점 아래 자리도 자릿값으로 읽는다. 10진수 0.25는 1/10이 2개, 1/100이 5개다. 2진수에서는 소수점 오른쪽 첫째 자리가 1/2, 그다음이 1/4, 1/8, 1/16으로 자리마다 절반이 된다.
| 소수점 아래 자리 | 첫째 | 둘째 | 셋째 | 넷째 |
|---|---|---|---|---|
| 자릿값 | 2⁻¹ = 1/2 | 2⁻² = 1/4 | 2⁻³ = 1/8 | 2⁻⁴ = 1/16 |
0.011의 숫자 | 0 | 1 | 1 | — |
1인 자리의 자릿값만 더하면 2진수 0.011은 1/4 + 1/8 = 0.375다. 정수 부분과 규칙이 같고, 자릿값이 2배씩 커지는 대신 절반씩 작아질 뿐이다.
여기서 『한입 CS』 0장이 0.1 + 0.2가 0.30000000000000004인 이유로 든 "0.1은 2진수로 끝나지 않는다"가 풀린다. 10진수에서 1/3을 소수로 쓰면 0.333…으로 끝나지 않는다. 1/3을 1/10, 1/100, 1/1000의 합으로 딱 맞게 쓸 수 없기 때문이다. 2진수에서는 0.1 = 1/10이 그런 수다. 1/10을 1/2, 1/4, 1/8, …의 합으로 딱 맞게 쓸 수 없어서 0.0001100110011…처럼 같은 자리가 끝없이 되풀이되고, 컴퓨터는 어딘가에서 잘라 저장한다. 『한입 CS』 0장의 식 0.1 = 1.6 × 2⁻⁴는 "1.6을 2로 네 번 나눈 값, 곧 1.6 ÷ 16 = 0.1"이라고 읽는다.
16진수: 한 자리 = 비트 4개
2진수는 길다. 255도 11111111 여덟 자리다. 그래서 비트를 사람에게 보여 줄 때는 16진수를 쓴다. 16진수는 한 자리에 16가지를 쓴다. 0~9 다음에 a~f를 이어 써서 a는 10, f는 15다. 자릿값은 오른쪽부터 1, 16, 256, 곧 16의 거듭제곱이다. 코드에서는 앞에 0x를 붙여 16진수임을 밝힌다.
16진수가 비트를 적기에 좋은 까닭은 이라서다. 비트 4개가 나타내는 16가지가 16진수 한 자리 16가지와 딱 맞는다. 그래서 2진수를 오른쪽부터 4자리씩 끊으면 묶음 하나가 16진수 한 자리가 된다.
| 10진수 | 2진수 | 16진수 |
|---|---|---|
| 9 | 1001 | 9 |
| 10 | 1010 | a |
| 15 | 1111 | f |
| 136 | 1000 1000 | 88 |
| 255 | 1111 1111 | ff |
이제 제목의 답을 식으로 쓸 수 있다. . f가 15이니 "16이 15개, 1이 15개"라고 읽는다. 2진수로 놓고 보면 1111 1111, 비트 8개가 모두 1인 수다. 바이트 하나는 비트 8개 = 4비트 묶음 두 개이므로 16진수로 꼭 두 자리다. 0x00부터 0xff까지가 바이트 하나가 담는 0~255다.
255, 0xff, 0b11111111은 같은 수를 다르게 적은 것이다. 코드가 실행되면 셋 다 비트 1111 1111 하나가 되고, 어느 진법으로 적었는지는 남지 않는다(255 === 0xff는 true). 10진수로도 같은 값을 적을 수 있지만, 240만 보고 1111 0000이 바로 떠오르지는 않는다. 16진수는 4비트씩 끊어 읽기만 하면 되니 계산 없이 비트 모양이 보인다.
CSS 색 #ff8800은 바이트 세 개를 16진수 두 자리씩 이어 적은 것이다. 앞에서부터 빨강 ff = 255, 초록 88 = 8 × 16 + 8 = 136, 파랑 00 = 0이다. rgb(255 136 0)과 같은 색이다. 『한입 CS』 1장의 0x80은 , 2진수로 1000 0000이고 0x3F는 3 × 16 + 15 = 63, 2진수로 0011 1111이다. 모양을 기억해 두자. 5절에서 쓴다.
(255).toString(16) // 'ff'
parseInt('ff', 16) // 255
0xff // 255
(8).toString(16).padStart(2, '0') // '08'
hex(255) # '0xff'
int('ff', 16) # 255
0xff # 255
format(8, '02x') # '08'
마지막 줄처럼 바이트 하나는 두 자리로 맞춰 적는다. 8을 그냥 toString(16)하면 '8' 한 자리가 나온다. 색이나 해시처럼 여러 바이트를 이어 적을 때 한 자리짜리가 끼면 뒤 바이트가 앞으로 당겨져 다른 값이 된다(Python 문서: hex, Python 문서: format).
비트 연산: 자리마다 따로 계산한다
비트 연산은 두 수를 2진수로 나란히 놓고 자리마다 따로 계산한다. 덧셈처럼 옆 자리로 올림이 넘어가지 않는다. 연산은 여섯 가지만 알면 된다.
| 연산 | 자리마다의 규칙 | 예(2진수) | 주로 쓰는 곳 |
|---|---|---|---|
AND & | 둘 다 1이면 1 | 1100 & 1010 = 1000 | 원하는 자리만 남기기(마스크) |
OR | | 하나라도 1이면 1 | 1100 | 1010 = 1110 | 자리 켜기 |
XOR ^ | 서로 다르면 1 | 1100 ^ 1010 = 0110 | 자리 뒤집기 |
NOT ~ | 수 하나의 모든 자리를 뒤집는다(0 ↔ 1) | ~0101 = 1010 | 끌 자리만 0인 마스크 만들기 |
왼쪽 시프트 << | 모든 자리를 왼쪽으로 민다 | 0011 << 1 = 0110 | 2배(n칸이면 2ⁿ배) |
오른쪽 시프트 >> | 모든 자리를 오른쪽으로 민다 | 0110 >> 1 = 0011 | 반(남는 자리는 버림) |
시프트가 2배·반이 되는 까닭은 1절의 자릿값에 있다. 모든 자리가 한 칸 왼쪽으로 가면 각 자리의 자릿값이 두 배가 되니 수 전체가 두 배다. 10진수에서 뒤에 0을 붙이면 10배가 되는 것과 같다. 오른쪽으로 밀면 맨 끝 자리가 떨어져 나가므로 2로 나눈 몫만 남는다. 13은 1101이고 13 >> 1은 110, 곧 6이다. ~만 두 수가 아니라 수 하나를 받는다. 뒤집을 자리를 고르는 XOR와 달리 모든 자리를 한꺼번에 뒤집는다.
비트 연산이 쓸모 있는 까닭은 수 하나에 여러 값을 칸칸이 나눠 담기 때문이다. CSS 색 0xff8800은 수 하나에 빨강·초록·파랑 8비트 칸 세 개를 담았고(4절), 아래에서 볼 권한 값은 읽기·쓰기·실행 1비트 칸 세 개를 담는다. 그중 한 칸만 보려면 나머지 칸을 지워야 한다. 이때 남길 자리만 1로 채운 수를 마스크(mask)라 하고, 그 수와 AND한다.
결과를 매번 진리표로 계산하지 않고 바로 떠올리려면, 연산하는 두 수 중 한쪽을 "자리마다의 지시"로 읽는다.
| 연산 | 지시하는 수의 자리가 1이면 | 0이면 |
|---|---|---|
x & m | x의 그 자리를 남긴다 | 지운다(0) |
x | m | 켠다(1) | x의 그 자리를 그대로 둔다 |
x >> n | 오른쪽 n자리를 버리고 나머지를 아래로 내린다 |
>>로 원하는 칸을 맨 아래로 내리고 &로 그 칸만 남기는 것, 이 두 동작이 칸 하나를 꺼내는 방법이다. 4비트 단위로 끊기는 수는 16진수 그대로 풀린다. 16진수 한 자리가 비트 4개이니 >> 8은 16진수 두 자리를 버리는 것이고, & 0xff는 마지막 두 자리만 남기는 것이다. 그래서 (0xff8800 >> 8) & 0xff는 0xff8800 → 0xff88 → 0x88, 곧 초록 136이다.
이제 이 Unit 첫머리에 적은 『한입 CS』 1장의 줄로 돌아간다. 코드 포인트(유니코드가 글자마다 붙인 번호)를 오른쪽에서 6비트씩 떼어 UTF-8 바이트를 하나씩 만드는 반복문의 두 줄이다.
encoded.unshift(0x80 | (rest & 0x3F))
rest = rest >> 6
여기서 칸은 6비트다. 이어지는 바이트는 8비트 중 앞 2자리를 10이 차지하니 담을 수 있는 건 8 − 2 = 6비트, 그래서 코드 포인트를 6비트씩 떼고 rest를 6씩 민다. 6비트 칸은 16진수 자리와 맞지 않는다. 2진수로 펼쳐 위아래로 맞춰 본다. 한의 코드 포인트 U+D55C(1101 0101 0101 1100)로 첫 바퀴를 돌리면 이렇다.
① rest & 0x3F : 0x3F = 0011 1111, 아래 6자리만 남긴다
rest 1101 0101 01|01 1100
0x3F 0000 0000 00|11 1111
결과 01 1100 = 0x1C
② 0x80 | 0x1C : 0x80 = 1000 0000, 맨 위 자리를 켠다
0x1C 0001 1100 ← ①에서 위 2자리가 00으로 비었다
0x80 1000 0000
결과 1001 1100 = 0x9C (10xxxxxx 모양)
③ rest >> 6 : 방금 쓴 6자리를 버리고 다음 6자리를 아래로 내린다
1101 0101 01|01 1100 → 11 0101 0101 = 0x355
②는 10을 앞에 덧붙이는 것이 아니다. ①의 마스크가 바이트의 위 2자리를 00으로 비워 두었기 때문에, OR로 그 빈자리를 10으로 채울 수 있다. 이어지는 바이트가 10으로 시작하는 것은 UTF-8의 규칙이다(『한입 CS』 1장). 둘째 바퀴도 같아서 0x355의 아래 6자리 010101이 0x95가 된다. 반복문이 끝나면 남은 1101은 3바이트 글자의 첫 바이트 모양 1110xxxx에 들어가 0xED가 되고, 한은 ED 95 9C 세 바이트가 된다.
비트 하나에 켜짐·꺼짐 하나를 담는 값을 플래그(flag)라 한다. Unix 파일 권한처럼 읽기 = 4, 쓰기 = 2, 실행 = 1로 정하면 세 값이 서로 다른 자리 하나씩(100, 010, 001)을 차지해서 한 수에 세 권한을 함께 담을 수 있다. rwx를 왼쪽부터 한 자리씩 놓은 것이라, chmod 755의 7은 111(읽기·쓰기·실행), 5는 101(읽기·실행)이다.
읽기 = 4, 쓰기 = 2, 실행 = 1이다. perm = 읽기 | 실행에서 시작해 perm | 쓰기, perm & ~실행, perm ^ 읽기를 차례로 하면 남는 권한은? (~는 모든 자리를 뒤집는다)
선택지와 해설 보기
실험 권한 플래그를 켜고, 끄고, 뒤집고, 확인하기
읽기·쓰기·실행 권한을 비트 하나씩에 담았습니다. 그림의 세 칸이 perm의 2진수 세 자리입니다.
const READ = 4const WRITE = 2const EXEC = 1let perm = READ | EXECperm = perm | WRITEperm = perm & ~EXECperm = perm ^ READconst flags = [['READ', READ], ['WRITE', WRITE], ['EXEC', EXEC]]for (const [name, bit] of flags) { const on = (perm & bit) !== 0 console.log(name, on ? '켜짐' : '꺼짐')}
- READ
- 4
- 해볼 것
- 읽기 | 실행에서 시작해 끝까지 실행해 본다
- 실행만으로 시작해 끝까지 실행하고 남은 권한을 견줘 본다
첫 입력을 끝까지 돌리면 READ 꺼짐, WRITE 켜짐, EXEC 꺼짐이 찍힌다.
101에서 쓰기를 켜면 111, 실행을 끄면 110, 읽기를 뒤집으면 010이라 쓰기만 남는다. 네 연산이 하는 일은 이렇게 정리된다.
- 켜기:
perm | WRITE. 쓰기 자리만 1인 수와 OR하니 그 자리만 1이 되고 나머지는 그대로다. - 끄기:
perm & ~EXEC.~EXEC는 실행 자리만 0이고 나머지가 모두 1인 마스크다. AND하면 실행 자리만 0이 된다. - 뒤집기:
perm ^ READ. 읽기 자리가 1이면 0으로, 0이면 1로 바뀐다. 실행만으로 시작했다면 꺼져 있던 읽기가 이 줄에서 켜진다. - 확인:
(perm & bit) !== 0. 그 자리만 남겼을 때 0이 아니면 켜져 있다. 결과는 1이 아니라 그 자리의 값(쓰기면 2)이라서=== 1로 견주면 틀린다.
a & b는 수 두 개를 받아 2진수 자리마다 "그리고"를 한 번씩 해서 수 하나를 냅니다. |는 자리마다 "또는"입니다.&의 표가 "그리고"의 표, |의 표가 "또는"의 표입니다.&&·||는 값 하나를 보고 앞에서 답이 나오면 뒤를 계산하지 않습니다. &·|는 늘 양쪽을 다 계산하고 모든 자리를 한꺼번에 처리합니다. 5 && 2는 2, 5 & 2는 0입니다(101과 010은 겹치는 1이 없습니다). 참·거짓 계산은 Unit 9에서 다룹니다.JavaScript의 32비트 함정과 Python
여기까지는 두 언어가 같다. 수가 커지면 갈라진다. JavaScript의 비트 연산은 계산하기 전에 연산하는 두 수(피연산자)를 32비트 부호 있는 정수(signed integer)로 바꾼다. 32비트 밖의 자리는 버리고, 맨 위 2³¹ 자리는 부호로 읽는다(MDN: 왼쪽 시프트, ECMAScript 명세: ToInt32). 부호로 읽는다는 것은 맨 위 자리의 자릿값을 이 아니라 로 센다는 뜻이다. 2³¹ = 2,147,483,648이니 맨 위 자리 하나만 켜지면 −2,147,483,648이다. 그래서 32비트 부호 있는 정수가 담는 범위는 −2³¹ ~ 2³¹ − 1이다. 같은 규칙을 4비트로 줄여 보면 이렇다.
| 4비트 | 자릿값 그대로(0~15) | 맨 위 자리를 −8로(−8~7) |
|---|---|---|
0111 | 7 | 7 |
1000 | 8 | −8 |
1111 | 15 | −8 + 4 + 2 + 1 = −1 |
맨 위 자리만 −8로 세면 0~15 열여섯 가지가 −8~7로 바뀐다. 32비트는 맨 위 자릿값이 −2³¹일 뿐 같은 규칙이다. Python의 정수는 크기 제한이 없고, 비트 연산도 그 값 그대로 계산한다(Python 문서: 정수형, Python 문서: 정수의 비트 연산). 값이 커지면 메모리를 더 써서 자릿수를 이어 붙이는 임의 정밀도(arbitrary precision) 정수라서, 한계는 메모리뿐이다. 음수의 비트 연산은 맨 위 자리 왼쪽으로 1이 끝없이 이어진 2진수처럼 계산한다. 다만 자리 수가 정해진 곳과 만나면 Python도 자른다. 32비트 정수를 쓰는 C 함수에 값을 넘기는 ctypes에서 ctypes.c_int32(1 << 31).value는 JavaScript와 같은 −2147483648이고(Python 문서: ctypes 기본 자료형), NumPy의 int32 배열도 32비트 안에서 계산한다.
JavaScript에서 x = 2 ** 30을 한 칸 왼쪽으로 민 x << 1의 값은? (Python이면 2147483648)
선택지와 해설 보기
실험 왼쪽으로 밀다가 2³¹을 넘으면
x를 한 칸씩 왼쪽으로 밉니다. TypeScript와 Python을 바꿔 가며 출력을 견줘 보세요. x는 두 언어에서 값이 달라 변수 칸 대신 그림에 비트를 그렸습니다. 32비트 안의 비트는 두 언어가 같고, '32비트 밖' 칸은 Python만 그대로 두고 JS는 버립니다. JS는 맨 위 32번째 자리를 부호로 읽습니다.
let x = 2 ** 29for (let i = 30; i <= 33; i++) { x = x << 1 console.log(i, x)}const y = 2 ** 32 + 5console.log(y | 0)
아직 변수가 없습니다.
- 32비트 밖(40~33)0000 0000
- 32~250010 0000
- 24~170000 0000
- 16~90000 0000
- 8~10000 0000
- 해볼 것
- TypeScript로 2²⁹에서 시작해 끝까지 실행해 본다
- 2²⁷에서 시작해 끝까지 실행해 본다
TypeScript로 2²⁹에서 시작하면 출력이 30 1073741824, 31 -2147483648, 32 0, 33 0이다.
1이 2³¹ 자리에 들어가면 부호 자리라 음수가 된다. −2147483648은 비트로 보면 맨 위 자리 하나만 켜진 1000 … 0000이고, 음수인 것은 JavaScript가 그 자리를 −2³¹로 세기 때문일 뿐이다. 여기서 한 칸 더 밀면 하나뿐인 1이 33번째 자리로 가서 버려지고, 32자리가 모두 0이 된다. 위의 4비트 표로 줄이면 이렇다.
| 4비트 | 비트 | 맨 위 자리를 −8로 읽은 값 |
|---|---|---|
| 시작 | 0100 | 4 |
<< 1 | 1000 | −8(1이 맨 위 자리에 들어감) |
<< 1 | 0000 | 0(1이 4비트 밖으로 밀려나 버려짐) |
한 번 0이 되면 더 밀어도 0이다. Python에서는 같은 계산이 2147483648, 4294967296, 8589934592로 계속 두 배가 된다. | 0도 갈린다. 0과 OR하면 값이 그대로여야 할 것 같지만 JavaScript는 2 ** 32 + 5의 32비트 밖 자리를 버리고 5만 남긴다. 같은 이유로 2 ** 32 | 0은 0이다. 2³²는 33번째 자리 하나만 켜진 수라 32비트 안에는 1이 없다. 이렇게 정해진 비트 수를 넘은 자리가 버려지는 일은 Unit 5에서 오버플로라는 이름으로 다시 본다.
정리하면 이렇다.
| 코드 | JavaScript | Python |
|---|---|---|
1 << 31 | -2147483648 | 2147483648 |
2 ** 32 | 0 | 0 | 4294967296 |
(2 ** 32 + 5) | 0 | 5 | 4294967301 |
~4 | -5 | -5 |
~4는 두 언어가 같다. 둘 다 ~x를 −x − 1로 계산하기 때문이다. ~(NOT)는 5절 표의 모든 자리 뒤집기다. 어떤 수와 그 수를 뒤집은 수를 자리마다 더하면 올림 없이 모든 자리가 1인 수가 된다(0111 + 1000 = 1111). 맨 위 자리를 −8로 읽으면 1111은 −8 + 4 + 2 + 1 = −1이다. 그래서 x + ~x = −1이고, 옮기면 ~x = −x − 1이다. 위의 4비트 표로 보면 ~0111(7)은 모든 자리를 뒤집은 1000, 곧 −8 = −7 − 1이다. 32비트에서도 모든 자리가 1인 수는 −1이라 같은 식이 선다. JavaScript에서 부호 없이 0 ~ 2³² − 1로 읽고 싶으면 부호 없는 오른쪽 시프트 >>> 0을 붙인다. -1 >>> 0은 4294967295다. 실무에서 기억할 것은 하나다. JavaScript에서 2³¹ 이상이 될 수 있는 수(타임스탬프, 큰 id, 바이트 수)에는 |·&·<<·>>를 쓰지 않는다. 소수점을 버리려면 x | 0 대신 Math.trunc(x), 반으로 나누려면 x >> 1 대신 Math.floor(x / 2)를 쓴다(MDN: Math.trunc). 정말 큰 정수를 비트로 다뤄야 하면 BigInt를 쓴다. BigInt끼리의 비트 연산은 32비트로 자르지 않는다. 다만 BigInt에는 >>>가 없다(MDN: BigInt).
함정 찾기 AI가 짠 권한·색·시간 함수, 어느 줄이 틀렸을까?
“권한 플래그 세 개(읽기·쓰기·실행)와 쓰기 권한 확인 함수, RGB를
#rrggbb로 바꾸는 함수, 밀리초 타임스탬프의 소수점을 버리는 함수를 JavaScript로 짜 줘.”의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 4개입니다.
확인 문제 연습
1. 2진수 1010은 10진수로 얼마일까?
2. 비트 10개로 나타낼 수 있는 값은 몇 가지일까?
3. 16진수 0x0F & 0xAB의 결과는? (0x0F는 2진수 0000 1111, 0xAB는 1010 1011)
4. CSS 색 #ff8800에서 초록(가운데 두 자리 88)은 10진수로 얼마일까?
5. JavaScript에서 1 << 31이 음수가 되는 이유는?
6. Python에서 1 << 31의 값은?
이 Unit이 쓰이는 곳
- 『한입 CS』 0장: 숫자는 비트다: 2진 소수의 자릿값(1/2, 1/4, 1/8)으로 0.1이 왜 끝나지 않는지 보고, 64비트를 부호·지수·가수로 나눈다. 안전한 정수가 2⁵³ − 1까지인 이유(2⁵³까지는 정확하지만 2⁵³ + 1은 2⁵³으로 반올림되어 옆 정수와 구별이 안 된다)도 "비트 n개로 2ⁿ가지"에서 나온다. 개념 정리는 부동소수점 노트에 있다.
- 『한입 CS』 1장: 문자는 바이트다: 코드 포인트를
U+D55C처럼 16진수로 적고, UTF-8 코드 추적이rest & 0x3F로 6비트를 꺼내0x80 |로10xxxxxx바이트를 만든 뒤rest >> 6으로 다음 6비트를 내린다. 바이트는 16진수 두 자리로 보여 준다. - 『한입 CS』 10장: 해시, 암호화, 인코딩: Base64가 바이트 3개(24비트)를 6비트씩(2⁶ = 64가지) 잘라 글자 4개로 바꾸고, SHA-256 해시 값 256비트를 16진수 64글자로 적는다.
- 『한입 알고리즘』 6장: 이분 탐색:
(lo + hi) >> 1이 반으로 나누는 시프트이고,>>가 32비트 정수로 바꿔 계산해서 2³¹ 이상이면 음수가 된다는 함정을 다룬다.
더 풀어 보기(선택) · n & (n - 1) 한 번으로 2의 거듭제곱 가려내기
양의 정수 n이 2의 거듭제곱(1, 2, 4, 8, …)이면 n & (n - 1)이 늘 0이고, 아니면 0이 아니다. n = 8과 n = 12를 2진수로 적어 직접 확인하고, 왜 그런지 설명해 보자. 또 n = 0일 때 이 검사가 틀리는 이유도 찾아보자.
n = 8 → 8 & 7 = ? n = 12 → 12 & 11 = ? n = 0 → 0 & -1 = ?