🍒 Language/📖 SoEn(C++,C)

[SoEn] 5장 연산자

후추 집사 2023. 1. 25. 14:04

5-2. 논리 연산자

5-2-다.비트 연산자

  • 비트 연산자는 논리 연산자와 비슷하지만 비트를 연산 대상으로 한다는 점이 조금 다르다.
    • 비트(bit)란 기억 장치의 최소 단위로서 1 또는 0을 기억하며 8개의 비트가 모여야 1바이트가 된다.
    • 32비트의 정수 1234는 16진수로는 0x4d2이며 메모리에 다음과 같이 기억된다.
    • 32비트이므로 32개의 비트가 있고 이 각각의 비트가 1이나 0을 기억함으로써 1234라는 숫자 하나를 저장하는 것이다.
  • 비트 연산자는 위 그림에서 각 격자인 비트들을 대상으로 조작을 한다. 비트가 연산대상이라는 말은 두 피연산자의 대응되는 비트끼리 연산해서 그 결과를 리턴한다는 뜻이다.
  • 비트 연산자의 종류연산자 설명
    ~ 비트를 반전시킨다.
    & 대응되는 비트가 모두 1일 때 1이다.
    | 대응되는 비트가 모두 0일 때 0이다.
    ^ 두 개의 비트가 달라야 1이다.
    << 지정한 수만큼 왼쪽으로 비트들을 이동시킨다.
    >> 지정한 수만큼 오른쪽으로 비트들을 이동시킨다.
    • ~만 단항 연산자이고 나머지는 모두 두 개의 피연산자를 취하는 이항 연산자이다.
    • 비트 연산은 정수 수준에서만 의미가 있기 때문에 피연산자는 모두 정수형이거나 또는 정수로 자동 변환될 수 있는 타입이어야 한다. 실수나 포인터 등은 비트 연산자와 함께 사용할 수 없다.
  • 단항 연산자 ~
    • 비트가 1이면 0으로 0이면 1로 바꾸어 1의 보수로 만든다.
    • a가 0x59라고 할 때 ~a가 어떻게 연산되는지 보자. 32비트 환경에서 정수는 32비트이지만 설명의 편의상 a가 8비트 정수타입(unsigned char)이라고 하자.
    • 0x59의 ~연산 결과는 비트를 모두 뒤집은 0xa6이 되는데 이 두 수는 1의 보수 관계이며 더하면 전체 비트가 모두 1인 0xff(이진수로 11111111)가 된다.
    • 비트 연산자를 설명할 때는 2진수나 16진수를 쓸 수밖에 없으며 이 동작을 잘 이해하기 위해서는 2진수와 16진수 사이를 암산으로 신속하게 변환할 수 있어야 한다.
    • 반전 연산자는 이미지 처리에 많이 사용되는데 이미지의 각 픽셀값을 반대로 뒤집으면 역상의 이미지를 얻을 수 있다.
  • &, | 연산자의 동작도 이해하기 쉬운데 특정 비트만 0으로 만들거나 또는 1로 만들 때 이 연산자들이 사용된다.
  • & 연산
    • a가 0x59일 때 a & 0xf(이진수 00001111)가 어떻게 연산되는지 보자.
    • &연산의 진리표를 보면 0과 &되는 비트는 그 값에 상관없이 무조건 0이 되며 1과 &되는 비트는 원래 비트값을 그대로 유지하는 특성이 있다.
    • 이진수 00001111과 &연산을 하면 상위 4비트는 0이 되며 하위 4비트만 값을 유지한다.
    • 이런 식으로 특정 비트를 강제로 0으로 만드는 연산을 마스크 오프(mask off)라고 한다.
  • | 연산
    • 1과 |되는 비트는 무조건 1이 되고 0과 |되는 비트는 원래 값을 유지하는데 이렇게 특정 비트를 강제로 1로 만드는 연산을 마스크 온(mask on)이라고 한다.
  • &, | 연산자일부 비트만 제한적으로 읽거나 변경할 때 흔히 사용된다.
  • 기억 공간을 절약하기 위해 하나의 정수값을 비트별로 잘라 여러 가지 값을 같이 기억시키는 방법이 많이 사용되는데 예를 들어 한글 조합형 코드는 16비트 길이를 가지며 다음과 같이 구성되어 있다.
    • 최상위 비트는 항상 1인데 이 값은 이 코드가 한글임을 표시한다. 영문 알파벳은 모두 128보다 작기 때문에 이 비트가 0으로 되어 있어 한글과 구분된다.
    • 16비트의 정수값을 5비트씩 잘라서 초성, 중성, 종성 코드를 기억시킨다. 한글 낱글자인 ㄱ,ㄴ,ㄷ,ㄹ,... 은 총 개수가 32개가 안되기 때문에 5비트면 낱글자 하나를 기억할 수 있고 이런 글자 세 개가 모이면 한글 1음절을 표현할 수 있다. 초성, 중성, 종성 코드를 각각의 정수에 기억하는 방법에 비해 훨씬 더 기억 공간이 절약된다.
    • 이런 조합된 값에서 일부만 추출해 내거나 일부만 변경하려면 &, | 비트 연산이 필요하다. 다음은 한글 1음절의 값을 가지는 변수 Han을 비트 조작하는 예이다.
    • Han & 0x1f // 종성만 분리한다. Han & 0x7c00 // 초성만 분리한다. Han & 0xffe0 | 2 // 종성만 ㄱ으로 바꾼다.
  • 윈도우의 스타일도 32비트의 정수에 각 스타일 비트들이 조합되어 있는데 이런 값들을 조작할 때도 비트 연산자가 사용된다.
  • 다음에 API를 배울 때 보게 되겠지만 간단히 예만 보이자면 다음과 같다. style 변수에 32개나 되는 스타일 비트가 기억되어 있는데 다른 스타일값은 무시하고 WS_CHILD 값만 조사하거나 변경하고자 할 때 마스크 연산을 해야 한다.
