비트 연산자
Operator | Usage | Description |
---|---|---|
AND | a & b | a 와 b를 AND 연산한다. |
OR | a | b | a 와 b를 OR 연산한다. |
XOR | a ^ b | a 와 b를 XOR 연산한다. |
NOT | ~ a | a 의 비트를 반전한다. |
shift | a << b | a 의 비트를 b 비트 자리 수 만큼 좌측으로 옮긴다. |
sign-propagation right shift | a >> b | a 의 비트를 b 비트 자리 수 만큼 오른쪽으로 옮긴다. |
zero-fill right shift | a >>> b | a 의 비트를 b 비트 자리 수 만큼 오른쪽으로 옮긴다. |
부호 비트
시프트 연산에 앞서서 부호 비트라는 개념을 이해해야 한다. 부호 비트는 값이 음수인지 양수인지를 결정하는 비트로 왼쪽에서 가장 첫번째 비트를 사용한다.
- 8비트 변수가 있다고 가정한다면
[ | | | | | | | ]
이런 모양으로 메모리가 준비된다. - -1을 할당하면
[1|1|1|1|1|1|1|1]
부호 비트가 음수임을 나타내도록 1로 할당되고 나머지 7비트로 숫자 -1이 표현된다. - 음수의 경우 2의 보수를 취하여 표현되기 때문에 이런 모양이 된다.
Left Shift
좌향 시프트를 이해하기 위해서 비트 연산 7 << 4
을 가정해보자. 1바이트 메모리에 7을 2진수로 변환하고 각 자리수를 표현하자면 [0|0|0|0|0|1|1|1]
이런 모습이 된다. << 4
연산을 진행하면 좌측으로 4칸 만큼 비트를 이동시킨다. 이때 빈 자리는 0으로 채워져 [1|1|1|0|0|0|0]
이런 모습이 된다. 이 비트는 2진수로 표현 되어있으니 01110000을 십진수로 표현하면 112라는 값이 된다.
Right Shift
좌향 시프트와 마찬가지로 우향 시프트도 방향만 다르지 동일한 작동을 한다. 비트를 우측으로 밀면서 0번째 비트 밖으로 벗어나는 값들은 버려진다. 좌향 시프트 연산은 빈 자리를 0으로 채우면 그만이지만 우향일 경우 두가지 옵션 중 하나를 택해야한다.
Sign Propagation Right Shift
부호 전파 시프트는 부호 비트를 연산된 값에게도 전파하겠다는 말이다. 쉽게 그냥 음수면 음수를 양수면 양수를 유지하겠다는 뜻이다. 물론 양수일 경우 부호 비트가 0이라 크게 신경쓰지 않아도 예상한대로 동작할 것이다. 비트를 우측으로 밀고 나서 원래의 부호 비트가 복사되어 부호 비트를 유지한다.
Zero Fill Right Shift
이름처럼 모두 동일한 연산이지만 부호비트와 관계없이 무조건 빈 자리를 0으로 채우겠다는 뜻이다.
하다보니 궁금한 점
계속해서 시프트 연산을 시도하면 최소값이 양수는 결국 0이되고 음수를 항상 -1 인걸 볼 수 있다. 이것은 음수가 2의 보수를 취해야 하기 때문에 나타나는 현상이다. 8비트로 표현할 수 있는 숫자의 범위가 음수를 포함하면 -128 ~ 127인 이유와도 연관이 된다.
2의 보수
제대로 설명해주는 사람도 없었고 학교에서도 단순히 공식만 짚고 넘어갈 뿐이기 때문에 -128 ~ 127 이 범위에 대해서 처음엔 정말 이해할 수가 없었다. 정말 궁금하지만 굳이 비트까지 까봐야 하나 싶기도 하고 저거 모른다고 요즘 프로그램이 안돌아가는 것도 아니고 말이다. 이참에 정리할겸 다시 파보자.
2의 보수를 적용하려면 몇 가지 배경지식이 필요하다. 알다시피 컴퓨터는 전기로 동작한다. 전기만으로 신호를 만들 수 있는 방법은 전류가 흐르는 상태1와 흐르지 않는 상태0 두가지뿐이다. 그래서 0과 1이라는 신호만을 만들 수 있고 이것을 2진수와 접목하면 다양한 숫자를 표현할 수 있게 된다. 전선 한 가닥으로는 1자릿수 밖에 표현이 안되기 때문에 여러가닥을 묶어 하나의 수로 보아야하며 이 전선 한 가닥을 1비트라 하고 8가닥을 묶어 일렬로 늘어놓으면 이것이 1바이트이다. 각각의 전선의 위치를 자릿수라고 생각한다면 전선들의 전류 상태를 00100110
처럼 표현할 수 있다.
이게 또 왜 하필 8비트를 1바이트로 보냐 라고하면 컴퓨터의 고향 미국에서 영어 한 글자 표현하기 위해 1바이트가 필요했다. 1바이트로 모든 영문자, 숫자, 그리고 몇몇 특수문자까지 표현할 수 있었고 다루기 편해서 컴퓨터의 기본 처리 단위로 많이 사용되었다.
1바이트 최소값: [0|0|0|0|0|0|0|0]
1바이트 최대값: [1|1|1|1|1|1|1|1]
1 바이트 공간에 2진수로 표현할 수 있는 모든 경우의 수는 2^8이 되므로 256가지를 숫자를 표현할 수 있다. 0도 포함되기 때문에 0 ~ 255
를 표현할 수 있다. 그런데 문제가 하나 있다. 음수는 어떻게 표현할 것인가?
여기에 대한 헤결책으로 가장 앞에 있는 비트를 하나 희생하여 값이 음수인지 양수인지를 판별하기로 하였다. 음수임을 표시하기 위하여 비트 하나를 잃는 고통을 감수하게 된 것이다. 이 비트를 부호 비트라 한다. 부호 비트가 1이면 값에 - 부호를 부여하면 되니 확실한 방법이다.
부호 비트 있는 1바이트 양수 최대값: [0 | 1|1|1|1|1|1|1] ( 127)
부호 비트 있는 1바이트 음수 최대값: [1 | 1|1|1|1|1|1|1] (-127)
이렇게 해서 모든 것이 잘 표현되었습니다~ 🎉
라고 할 뻔 어림도 없지 컴퓨터란놈은 생각보다 멍청한 놈이다. 앞서 설명했듯 전류의 상태만으로 0과 1말고는 표현할 방법이 없기 때문에 음수라는 개념 자체가 없다. 그래서 뺄샘을 할 때에는 6 + (-5)
처럼 덧셈으로 뺄셈을 구현하도록 되어있다.
아니 이보시오,
음수라는 개념이 없다면서 저기 음수 부호가 있는데 무슨 개소리이십니까?
여기서 구원투수 2의 보수가 등판한다.
보수
양수지만 음수라고 부를 수 있는 꼼수를 찾았읍니다.
뭐 대단한 것 같지만 쉽게 말하면 수에 인코딩을 적용한 것이다. 보수는 어떤 값에 대해서 대치되는 값을 말한다. 직선 위에 수를 놓고 보면 좀 더 이해하기 쉽다.
정방향 >> [ 0 1 2 3 4 5 6 7 8 9 ]
[ 9 8 7 6 5 4 3 2 1 0 ] << 역방향
위 처럼 0부터 9까지의 수가 늘어서 있다고 생각해보자. 여기서 기수기준이 되는 수4는 0을 기준으로 4번째이다. 이 수열이 시작되는 방향을 역방향으로 생각하면 9로부터 4번째 수인 5는 기수 4의 보수라고 한다.
다른 수들도 마찬가지로 9의 보수는 0, 8의 보수는 1, 6의 보수는 3이다. 이렇게 역으로 셈하여 대치되는 값을 보수라 한다. 좀 더 규칙성을 고려해보면 기수와 보수를 더하면 항상 같은 값이 나온다. 위에서 든 예시로는 항상 9가 나온다.
양수 + 보수 = 보수
2 + 5 = 7
2 + 4 = 6
2 + 3 = 5
2 + 2 = 4
이 원리를 이용하여 덧셈을 해보면 위와 같은 모양이 된다. 느낌이 오는가? 앞서 보수는 일종의 인코딩을 한 것이라고 하지 않았던가. 인코딩된 값은 해석하려면 반드시 디코딩을 해야한다. 이 보수를 기수로 치환디코딩하고 음수라고 생각해보자
보수를 디코딩하고 음수라 가정한다면
2 + (-4) = -2
2 + (-5) = -3
2 + (-6) = -4
2 + (-7) = -5
띠용, 양수의 합만으로 뺄샘을 구현하였다. 정확한 계산을 위해서는 자리 올림이 생기면 버리고 1을 더하는 등 좀 더 많은 규칙들이 필요하지만 뭐 대회 나갈것도 아니고 일단 이 원리만 이해하자.
용어
이름이란 것도 참 중요하다고 생각한다. 나는 빡대가리라 이 이름에서부터 컷 당했다. 영어권에서는 one's complement, two's complement 라고 한다. 위키백과에서는 또 뭔 1들로만 이루어진 보수 이렇게 설명하는데 아무래도 매치가 안된다. 그러면 two's complement는 2들로 이루어져야지 그건 또 아니지 않은가?. 애초에 단어가 이상해서 그런건지 원어민이 아니라 모르겠다.
1의 보수는 뭐 그렇다 치자. 2는 개뿔 코빼기도 안보이는데 왜 2의 보수인가? 교수들이 가장 신나하면서 애들 머릿속 해집기 시작하는 챕터이기도 하지만 용어 때문에 처음부터 그 의미가 연결이 잘 안된다. 직역, 한자화, 명사화, 출판이라는 지옥의 콜라보로 지금의 모습이 된 것 같은데 한 번 계산해서 얻은 보수, 두번 계산해서 얻은 보수 이게 더 와 닿는다. 누구누구 방정식 이런 가늠조차 안되는 이름도 많은데 굳이 줄일 필요가 있었단 말인가? 차라리 이 방법 쓰자고 제안한 이름을 붙여놓는게 나았을 것 같다. 굳이굳이 명사화 하고 싶었다면 초회 보수, 차회 보수 이게 차라리 낫지 않았을까...
1의 보수
왜 하필 2의 보수를 사용하는가? 1의 보수는 없나? 있다. 더하고 빼고 복잡한 공식도 있지만 위에서 십진수로 보수 구할 때 보다 훨씬 편하다. 2진수에서 0의 보수는 어차피 1이라 그런지 각 자리수 값들을 반전만 시켜주면 된다.
1|1111110: 주어진 값 부호비트 확인
0|0000001: 1의 보수 수행 (비트 반전)
-1: 보수는 음수이므로 -부호 추가
그러나 위 처럼 1의 보수만을 사용하여 음수를 표현하면 양의 +0과 음의 -0을 얻을 수 있다. 나는 수학을 잘 모르지만 0이라는 값에 부호가 있다는 게 뭔가 이상하다는 것 쯤은 느낌적인 느낌으로다가 알 수 있었다.
다시 2의 보수
2진수에서 2의 보수를 취하는 방법도 간단하다. 1의 보수를 얻고 +1만 해주면 된다. 즉, 비트를 반전하고 +1만 하면 2의 보수다.
1|1111111: 주어진 값 부호비트 확인
0|0000000: 2의 보수 1단계 수행 ( 비트 반전 )
0|0000001: 2의 보수 2단계 수행 ( +1 )
-1: 보수는 음수이므로 -부호 추가
1|0000000: 주어진 값 부호비트 확인
0|1111111: 2의 보수 1단계 수행 ( 비트 반전 )
1|0000000: 2의 보수 2단계 수행 ( +1 )
-128: 보수는 음수이므로 -부호 추가
나도 생각날 때쯤되면 항상 까먹어서 지금 이 나이에 여기다가 쓰고 자빠져있다. 저 공식에 눈이 팔려서 대체 왜 -128인지 이해를 잘 못했는데 오늘 문득 깨달아 버렸다. 이렇게 순서대로 생각하니 아주 명료해졌다.
- 1바이트 표현가능한 경우의 수 0부터 255까지2^8
- 이렇게 하면 되긴 하는데 0조차 반갈죽 당하네 -0 뭐임 ㅋㅋ
- 하... -0 개 거슬리네, 야, 1의 보수. +1 넣을게
- 문제도 해결하고 값 하나 더 표현가능 개꿀 ㄹㅇ 천재인듯ㅋㅋ 이름은 걍 2의 보수임. 내가 이름 짓겠다는데 네가 뭘 할 수 있는데 ㅋㅋㅋ 꼬우면 컴퓨터 기초 이론 혁명 일으키시던가 ㅋㅋㅋㅋㅋㅋㅋㅋ
1의 보수에 +1 하겠다는 의미를 수열에서 생각해 본다면 뭘 하려고 했는지 확실해진다. 보수는 역방향이므로 음수로 전환할 때 +1을 하게 되면 1 더 작은 값이 나온다. -0을 포함해 1씩 더 밀어버리겠다는 의미이다. 때문에 -0부터 였던 음수의 표현범위가 1씩 더 밀려난 -1부터 -128이 된다. 다른 자료형도 동일한 매커니즘 때문에 음수를 양수보다 하나 더 표현할 수 있다.
두 수의 연산
이제는 두 바이트를 더해보자. 연산에는 규칙이 더 있다.
- 부호 비트가 있다면 2의 보수를 먼저 구한 다음 합한다.
- 부호 비트에 자리올림이 생겼다면 부호 비트를 제외한 나머지가 결과값이다.
- 부호 비트에 자리올림이 없었다면 다시 2의 보수를 취하고 -기호를 부여한 것이 결과값이다.
자리올림이 생긴경우
0|1111111: 양수 최대값 (127)
+ 1|1001101: (-77)
-------------------------
부호 비트가 있는 값의 2의 보수를 먼저 구한다.
0|0110010: 1의 보수
0|0110011: 2의 보수
-------------------------
2의 보수값과 합한다
0|1111111: 양수 최대값
+ 0|0110011: 2의 보수
-------------------------
1|0110010:
0110010: 부호 비트에 자리올림이 생겼으므로 부호 비트를 제외한다. (10진수: 50)
자리올림이 없는 경우
0|1111111: 양수 최대값 (127)
+ 1|0000000: 음수 최대값 (-128)
-------------------------
부호 비트가 있는 값의 2의 보수를 먼저 구한다.
0|1111111: 1의 보수
1|0000000: 2의 보수
-------------------------
2의 보수값과 합한다
0|1111111: 양수 최대값 (127)
+ 1|0000000: 2의 보수 (-128)
-------------------------
1|1111111: 부호 비트에 자리올림이 생기지 않았으므로 다시 2의 보수를 취한다.
0|0000000: 1의 보수
0|0000001: 2의 보수
-1: 부호 비트에 자리올림이 생기지 않았으므로 - 기호를 붙인다.
덧셈밖에 모르는 바보 컴퓨터는 이제 더하기 만으로 뺄샘 결과를 출력할 수 있게 되었다. 🎉🎉🎉
형식화 배열
유사 배열로 이진 데이터를 다루기 위해 사용한다. 일반적으로 잘 사용되지는 않기에 생소하지만 websocket이나 비디오, 오디오 등 브라우저가 점점 강력해짐에 따라서 자바스크립트에서 직접 이진 데이터를 다루어야하는 상황이 점점 늘어나 추가된 기능이다. 유연성과 성능을 위해 형식화 배열은 버퍼와 뷰 두가지로 나뉜다.
유사 배열
진행하기 앞서서 유사 배열을 짚고 넘어가자. JavaScript를 하다보면 Array-Like 또는 유사 배열, 배열같은 이런 표현들을 볼 수 있을 것이다. 멤버들의 키가 숫자로 인덱싱 되어있고 length 멤버를 가지고 있는 객체를 말한다. 자바스크립트가 객체의 멤버에 접근하는 방식과 배열의 요소에 접근하는 방식이 동일해서 호환이 가능하기 때문에 이런 이름이 붙은 것으로 보인다.
const nodes = document.querySelectorAll('div');
const el = nodes[0];
console.info(el); // <div>...</div>
el.filter(Boolean); // Uncaught TypeError: document.querySelectorAll(...).filter is not a function
어쩌면 생각보다 더 자주 봤을 수도 있다. document.querySelectorAll()
함수는 유사 배열을 반환한다. 분명 배열처럼 생겨서 배열을 다루듯이 요소에 접근할 수도 있고 forEach처럼 배열을 순회할 수 있는 함수를 제공하여 많이 속았을 것이다. 조금만 가지고 놀다보면 map이나 filter처럼 분명 있어야할 배열 함수가 없다는 걸 눈치챘을 것이다. 이런 유사 배열들은 Array.from()
함수를 사용하면 온전한 배열로 쉽게 전환하여 진짜 배열처럼 다루는게 가능하다.
또 다른 예를 들자면 문자열도 array like라고 볼 수 있다. 각 문자들은 숫자로 접근키가 숫자로 인덱싱 됨할 수 있고 length 멤버를 가지고 있다. 실제로 Array.from("hello world")
는 문제 없이 잘 동작한다.
버퍼
버퍼는 ArrayBuffer로 구현되고 chunk 데이터를 나타내는데 사용된다. 별도의 형식이 없고 콘텐츠에 엑세스할 수 없다.
ArrayBuffer
ArrayBuffer는 고정길이의 이진 데이터 버퍼를 나타내는 데 사용하는 자료형이다. 콘텐츠를 직접 조작할 수는 없지만 형식화 배열 뷰나 특정 형식의 버퍼로 나타내는 DataView를 만들어 콘텐츠를 읽거나 쓰기 위해서 사용한다.
형식화 배열 뷰
형식화 배열 뷰는 숫자형을 위한 뷰를 제공한다. Uint8ClampedArray 형식은 예외적으로 값을 0에서 255 사이로 제한한다. 범위를 벗어난 값을 지정하면 0 또는 255로 설정되고, 정수가 아닌 값이면 가장 가까운 정수가 설정된다. Canvas 데이터 처리에 유용하다.
유형 | 값 범위 | 바이트 크기 | 설명 | Web IDL 자료형 | 동격 C 자료형 |
---|---|---|---|---|---|
Int8Array | -128 ~ 127 | 1 | 8비트 2의 보수 부호비트 있는 정수 | byte | int8_t |
Uint8Array | 0 ~ 255 | 1 | 8비트 부호비트 없는 정수 | octet | uint8_t |
Uint8ClampedArray | 0 ~ 255 | 1 | 8비트 부호비트 없는 정수 (고정됨) | octet | uint8_t |
Int16Array | -32768 ~ 32767 | 2 | 16비트 2의 보수 부호비트 있는 정수 | short | int16_t |
Uint16Array | 0 ~ 65535 | 2 | 16비트 부호비트 없는 정수 | unsigned short | uint16_t |
Int32Array | -2147483648 ~ 2147483647 | 4 | 32비트 2의 보수 부호비트 있는 정수 | long | int32_t |
Uint32Array | 0 ~ 4294967295 | 4 | 32비트 부호비트 없는 정수 | unsigned long | uint32_t |
Float32Array | -3.4E38 ~ 3.4E38, 최소 양수 값 1.2E-38 | 4 | 32비트 IEEE 실수 (7 자리 e.g., 1.123456) | unrestricted float | float |
Float64Array | -1.8E308 ~ 1.8E308, 최소 양수 값 5E-324 | 8 | 64비트 IEEE 실수 (16 자리 e.g., 1.123...15) | unrestricted double | double |
BigInt64Array | -2^63 ~ 2^63 - 1 | 8 | 64비트 2의 보수 부호비트 있는 정수 | bigint | int64_t (signed long long) |
BigUint64Array | 0 ~ 2^64 - 1 | 8 | 64비트 부호비트 없는 정수 | bigint | uint64_t (unsigned long long) |
DataView
버퍼에 데이터를 읽고 쓰기 위해 getter/setter를 제공하는 저레벨 인터페이스이다. 서로 다른 유형의 데이터 처리에 유용하다. 형식화 배열 뷰는 플랫폼의 내장된 바이트 순서를 따르며 이 순서를 제어할 수 있다. 기본은 big-endian이고 getter/setter 메서드로 little-endian으로 설정할 수 있다.
사용해보기
const buffer = new ArrayBuffer(16);
const int32View = new Int32Array(buffer);
for (let i = 0, ilen = int32View.length; i < ilen; i++) int32View[i] = i * 2;
const int16View = new Int16Array(buffer);
for (let i = 0, ilen = int16View.length; i < ilen; i++) console.log(`\${i} 번째: \${int16View[i]}`);
16 바이트 길이의 메모리를 준비하고 각각 Int32, Int16 형식의 형식화 배열 뷰를 만들어서 엑세스하는 예제이다. 이것 또한 앞서 설명했듯이 유사 배열 이기 때문에 일반 배열처럼 엑세스할 수 있어 편하고 마찬가지로 Array.from()
을 통해서 쉽게 배열로 변환할 수도 있다.
const buffer = new ArrayBuffer(16);
const valueView = new Uint32Array(buffer, 0, 1);
const value2View = new Uint8Array(buffer, 4, 8);
또는 이런 식으로 바이트로 잘라서 데이터를 엑세스 할 수도 있기 때문에 다양한 형태의 자료형과 상호작용할 수도 있다.