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

[SoEn] 18장 타입, 전처리기, #pragma

후추 집사 2023. 2. 7. 19:13

18-1.타입

  • 컴퓨터의 메모리는 0과 1만을 기억할 수 있는 비트로 구성되어 있다. 이 절에서는 이 단순한 비트의 집합으로 큰 수와 복잡한 실수 등이 어떻게 기억되고 관리되는지에 대해 연구해 본다. 이 내용은 당장 실무에 도움을 줄 수 있는 실용적인 이론은 아니므로 꼭 몰라도 상관은 없다. 하지만 타입의 내부 구조를 이해함으로써 컴파일러의 동작을 좀 더 깊이있게 이해하고 메모리에 저장된 값을 직접 평가하고 다룰 수 있는 직관력을 키울 수 있다.

18-1-가.정수의 내부

  • 컴퓨터가 정수를 다루는 방법은 비교적 간단하다. 1과 0을 기억할 수 있는 하나의 비트로 두 가지 상태를 표현할 수 있으며 이런 비트를 여러 개 모으면 1과 0의 조합으로 더 큰 이진수를 나타낼 수 있다. 먼저 부호가 없는 경우의 정수 구조를 연구해 보자. 일반적으로 비트 n개가 모일 때 2n가지의 수를 표현할 수 있으며 시작 수가 0이므로 최대 표현 가능한 수는 2n-1이 된다. 부호가 있으면 최대 표현수는 절반이 되지만 음수를 표현할 수 있다.
  • 2진수의 각 자리수는 2의 거듭승에 해당하는 가중치를 가진다. 10진수의 각 자리수가 일자리, 십자리, 백자리 등 10의 거듭승에 해당하는 가중치를 가지는 것과 마찬가지이다. 이진수 101은 각 자리수의 가중치를 곱해 더하면 십진수 5가 된다.
101 = 1\*2^2+0\*2^1+1\*2^0 = 4+0+1 = 5
  • 16비트 길이의 unsigned short형은 216-1(65535)까지 표현 가능하고 32비트 길이인 unsigned int형은 232-1(4294967295)의 비교적 큰 값을 표현할 수 있다. 변수가 기억할 수 있는 최대 표현 범위를 넘어설 경우를 오버플로우(Overflow)라고 하는데 오버플로우가 발생하면 변수는 초과된 값을 기억하지 못하고 엉뚱한 값을 가지게 된다. 다음 예제를 보자.

예제 : IncOverflow

#include <Turboc.h>

void main()
{
     unsigned short us;

     us=65535;
     printf("us=%d\n",us);
     us++;
     printf("us=%d\n",us);
}
  • 16비트 크기의 us변수에 이 변수가 기억할 수 있는 최대값 65535를 대입해 놓고 이 값을 1증가시켜 보았다. 실행 결과는 다음과 같다.
us=65535
us=0
  • 65535를 1증가시키면 65536이 되는데 이 값은 16비트의 최대 표현 범위를 넘어 서므로 오버플로우가 된다. us 변수는 65536이라는 값을 제대로 기억하지 못하고 0이 되어 버리는데 비트의 세계에서 이 현상을 좀 더 상세하게 살펴보도록 하자.

  • 65535는 이진수로 1이 16개인 수인데 여기에 1을 더하면 1 다음에 0이 16개인 이진수(총 17자리)가 만들어진다. 이때 최상위 비트 1은 16비트 범위 바깥에 존재하므로 잘려 나가고 모든 비트는 0이 되므로 결국 16비트 범위에서 65536은 오버플로우에 의해 0과 같은 수가 되는 것이다. 8비트 범위에서 256(28)은 0과 같으며 32비트 범위에서 4294967296(232)도 결국 0이다. 반대로 변수가 최소값보다 더 작은 값을 가질 경우 최대값이 되어 버리는데 16비트 정수 0을 1감소시키면 65535가 된다.
  • 이런 의미에서 볼 때 컴퓨터의 정수는 수학의 정수와 정의가 약간 다르다고 할 수 있다. 수학의 정수는 원칙적으로 음양으로 무한대까지 표현할 수 있지만 비트 세계의 변수는 길이에 따라 표현할 수 있는 최소, 최대값에 제한이 있다. 따라서 정수형 타입을 선택할 때 표현하고자 하는 값의 최대값을 잘 고려하여 적당한 길이의 타입을 선택해야 한다. 일반적으로 실생활에서 수십억이 넘는 수가 필요한 경우는 무척 드물기 때문에 int형이면 무난하다.

18-1-나.음수의 표현

  • 다음은 부호가 있는 정수 타입에 대해 알아보자. 비트의 세계에서 음수를 표현할 수 있는 방법은 여러 가지가 있을 수 있다. 0과 1로 된 일련의 비트를 어떤 수로 해석할 것인가는 일종의 약속이기 때문에 처음 정한 약속대로 부호를 표현하기만 하면 된다. 다음은 3비트의 정수 타입으로 음수를 표현하는 다양한 약속의 예이다.이진수부호 없음일정 수 감소부호 비트와 절대값1의 보수2의 보수
    000 0 -4 0 0 0
    001 1 -3 1 1 1
    010 2 -2 2 2 2
    011 3 -1 3 3 3
    100 4 0 -0 -3 -4
    101 5 1 -1 -2 -3
    110 6 2 -2 -1 -2
    111 7 3 -3 -0 -1
  • 부호가 없는 경우는 모든 비트를 수로만 인식하며 오른쪽부터 2의 거듭승에 해당하는 가중치를 곱하고 모든 자리수를 더해 양수만 표현한다. 비트를 해석하는 방식을 변경하면 음수를 표현할 수도 있다.
  • 일정수 감소법 : 부호없는 값에서 일정한 수를 빼 음수를 표현한다. 일종의 평행 이동법이라고 할 수 있다. 이때 빼 주는 값을 바이어스(bias)라고 하는데 전체 범위의 절반값 정도를 빼면 음양의 범위가 비슷해질 것이다. 위 도표에서는 4를 바이어스로 사용했다. 이렇게 되면 원래 0이 -4가 되고 4가 0이 되며 7은 3이 되어 -4~3까지 표현할 수 있는 타입이 된다. 논리적이고 충분히 가능한 방법이기는 하지만 직관적이지 못한 것이 단점이다.
  • 부호 비트와 절대값 : 최상위 1비트를 부호 비트로 사용하고 나머지 2비트로 절대값을 표현하는 방법이다. 부호는 음, 양 두 가지 상태 중 하나이므로 1비트로 충분히 표현할 수 있으며 0을 양수 부호로, 1을 음수 부호로 약속한다. 3비트중 한 비트가 부호 비트로 사용되었으므로 최대 표현 절대값은 절반으로 줄어들지만 음수를 표현할 수 있어 표현 가지 수는 비슷해진다. 이 방법의 단점은 부호에 따라 0이 두 개나 존재하여 모호함이 발생한다는 것이다. 이 타입에서 100은 000과 같은 값으로 비교될 것이다.
  • 1의 보수법 : 양수 비트를 모두 반전하여 음수를 만드는 방법이다. 예를 들어 양수 2는 010이므로 이 비트를 모두 반전하여 101로 -2를 표현한다. 음수로 만드는 연산이 간단하다는 장점이 있지만 이 경우도 앞의 방법과 마찬가지로 +0, -0이 따로 존재하는 단점이 있다.
  • 2의 보수법 : 1의 보수에 1을 더해 음수를 표현한다. 즉 모든 비트를 반전시킨 후 1을 더하는 방식인데 2(010)를 모두 반전시키면 101이 되고 여기에 1을 더한 110을 -2로 표현한다. 이렇게 되면 1의 보수법과는 달리 -0은 1000이 되며 오버플로우되어 사라지므로 한가지의 0만 존재하게 된다.
  • 이 외에도 비트로 음수를 표현하는 다른 방법들을 많이 생각할 수 있는데 현대의 컴퓨터들은 모두 2의 보수법으로 음수를 표현한다. 2의 보수를 만드는 방법이 다소 복잡해 보일지 모르겠지만 반전과 증가는 기계적으로 무척 간단한 동작이기 때문에 음수를 만드는 속도가 빠르며 2의 보수를 사용하면 덧셈으로 뺄셈을 대신할 수 있기 때문이다.
  • 8비트의 부호없는 char형 변수 c가 100의 값을 가지고 있을 때 여기에 256(0x100)을 더하면 어떻게 될 것인가 생각해 보자. 결과는 356이지만 오버플로우되어 원래값 100을 그대로 가지게 된다. 100부터 1씩 계속 증가해서 256번 반복하면 결국 제자리로 돌아오는 것이다. 그래서 8비트에서 256은 0과 같다.

  • 그렇다면 이번에는 100+255(0xff)를 계산해 보자. 결과는 355가 되고 이 값도 8비트 범위를 넘어서므로 256(0x100)이 잘려 나가 99만 남게 된다. 결국 255는 -1과 같아지는데 255가 바로 -1을 2의 보수로 표현한 값이다. 같은 원리로 254는 -2이고 253은 -3이다. 컴퓨터는 -n 연산을 할 때 n을 2의 보수로 만든 후 이 값을 더하는 방식으로 뺄셈을 수행한다. 그래서 컴퓨터는 덧셈을 하는 가산기만 가지고 있으며 뺄셈을 하는 감산기는 별도로 가지지 않는다.
  • 보수(Complement)란 어떤 수(기수라고 한다)가 되기 위해 보충되어야 하는 수를 의미하는데 가령 기수 10에 대한 3의 보수는 7이다. 일반적으로 a+b=기수일 때 a와 b는 기수에 대해 보수 관계에 있다고 표현한다. 2의 보수란 n비트에 대해 2n을 기수로 한 보수이다. n이 8일 때 기수는 28=256이 되며 결국 8비트에서 2의 보수는 256이 되기 위해 더 필요한 수로 정의할 수 있다. a의 2의 보수 b가 있을 때 a+b=256이며 8비트에서 256은 0과 같다. 그러므로 a+b=0이 되고 a=-b, b=-a의 관계가 성립한다.
  • 1의 보수란 2n-1을 기수로 한 보수이며 n이 8일 때 255(11111111)가 기수이다. 1의 보수끼리는 정확하게 비트 반전 관계가 성립하며 이 값은 기계적으로 아주 쉽게 구할 수 있다. 1의 보수에 대한 기수보다 2의 보수에 대한 기수가 1 더 크므로 반전 후 1을 더하면 2의 보수가 된다.