if (style & WS_CHILD)            // WS_CHILD 스타일을 가지고 있으면
style |= WS_CHILD           // WS_CHILD 스타일 지정
  • XOR 연산자인 ^ 는 배타적 논리합이라고 부르며 ~연산자와 마찬가지로 비트를 반전시키는 기능을 하는데 ~연산자가 전체 비트를 반전시키는 반면 ^는 지정한 비트만을 반전시킨다.
    • 배타적 논리합은 비트가 서로 다를 때만 1이 되고 같으면 0이 되기 때문에 1과 ^되는 비트는 반전되고 0과 ^되는 비트는 원래 값을 유지한다. 그래서 반전시키고자 하는 부분만 1로 만든 값과 ^연산을 취하면 원하는 부분만 반전된다. 이름하여 마스크 반전이라고 할 수 있다.

  • 반전된 값은 다시 반전시키면 원래대로 돌아오는 특성이 있다.
    • 1을 0으로 만들었다가 다시 반전하면 원래값 1이 되기 때문이다.
  • 그래서 XOR 연산은 이미지의 이동이나 반복적인 점멸 처리에 사용된다. 캐럿이 깜박거리거나 텍스트의 선택 블록을 보여주는 처리가 모두 이 연산을 사용하는데 반복적으로 XOR 연산을 하면 원형을 손상하지 않고 복구 가능하기 때문이다.