18-1-다.바이트 순서

  • 우리가 사용하는 정수, 실수 따위의 변수들은 모두 메모리에 저장된다.
    • 메모리의 저장 단위는 8비트로 구성된 바이트인데 비해 실제 저장해야 할 값은 32비트나 64비트로 바이트 길이보다 훨씬 더 크다.
    • 그래서 여러 개의 연속적인 바이트에 이 값들을 나누어 저장해야 하는데 어떻게 나누는가에 따라 두 가지 방식이 있다.
    • 예를 들어 정수 0x12345678이라는 값을 저장한다고 해 보자. 이 값은 총 32비트이며 0x12, 0x34, 0x56, 0x78의 8비트값 4개로 구성된다. 일련의 4바이트를 여러 바이트에 나누어 저장하는 방법으로 다음 두 가지를 생각할 수 있다.


  • 빅 엔디안(Big Endian:순워드) : 이 방식은 높은 자리수를 먼저 저장한다. 0x12가 가장 앞쪽 바이트에, 그리고 0x34가 그 다음 바이트에 저장되는 식이다. 모토롤라 계열의 CPU와 대부분의 RISC CPU가 이 방식을 사용한다. 사람들은 오랫동안 글을 읽을 때 왼쪽에서 오른쪽으로 읽어 왔으므로 읽는 순서에 맞게 4바이트를 나누어 저장한 것이다. 메모리에 나타난 순서대로 읽을 수 있고 자연스러우며 이해하기 쉽다.
  • 리틀 엔디안(Little Endian:역워드) : 이 방식은 낮은 자리수를 먼저 저장한다. 가장 뒤쪽 바이트인 0x78이 메모리의 가장 앞쪽 바이트에 저장되며 그 다음에 0x56이, 그리고 0x12는 가장 뒤쪽에 저장된다. 인텔 계열의 CPU와 DEC의 알파 칩이 이 방식을 사용한다. 메모리의 값을 읽을 때 거꾸로 읽어야 하므로 사람이 직접 읽기에는 다소 불편한 면이 있지만 기계가 값을 다루기는 더 효율적이고 몇 가지 연산에서 편리한 점이 있다.
  • 두 방식은 앞쪽에 어떤 바이트부터 저장하는지가 다른데 빅 엔디안은 큰 값부터 리틀 엔디안은 작은 값부터 저장한다. 언뜻 보기에는 빅 엔디안이 훨씬 더 자연스러워 보이고 리틀 엔디안은 다소 이상해 보이지만 CPU가 값을 처리하는 과정을 분석해 보면 리틀 엔디안이 몇 가지 면에서 장점이 있음을 발견할 수 있다.
  • 0x1234라는 32비트의 정수값이 메모리에 저장되어 있을 때 이 값의 하위 2바이트만 읽는다고 해 보자. int형의 값을 short형 변수에 대입한다거나 포인터를 통해 간접적으로 값을 읽을 때 이런 일이 일어나는데 다음 그림은 정수형 포인터 pi가 가리키는 32비트 값을 (short *)로 캐스팅해서 읽는 예이다.

  • 리틀 엔디안은 pi가 가리키는 원래 번지에서 2바이트만 읽어들이면 된다. 낮은 자리수가 더 앞쪽에 있기 때문에 이 위치의 값을 그대로 읽으면 바로 16비트값이 되는 것이다. 이에 비해 빅 엔디안의 0x1234라는 값은 0x00001234로 pi가 가리키는 곳에 선행 제로 2바이트가 있다. 이 상태에서 뒤쪽의 2바이트를 읽으려면 pi 자리를 뒤쪽으로 2바이트 먼저 옮겨야 하는 번거로움이 있다.
  • 타입의 확장이 일어날 때도 마찬가지이다. 0x1234라는 16비트 정수를 4바이트 정수로 확장해야 한다고 해 보자. 이런 경우는 늘상 일어나는데 short형 변수를 인수로 전달할 때, 수식 내에서 연산될 때, short형 값을 리턴할 때 항상 32비트로 확장된다. 32비트 환경에서는 32비트 단위로 처리하는 것이 가장 유리하며 스택의 크기가 32비트로 고정되어 있기 때문이다.

  • 리틀 엔디안은 0x34, 0x12로 저장되어 있는 뒤쪽에 0x00, 0x00를 덧붙이기만 하면 간단하게 32비트로 확장된다. 뒤쪽에 더 높은 자리수가 있으므로 뒤에 붙이는 0값은 선행 제로가 되어 값 자체에 영향을 미치지 않기 때문이다. 이에 비해 빅 엔디안은 선행 제로가 들어갈 공간을 만들기 위해 앞쪽의 0x12, 0x34를 뒤쪽의 메모리로 이동시켜야 하므로 여분의 연산이 필요하다.
    • 보다시피 값의 임시적인 축소나 확장이 일어날 때는 리틀 엔디안이 훨씬 더 편리하고 효율적이다. 그렇다면 리틀 엔디안이 항상 좋기만 한가 하면 단점도 있다. 값을 구성하는 각 바이트를 배열처럼 다루고자 할 때는 빅 엔디안이 더 편리하다. 예를 들어 정수형 값을 8비트씩 읽어서 출력한다고 해 보자.

예제 : ReadEndian

#include "TurboC.h"

void main()
{
     int i=0x12345678,j;
     char *p=(char *)&i;

     // 빅 엔디안
//  for (j=0;j<sizeof(int);j++) {
//       printf("%x ",p[j]);
//  }

     // 리틀 엔디안
     for (j=sizeof(int)-1;j>=0;j--) {
          printf("%x ",p[j])
     }
     putch('\n');
}
  • 빅 엔디안은 높은 자리수가 앞쪽에 있으므로 순서대로 출력하기를 길이만큼만 반복하면 된다. 반면 리틀 엔디안은 앞쪽의 높은 자리수부터 출력하기 위해 배열의 뒤쪽부터 값을 읽어야 한다. 값을 출력할 때 거꾸로 읽어야 하는 것과 마찬가지로 사람이 값을 읽을 때도 이 출력 순서대로 읽어야 한다는 점이 무척 불편하다. 사람의 상식적인 생각과는 반대로 되어 있어 때로는 이것이 황당한 실수의 원인이 되기도 하고 메모리를 직접 조작할 때 항상 주의를 기울여야 한다. 한마디로 헷갈린다는 얘기다.
  • 바이트끼리의 순서를 정하는 방식에 두 가지가 있듯이 바이트를 구성하는 8비트를 나열하는 순서도 두 가지를 생각할 수 있다. 예를 들어 0x64(십진수로 100)을 저장할 때 왼쪽에서 오른쪽으로 나열하면 01100100이 될 것이고 오른쪽에서 왼쪽으로 나열하면 00100110이 될 것이다. 두 방식은 각 이진 자리수의 가중치가 다르게 매겨진다. 그러나 다행히 현존하는 모든 CPU 의 비트 순서는 빅 엔디안으로 통일되어 있어 이런 것까지는 신경쓰지 않아도 된다.
  • 빅 엔디안과 리틀 엔디안 방식은 큰 값을 작은 단위에 나누어 저장하는 두 가지 방식 중의 하나일 뿐이며 어떤 방식이 절대적으로 우수하다고 할 수는 없다. 값의 조각을 저장하는 순서가 다른 것 뿐이며 CPU 설계자들은 CPU의 구조나 설계 방식, 활용 방안 등에 따라 두 방식 중 하나를 선택한 것 뿐이다.
  • 그렇다면 개발자인 우리들은 플랫폼의 바이트 저장 순서에 관심을 가질 필요가 있을까? 사실 이런 내부적인 저장 순서는 신경쓸 필요가 거의 없다. 왜냐하면 리틀 엔디안 방식이 기록할 때 거꾸로 기록해 놓더라도 다시 읽을 때 역시 거꾸로 읽어 오기 때문에 어차피 우리가 받는 값은 최초 저장해 놓은 값이다. 0x3a991bc8을 3a, 99, 1b, c8로 저장하든 c8, 1b, 99, 3a로 저장하든 다시 읽어올 때 그 값이 0x3a991bc8이기만 하면 되는 것이다. 내부적인 저장 방식만 반대로 되어 있는 것이지 값 자체를 바꿔 버리는 것은 아니므로 고급 언어 사용자들은 이를 신경쓸 필요가 없으며 심지어 이런 것들이 있다는 것조차 몰라도 별 지장이 없다.
  • 그러나 아주 특수한 상황에서는 이 사실을 알아야 되는 경우도 있는데 디버깅 중에 변수가 저장된 메모리 영역을 직접 들여다 본다거나 아니면 변수의 값을 바이트 단위로 직접 조립해야 하는 경우 등이다. 이외에 바이트 저장 방식이 다른 이기종 컴퓨터간의 네트워크 통신을 할 때, 구체적으로 팬티엄 PC와 매킨토시가 통신할 때 엔디안을 맞추어야 하는 번거로움이 있다. 소켓은 기본적으로 빅 엔디안으로 통일되어 있으므로 인텔 계열 CPU는 보낼 때 뒤집어 보내고 받은 값도 뒤집어야 원래 값을 제대로 읽을 수 있다.

18-1-라.부동 소수점

  • 실수는 소수점 이하의 수를 가지기 때문에 정수에 비해 훨씬 더 복잡하다. 이런 복잡한 실수를 비트의 세계에서 어떻게 표현할 수 있을지 생각해 보자. 실수는 부호, 정수부, 소수부로 구성되므로 각 요소에 적당량의 비트를 할당하는 단순한 방법을 우선 생각해 볼 수 있다. 실수 -3.14는 음수 부호와 정수부 3, 소수부 .14로 구성되므로 이 셋을 각각의 비트에 저장하는 방식이다. 다음은 32비트 길이로 실수를 표현하는 약속의 한 예이다.

  • 32비트를 잘라 부호에 1비트, 정수부에 15비트, 소수부에 16비트를 할당했다. 이 구조로 일단 실수를 표현할 수는 있지만 정수부와 소수부의 자리수가 그다지 크지 않기 때문에 표현 범위가 넓지 못하다. 정수부의 최대 절대값은 기껏해야 32767밖에 안되어 실생활에 사용되는 수를 표현하기에는 턱도 없이 부족하며 소수부도 65535이상을 표현할 수 없으므로 정밀도가 10진수로 소수점 이하 다섯 자리도 안된다.
    • 이 정도 범위와 정밀도를 가지고는 정밀한 과학, 공학용 프로그램은 물론이고 간단한 성적 처리 프로그램에도 부적합하다. 32비트가 아닌 64비트로 길이를 늘린다면 이보다 더 크고 정밀한 수를 표현할 수 있겠지만 이 역시도 충분한 크기와 정밀도를 제공하지는 못한다. 천문학이나 설계, 회계 등의 분야에서는 조 단위의 수를 다루어야 하고 소수점 이하 수십자리까지 정밀하게 표현할 수 있어야 한다.
  • 실수를 정수부와 소수부로 분할하여 표현하는 방식은 단순하기는 하지만 전혀 치밀하지 못하고 효율적이지도 않아 질적으로 다른 방법을 필요로 하는데 그 방식이 바로 부동 소수점 방식이다. 실수는 정수부와 소수부로 구성되어 있지만 다음과 같이 가수와 지수로도 표현할 수 있다. 비트의 세계는 2진수만 쓰지만 설명의 편의상 실생활에서 사용하는 10진수를 예로 든다.

고정 소수점 방식부동 소수점 방식

123.456 1.2345 * 102
0.0123 1.23 * 10-2
1.2345 1.2345 * 100
  • 가수는 실수의 실제값을 표현하며 지수는 크기를 표현하여 가수의 어디쯤에 소수점이 있는지를 나타낸다. 지수의 값에 따라 소수점이 움직이기 때문에 이 방식으로 실수를 표현하는 방법을 부동소수점이라고 한다. 부동 소수점 표현 방식은 C언어뿐만 아니라 모든 프로그래밍 언어, 그래픽, 공학 프로그램 등이 준수하는 국제 표준(IEEE-754)으로 지정되어 있다. 다음은 32비트 실수형인 float형의 비트 구성이다.

  • 부호는 음 아니면 양이므로 1비트만 있으면 되고 지수부 8비트, 가수부 23비트로 구성되어 있다. 가수부가 길기 때문에 정밀도가 비교적 충분하고 지수부가 따로 있으므로 1038정도의 큰 수까지 표현할 수 있다. 물론 정밀도가 무한하지는 않기 때문에 수학에서의 실수처럼 소수점 이하 무한대까지를 기억하지는 못한다.
    • 부동 소수점 방식은 한가지 큰 문제점이 있는데 같은 수를 표현하는 지수와 가수의 조합이 여러 벌 나올 수 있다는 점이다. 예를 들어 다음 수식들은 모두 12.345라는 실수값을 표현한다.
12.345*10^0, 1.2345*10^1, 0.12345*10^2, 123.45*10^-1, 1234.5*10^-2
  • 같은 수를 표현하는 똑같은 방법이 여럿 존재하게 되면 두 변수의 상등 비교 연산을 하기가 까다로와진다. 그래서 한 수를 표현하는 방법은 하나만 존재하도록 정규화(Normalization)를 할 필요가 있는데 가수의 정수부를 한자리로 제한하면 12.345는 1.2345*10^1만 가능해진다. 즉, a.bcd*10^n 식으로 소수점이 항상 가수의 첫 번째와 두 번째 사이에 있도록 하는 것이다.
  • 위는 10진수를 기준으로 한 부동 소수점 표현 방식인데 직관적인 이해를 위해 10의 거듭승을 사용했다. 실제 컴퓨터는 2의 거듭승으로 지수를 표현하고 가수도 이진수이므로 가수가 1~2사이의 수로 제한되어 가수는 항상 1.의 형태를 띠게 된다. 실수를 구성하는 각 요소가 어떻게 구성되고 해석되는지 요소별로 알아보자.
  • 부호 : 부호는 음수 또는 양수 둘 중의 하나이므로 1비트만 있으면 된다. 0이 양수이고 1이 음수이다. 이 부호는 실수 자체의 부호만을 나타내며 지수의 부호는 아니다.
  • 지수 : 지수를 n이라고 했을 때 가수부에 2^n이 곱해진다. 음수 지수도 표현해야 하므로 지수는 자체에 부호를 따로 가져야 하는데 이때는 부호 비트를 따로 쓰지 않고 127의 바이어스를 적용한다. 지수의 길이는 8비트이므로 0~255까지의 범위를 가지며 바이어스 127을 적용하면 지수의 표현 범위는 -127~128까지이다. 최소 지수 -127과 최대 지수 128은 0과 무한대를 표현하는 특별한 용도로 예약되어 있다. 그래서 float형의 최대 표현 범위는 2127이며 대략 1038이 된다.
  • 가수 : 23개의 비트로 구성되어 있으며 각 자리수에 2의 음수 거듭승으로 가중치가 부여되어 있다. 정규화 규칙에 의해 가수는 항상 이진수 1~2사이(1.~~~)여야 하며 이 규칙을 만족하기 위해 제일 왼쪽 비트(20자리)는 항상 1이라고 가정한다. 이 비트를 별도로 저장하지 않는 대신 정밀도는 2배 더 높아진다.

  • 가수의 제일 왼쪽 비트부터 1/2, 1/4, 1/8, 1/16의 가중치를 가지는 셈이며 이 비트들로부터 계산된 값에 1(20)을 더하면 실제 가수가 된다. 다음 실수는 십진수로 어떤 수인지 계산해 보자.
0 01111101 10000000000000000000000
  • 부호가 0이므로 이 값은 일단 양수이다. 지수는 125인데 바이어스 127을 빼면 -2이다. 가수는 생략된 1과 첫 번째 비트의 가중치 1/2를 더하면 3/2이 된다. 가수의 제일 왼쪽에 1이 생략되어 있다고 볼 수 있으므로 실제 가수는 (1100000~)이다. 그래서 이 값은 다음과 같이 십진수로 바꿀 수 있다.
3/2 * 2^(-2) = 3/2 * 1/4 = 3/8 = 0.375
  • 모든 것이 2진수로 계산되고 바이어스, 정규화를 위한 생략치 등을 고려해서 계산해야 하므로 사람이 직접 부동 소수점 비트를 해석해서 10진수로 값을 알아내는 것은 무척이나 어렵고 복잡하다. 하지만 2진수를 잘 다루는 컴퓨터에게는 그다지 어렵지 않은 일일 것이다. 다음 예제는 부동 소수점 수의 비트 구조를 덤프해서 출력하는데 이 예제로 다양한 실수값을 분석해 보면서 float형의 구조를 연구해 보기 바란다.

예제 : PrintFloat

#include <Turboc.h>

void printfloat(float f)
{
     unsigned t;
     char temp[35],bin[35];
    
     // 비트를 다루기 쉽도록 정수형 변수에 대입한다.
     t=*(unsigned *)&f;

     // 선행 제로를 포함한 32자리의 2진수 문자열로 변환
     itoa(t,bin,2);
     memset(temp,'0',35);
     strcpy(temp+32-strlen(bin),bin);

     // 부호, 지수 다음에 공백을 하나씩 넣음
     bin[0]=temp[0];
     bin[1]=' ';
     strncpy(bin+2,temp+1,8);
     bin[10]=' ';
     strcpy(bin+11,temp+9); 

     printf("실수=%f(%s), ",f,bin);

     // 지수 출력
     printf("지수부 = %d\n",(t >> 23 & 0xff) - 127);
}

void main()
{
     printfloat(0.375f);
     printfloat(3.14f);
     printfloat(-0.5f);
     printfloat(0.1f);
}
  • 부동 소수점의 각 비트를 요소별로 잘라 문자열로 조립해서 화면으로 출력하는데 길지는 않지만 비트 조작문이 다소 어려워 보일 것이다. 이 절의 주제는 실수 타입의 내부 구조이므로 PrintFloat 함수에 대한 분석은 따로 하지 않기로 한다. 비트와 문자열을 섬세하게 다루는 것을 구경할 수 있으므로 한가할 때 분석해 보아라. 실행 결과는 다음과 같다.
실수=0.375000(0 01111101 10000000000000000000000), 지수부 = -2
실수=3.140000(0 10000000 10010001111010111000011), 지수부 = 1
실수=-0.500000(1 01111110 00000000000000000000000), 지수부 = -1
실수=0.100000(0 01111011 10011001100110011001101), 지수부 = -4
  • 이 결과에서 보다시피 부동 소수점은 10진수를 정확하게 표현하지 못하는데 0.1이라는 간단한 10진수를 2진수로 표현했을 때 굉장히 복잡한 비트열이 나온다. 근본적으로 2진수와 10진수의 수체계가 다르기 때문이다. 2진 가수부의 각 자리수는 이전 자리수의 절반만큼인데 정확한 10진수를 표현할 수 있을 때까지 절반씩 더해 나간다. 그래도 원하는 10진수에 꼭 맞는 수가 잘 안 만들어지며 그러다 보면 아주 낮은 자리까지 더하기를 계속 반복해야 한다. 다음 코드를 실행해 보자.
float f=0.1f;
printf("%.10f",f);
  • 0.1을 소수점 이하 10자리까지 출력하면 0.1000000015라는 값이 나오는데 2의 음수 거듭승으로 십진수 0.1을 정확하게 표현하지 못하므로 가장 근접한 수를 유효자리 범위에서 표현한 것이다. 사실 이 정도의 오차라면 거의 무시해도 될 정도의 경미한 값이다. 그러나 이런 경미한 값이 모이면 오차가 점점 커진다. 다음 예제를 실행해 보자.

예제 : FloatError

#include <Turboc.h>

void main()
{
     float d=0.0f;
     int i;

     for (i=0;i<1000;i++) {
          d+=0.1f;
     }

     printf("%f",d);
}
  • 0.1을 1000번 더했으므로 100.0이 되어야 하는데 실제로 실행해 보면 99.999046이 된다. 작은 오차들이 누적되다보면 이 오차들이 모여서 원하는 값과 점점 더 멀어진다. 반복 회수를 10000으로 늘리면 999.902893이 되고 100000으로 늘리면 9998.556641이 된다. 오차의 누적회수가 많을수록 문제가 점점 심각해지는 것이다. 물론 0.1f*1000을 바로 계산하면 정확하게 100.0이 계산된다.
  • 실수는 비록 작기는 하지만 항상 어느 정도의 오차가 있다. 그러나 이런 미세한 오차가 실제 프로그램에서 말썽을 일으키는 경우는 거의 없는데 왜냐하면 연산 결과에 영향을 줄만큼 크지 않기 때문이다. 설사 정밀도를 요하는 과학 계산이라 해도 말이다. 그러나 항상 오차가 발생할 수 있다는 점은 주의해야 하는데 예를 들어 실수끼리 상등 비교 연산을 해서는 안된다. 위 예제에서 루프를 실행한 후 if (d == 100.0) 이라고 비교하면 항상 거짓으로 평가되는데 아무리 비슷한 값이라도 비트열은 분명히 다르기 때문이다. 상등 비교 연산을 하는 대신 if (abs(d-100.0) < 0.01) 이런 식으로 부등 비교하여 차이가 오차 범위안이라면 같은 수로 봐야 한다. 실수 자체를 많이 쓰지 않으므로 비교할 일도 별로 없겠지만 혹시 있다면 주의를 할 필요가 있다.
  • 부동 소수점 타입에 대한 또 다른 주의 사항은 범위와 정밀도가 다르다는 것이다. float형이 10^38까지 표현할 수 있다고 해서 10진수 38자리수를 정확하게 기억할 수 있다는 얘기는 아니다. 지수의 범위가 10진수로 38자리 정도 된다는 것이지 가수의 정밀도는 기껏해야 10진수로 7자리 정도밖에 되지 않는다.
float f=123456789.123456789f;
  • 이 선언문은 float형 변수에 긴 실수를 대입하는데 실제 이 변수에 기억되는 값은 123456792.0이다. 8자리 이상의 값은 반올림되어 잘려 나가므로 원하는 바와 다른 오차가 발생한다. 이런 경우는 가수부가 충분히 큰 double형을 사용해야 한다. double형은 십진수로 15자리까지 유효하다. 다음 예제를 보자.

예제 : FloatError2

#include <Turboc.h>

void main()
{
     float f1,f2,f3;
     f1=123456.0f;
     f2=0.0001f;
     f3=f1+f2;
     printf("f1=%f\nf2=%f\nf3=%f\n",f1,f2,f3);
}
  • f1, f2 두 개의 float형 변수에 초기값을 대입한 후 두 값을 더해 f3에 대입했다. 123456.0과 0.0001을 더했으므로 f3은 당연히 123456.0001이 되어야 할 것 같지만 결과는 123456.0이 되어 f2를 더하나 마나의 결과가 나온다. f1과 f2는 float형에 적절한 정밀도를 가지지만 이 두 값의 지수차가 심해 더한 결과는 float형에 맞지 않기 때문이다. 실수끼리 더할 때는 지수부를 일치시킨 후 가수부를 더하는데 두 값의 지수차가 심할수록 덧셈 결과의 가수부가 길어져 정밀도를 초과할 확률이 높다. 실행 결과는 다음과 같다.
f1=123456.000000
f2=0.000100
f3=123456.000000
  • float형의 비트 구조를 분석해 보면 이 예제의 결과가 왜 이렇게 나오는지를 설명할 수 있다. 비주얼 C++ 6.0의 경우는 f3이 123456.0001으로 출력되기도 하는데 이는 컴파일러가 이어지는 출력문을 위해 float를 double로 임시 확장하여 변수의 능력치를 초과하는 불필요한 서비스를 하기 때문이다. 비주얼 C++ 7.0, Dev-C++ 등 다른 컴파일러는 제대로 틀린 결과를 출력한다. 복잡한 실수 계산은 컴파일러마다 조금씩 다를 수도 있어 가급적이면 큰 타입을 사용하는 것이 좋다.
  • 실수의 대표격으로 float형을 분석해 봤는데 64비트의 double형은 지수부 11, 가수부 52로 좀 더 큰 크기를 가지고 바이어스가 1023이라는 것 외에 float형과 구조적인 차이점은 없다. 각 요소의 크기가 크기 때문에 double형은 float형보다 훨씬 더 크고 정밀한 수를 표현할 수 있다. 무려 10^308승이라는 도저히 상상할 수 없는 무지막지한 수를 표현할 수 있고 10진수 15자리 정도의 정밀도를 가진다.
  • 이상으로 실수의 구조에 대해 연구해 봤는데 제한된 비트 길이에 최대한 큰 수를 정밀도를 잃지 않으면서 저장하기 위해 무척 복잡한 구조를 가지고 있다. 그렇다면 실수의 구조를 연구해 보는 것은 과연 어떤 의미가 있을까? 수치 연산 보조 프로세서가 실수 연산을 하고 출력, 변환 등을 해 주는 함수들이 있으므로 우리가 직접 실수의 비트를 해석할 일은 거의 없다고 할 수 있다. 그러나 적어도 실수의 비트 구조가 정수와 다르므로 다음과 같은 코드가 잘못되었다는 것은 알 수 있게 될 것이다.