5-2-라.쉬프트 연산자

  • 쉬프트(Shift) 연산자는 비트들을 지정한 수만큼 좌우로 이동시킨다.
    • >> 연산자는 오른쪽으로 비트를 이동시키며 << 연산자는 왼쪽으로 비트를 이동시킨다.
    • 연산자의 모양이 이동 방향의 화살표와 비슷하게 되어 있어 직관적이다.
    • a << 1은 a를 1비트 왼쪽으로 이동시키며 a << 3은 3비트 왼쪽으로 이동시킨다.
    • 두 개의 피연산자를 취하는 이항 연산자인데 좌변은 보통 정수형 변수가 오고 우변은 정수 상수가 온다. 물론 양변이 모두 변수(a << b)일 수도 있고 양변이 모두 상수(1 << 3)일 수도 있다.
  • 동작은 간단하지만 상당히 고급 연산자이며 응용 가치가 많다. a가 0x59라 할 때 a >> 1연산이 어떻게 동작하는지 그림으로 살펴보자. 그림에서 제일 왼쪽 비트를 b0라고 하며 제일 오른쪽 비트를 b7이라고 한다.
    • b7은 b6으로 이동하고 b6은 b5로 이동하여 모든 비트들이 한칸씩 오른쪽으로 이사를 갔다. 단, 제일 오른쪽에 있는 b0는 갈 곳이 없으므로 쫒겨나서 사라지고 제일 왼쪽 비트 b7은 0으로 채워진다.
  • << 연산자는 반대로 비트들을 왼쪽으로 이동시킨다.
    • 이번에는 제일 왼쪽에 있는 b7이 밀려나고 b0는 0으로 채워진다. 연산의 동작이 아주 간단하기 때문에 쉽게 이해가 갈 것이다. 그렇다면 이 연산을 어디다 써 먹을 수 있을지 연구해 보자.
  • 다음은 조합형 한글 "강"자에 대한 코드를 가지고 있는 변수 a의 비트 구조이다. 상용 조합형 코드표를 찾아 보면 초성 ㄱ은 00010(2)이고 중성 ㅏ는 00011(3)이고 종성 ㅇ의 코드는 10111(0x17)로 되어 있으며 이 코드들이 5비트씩 분할되어 16비트를 구성한다.
    • 이런 구조에서 a의 초성이 ㄱ인지 조사하고 싶다면 어떻게 해야 할까? 변수 하나에 초성, 중성, 종성이 모두 들어있기 때문에 if (a==값) 식으로 단순히 비교해서는 초성값만 비교할 수가 없다. a값에서 다른 코드는 제외하고 초성만 쏙 빼내야 한다. 이럴 때는 앞에서 배운 &연산자를 사용하여 초성 이외의 나머지 코드를 모두 마스크 오프시켜 0으로 만든다.
    • 초성 부분만 남겨야 하므로 마스크는 0x7c00을 사용했다. 이렇게 되면 중성, 종성 비트는 모두 0이 된다. 남은 값 0x0800은 초성 코드만 가진 값인데 ㄱ의 코드인 00010과는 자리가 맞지 않기 때문에 직접 비교를 할 수 없다. 그래서 이 초성 비트를 오른쪽으로 10번 쉬프트시킨다.
    • 이렇게 하면 a의 초성 코드만 남게 되고 자리도 맞추었으므로 원하는 초성 코드와 직접 비교할 수 있다. 결과 코드는 다음과 같다.
    • if (((a & 0x7c00) >> 10) == 2)
      • a를 0x7c00과 & 연산하여 마스크 오프시키고 10번 오른쪽으로 민 후 이 값이 ㄱ의 코드인 2인지 비교했다.
      • 반대로 초성만 특정한 값으로 바꿀 때는 어떻게 할지 생각해 보기 바란다.
      • 쉬프트 연산은 고속의 그래픽 처리가 필요할 때 비디오 램을 직접 액세스하기 위해서도 많이 사용된다. 비디오 램에 들어 있는 이미지를 쉬프트하면 스크롤될 것이다.
  • 쉬프트 연산의 피연산자는 주로 부호없는 정수형이다.
    • 실수형은 당연히 안된다.
    • 부호있는 정수형은 가능은 하지만 이 경우 동작이 조금 달라진다. 최상위에 있는 부호 비트는 쉬프트 대상에서 제외되는데 부호는 값이 아니기 때문에 유지하는 것이 옳다. 부호있는 정수에 대한 쉬프트 연산은 권장되지 않으며 실제로 의미를 가지는 경우도 드물다.