18-1-마.구조체의 정렬

  • 구조체의 멤버들은 선언된 순서대로 인접한 번지에 배치된다. 첫 번째 멤버가 오프셋 0에 오고 두 번째 멤버가 첫 번째 멤버의 길이만큼 뒤쪽의 오프셋에 자리를 잡는 식이다. 그래서 구조체의 총 크기는 구조체에 속한 멤버들의 총 크기와 같다. 과연 그런지 다음 예제로 테스트해 보자.

예제 : StructAlign

#include "Turboc.h"

struct tag_st1
{
     char c;
     double d;
};
tag_st1 st1={'A',1.234};

void main()
{
     printf("addr=%p, &c=%p, &d=%p, size=%d\n",&st1,&st1.c,&st1.d,sizeof(st1));
}
  • tag_st1 구조체에는 1바이트의 문자형 멤버 하나와 8바이트의 실수형 멤버 하나가 포함되어 있으며 그래서 구조체의 총 크기는 9바이트가 되어야 한다. 그러나 sizeof연산자로 st1 구조체의 실제 크기를 조사해 보면 16바이트라는 결과가 나온다. 어째서 이런 결과가 나오는 것일까? 그 이유는 물리적인 기계의 성능을 최대한 끌어 올리기 위해서 컴파일러가 구조체를 메모리에 배치할 때 두 가지 사항을 고려하여 번지를 잡기 때문이다.
  • 첫 번째로 구조체가 시작될 번지(base)를 고를 때 가급적이면 16바이트 경계에서 시작하도록 한다. 왜냐하면 최신 CPU들은 속도 증가를 위해 캐시를 사용하는데 캐시의 단위가 16바이트로 되어 있기 때문이다. 캐시 크기의 배수 위치에 구조체를 배치하면 이 구조체를 자주 액세스할 때 캐시 용량을 덜 차지하면서도 빠르게 액세스할 수 있다. 만약 16바이트 경계의 양쪽에 걸치게 되면 캐시도 많이 차지할 뿐더러 액세스 속도도 느려질 것이다.
  • 두 번째로 구조체의 멤버를 배치할 때 멤버의 오프셋도 액세스하기 유리한 위치로 조정한다. 별다른 지정이 없으면 멤버의 크기에 따라 자연스러운 경계 위치에 맞추도록 되어 있는데 예를 들어 int는 4바이트, double은 8바이트 경계에 맞춘다. 그래서 위 예제의 경우 c가 1바이트를 차지하고 난 후 d는 다음 8바이트 경계에 배치되므로 c와 d사이에 7바이트는 버려지고 사용되지 않는다. 이렇게 사용되지 않고 버려지는 공간을 패딩(Padding) 이라고 한다.
    • 설사 두 멤버의 순서를 바꾸어서 d를 먼저 배치하고 c를 뒤에 배치해도 마찬가지로 c뒤쪽의 7바이트가 버려진다. c위치에서 구조체가 끝나므로 이 경우는 굳이 패딩을 쓰지 않아도 될 것 같지만 이렇게 할 경우 구조체의 배열을 만들 때 두 번째 요소의 d가 8바이트 경계에 올 수 없을 것이다. 배열의 첨자 연산이 가능하기 위해서는 배열 요소는 인접해 있어야 한다. 그래서 요소 사이에 패딩을 넣지 못하며 구조체 내부에 패딩을 둘 수밖에 없다.
  • 컴파일러는 CPU가 메모리를 최대한 빠른 속도로 액세스할 수 있도록 구조체의 베이스와 멤버의 오프셋을 조정해서 배치하는데 이를 구조체의 정렬(alignment)이라고 한다. 자료를 크기순으로 나열하는 정렬(Sort)과는 번역만 같으며 뜻은 다르다. 개발자들은 일반적으로 구조체의 정렬 방식에 대해 몰라도 별 문제가 없다. 왜냐하면 변수가 어떤 메모리에 배치되는가는 원칙적으로 컴파일러 마음이며 개발자는 변수명으로 그 번지의 내용을 읽고 쓰기 때문이다. 또한 멤버의 오프셋이 어떻게 설정되든간에 코드에서는 st1.c, st1.d 연산문으로 멤버를 액세스할 수 있으며 . 연산자는 컴파일러가 정한 오프셋을 정확하게 찾아 준다.
  • 구조체의 정렬 기능에 의해 액세스 속도는 빨라지지만 효율을 위해 버려지는 메모리가 있다는 점이 다소 안타까워 보일 것이다. 그러나 위의 tag_st1은 이런 효과를 극대화해서 보여주기 위해 1바이트 멤버 다음에 8바이트 멤버를 의도적으로 배치했을 뿐이지 현실적으로 구조체의 멤버들은 대부분 int, unsigned, char [ ] 등이기 때문에 걱정하는 것만큼 메모리가 낭비되지는 않는다.
    • 만약 메모리를 꼭 절약하고 싶다면 예를 들어 tag_st1 arst[10000] 따위의 큰 배열을 만들어야 한다면 옵션을 조정하여 구조체의 정렬 방식을 변경할 수 있다. 비주얼 C++ 6.0의 경우 프로젝트 설정 대화상자의 C/C++ 탭에서 Code Generation 카테고리를 선택하면 아래쪽에 Struct member alignment라는 옵션을 볼 수 있다.
  • 디폴트는 8바이트 단위로 정렬하도록 되어 있으며 1, 2, 4, 8, 16 등 2의 거듭승으로 정렬값을 변경할 수 있다. 비주얼 C++ 7.0의 경우도 비슷한 위치에서 이 옵션을 찾을 수 있으며 gcc 등의 명령행 컴파일러들도 관련 옵션을 모두 제공한다. 컴파일러 옵션이 한글화되어 있지만 그렇다고 해서 이 옵션들이 쉬워지지는 않는 것 같다.
  • 컴파일러는 이 옵션에서 지정한 정렬값 n과 멤버의 크기값 중 작은 쪽에 맞추어 두 번째 멤버 이후의 오프셋을 결정한다. 물론 첫 번째 멤버는 항상 오프셋 0인 구조체 선두 번지에서 시작한다. 이 옵션을 바꿔 가며 위 예제를 다시 컴파일해 보면 정렬값 n에 따라 구조체의 크기와 두 번째 멤버의 시작 번지가 달라지는 것을 확인할 수 있다.

정렬값결과

1 addr=00426A30, &c=00426A30, &d=00426A31, size=9
2 addr=00426A30, &c=00426A30, &d=00426A32, size=10
4 addr=00426A30, &c=00426A30, &d=00426A34, size=12
8 addr=00426A30, &c=00426A30, &d=00426A38, size=16
16 addr=00426A30, &c=00426A30, &d=00426A38, size=16
  • 정렬값에 따라 st1 구조체가 메모리에 어떻게 배치되는지 보면 아래와 같다.
  • 정렬값을 1로 주면 모든 멤버를 1바이트의 경계에 맞춤으로써 정렬을 하지 않게 되며 따라서 구조체의 크기는 멤버의 크기와 정확하게 같아진다. 정렬값이 2인 경우 두 번째 멤버 d가 2바이트의 배수 위치에 오도록 하기 위해 c다음의 1바이트를 버리며 4인 경우는 3바이트가 버려지고 8인 경우 7바이트를 버린다. 정렬값이 16인 경우는 정렬값보다 d의 크기 8이 더 작으므로 8의 배수에 맞춰지고 정렬값이 8인 경우와 같아진다.
  • 구조체 정렬 기능에 의해 멤버들이 구조체 내의 어느 오프셋에 배치될지는 미리 예측하기 어렵다. 설사 계산 가능하다 하더라도 옵션을 바꾸면 오프셋이 다시 바뀔 수 있으므로 암산으로 계산한 오프셋을 사용하는 것은 바람직하지 않다. 만약 특정 멤버가 배치된 오프셋을 조사하고 싶다면 stddef.h에 다음과 같이 정의되어 있는 offsetof 매크로 함수를 사용한다.
#define offsetof(s,m)   (size_t)&(((s *)0)->m)
  • 첫 번째 인수로 구조체, 두 번째 인수로 멤버의 이름을 주면 이 멤버가 실제로 배치된 오프셋을 리턴한다. 어째서 그렇게 되는지는 매크로 내용을 직접 분석해 보아라. 다음 예제로 이 매크로의 동작을 테스트해 보자.

예제 : offsetof

#include "Turboc.h"
#include <stddef.h>

void main()
{
     struct Node {
          int a;
          double b;
          char c[16];
          Node *prev;
          Node *next;
     };
     Node A, B;

     printf("a의 오프셋 = %d\n",offsetof(Node,a));
     printf("b의 오프셋 = %d\n",offsetof(Node,b));
     printf("c의 오프셋 = %d\n",offsetof(Node,c));
     printf("prev의 오프셋 = %d\n",offsetof(Node,prev));
     printf("next의 오프셋 = %d\n",offsetof(Node,next));
}

실행 결과는 다음과 같다.

a의 오프셋 = 0
b의 오프셋 = 8
c의 오프셋 = 16
prev의 오프셋 = 32
next의 오프셋 = 36
  • 만약 구조체 전체가 아니라 특정 멤버 앞쪽의 멤버만 복사하고 싶다면 이 멤버의 오프셋을 알아야 하는데 이때 offsetof 매크로가 유용하게 사용된다. 예를 들어 A 구조체의 링크 정보는 빼고 실제 데이터만 B로 복사하고 싶다면 memcpy(&B,&A,offsetof(Node,prev)); 하면 된다.
  • 현대의 컴퓨터 환경에서 메모리는 아주 흔한 자원이 되어 버렸으며 그래서 속도를 위해서라면 메모리 얼마쯤을 버리는 것은 크게 문제 삼지 않는 분위기이다. 최신 컴파일러들의 최적화 옵션은 가급적 속도를 높이는 쪽으로 맞춰져 있으며 구조체를 정렬하는 것도 속도를 높이기 위한 방편중의 하나로 이해하면 된다. 컴파일러 내부에서 일어나는 일이므로 신경쓰고 싶지 않다면 알아서 잘 하겠거니라고 생각해도 무방하되 단 sizeof(st1)이 꼭 9가 아닐 수도 있다는 것 정도는 상식적으로 알아 두도록 하자.
  • 구조체의 크기를 꼭 알아야 하는 경우는 사실 그리 흔하지 않다. 물론 아주 가끔이기는 하지만 정렬된 크기를 정확하게 알아야 하는 경우도 있다. 이런 경우 쓰라고 만들어 놓은 연산자가 바로 sizeof 연산자이다. 구조체의 크기가 필요하면 반드시 sizeof 연산자를 사용해야 하며 구조체가 간단하다고 해서 대충 암산으로 구한 상수를 바로 쓰는 것은 지극히 위험하다.

18-2.전처리기

18-2-가.#과

  • #과 ##은 전처리기의 연산자로서 컴파일러가 #define 전처리 과정에서만 사용하는 특수한 연산자이다. C 언어 자체의 연산자는 아니므로 우선 순위나 결합 규칙 등은 적용되지 않는다. 둘 다 사용 빈도가 높지는 않지만 잘 알아 두면 매크로의 활용도를 높여 반복되는 코드를 간단하게 작성할 수 있어 작업 효율 향상을 꾀할 수 있다.
  • # 연산자(stringizing operator)는 #define문의 인수 앞에 사용되며 피연산자를 문자열로 바꾸는 역할을 한다. 피연산자가 실인수로 치환된 후 양쪽에 따옴표를 붙여 치환된 결과 그대로 문자열 상수가 된다. 다음 예제가 이 연산자를 사용하는 가장 전형적인 예제이다.

예제 : SharpOp

#include "Turboc.h"

#define result(exp) printf(#exp"=%d\n",exp);

void main()
{
     result(5*3);
     result(2*(3+1));
}
  • result 매크로는 인수로 전달된 수식을 printf 함수로 출력하되 수식 자체와 수식의 평가 결과를 같이 출력한다. 실행 결과는 다음과 같다.
5*3=15
2*(3+1)=8
  • result(5*3) 호출문은 전처리기에 의해 다음과 같이 치환된다.

  • 매크로로 전달된 5*3 수식이 # 연산자에 의해 문자열로 치환되며 인접한 문자열은 합쳐지므로 5*3이라는 수식 자체가 printf의 서식 문자열의 일부가 된다. 만약 다음과 같이 매크로의 인수 자체를 문자열 내에서 직접 쓰게 되면 이 실인수는 치환되지 않고 exp라고만 출력될 뿐 호출부의 실제 수식이 출력되지 않을 것이다.
#define result(exp) printf("exp=%d\n",exp);
  • #연산자는 문자열 상수 내부의 형식 인수를 실인수로 치환시킬 때 사용하는 연산자라고 할 수 있다. #연산자는 정확한 문자열 변환을 위해 몇 가지 규칙을 적용하는데 상식 수준에서 쉽게 이해되는 규칙들이다. #과 형식 인수 사이의 공백, 형식 인수 다음의 공백은 무시되므로 #exp, # exp는 동일하다. 실인수내의 공백은 하나만 인정되며 둘 이상의 공백은 하나만 남기고 모두 삭제된다. 실인수내에 주석이 있으면 이 주석은 하나의 공백으로 대체된다.

호출부치환 결과

result(5*3) 5*3=15
result(5 * 3) 5 * 3=15
result(5* 3) 5* 3=15
result(5     *     3) 5 * 3=15
result(5*/*곱하기*/3); 5* 3=15
  • 실인수에 겹따옴표나 역슬레쉬 등 확장열로 처리해야 할 문자가 있다면 이 문자 앞에 확장열 선두 문자인 \가 자동으로 삽입된다. #define println(msg) printf(#msg"\n") 이라는 매크로가 있을 때 이 매크로의 치환 결과는 다음과 같다.

호출부치환 결과출력 결과

println(메시지); printf("메시지\n") 메시지
println("메시지"); printf("\"메시지\"\n") "메시지"
println("메"시"지"); printf("\"메\"시\"지\"\n") "메"시"지"
  • #연산자를 잘 활용하면 2진수 형태의 상수를 표기할 수 있다. C++은 8진, 10진, 16진 상수 표기법은 지원하지만 2진 상수 표기법은 지원하지 않으므로 암산을 통해 16진수로 만들어야 한다. 꼭 필요할 경우 좀 색다른 방법을 동원할 수 있는데 표준 함수중에 문자열을 수치로 변환하는 strtol 함수는 기수를 지정할 수 있다. 그래서 2진수 형태의 문자열로부터 원하는 값을 만들어 내는 것이 가능하다. 예를 들어 이진수 00110100 상수를 정의하고 싶다면 strtol("00110100",NULL,2)라고 호출하면 된다.
  • 그런데 이 함수를 매번 호출하는 것은 무척 번거로우므로 좀 더 편리하게 사용할 수 있는 매크로 함수를 정의하고 싶다고 하자. 문제는 이 함수가 요구하는 2진 표기가 반드시 문자열이어야 한다는 점이다. 이럴 때 #연산자를 사용하면 실인수를 문자열로 바꿔 주므로 2진값을 바로 적어도 된다. 다음은 2진 상수를 표기하는 BIN 매크로이다.

예제 : BinaryConst

#include "Turboc.h"

#define BIN(a) strtol(#a,NULL,2)

void main()
{
     printf("%x\n",BIN(00010010001101001111000001011100));
}
  • BIN 매크로의 실인수로 2진수 표기를 적기만 하면 이 표기를 문자열로 바꾼 후 strtol 함수에 의해 수치값으로 변환되어 리턴될 것이다. 출력 결과는 16진수 1234f05c이며 BIN 매크로의 2진수와 같은 값이다. 이 매크로는 문자열을 거쳐 수치를 만들어 내므로 효율은 좋지 못하지만 2진수 암산이 잘 안되는 사람에게는 아주 유용하다.
  • ## 연산자(merge operator) 역시 #define 문 내에서만 사용되며 형식 인수 사이에 위치한다. 형식 인수를 분리하여 각각 치환되도록 하며 치환 후에는 주변의 공백과 함께 사라져 두 인수의 치환 결과가 하나의 토큰으로 연결될 수 있도록 한다. 다음 예제를 보자.

예제 : SharpSharpOp

#include "Turboc.h"

#define var(a,b) (a##b)

void main()
{
     int var(Total, Score);
     TotalScore=256;
     printf("총점 = %d\n",TotalScore);
}
  • var 매크로는 두 개의 형식 인수를 받아 들여 이 두 명칭을 연결해서 하나의 명칭으로 만드는데 형식 인수 a와 b 사이에 ## 연산자가 사용되었다. 만약 ## 연산자없이 var(a,b) (ab)로 정의한다면 전처리기가 ab를 a와 b 인수가 아닌 별도의 명칭으로 인식하므로 실인수로 치환되지 못하고 그대로 ab로 남아 있을 것이다. 이 두 형식 인수가 ##에 의해 구분됨으로써 양쪽 모두 실인수로 치환되며 치환 후에 ##은 사라진다. var(Total, Score)가 치환되는 과정은 다음과 같다.

  • ##은 주변의 공백까지 같이 제거하므로 매크로 정의문의 ## 좌우 공백은 무시된다. (a##b)로 쓰나 (a ## b)로 쓰나 결과는 동일하다. ##은 치환전에 두 토큰을 분리하여 각 토큰이 치환될 수 있도록 구분하는 역할을 하며 치환 후에는 주변의 공백과 함께 자폭하여 두 토큰을 하나로 연결한다. 이 연산자는 주로 일괄적인 타입 정의에 사용된다.

예제 : DefineType

#include "Turboc.h"

#define defptype(type) typedef type *p##type

void main()
{
     defptype(int);
     defptype(double);
     defptype(char); 

     pint pi;
     int i=3;
     pi=&i;
     printf("i = %d\n",*pi);
}
  • defptype 매크로는 int, double 등의 타입을 인수로 전달받으며 원래 타입앞에 p를 붙여 포인터 타입을 새로 정의한다. 예를 들어 defptype(int)는 정수형 포인터 pint를 정의하고 defptype(double)은 실수형 포인터 pdouble을 정의한다. 형식 인수 type이 매크로 호출문으로 전달된 실인수(int, double 등)으로 먼저 치환된 후 앞에 p자를 붙이기 위해 ##이 치환을 돕고 있다. 사용자 정의 타입에 대해서도 물론 사용할 수 있다.
  • 이런 목적으로 사용되는 ##연산자는 윈도우즈의 표준 헤더 파일과 메시지 크래커, MFC 소스 코드, COM 헤더 파일에서 흔히 발견할 수 있다. 다음이 몇 가지 예이다.
#define OLESTR(str)     L##str

#define MAKE_ENUM(Method, Interface)    Interface##_##Method

#define HANDLE_MSG(hwnd, message, fn)    \

    case (message): return HANDLE_##message((hwnd), (wParam), (lParam), (fn))

#define RUNTIME_CLASS(class_name) ((CRuntimeClass*)(&class_name::class##class_name))

static const AFX_DATA CRuntimeClass class##class_name; \
  • 매크로 정의문들이 하나같이 무척 복잡해 보이는데 이런 매크로의 도움으로 실제 코드는 훨씬 더 간단해질 수 있는 것이다. 이 코드들은 관련 부분에서 다시 살펴볼 기회가 있을 것이며 또한 분석해봐야 한다. 당장 이 문장들의 의미를 분석할 수는 없겠지만 장래 이 매크로를 분석해야 할 때를 대비해서 여기서는 ## 연산자의 정의와 동작에 대해서만 잘 정리해 두도록 하자.
  • 행 계속 문자로 알려진 \도 일종의 전처리 연산자이며 자신과 뒤쪽의 개행 문자를 없는 것으로 취급하여 두 줄을 하나로 연결하는 용도로 사용한다. 이 문자가 행 끝에 올 때 자신의 뒤쪽에 있는 공백들과 개행코드까지 몽땅 제거하는 역할을 한다. 그래서 다음 문장은 두 행으로 분리되어 있지만 전처리 후에 한 문장으로 합쳐진다. 초기화할 문자열이 한 행에 다 쓸 수 없을만큼 길어질 때 줄 끝에 \를 적고 개행한 후 계속 쓰면 된다.
char Message[]="이 문자열은 \
아래의 문자열과 합쳐집니다.";
  • 단, \ 연산자는 기계적으로 두 행을 연결할 뿐이며 다음 행의 선두에 있는 공백까지도 윗줄에 붙이기 때문에 두 번째 줄을 들여쓰기해서는 안되는 불편함이 있다. 그래서 이 방법보다는 문자열 상수를 연속으로 적는 방법이 더 편리하다. 재미있는 것은 이 연산자가 컴파일되기 전에 처리되기 때문에 명칭의 중간에도 사용할 수 있다는 점이다. printf를 pri\까지만 쓰고 다음행에 나머지 ntf를 적어도 잘 동작한다. 물론 이렇게 해야 할 이유는 없지만 전처리 과정이 컴파일 전에 수행된다는 것을 보여주는 명백한 증거로 볼 수 있다.

18-2-나.조건부 컴파일

  • 조건부 컴파일 지시자(Conditional Compile Directive)는 지정한 조건의 진위 여부에 따라 코드의 일정 부분을 컴파일할 것인지 아닌지를 지정한다. 전처리문이므로 컴파일되기 전에 조건을 평가하며 코드를 컴파일 대상에 포함시키거나 제외시키는 역할을 한다. 이때 조건의 형태는 여러 가지가 있지만 주로 매크로 상수의 존재 여부나 값에 대한 평가식이 사용된다. 실행중에 결정되는 변수의 값이나 함수 호출은 당연히 조건문이 될 수 없다.
  • 조건부 컴파일 지시자를 잘 활용하면 하나의 코드를 조건에 따라 다르게 컴파일하여 상이한 실행 파일을 만들어낼 수 있다. 만약 조건부 컴파일 기능이 없다면 실행 파일별로 소스를 따로 유지해야 하므로 무척 번거로와진다. 조건부 컴파일 지시자는 다양한 상황과 목적에 맞게 소스를 컴파일하여 호환성과 이식성을 확보하는 수단으로 빈번하게 활용되므로 잘 알아 두도록 하자. 다음 구문이 조건부 컴파일문의 가장 전형적인 예이다.
#ifdef 매크로명
코드
#endif
  • #ifdef 다음에 조건이 되는 매크로명을 써 주고 #endif 사이에 조건부로 컴파일할 코드를 작성한다. 조건부 컴파일 블록에 포함된 코드는 매크로가 존재하면 컴파일될 것이고 그렇지 않다면 전처리 과정에서 삭제되어 아예 없는 것으로 취급된다. #ifdef ~ #endif 블록으로 조건부 컴파일 대상 코드를 명시하므로 { } 로 이 코드를 감쌀 필요는 없다.
  • 실제 프로젝트에서 조건부 컴파일이 사용되는 예를 들어 보자. 워드 프로세서를 만드는데 전문가용과 일반용 두 버전을 만들고 전문가용은 좀 더 고급스럽고 강력한 기능을 지원하는 대신 가격을 좀 더 비싸게 받으려고 한다. 이런 가격 차별화 정책은 구매자의 경제적 능력에 맞는 다양한 버전을 제공함으로써 최대한의 수익을 올리는 중요한 마케팅 기법이며 소프트웨어 업계에서 흔하게 볼 수 있다.
    • 이 경우 전문가용은 일반용 버전의 기능을 모두 가지고 추가로 고급 기능을 더 가지는 셈이므로 일반용 버전의 코드 전체를 필요로 하며 일반용은 전문가용 버전의 코드 중 일부를 빼야 한다. 대부분의 코드가 중복되므로 두 버전의 프로젝트를 따로 유지하는 것보다는 차이가 나는 부분만 조건부 컴파일하는 것이 훨씬 더 유리하다. 전문가용에만 포함되는 고급 기능은 다음과 같이 조건부 컴파일 블록에 배치한다.