5-3.기타 연산자

5-3-가.삼항 조건 연산자

  • 삼항 조건 연산자는 특이하게도 피연산자를 세 개나 가지는데 다른 언어에는 없는 C언어의 독특한 연산자이다. 기본 형식은 다음과 같다.

(조건식) ? 값1:값2

  • ? 앞에 조건식이 있고 ? 뒤에 :을 사이에 두고 두 개의 값이 온다.
  • ?와 :은 한 연산자를 구성하는 짝이기 때문에 반드시 같이 와야 하며 단독으로 사용할 수는 없다. if else, do while처럼 짝을 이루어 사용되는 연산자이다.
  • 이 연산자는 조건식을 평가해 보고 참이면 값1을 리턴하고 거짓이면 값2를 리턴한다.
  • 조건식 자리에는 보통 변수의 값을 비교하는 관계 연산문이 오지만 조건식으로 사용될 수 있는 식이면 어떤 것이든지 가능하다. 변수나 상수, 함수호출문 등 논리값을 리턴하는 모든 식이 올 수 있다.
  • 조건식을 감싸는 괄호는 반드시 필요한 것은 아니나 괄호가 있는 것이 보기에 좋고 안정감이 있어 보인다. 간단한 사용예를 보면 이 연산자의 동작을 쉽게 이해할 수 있을 것이다.

예제 : samhang

#include <Turboc.h>

void main()
{
     int i=3,j=4,k; 

     k=(i > j) ? i:j;
     printf("큰 수=%d\n",k);
}
  • i는 3이고 j는 4로 초기화되었으며 k는 삼항 조건 연산자로 i나 j중에 큰 값을 대입받는다. 보다시피 i보다 j가 더 크게 초기화되었으므로 j가 선택될 것이다.
  • ? 연산자는 조건식 (i > j)를 평가하여 i가 j보다 더 큰지 점검한다. 점검 결과가 참이면 i를 리턴하고 그렇지 않으면 j를 리턴할 것이다. 그래서 이 연산문은 두 변수 중 큰 값을 취한다.
  • 삼항 연산자가 들어간 문장은 ?를 우리말의 "이면"으로 바꾸고 :을 "아니면"으로 바꾸면 쉽게 읽을 수 있다. 위 예제의 연산문을 말로 바꾸면 "i > j 이면 i, 아니면 j"가 되어 금방 의미를 파악할 수 있다. 삼항 조건 연산자는 일종의 조건문이기 때문에 if else문으로 바꿀 수 있다.
if (i > j) {
     k=i;
} else {
     k=j;
}
  • 그러나 여러 줄로 써야 할 코드를 한줄로 간편하게 표현할 수 있다는 점에서 삼항 조건 연산자가 더 간단하다.
  • 게다가 중간 변수없이 수식내에서 바로 사용할 수 있으므로 조건에 따라 두 개의 값 중 하나를 선택할 때 주로 많이 사용된다. 위 예제에서 k변수를 따로 선언할 필요없이 삼항 조건 연산문을 printf의 인수로 바로 사용하는 것도 가능하다.
  • 여러 개의 값 중 하나를 선택해야 한다면 삼항 조건 연산자를 중복해서 사용할 수도 있다.

k=(i > j) ? i: (j < 0) ? 0:j;

  • 이 코드는 i가 j보다 크면 i를 취하고 j가 더 크면 j를 취하되 단, j가 음수일 경우는 0을 돌려준다.
  • 원래 j가 있던 자리를 삼항 조건 연산문으로 대체했는데 k=(i > j) ? i:((j < 0) ? 0:j);식으로 괄호를 써 주면 좀 더 의미가 분명해질 것이다.
  • (결과) 값1과 값2는 형식에 제한없이 어떤 값이나 올 수 있지만 동일한 타입이어야 한다. 왜냐하면 연산 결과를 대입받는 변수의 타입이 가변적일 수는 없기 때문이다. 똑같이 정수형이거나 아니면 똑같이 실수형이어야 한다. 다음과 같은 표현도 가능하다.