#ifdef PROFESSIONAL
고급 기능
#endif
  • 전문가용에만 포함되는 코드를 모두 이런 조건부 컴파일 블록에 포함시켜 놓으면 이 코드들은 PROFESSIONAL 매크로 상수가 정의되어 있을 때만 컴파일될 것이다. 소스 선두에 #define PROFESSIONAL이라는 매크로 정의문을 미리 작성해 놓고 이대로 컴파일하면 전문가용 실행 파일이 생성되고 이 정의문을 주석 처리한 후 다시 컴파일하면 고급 기능은 컴파일 대상에서 제외되는 일반용 실행 파일이 생성된다. 매크로 정의문 하나로 전체 코드의 컴파일 범위를 간편하게 통제할 수 있는 것이다.

  • #ifndef는 #ifdef와 반대의 조건을 점검하는 지시자이다. #ifdef는 매크로가 정의되어 있을 때만 컴파일하지만 #ifndef는 반대로 매크로가 정의되어 있지 않을 때만 컴파일한다.
    • 가령 일반용 버전에만 어떤 코드를 넣고 싶다면 #ifndef PROFESSIONAL ... #endif 블록을 구성하면 된다. 셰어웨어의 데모 버전에 시간 제한 기능을 넣는다거나 실행할 때마다 등록 대화상자를 출력하여 공짜 사용자를 귀찮게 하는 기능들은 보통 조건부 컴파일로 처리한다.
  • #ifdef ~ #endif 사이에 #else를 넣을 수도 있는데 말 뜻 그대로 #else 이하는 그 외의 조건인 경우를 처리한다. 코드의 포함 여부뿐만 아니라 조건에 따라 코드를 바꿔가며 컴파일하고 싶다면 #ifdef와 #else를 같이 사용한다. 전문가용인 경우와 일반용인 경우 기능을 조금 다르게 작성하고 싶다면 다음과 같이 조건부 블록을 작성한다.
#ifdef PROFESSIONAL
전문가용의 코드
#else
일반용의 코드
#endif
  • 이렇게 하면 전문가용과 일반용의 기능을 쉽게 차별화할 수 있다. 예를 들어 전문가용에는 자동 저장 기능을 넣어주고 일반용에는 수동 저장 기능을 넣는다거나 동시 편집 가능한 문서의 개수를 다르게 지정할 수 있다. 조건부 컴파일 지시자는 아주 여러 가지 용도로 사용되는데 표준 헤더 파일에도 무수히 많은 #ifdef를 볼 수 있다. 다음은 윈도우즈의 표준 헤더 파일에 있는 조건부 컴파일 블록의 예이다.
#ifdef UNICODE
   LPWSTR   pszValue;
#else
   LPSTR    pszValue;
#endif
  • 윈도우즈용 프로그램은 ANSI 버전과 UNICODE 버전으로 각각 컴파일할 수 있는데 UNICODE 매크로가 정의되어 있을 때와 그렇지 않을 때의 변수 타입을 다르게 정의한다. 이렇게 하면 한 소스로 유니코드와 안시를 모두 지원할 수 있으며 플랫폼이나 실행 환경에 따라 매크로 정의문만 조정하면 되므로 이식성을 쉽게 확보할 수 있다. 다음 코드는 개발 중에 디버깅을 위해 흔히 사용하는 예이다.
#ifdef _DEBUG
printf("변수 값 확인. i=%d\n",i);
#endif
  • 관심있는 변수를 화면에 출력하여 실행중에 값을 확인해 보도록 했는데 이 출력문이 조건부 컴파일 블록에 포함되어 있으므로 _DEBUG 매크로가 정의되어 있을 때만 컴파일된다. 즉 이 코드는 개발중에 디버깅 편의를 위해 삽입된 임시 코드이며 실제로 릴리즈할 때는 컴파일하지 말아야 한다. 만약 조건부 컴파일 지시자가 없다면 이런 임시 코드를 일일이 넣었다 뺐다 해야 하므로 무척 불편할 것이다.
  • 조건부 컴파일 지시자의 조건으로 사용되는 매크로는 물론 #define 전처리문으로 정의하며 이 문장 자체는 소스에 작성된다. 조건을 바꾸려면 최소한 이 정의문은 편집해야 하므로 컴파일 조건을 바꾸려면 주석 처리는 수동으로 해야 한다. 소스를 전혀 수정하지 않고 한 벌의 소스로 여러 종류의 실행 파일을 빌드할 수 있다면 더 편리할 것이다. 그래서 대부분의 컴파일러는 소스가 아닌 외부에서 매크로를 정의할 수 있는 방법을 제공한다.
    • 비주얼 C++ 6.0의 프로젝트 설정을 보면 C/C++탭의 General 카테고리의 Preprocessor definitions 란에 컴파일전에 미리 정의할 매크로 상수 목록이 있다. 여기에 원하는 매크로를 적어주면 소스는 건드리지 않고도 조건을 바꿀 수 있다. 또한 비주얼 C++은 이런 설정 상태를 여러 벌 만들고 편집할 수 있으며 배치 빌드까지 지원하므로 한 번 설정만 잘 해 놓으면 원터치로 전문가용, 일반용, 데모 버전, 한국어/영어 버전을 빌드할 수 있다. 명령행 컴파일러들은 컴파일 전에 미리 특정 매크로를 정의하는 옵션을 제공하기도 한다.

18-2-다.#if

  • #ifdef, #ifndef는 매크로의 존재 여부만으로 컴파일 조건을 판단하며 매크로가 어떤 값으로 정의되어 있는지는 평가하지 않는다. 이에 비해 #if는 매크로의 값을 평가하거나 여러 가지 조건을 결합하여 컴파일 여부를 결정하는 좀 더 복잡한 전처리문이다. #ifdef보다는 사용법이 조금 까다롭지만 C 언어의 조건문과 유사하므로 쉽게 익힐 수 있다. 기본 형식은 다음과 같다.
#if 조건1
코드1           // 조건1을 만족하면 코드1을 컴파일
#elif 조건2
코드2           // 조건 2가 만족되면 코드2를 컴파일
#else
코드3           // 둘 다 맞지 않으면 코드 3을 컴파일
#endif
  • C 언어의 if .... else if .... else와 거의 유사한 구문이라고 볼 수 있다. #elif와 #else는 필요없을 경우 생략 가능하며 #elif는 얼마든지 올 수 있다. #elif를 반복적으로 계속 사용하면 C언어의 switch case 구문과 유사한 구조를 만들 수 있다. #if는 뒤의 조건을 평가해 보고 이 조건이 참이면 바로 아래의 블록을 컴파일러에게 넘기고 그렇지 않다면 삭제하여 없는 것으로 취급한다. 뜻 그대로 조건에 따라 컴파일할 블록을 선택하는 것이다.
  • #if와 #elif다음에는 컴파일 여부를 결정하는 조건문이 오는데 이 조건문은 전처리 과정에서 진위 여부를 판단할 수 있는 것이어야 한다. 실행 중에 값이 결정되는 변수를 참조한다거나 함수를 호출하는 것은 안되며 주로 매크로의 값이 평가 대상이다. 다음은 #if 전처리문의 작성 규칙인데 대부분 상식과 일치하므로 쉽게 이해할 수 있을 것이다.
  • 1. 매크로값을 비교할 때는 상등, 비교 연산자를 사용한다.
    • 같다, 다르다는 ==, != 연산자를 사용하며 대소를 비교할 때는 >, <, >=, <= 비교 연산자가 사용된다. 구형 컴파일러들은 상등 연산자만 허용했었으나 최신 컴파일러는 비교 연산자도 사용할 수 있다. C언어의 상등, 비교 연산자와 모양이 완전히 일치하는 셈이다.
    #if (LEVEL == 3)
    #if (VER >= 7)
    • 조건문은 꼭 괄호로 싸지 않아도 상관없지만 C의 조건문에 익숙한 개발자들은 #if에도 가급적 괄호를 붙여 주는 편이며 괄호가 있는 편이 보기에도 안정감이 느껴져 좋다. #if는 조건문이 참일 때 1로, 거짓일 때 0으로 평가하는데 결과가 0이 아니면 이어지는 코드 블록을 컴파일한다. 이 점도 C와 동일하다.
  • 2. 비교 대상은 정수 상수여야 하며 실수나 문자열은 매크로와 비교할 수 없다.
    • 컴파일러에 따라 실수 비교를 허용하는 것들도 있는데 조건부 컴파일을 통제하는 매크로는 대소가 있는 값이라기 보다는 주로 표식이기 때문에 실수는 별로 실용성이 없다고 할 수 있다. 정수값을 가지는 다른 매크로와 값을 비교하는 것은 가능하다.
    #if (VER == 3.14)          // 에러
    #if (NAME == "Kim")          // 에러
    #if (LEVEL == BASIC)        // 가능
    • 버전 번호 같은 경우에 1.0, 1.5같이 실수로 표기하지만 매크로 상수로 버전을 표시할 때는 100, 150 등과 같이 정수화해서 사용하는 것이 일반적이다.
  • 3. 수식 내에서 간단한 사칙 연산을 할 수 있다.
    • 전처리기가 연산문을 평가한 후 그 결과를 비교하므로 다소 복잡한 식은 굳이 결과를 계산해 넣을 필요없이 수식을 바로 써도 상관없다.
    #if (LEVEL*2 == 6)
    #if (TIME == 365*24)
    • 나머지 연산, 비트 연산 등도 가능하다. 그러나 ++, --, 포인터 연산, sizeof, 캐스트 연산 등은 사용할 수 없다. 이런 연산들은 전처리문에서 불가능하거나 의미가 없기 때문이다. 매크로는 상수이므로 좌변값이 아니며 sizeof 연산자는 컴파일시에 평가된다. 전처리는 컴파일 이전의 단계임을 명심하도록 하자.
  • 4. 논리 연산자로 두 개 이상의 조건을 동시에 평가할 수 있다.
    • C언어의 논리 연산자와 같은 &&, ||, !를 그대로 사용하면 된다.
     #if (LEVEL == 8 && VER != 3)
    • 세 개 이상의 조건도 물론 평가할 수 있다. 이때 필요하다면 조건 평가의 우선 순위 지정을 위해 괄호를 사용한다.
  • 5. defined 연산자로 매크로의 존재 여부를 평가할 수 있다.
    • #if defined(MACRO) 전처리문은 #ifdef MACRO와 완전히 동일한 문장이다. 그러나 다른 조건과 함께 매크로의 존재 여부를 평가할 때는 #ifdef를 쓸 수 없으므로 defined 연산자가 따로 제공된다.
    #if (LEVEL == 8 || defined(PROFESSIONAL))
    • defined 연산자는 전처리문내에서만 사용되므로 일반 C코드에서는 사용할 수 없다.
  • 6. #if 다음의 조건부 컴파일 블록에는 어떤 문장이든지 올 수 있다.
    • a=b+c; 연산문이나 함수 호출문, int i; 같은 선언문은 물론이고 struct tag_A { ~ 같은 정의문도 올 수 있다. 심지어 #include, #define같은 다른 전처리문도 조건부 컴파일(정확하게 표현한다면 조건부 전처리) 대상이 될 수 있다. 그렇다면 #if안에 또 다른 #if문이 올 수 있다는 얘기가 되며 즉 #if는 중첩가능한 전처리문이다.
    #if (LEVEL == 8)
    LEVEL이 8일 때의 코드
    #if (VER > 5)
    버전이 5보다 클 때의 코드
    #endif
    LEVEL이 8일 때의 코드
    #endif
    • #if안에 #ifdef가 올 수도 있고 반대도 가능하며 중첩 깊이에 제한도 없다. 조건속에 또 다른 조건이 있는 것은 자연스러운 것이므로 전처리기는 당연히 조건부 컴파일문의 중첩을 허용한다. 단, 전처리문이 중복될 경우 짝이 되는 #endif가 반드시 존재해야 한다는 것만 주의하면 된다. #endif는 조건부 컴파일 대상의 끝을 명시하는 중요한 역할을 한다. C 코드는 블록이 중첩될 때 적당히 들여쓰기를 하지만 조건부 컴파일 지시자가 중첩될 경우 들여쓰기는 하지 않는 것이 보통이다.
    • 다음은 #if의 활용예를 보자. 어떤 문제를 해결하는데 세 가지(또는 그 이상) 방법이 있고 각 방법을 적용했을 때의 성능을 테스트해 보려고 한다. 이때 각 방법의 코드를 지웠다 넣었다 할 필요없이 다음과 같이 조건부 컴파일문으로 작성해 놓으면 METHOD 매크로만 변경하여 적용할 방법을 쉽게 선택할 수 있다. 이 코드가 다른 프로그램의 부품으로 사용되는 라이브러리이고 고객마다 선호하는 방법이 다르다면 모든 코드를 소스에 둔 채 고객의 주문대로 조건부 컴파일하기만 하면 된다.
    #define METHOD 1
    #if (METHOD == 1)
    방법1
    #elif (METHOD == 2)
     방법2
    #else
    방법3
    #endif
    • #if 0도 주석 대신 흔히 사용되는 구문이다. 아주 긴 소스를 잠시 주석 처리해 놓고 싶을 때는 이 부분을 #if 0 .... #endif로 감싸 버리면 항상 거짓이므로 전처리기에 의해 이 코드는 없는 것으로 취급된다. /* */ 주석은 중첩될 수 없어 긴 소스를 주석 처리할 때 불편한 반면 #if 0는 중첩 가능하기 때문에 이런 문제가 없다.

18-2-라.#undef

  • #undef는 #define의 반대되는 동작을 하는 전처리문이다. #define이 매크로를 정의하는데 비해 #undef는 정의되어 있는 매크로를 삭제한다. 전처리기는 이 명령을 만나면 지정한 매크로의 정의를 취소하고 이후부터 이 명칭에 대해서는 치환을 중지한다. #undef 다음에 취소하고 싶은 매크로의 이름만 적어주면 된다.
  • 일반적으로 매크로는 한 번 정의되면 프로젝트 전체에 걸쳐 일관된 값으로 사용되므로 이미 정의되어 있는 매크로를 취소할 경우란 그리 흔하지 않다. #undef가 꼭 필요한 경우는 사용중인 매크로를 다른 값으로 재정의하고 싶을 때이다. 존재하는 매크로를 #define으로 다시 정의할 경우 재정의할 수 없다는 경고 메시지(컴파일러에 따라서는 에러 메시지)가 출력되므로 매크로를 먼저 지운 후 다시 정의해야 한다. 다음이 그 예이다.

예제 : undef

#include "Turboc.h"

void main()
{
#define SIZE 10
     printf("SIZE=%d\n",SIZE);
#undef SIZE
#define SIZE 20
     printf("SIZE=%d\n",SIZE);
}
  • SIZE 매크로 상수를 최초 10으로 정의해 놓고 사용했다. 어떤 이유로 이 매크로를 다른 값으로 바꾸고 싶다면 #undef로 SIZE 매크로를 삭제한 후 #define으로 다시 정의해야 한다. 실행 결과는 다음과 같다.
SIZE=10
SIZE=20
  • 위 예제는 #undef의 동작을 설명하기 위해 의도적으로 작성한 것이므로 다소 부자연스러운 면이 있다. 존재하는 매크로를 재정의하기보다는 두 개의 다른 매크로를 만들어 쓰면 훨씬 더 쉽게 해결될 수 있을 것이다. 실전에서는 어쩔 수 없이 매크로를 재정의해야 하는 경우도 있는데 예를 들자면 외부에서 가져온 헤더 파일의 매크로가 충돌될 때를 들 수 있다. ext1.h, ext2.h 두 헤더 파일이 모두 TIME이라는 매크로를 사용하는데 이 값이 서로 달라야 한다면 다음과 같이 헤더 파일을 포함하기 전에 매크로를 재정의하면 된다.
#define TIME 800
#include <ext1.h>
#undef TIME
#define TIME 1400
#include <ext2.h>
  • 이렇게 하면 ext1.h를 컴파일할 때 TIME은 800이 되고 ext2.h를 컴파일할 때 TIME은 1400이 된다. 일정한 범위 내에서만 매크로의 의미를 잠시 바꾸고 싶을 때 매크로 재정의 기법이 가끔 필요하다. 또한 자신이 사용하는 매크로를 외부에서 정의하고 있더라도 자신만의 매크로를 꼭 정의하고 싶다면 조건부 컴파일 지시자와 함께 사용할 수도 있다.
#ifdef MACRO
#undef MACRO
#endif
#define MACRO 내가 원하는 값
  • 만약 MACRO가 이미 정의되어 있다면 취소해 버리고 원하는 값으로 다시 정의하는 것이다. #define은 중복 정의를 허용하지 않는 반면 #undef는 존재하지 않는 매크로이더라도 에러로 처리되지 않는 특성이 있다. 그래서 위 코드에서 조건부 컴파일 지시자가 꼭 필요한 것은 아니다.

18-2-마.미리 정의된 매크로

  • 미리 정의된 매크로(Predefined Macro)는 컴파일러가 제공하는 매크로이다. 주로 컴파일러가 현재 상황이나 컴파일중에 참고할만한 정보를 알려주기 위한 용도로 사용한다. #define으로 정의하지 않아도 사용할 수 있으며 재정의할 수도 없다. 필요할 때 참조하여 정보를 조사할 수만 있는 읽기 전용 매크로 상수라고 생각하면 된다.
  • 사용할 때마다 참조해서 프로그래밍한다.

18-2-바.#error, #line

  • #error 전처리문은 지정한 에러 메시지를 출력하고 전처리 과정에서 컴파일을 중지하도록 한다.
    • 에러 메시지는 메시지 창에 출력되는데 비주얼 C++의 경우 Output창에 나타난다. 단독으로 사용되는 경우는 없으며 주로 조건부 컴파일 지시자와 함께 사용되어 컴파일 불가능한 상황임을 개발자에게 알려 주는 역할을 한다. 다음이 그 예이다.
#ifndef UNICODE
#error This program require unicode environment
#endif
  • 유니코드로 컴파일되고 있는 상황이 아니면 이 모듈은 컴파일을 거부하며 #error 이후는 컴파일되지 않는다. 코드 중에 반드시 유니코드가 필요한 부분이 있는데 현재 상황이 그렇지 못하다면 오동작하도록 내버려두는 것보다 컴파일을 거부하여 개발자가 환경을 맞춘 후 다시 컴파일하도록 유도하는 것이 훨씬 더 좋은 방법이다. 이 문장의 사용 위치는 어디든 상관없으나 보통 소스 선두에 둔다.
  • 운영체제의 버전이 맞지 않다거나 개발툴이 부적합하다거나 할 때도 #error 전처리문을 사용한다. 자신이 만든 모듈을 혼자만 쓴다면 굳이 이렇게까지 할 필요는 없겠지만 불특정 다수가 사용할 예정이라면 안전을 위해 이 모듈이 컴파일될 수 있는 상황을 명확하게 알려 주는 것이 좋다. 컴파일 조건을 문서에 적어 놓거나 주석으로 기록할 수도 있겠지만 모든 개발자가 문서를 꼼꼼하게 읽어보지 않기 때문에 이런 강제적인 에러 메시지 출력문이 필요하다.
  • #line 전처리문은 __LINE__, __FILE__ 매크로를 재정의한다.
    • 줄 수는 정수로 주고 파일명은 문자열 형태로 지정하되 파일명을 생략할 경우 기존의 파일명이 그대로 유지된다. #line 123 "a.cpp" 명령은 현재 줄을 123으로 바꾸고 파일명을 a.cpp로 변경한다. 이 전처리문은 사용자가 직접 사용하는 경우가 드물며 주로 컴파일러가 에러 메시지 조립을 위해 내부적으로 사용한다.

18-3.pragma 지시자

18-3-가.once

  • C언어의 장점 중 하나는 어느 운영체제나 플랫폼으로 쉽게 이식될 수 있는 이식성(Portability)이다. 유닉스에서 작성한 소스를 윈도우즈로 가져와 컴파일하면 똑같은 동작을 하는 실행 파일을 얻을 수 있다. 그러나 이 이식성은 어디까지나 소스 차원에서 이식 가능성을 의미하는 것이지 컴파일된 결과인 실행 파일은 그렇지 않다. C언어는 이식성이 있지만 C언어를 특정 플랫폼에 맞게 컴파일하여 고유의 실행 파일을 만들어 내는 컴파일러는 본질적으로 플랫폼에 종속적이다.
  • 그래서 각 플랫폼에서 실행되는 컴파일러는 플랫폼의 고유한 기능을 수행하기 위한 지원을 해야 한다. 플랫폼별로 구조나 기능이 다르기 때문에 구현도 약간씩 달라질 수 있는데 예를 들어 메모리를 관리하는 방식이나 실행 파일의 특수한 구조로 인한 코드 배치 방법이 플랫폼별로 고유하다. #pragma 지시자는 플랫폼별로 다른 이런 기능에 대한 지시 사항을 컴파일러에게 전달하는 방법이다. #문자로 시작하므로 전처리 명령처럼 보이지만 컴파일러 지시자이다. #pragma 지시자의 기본 형식은 다음과 같다.
#pragma 토큰문자열
  • #pragma 다음에 지시 사항을 전달하는 토큰 문자열이 오는데 이 토큰의 종류는 컴파일러별로 다르다. 플랫폼에 종속적인 기능에 대한 지시자이므로 #pragma 지시자는 컴파일러에 대해서 종속적일 수밖에 없다. 그래서 특정 플랫폼을 위한 프로그램을 작성할 때만 사용해야 하며 꼭 이식성을 유지하려면 조건부 컴파일 지시자와 함께 사용해야 한다. 컴파일러는 #pragma 다음의 토큰을 인식할 수 없을 경우 단순히 무시해 버리며 컴파일은 계속 수행한다. 다음은 비주얼 C++ 6.0의 pragma 토큰들이다.
alloc_text, auto_inline, bss_seg, check_stack, code_seg, comment, component, conform

const_seg, data_seg, deprecated, function, hdrstop, include_alias, init_seg, inline_depth

inline_recursion, intrinsic, managed, message, once, optimize, pack, pointers_to_members

pop_macro, push_macro, runtime_checks, section, setlocale, unmanaged, vtordisp, warning
  • 종류가 굉장히 많고 이 중 몇 가지는 굉장히 어렵고 복잡한 것도 있다. 우선 가장 이해하기 쉬운 once부터 구경해 보자. 이 지시자를 헤더 파일 선두에 써 두면 컴파일러는 딱 한 번만 헤더 파일을 포함하여 컴파일 시간을 절약한다. 다음과 같은 조건부 컴파일 지시자로 한 번만 포함되도록 하는 것과 효과가 동일하다.
#ifndef _SOME_HEADER_FILE
#define _SOME_HEADER_FILE
// 헤더 파일 내용
#endif // _SOME_HEADER_FILE
  • 같은 헤더 파일을 일부러 두 번 포함하지는 않겠지만 헤더 파일끼리 서로 중첩을 하다 보면 원치 않게 두 번 포함되는 경우도 있다. 헤더 파일에 중복해도 상관없는 선언만 있다면 아무 문제가 없겠지만 중복해서는 안되는 정의가 있는 경우는 이런 식으로 한 번만 포함하도록 해야 한다.

18-3-나.pack

  • pack 지시자는 이후부터 선언되는 구조체의 정렬 방식을 지정한다. 프로젝트 설정 대화상자에서 구조체 정렬 방식을 각 모듈별로 조정할 수 있지만 pack 지시자는 소스의 중간에서 원하는 구조체에 대해 정렬 방식을 변경할 수 있도록 한다는 점이 다르다. 이 지시자를 사용하면 같은 소스에 있는 두 구조체를 다른 방식으로 정렬할 수 있다. 다음 선언문을 보자.
#pragma pack(2)
struct st1 { short s; int i; };
#pragma pack(4)
struct st2 { short s; int i; };
  • 이렇게 선언하면 st1 구조체는 2바이트 정렬되므로 6바이트를 차지하며 st2는 4바이트 정렬되므로 8바이트를 차지한다. 프로젝트 설정에 지정된 정렬값을 다른 값으로 바꾸고 싶을 때 pack(n) 지시자의 괄호안에 원하는 값을 적어주면 된다. 정렬값의 디폴트는 8이며 n을 생략하여 pack()이라고만 적으면 디폴트 정렬값으로 돌아간다.
  • pack(n)으로 정렬값을 변경하면 이후부터 선언되는 구조체는 이 정렬값의 영향을 받는다. 만약 특정 구조체에 대해서만 임시적으로 원하는 정렬값을 적용한 후 원래의 정렬값으로 돌아오려면 변경하기 전에 원래 값을 보관해 두어야 하는데 이 때는 push, pop 명령을 사용한다. 컴파일러는 내부에 정렬값 저장을 위한 스택을 유지하고 있으며 이 스택에 정렬 상태를 LIFO 원칙에 따라 저장하고 다시 빼내올 수 있다.
    • pack(push, n) 명령은 현재의 정렬 상태를 스택에 저장하면서 정렬값을 n으로 변경하는데 n을 생략하면 현재 정렬값을 스택에 저장하기만 한다. pack(pop,n)은 스택의 최상단에 있는 정렬값을 제거하고 새로운 정렬값을 n으로 변경하는데 n을 생략하면 스택에서 꺼낸 정렬값을 새로운 정렬값으로 설정한다. push는 저장과 동시에 다른 값으로 변경하는 경우가 많으므로 보통 n과 함께 쓰며 pop은 저장된 값을 복구시킬 때 사용하는 경우가 많으므로 보통 단독으로 사용한다. push, pop은 원하는만큼 중첩해서 사용할 수 있다. 다음 코드를 보자.
#pragma pack(2)
struct st1 { short s; int i; };                   // 2바이트 정렬
#pragma pack(push,4)               // 푸시하면서 4바이트 정렬로 바꿈
struct st2 { short s; int i; };                   // 4바이트 정렬
#pragma pack(pop)                    // 원래 정렬값 복원
struct st3 { short s; int i; };                   // 2바이트 정렬
  • 최초 정렬값 2를 가지는 상태에서 4로 변경하면서 원래 정렬 상태인 2를 스택에 푸시해 두었다. 그래서 st2 구조체를 선언한 후 다시 팝하면 정렬 상태 2로 복구될 것이다. 어떤 구조체가 반드시 특정 정렬 상태를 가져야 한다면 pack(push, n) 지시자로 원래 정렬상태를 유지하면서 설정을 잠시 변경할 수 있다. 예를 들어 어떤 파일을 읽어야 하는데 이 파일의 헤더가 구조체로 되어 있고 이 구조체는 반드시 1바이트로 정렬되어야 한다면 다음과 같이 이 구조체를 선언해야 한다.
#pragma pack(push,1)
struct Header
{
     char Magic[2];
     int Version;
     char NumRecord;
     double xsize, ysize;
};
#pragma pack(pop)
  • 구조체를 선언하기 전에 정렬 상태를 1바이트로 바꾸되 이전의 정렬 상태는 스택에 푸시해 두었으며 구조체 선언이 끝난 후 다시 원래대로 정렬값을 복구한다. 이렇게 하지 않으면 Header 구조체는 프로젝트 설정대로 정렬되어 버리므로 이 구조체로는 파일을 제대로 읽을 수 없을 것이다.

18-3-다.warning

  • 컴파일러는 컴파일한 결과를 에러와 경고라는 진단 메시지로 출력한다. 모든 문법이 정확하다면 아무런 진단 메시지도 출력되지 않지만 사람이 컴퓨터가 아닌 한 보통은 한 두 개 정도의 메시지를 받게 된다. 이 중 에러는 명백하게 틀린 것이므로 반드시 수정한 후 재 컴파일해야 하나 경고는 경우에 따라 참고만 하고 무시해도 상관없다. 다음 예제는 별 특별한 동작은 하지 않지만 의도적으로 경고를 많이 받도록 작성해 본 것이다.

예제 : Warning

#include "Turboc.h"

void main()
{
     int i,j,k;
     unsigned u,v=1234;
     double d=3.14;

     i=u;
     if (i = 3) {
          i=d;
     }
     if (i == v) {
          switch (i) {
          }
     }
}
  • 컴파일하면 다음 여섯 개의 경고 메시지가 출력된다. 어디까지나 경고일 뿐이므로 컴파일은 일단 성공한다. 에러는 하나라도 있으면 실행 파일을 만들 수 없지만 경고만 있는 상태에서는 실행 파일을 만들 수 있다.
warning C4101: 'j' : unreferenced local variable
warning C4101: 'k' : unreferenced local variable
warning C4700: local variable 'u' used without having been initialized
warning C4244: '=' : conversion from 'double' to 'int', possible loss of data
warning C4018: '==' : signed/unsigned mismatch
warning C4060: switch statement contains no 'case' or 'default' labels
  • 경고는 심각한 정도에 따라 1~4단계까지 레벨이 분류되어 있는데 비주얼 C++은 디폴트로 레벨 3까지의 경고를 출력하도록 되어 있다. 프로젝트 설정 대화상자의 C/C++ 페이지에서 경고 레벨을 4단계로 높이면 다음 추가 경고가 하나 더 발생한다.
warning C4706: assignment within conditional expression
  • 각 경고의 의미는 영문으로 짧게 설명되어 있으며 C4101처럼 번호가 붙어 있는데 조금 더 구체적으로 설명해 보면 다음과 같다. MSDN의 인덱스창에서 C4101을 검색하면 경고가 발생한 원인에 대한 상세한 설명을 읽을 수 있다.
  • C4101 : 사용하지도 않은 지역변수를 선언했다는 뜻이다. 쓸데 없는 변수이므로 선언문을 삭제하는 것이 옳지만 컴파일하는데 지장은 없으므로 경고로 처리된다. 예제의 j, k 변수는 선언만 하고 실제 코드에서는 쓰지 않고 있다.
  • C4700 : 지역변수를 초기화하지 않고 사용했다는 뜻이다. 이 경우 쓰레기값을 그대로 사용되는데 일단 가능은 하지만 대부분의 경우 말썽을 일으킨다. 예제에서 i에 초기화하지 않은 u를 대입하는데 이때 u의 값이 무엇인지는 알 수 없으므로 i도 같이 쓰레기값이 가지게 될 것이다.
  • C4244 : i=d 대입문에 의해 i에 3이 대입되는데 이 과정에서 하강 변환이 발생해서 소수점 이하 0.14가 버려진다는 뜻이다. 만약 개발자가 실수값의 정수부만을 대입받고자 했다면 이것은 옳은 대입이지만 그렇지 않다면 일부 정보를 잃을 수 있으므로 경고로 처리한다.
  • C4018 : 부호있는 변수와 부호없는 변수를 상등 연산했으므로 좌우의 타입이 맞지 않다는 뜻이다. i와 v는 부호 여부가 다르므로 때로는 틀린 비교를 할 수도 있다.
  • C4060 : switch문의 case가 전혀 없어 이 switch 문 자체가 있으나 마나한 문장이라는 뜻이다. 일부러 이런 코드를 만들지는 않겠지만 개발 중에 case를 편집하다 보면 껍데기만 남는 경우가 가끔 있는데 이런 경고를 받았으면 빈 switch 문을 삭제하든가 아니면 case를 작성해야 한다.
  • C4706 : 조건문에 대입 연산자를 사용했다는 경고이다. C 문법은 조건문을 관계 연산문으로 제한하지 않으므로 대입문을 사용하는 것도 적법하다. 그러나 ==을 =로 잘못 쓰는 실수를 흔히 하기 때문에 경고로 이 사실을 알려 준다. 이 경고는 레벨 4로 일반적인 경고보다 수준이 낮다.
  • 개발자는 이런 경고 메시지를 보고 자신의 코드가 잘못되었는지 점검해 보고 컴파일러의 경고대로 코드를 수정하거나 아니면 별 이상이 없을 경우 경고를 무시할 것이다. 컴파일러는 개발자의 실수나 또는 호환성 문제, 성능상의 문제 등을 지적하기 위해 경고 메시지로 충고를 하는 것이다. 때로 경고 메시지가 귀찮을 때는 컴파일러가 경고를 출력하는 방법을 바꾸고 싶다면 다음 명령을 사용한다.
#pragma warning(경고제어문:경고번호)
  • 경고 제어문의 종류는 다음과 같으며 제어문 다음에 : 과 함께 대상 경고의 번호를 적는다. 경고 번호는 공백으로 구분하여 여러 개를 나열할 수 있으면 경고 제어문도 콜론으로 구분하여 여러 개를 나열할 수 있다.

제어문설명

once:번호 반복되는 경고를 한 번만 출력한다.
default:번호 원래 설정대로 되돌린다.
disable:번호 경고를 출력하지 않는다.
error:번호 경고를 에러로 처리한다.
레벨:번호 경고의 레벨(1~4)을 변경한다.
push[,n] 모든 경고의 레벨을 저장한다. n이 있을 경우 저장과 동시에 전역 경고 레벨을 n으로 변경한다.
pop 스택에 마지막으로 저장된 경고 레벨을 복원한다.
  • 소스의 어느 위치에나 다음 명령을 삽입하면 이후부터 컴파일러가 경고를 통제하는 방법이 바뀐다.
#pragma warning (disable:4101)            // 경고를 무시하도록 한다.
#pragma warning (once:4101)          // 4101경고를 한 번만 출력한다.
#pragma warning (error:4700)               // 경고 대신 에러를 출력한다.
#pragma warning (3:4706)                // 4706번 경고를 레벨 3으로 올린다.
  • disable:4101은 미사용 지역변수에 대한 경고를 아예 무시하도록 한다. 지역변수를 쓰건 말건 간섭하지 말라는 얘기다. 이 명령을 사용하면 미사용 지역변수에 대한 경고는 더 이상 출력되지 않는다.
  • once:4101은 이 경고를 딱 한 번만 출력하라는 뜻인데 비슷한 경고가 너무 많이 반복될 때는 이 명령으로 반복된 출력을 한 번으로 제한한다. 예제에 j, k 두 개의 미사용 지역변수가 있는데 j에 대해서만 경고하고 k는 경고하지 않는다.
  • error:4700은 이 경고를 아예 에러처럼 취급하라는 뜻이다. 지역변수를 초기화하지 않은 상태로 쓰레기값을 바로 쓸 경우 심각한 문제가 될 수 있으므로 이런 실수를 하면 에러로 지적해서 아예 빌드를 못하도록 하라는 것이다. 에러가 있는 상태로는 컴파일을 완료할 수 없는데 이렇게 하면 개발자는 반드시 명시적으로 초기화를 하게 될 것이며 쓰레기값으로 인한 문제를 방지할 수 있다.
  • 3:4706은 조건문에 대입 연산자를 쓸 경우를 레벨 3으로 높이는데 if (i = 3)같은 실수는 비록 적합한 코드라 하더라도 흔하게 하는 실수이므로 이런 코드를 보면 반드시 알려 달라는 뜻이다. 4706번 경고의 레벨이 4이므로 비주얼 C++의 기본 설정으로는 이 경고가 출력되지 않는다. push와 pop 명령은 경고 레벨을 잠시만 변경하고 싶을 때 사용한다.
#pragma warning(push)
// 중간에 경고 레벨을 마음대로 바꾼다.
#pragma warning(pop)
  • 특정 함수에 대해서만 경고 출력 방법을 바꾸고 싶고 그 외의 코드는 디폴트를 적용하고 싶을 때 이 두 명령을 사용한다. 변경하기 전의 상태를 push 하여 저장해 놓고 마음대로 옵션을 변경한 후 pop 명령으로 다시 복원하면 이 코드 바깥은 영향을 받지 않는다.