printf("%s", (a==6) ? "a는 6이다.":"a는 6이 아니다.");

  • 문자열 자체는 포인터 상수인데 조건에 따라 둘 중 하나를 선택할 수 있다. 다음과 같이 조금 더 압축할 수도 있다.

printf("a는 6이%s다.", (a==6) ? "":" 아니");

  • %s 서식 자리에 어떤 문자열을 삽입하되 a가 6이면 "", 즉 빈 문자열을 삽입하고 6이 아니면 "아니"라는 부정어를 삽입하면 된다. 결과는 동일한데 상당히 깜직하지 않은가? 이런 비슷한 예가 앞 장의 예제에도 있었다. 홀짝을 판별하는 if5예제의 경우 if문을 쓸 필요없이 다음과 같이 바로 홀짝 판별을 할 수 있다.

printf("%d는 %s수입니다.\n",i,i%2==0 ? "짝":"홀");

  • 서식 문자열 내에 %s 서식을 넣고 i를 2로 나눈 나머지가 0이면 "짝" 아니면 "홀"과 대응시키면 된다. 또 나누기 연산의 결과를 바로 조건에 사용하면 i%2 ? "홀":"짝"으로 조금 더 압축할 수 있다. 연산자 자체의 기능은 간단하지만 실제 활용되는 곳을 보면 응용하기에 따라서는 여러 가지 역할을 할 수 있는데 이런 것을 보면 문법에는 역시 응용의 묘미가 있다. 단, 가독성과 이식성을 해치지 않는 범위에서만 응용을 해야 한다.

5-3-다.sizeof 연산자

  • 다른 연산자들은 모두 +, -, && 같은 기호로 표현하는데 sizeof 연산자는 단어로 되어 있어 조금 특이해 보인다.
  • 이 연산자는 피연산자로 주어진 타입 또는 변수의 크기를 계산한다. 기본 형식은 다음과 같다.

sizeof(타입 또는 변수)

  • 피연산자로 int, double같은 타입을 쓸 수도 있고 변수를 쓸 수도 있으며 상수를 사용할 수도 있다. 괄호안에 있는 대상이 메모리를 얼마나 차지하고 있는지 계산한다. 다음 예제를 실행해 보면 이 연산자가 어떻게 동작하는지 금방 알 수 있을 것이다.

예제 : sizeof

#include <Turboc.h>

void main()
{
     int i;

     printf("int=%d\n",sizeof(int));
     printf("double=%d\n",sizeof(double));
     printf("i=%d\n",sizeof(i));
     printf("string=%d\n",sizeof("string"));
}
  • 정수, 실수 타입과 정수형 변수, 문자열 상수에 대해 크기를 계산해 보았다. 실행 결과는 다음과 같다.
int=4
double=8
i=4
string=7
  • 정수형은 4바이트를 차지하고 실수형은 8바이트를 차지한다. "string"이라는 문자열은 6자이지만 제일 뒤의 널 종료 문자도 메모리를 차지하므로 크기는 7바이트이다.
  • sizeof 연산자는 사용자가 직접 계산해야 할 변수의 크기를 컴파일러가 대신 계산해 주는 연산자이다.
  • sizeof(int)의 결과가 4라는 것은 누구나 알고 있는 사실이다. 그러나 만약 이 소스가 16비트와 32비트를 동시에 지원해야 한다면 sizeof(int)는 상황에 따라 2가 될 수도 있고 4가 될 수도 있다. 그때마다 소스를 직접 고치는 것보다는 sizeof(int)라고 써 놓고 컴파일러가 알아서 계산하도록 하는 편이 더 안전하다.
  • sizeof 연산자의 실용적인 사용예는 배열의 크기를 계산할 때이다. 다음과 같은 배열을 사용하고 있다고 하자.
int price[100][3];
  • 이 배열은 100가지 상품의 세가지 종류(대, 중, 소 등)에 대한 가격 정보를 가지는데 배열의 크기는 100*3이고 정수형 배열이므로 총 1200바이트가 될 것이다. 만약 이 정보를 파일로 저장하거나 배열 크기만큼 메모리를 새로 할당하려면 1200이라는 크기값을 지정해야 할 것이다.
파일쓰기(200바이트);
메모리 할당(1200바이트);
  • 이런 식으로 소스에 직접 필요한 크기를 적으면 일단은 제대로 동작한다. 그런데 개발중에 상품의 개수가 100가지에서 120가지로 늘어나고 각 상품이 4가지 종류로 세분되도록 바뀌었다고 하자. 그러면 필요한 price 배열은 int price[120][4]가 될 것이며 이 배열의 크기는 더 이상 1200바이트가 아니다. 배열처럼 개발중에라도 크기가 종종 바뀌는 값은 그 크기를 직접 계산하지 말고 sizeof 연산자를 사용해야 한다.
파일쓰기(sizeof(price));
메모리 할당(sizeof(price));
  • 이렇게만 써 놓으면 컴파일러가 컴파일할 때마다 알아서 계산하므로 배열의 크기를 바꾸어도 다른 부분은 손 댈 필요가 없어진다. sizeof연산자는 단순한 편리외에도 불일치의 위험을 제거하는 역할도 한다. 위 예에서 1200이라는 수를 직접 쓴 곳이 8군데라고 할 때 수작업으로 고칠 경우 한 곳을 빠뜨릴 위험이 있지만 sizeof연산자를 쓰면 더 이상 이런 걱정을 하지 않아도 된다.
  • sizeof 연산자는 피연산자의 총 크기를 바이트 단위로 계산한다. int array[34]라는 배열이 있을 때 sizeof(array)는 34*4=136이다.
  • 만약 배열의 총 바이트수가 아닌 배열의 요소 개수, 그러니까 이 경우에 34라는 값을 알고 싶으면 다음과 같이 한다.
sizeof(array) / sizeof(array[0])
  • 배열 전체 크기를 배열 요소의 크기로 나누면 배열 요소의 개수가 된다. 자주 사용되는 식이므로 외워 두도록 하자.
  • 배열은 그래도 암산으로 그 크기를 구하기가 쉽지만 구조체는 각 멤버 크기의 총합을 구해야 하기 때문에 사람이 직접 그 크기를 계산하는 것은 무척 귀찮고 비생상적인 일이다. 뿐만 아니라 구조체는 정렬방식이라는 컴파일러 옵션에 따라 크기가 약간씩 달라질 수도 있기 때문에 직접 계산하면 틀릴 위험도 있다. 이럴 때는 크기를 직접 계산하지 말고 반드시 sizeof 연산자를 사용해야 한다.
  • sizeof 연산자는 컴파일시에 컴파일러에 의해 계산되며 그 결과는 정수 상수이다. 타입이나 변수의 크기는 컴파일할 때 미리 알 수 있기 때문에 컴파일러가 컴파일할 때 계산해서 결과 상수를 대신 집어 넣는다. 실행 시간에 계산되는 것이 아니다.
arsize = sizeof(array) / sizeof(array[0]);
  • 이 문장에서 사용된 두 개의 sizeof는 둘 다 상수이다. 그래서 arsize = 136 / 4;가 되며 상수끼리의 연산도 상수이므로 arsize=34;가 된다. 결국 실행 파일에는 arsize=34라는 대입문이 대신 들어가게 된다. sizeof 연산자를 아무리 과도하게 쓰더라도 실행 시간이 느려지거나 프로그램이 커지는 불이익은 없다. 프로그래머가 해야 할 잡스러운 계산을 컴파일러가 대신하는 것이므로 적극적으로 활용하도록 하자.