본문 바로가기

Software/C/C++

[C++] C언어의 진화 ① 최신 C 표준「C99」

전웅 (필자) (마이크로소프트웨어)
필자가 가지고 있는 가장 오래된 C 언어 책은 1970년대에 출판된 것이다. 지금은 자바나 파이썬 같은 강력한 기능의 좋은 언어들이 쏟아져 나와 지나치게 광범위한 분야에서 사용되어온 C 언어가 자리를 내어주고 있지만, 지금까지도 강산이 3번 변하기 전에 탄생한 언어가 각종 개발에서 가장 빈번하게 쓰이는 언어로 자리를 지키고 있다는 사실이 조금은 부조리해 보일 수도 있다.

하지만 이 글의 제목에서도 알 수 있듯이 이는 기우일 뿐이다. 우리에게 그 사실이 잘 알려지지 않을 뿐 지금 이 순간에도 C 언어는 최신 기술들과 빠르게 변화하는 주변 환경에 적응하기 위해 진화를 거듭하고 있다. 이 글에서는 가장 최근에 C 언어에 일어난 커다란 진화에 대해 살펴보려 한다. 구체적인 이야기를 시작하기 전에 더욱 쉽게 이해하기 위한 몇 가지 기본적인 사실들을 알아보자. 지금부터 잠시 동안 다루는 이야기에 너무나도 익숙한 독자라면 원하는 이야기가 나올 때까지 가뿐하게 건너뛰어도 좋다.

C 언어 표준화의 역사
유닉스라는 지금은 너무나도 유명한 운영체제를 구현하기 위해 C 언어가 처음 만들어진 것은 1970년대 초. 지금으로부터 30년 전의 일이다. 그 이후 C 언어는 창시자인 Dennis Ritchie가 처음 생각했던 것보다 더 다양한 분야에서 다양한 사람들에 의해 사용되고 또 발전했다. 물론 서로 다른 집단에 의해 변형된 C 언어가 우연 혹은 필연에 의해 어느 정도 같은 형태를 공유한다고 해도 1980년대에 들어서면서 각 C 언어의 방언들이 자랑하는 다양한 확장 기술은 서로 호환되지 않기에 충분했다.

결정적으로 C 언어가 미국 정부 프로젝트를 위해 사용되기 시작하면서 미국의 표준화 기구인 ANSI에 의해 C 언어의 표준화가 시작된다. 이것이 1980년대 초반의 일이다. 이후 5년 이상의 표준화 과정을 거쳐 탄생한 C 언어 표준이 지금도 여러 C 언어 서적에서 너도나도 인용하는 ANSI C(정확히는 ANSI X3.159-1989)이다. 이 최초의 C 언어 표준이 1989년에 발표됐기에 이를 비공식적으로 C89라고 부르기도 한다. 당시 C 언어가 비단 미국에서만 제한적으로 사용되는 프로그래밍 언어는 아니었기에 국제 표준화 기구인 ISO는 ANSI의 표준화 과정을 감독했고, 몇 가지 형식적인 부분만을 바꿔 1990년 명실상부한 국제 표준인 ISO 표준(ISO 9899:1990)을 발표한다. 유사하게 이를 C90이라고 부른다.

C89와 C90은 본질적으로 같은 내용을 전달하기에 특별히 ANSI와 ISO를 구분하지 않는 이상 서로 혼용되는 표현이다. C90이 발표되면서 C 언어의 표준화 과정은 공식적으로 ISO(ISO JTC1/SC22/WG14)로 이양된다(물론 ANSI는 ISO의 미국 대표이기에 영국을 포함한 유럽, 일본 등과 함께 계속 표준화 과정에 참여하게 된다). 따라서 표준화된 C 언어를 부르는 좀 더 올바른 호칭은 ANSI C가 아닌 ISO C나 표준 C라고 할 수 있다.

ISO에 의해 관리되는 모든 국제 표준은 주기적으로 재검토되고 개정된다. 이후 유럽을 중심으로 터져 나온 ‘C90이 지나치게 미국적’이라는 불만을 잠재우기 위해 1995년 일종의 부록 형태로 C90의 확장이 발표된다. 이를 Amendment 1(줄여서 AMD1)이라고 부르며, C90과 AMD1을 합해 C95라고 부르기도 한다. C90 혹은 C95가 자리를 잡아가는 동안 일어난 다양한 변화(예를 들면 C++와 유니 코드의 발전)를 수용하기 위해 90년대 중반 표준화 위원회는 C 표준의 대대적인 첫 번째 개정 작업에 착수하고 1999년 12월에 지금 이 순간 공식적인 국제 표준으로 영향력을 갖는 C99(ISO 9899:1999)를 발표하게 된다. 이 20년 동안의 복잡다단한 사건을 줄이고 줄여 단순하게 표현하면 <그림 1>과 같다.



<그림 1> C 언어 표준화의 역사


표준이 발표된 이후 최소한 수년은 흘러야 표준이 실제 프로그래밍 환경에서 영향력을 발휘하는 것이 보통이기에 C99가 비록 5년 전에 발표된 표준이지만, 아직도 대부분의 개발 환경은 C99를 부분적으로만 지원하고 있으며 다수의 C 언어 서적은 C90이나 C95에 그 뿌리를 두고 있다. 일부 상용 컴파일러만 C99를 완벽히 지원하는 것이 현실이지만 C99에서 도입된 새로운 기술들이 그 편리성과 효율성으로 인해 이미 많은 컴파일러에서 확장으로 제공되던 것들이기에 새로운 프로그램을 개발할 때 이 신기술을 적극적으로 활용하는 것은 굳이 상위 호환성(forward compatibility)을 고려하지 않아도 충분히 가치 있는 일이라고 볼 수 있다.

C 언어 표준화의 원칙     


C 표준의 가장 중요한 역할 중 하나는 새 기술을 고안해 추가하는 것보다 기존의 실례(existing practice)를 다듬어 표준화하는 것이다. 그렇다고 시중에 존재하는 모든 컴파일러의 확장이나 여러 프로그래머들에 의해 제안되는 기술을 무턱대고 도입할 수는 없는 노릇이기에, 위원회는 그러한 기술들을 C 언어 표준에 담기 위해 고민할 필요가 있는 원칙 몇 가지를 제시하고 있다. 여기서는 그 중 C99에 새로 도입된 기술을 바라볼 때 염두에 둘 만한 몇 가지만을 추려서 소개한다.

◆ 기존의 코드는 중요하다. 하지만 기존의 컴파일러는 중요하지 않다.
이는 위원회가 새로운 기술의 도입 여부를 결정할 때, 이미 사용되고 있는 컴파일러보다는 널리 쓰이는 가치 있는 코드를 더 고려한다는 사실을 의미한다. 즉 도입하려는 새 기술이 이미 사용되고 있는 컴파일러의 확장과 충돌하는 경우는 컴파일러의 수정을 기대하며 과감히 도입하지만 이미 널리 쓰이고 있는 코드와 충돌하는 경우에는 새 기술 도입을 재고함을 뜻한다. 참고로 여기서는 표준에 익숙하지 않은 독자들을 위해 컴파일러라는 용어를 사용했지만, 그보다는 임플리멘테이션(implementation)이 더 정확한 개념을 표현하는 용어이다.

◆ 조용한 변화를 피한다.
새 기술이 도입되는 탓에 기존의 사소한 코드가 명시적인 오류를 일으킨다면 오히려 문제가 되지 않는다. 대개 컴파일러가 해당 오류의 위치와 원인까지도 적절히 지적해 줄 수 있기에 프로그래머가 조금만 수고를 들이면 이를 가볍게 수정할 수 있기 때문이다. 오히려 가장 위험한 것은 새 기술이 기존 코드의 의미를 조용히 바꾸어버리는 경우이다.

◆ 국제적인 프로그래밍을 지원한다.
프로그래밍 언어뿐 아니라 컴퓨터 분야의 가장 두드러지는 특징은 지극히 미국 혹은 영어 중심적이라는 것이다. 분명 컴퓨터와 이를 다루기 위한 프로그래밍 언어가 미국 및 영어 문화권뿐 아니라 다양한 국가에서 사용되고 있기에 C 언어는 국제적인 프로그래밍 환경과 이를 통해 확보되는 국제적인 시장을 적극적으로 지원하기 위해 노력하고 있다. 대표적인 예로 AMD1를 통해 더욱 잘 지원되는 멀티바이트 문자와 확장 문자, C99에서 도입된 유니 코드(정확히는 ISO 10646) 등을 들 수 있다.

◆ C90과의 호환성을 유지한다.
프로그램이나 파일 형식이 하위 버전과의 호환성을 유지하는 것이 중요하듯이 프로그래밍 언어 역시 마찬가지다. 따라서 C90 표준을 따라 올바르게 작성된 프로그램은 C99에서도 거의 변화 없이 처음 의도된 행동을 보일 수 있도록 호환성을 유지하려고 노력한다.

◆ C++와의 호환성을 유지한다.
C 언어와 C++는 전혀 별개의 위원회에 의해 관리되며 발전하고 있는 독립된 언어이다. 따라서, C++의 창시자인 Bjarne Stroustrup의 표현을 빌어 C와 C++가 역사적 우연성으로 인해 공통점을 갖는다고 해도 앞으로 완전히 다른 방향으로 발전해나가도 전혀 이상한 일이 아니다. 다만 현재 실질적인 시장의 이익을 고려하면 C와 C++ 사이의 차이를 가급적 줄여주는 것이 이득이 되는 경우가 적지 않기에 가급적 C++와의 호환성을 유지하는 방향으로 표준화가 진행된다. 하지만 아이러니하게도 C99는 C90보다 C 언어와 C++ 사이의 거리를 더 벌려 놓았다. 이는 C++의 1998년 표준이 C90을 참조했던 것처럼 차기 C++ 표준이 C99를 참조하면서 조금이나마 해결될 것으로 기대한다.



 

 


C99의 새 기술들
이제부터 부족하게나마 다져둔 기반 지식을 바탕으로 C99의 새로운 기술들을 차례로 살펴보도록 하자. 사실 C90 표준과 C99 표준을 펼쳐놓고 한 줄씩 대조해가며 차이점을 찾아보면 상당히 방대한 양의 차이를 확인할 수 있다. 하지만 실제 프로그래밍 환경에 유효한 영향을 미치는 변화는 C99 표준의 머리말(foreword)에서 나열하고 있는 50여개로 정리될 수 있다. 그 중 일부 기술은 부동소수 연산과 IEEE 754 표준과 관련된 고급 기술에 대한 깊이 있는 이해를 요구하기에 이 연재에서도 겨우 소개만 할 계획이다. 기본적으로 제한된 지면에서 다룰 기술들은 크게 다음과 같은 기준에 따른다.


◆ 과거 표준(C90이나 C95)의 단점을 보완하기 위해 도입된 새 기술은 그 기술의 도입 배경을 중심으로 설명한다.
◆ C++에서 이미 지원하고 있는 기술은 C 언어에서의 몇 가지 특징과 주의사항만을 소개한다.
◆ 현재 다수의 컴파일러가 지원하고 있고, 프로그래밍 환경에서 매우 유용하게 쓰일 수 있는 기술은 비교적 구체적으로 설명한다.
◆ 가급적 각 기술의 구체적인 모습을 확인할 수 있는 예를 보인다.


이 글을 보면서 가장 유의해야 할 사항은 이 글이 C99의 새 기술에 대한 완벽한 설명을 제공하지는 않는다는 사실이다. 이 글의 주요한 목적은 C99가 대충 어떠한 모습을 가지고 있는지, C 언어가 어떠한 모습으로 변해가고 있는지 짚을 수 있는 큰 흐름만을 보이고자 하는 것이다. 지금부터 소개하는 기술의 제목 옆에 붙은 표시는 다음과 같은 의미로 쓰였다.


◆ Lang : C 표준은 크게 언어 부분과 라이브러리 부분으로 구분되어 있다. 실제 C 언어의 구현체인 컴파일러와 라이브러리는 서로 밀접하게 관련되어 칼로 자르듯이 나누기 어려운 경우(예를 들어 특정 라이브러리 함수 호출에 대해 컴파일러가 최적화된 코드를 삽입해 주거나 특정 라이브러리 함수가 컴파일러에서 제공되는 특정 기술을 사용해야만 하는 경우 등)가 많지만, 표준은 언어를 추상적인 관점에서 기술하고 있기에 언어와 라이브러리를 명확히 나누고 있다. 그 중 언어 부분에 추가된 변화일 경우 이와 같은 표시가 붙는다.
◆ Lib : C 표준의 라이브러리 부분에서 일어난 변화일 경우에 붙는다.
◆ C++ : C++를 통해 이미 유명해진 기술일 경우 붙는다. 다만 C와 C++에서 제공되는 기술이 정확히 동일한 의미를 갖는 것은 아님을 유의하기 바란다. 외양은 동일해도 세부적으로는 다소 다른 행동을 갖기도 한다.
◆ Open : 리눅스나 BSD 같은 오픈소스 환경에서 제공되는 컴파일러에서 직접 혹은 유사한 형태로 이미 제공되는 기술일 경우에 붙는다. 물론 시간이 지나 거의 모든 환경에서 C99가 완벽하게 지원된다면 이 표시는 무의미해질 것이다.


이중자와 를 통한 제한된 문자셋 지원[Lang][Lib][Open]
이 기술은 사실 C99가 아닌 AMD1에서 추가된 기술이다. AMD1는 C90에 붙는 부록 형태로 발표된 반면, C99가 사실상 C90의 첫 번째 개정이기에 AMD1에 추가된 주요한 기술이 C99에도 나열된 셈이다. 초기 C 언어가 기반으로 둔 문자셋은 우리에게 너무 익숙한 7비트 문자셋인 ASCII이다(공식적인 ASCII는 7비트 문자셋이다. 우리가 흔히 알고 있는 8비트 문자셋은 ASCII의 확장 버전으로 보통 ASCII-8이라고 부르며, 이 경우 코드 번호 128번 이후의 문자에 대한 이식성은 보장되지 않는다).

하지만 전 세계 모든 환경에서 ASCII의 128개 문자가 같은 의미, 같은 모양으로 쓰이지 않기에 국제적인 환경을 지향하는 C 표준은 ASCII를 언어의 기반 문자셋으로 도입할 수 없었다. 따라서 여러 나라에서 변형되어 사용되는 ASCII의 공통 부분(ASCII의 부분 집합)만을 묶은 ISO 646 Invariant Set을 언어의 기반 문자셋으로 삼았으며, 이로 인해 적절하게 표현할 수 없는 9개 문자를 ISO 646 Invariant Set 내의 문자들로 표현할 수 있도록 하기 위해 C90에서 특이한 형태의 삼중자(trigraph)를 도입하게 된다. <리스트 1>은 이런 삼중자를 사용한 프로그램을 보여주고 있다.

많은 개발 환경이 널리 쓰이지 않는 삼중자를 기본적으로 인식하지 않기에 별도의 옵션(gcc의 경우 -ansi -pedantic)을 주어야 올바르게 번역되지만, 이 이상한 모양의 프로그램이 분명 표준을 따르는 올바른 프로그램임에 유의하자.

  <리스트 1> 삼중자(밑줄 친 부분)를 사용한 프로그램
 


??=include

int main(void)
??<
  printf(Hello, world??-??/n);
  printf(What??!);

  return 0;
??>



하지만 이 삼중자는 모양 자체도 프로그램의 가독성(readability)을 심하게 떨어뜨릴 정도로 흉할 뿐더러 컴파일러가 프로그램을 인식하는 가장 초기 단계에 마치 워드프로세서의 바꾸기 기능처럼 무식하게 처리되기 때문에(심지어 문자열 상수 안에서의 삼중자도 치환된다) 정작 이를 필요로 하는 곳에서조차 외면받게 됐다. 이렇게 유럽 일부 국가를 중심으로 삼중자에 대한 불만이 나타나자 빈번하게 사용되는 삼중자 일부를 대체하기 위해 새로 추가된 것이 6개의 이중자(digraph)다. 이중자는 그 행동이 언어를 구성하는 다른 토큰(token)들과 같다는 장점을 갖고 <리스트 2>에서 볼 수 있듯이 외관도 상대적으로 수려하다.

  <리스트 2> 이중자(밑줄 친 부분)를 사용한 프로그램
 


%:include

int main(void)
%<
  printf(Hello, world??-??/n);

  return 0;
%>



또한 ASCII를 제대로 지원하지 못하는 환경에서도 가독성 높은 프로그램을 작성할 수 있도록 아래 예에서 볼 수 있듯이 삼중자나 이중자를 통해 기술되는 연산자들을 매크로(예를 들면 &&를 위한 and, ||를 위한 or, ~를 위한 compl)로 제공하는 가 추가로 지원된다.


%:include
// ...
if (isspace(uc) or isalpha(uc))
  flag = compl flag;


다행스럽게도 우리나라의 대다수 기본 프로그래밍 환경은 ASCII의 128개 문자를 제대로 제공하고 있기에 삼중자나 이중자 혹은 와는 거리가 멀다. 다만 종종 삼중자나 이중자의 존재를 모르는 상태에서 그와 유사한 형태의 특수 문자를 프로그램 내에 사용했다가 이해할 수 없는 경고 메시지로 고민하는 경우가 있기에 그 존재만큼은 기억해 둘 필요가 있다.

유효 데이터형을 통해 자세해진 에일리어징 규칙[Lang][Open]
C 언어에서 에일리어징(aliasing)이란 메모리에 존재하는 하나의 대상체(object)에 접근할 수 있는 경로가 다양함을 의미한다. 이는 보통 공용체와 포인터를 통해 일어난다. 예를 들어 다음과 같은 프로그램에서는 대상체에 접근할 수 있는 2가지 방법(object를 통한 방법, 이를 가리키는 포인터 pi를 통한 방법)이 존재한다.


#include

int main(void)
{
  int object = 7903, *pi = &object;
  printf(%d, %d\n, object, *pi);
  return 0;
}


중요한 것은 C 언어가 모든 가능한 에일리어징을 허락해 주지는 않는다는 사실이다. 포인터의 정렬 제한(alignment restriction) 문제는 차치하고라도 표준이 에일리어징을 상당히 제한하는 큰 이유 중 하나는 바로 최적화와 관련되어 있다.

  <리스트 3> 에일리어징과 최적화(1)
 


void func(int *pi, float *pf)
{
  *pf = 2.0;
  *pi = 3;
  another_func(*pf);  // another_func(2.0); 으로 최적화 가능?
}



<리스트 3>에서 주석이 설명한대로 another_func()에 대한 호출을 최적화할 수 있을까? 만약 다음과 같은 함수 호출이 허용되는 것이라는 주석에서 보인 최적화는 허락되어서는 안 될 것이다.


int i;
func(&i, (float *)&i);  // wrong


하지만 실질적으로 지원할 가치가 별로 없는 에일리어징을 위해 효율적인 최적화를 과도하게 막는 것은 언어의 성능 면에서 결코 바람직하지 않기에 표준은 float형 대상체를 int형으로 에일리어징하는 것을 허락하지 않는다. 결과적으로 주석에서 보인 최적화는 항상 허락된다. 즉 포인터의 정렬 제한 문제가 없다고 해도 앞에서 보인 것 같이 func()를 호출해 불법적인 에일리어징을 시도하는 프로그램은 최적화로 인해 전혀 엉뚱한 결과를 얻을 수도 있다. 물론 모든 에일리어징이 금지되어 마땅한 것은 아니다.

  <리스트 4> 에일리어징과 최적화(2)
 


void func(int *pi, unsigned int *pui)
{
  *pui = 2;
  *pi = 3;
  another_func(*pui);
}



예를 들어 <리스트 4>와 같은 함수 func()가 주어졌을 때, 다음과 같은 무부호/유부호 정수형 사이의 에일리어징은 충분히 허락할 가치가 있다. 이는 표준이 무부호/유부호 정수형의 내부 표현의 형태를 어느 정도 보장하기에 에일리어징을 통해 의미 있는 결과를 얻을 수 있기 때문이다.


int i;
func(&i, (unsigned int *)&i);  // okay


따라서 이 같은 경우에는 컴파일러가 함수 func() 안에서 pui와 pi가 가리키는 대상체가 다름을 확신하지 못하는 이상 another_func(*pui);을 another_func(2);으로 최적화할 수 없게 된다. 이렇게 C 언어는 일부 최적화를 제한하면서까지 허용해 줄 필요가 있는 에일리어징과 무의미하기에 금지되어야 마땅한 에일리어징을 구분하고 있지만 불행히도 C90에서는 그 규칙이 완벽하지 못했다.

예를 들어 malloc()를 통해 동적으로 할당되는 대상체의 경우, 선언된 대상체와는 달리 기본적으로 갖는 데이터형이 없기 때문에 무엇을 기준으로 에일리어징을 허가 혹은 금지해야 하는지 불분명했다. 따라서 C99에서는 이러한 경우까지도 모두 포용할 수 있도록 유효 데이터형(effective type)이라는 개념을 도입해 에일리어징 규칙을 상세히 기술하고 있다. 하지만 보완된 규칙 역시 여전히 공용체와 관련된 에일리어징을 올바르게 다루지 못해 현재 표준화 위원회는 이 부분에 대한 해결책을 마련 중에 있다(자세한 문제는 http://groups.google.com/groups?selm=G%25_i7.1992%24T4.16873%40www.newsranger.com 참고).

제한된 포인터[Lang][Lib][Open]
하드웨어적으로 병렬 처리를 지원하는 환경(예를 들면 벡터 프로세서)에서 제공하는 병렬화 기능을 제대로 활용하기 위해서는 기본적으로 특정 연산이 반복해서 적용되는 두 배열 대상체가 서로 무관해야 한다. 즉 그와 같은 환경에서 제공하는 성능 좋은 최적화 기능을 십분 활용하기 위해서는 특정 연산을 수행하는 함수에 매개변수를 통해 주어지는 두 배열의 모든 요소들이 서로 에일리어징되어서는 안 된다는 의미이다.

C 언어를 처음 표준화하던 시기에 컴파일러에게 두 대상체가 서로 에일리어징되지 않았음을 확신시키기 위한 방법으로 noalias라는 형 한정어(type qualifier)를 도입하려 했다. 하지만 이 형 한정어는 상당히 엄격하고 복잡한 의미를 가지고 있어 제대로 기술하기도 쉽지 않았고, 이를 도입할 경우 언어에 심각한 오점을 만들 가능성도 있기에 심한 반대에 부딪혀 결국 표준에 입성하지 못했다(http://www.lysator.liu.se/c/dmr-on-noalias.html 참고).

다만 C90의 끝자락에 서로 겹쳐진 두 배열 대상체를 함수에 전달하는 것은 병렬 환경에서의 최적화를 고려해 별로 바람직하지 않다는 일종의 충고만이 자리잡고 있을 뿐이었다(<그림 2>).



<그림 2> 겹쳐진 배열 대상체의 전달


그렇게 강력한 반대 속에 사라진 noalias의 의도는 훨씬 유연한 의미를 갖는 제한된 포인터(restricted pointer)라는 이름으로 C99에서 부활하게 된다. 물론 이미 noalias를 통해 시행착오를 한번 겪었기에 noalias와는 많이 다른 방법을 통해 접근하게 된다. 제한된 포인터란 포인터에만 적용되어 유효한 의미를 갖는 형 한정어 restrict를 갖는 포인터를 말한다. 이렇게 선언된 포인터의 정확한 의미는 표준에서조차 상당히 수학적으로 기술되어 있기에 이곳에서 모두 다루기에는 무리가 있다. 다만 대략적인 의미를 다음과 같이 설명할 수 있다.


void func(double *restrict d, cont double *restrict s, size_t n);


이 선언은 함수 func() 안에서 d가 가리킬 수 있는 대상체와 s가 가리킬 수 있는 대상체가 서로 무관함을 의미한다. 이러한 보장을 통해 병렬 연산을 통한 최적화가 지원되는 환경의 컴파일러는 d와 s가 각각 가리키는 대상체에 적용되는 연산을 병렬화하여 최적화할 수 있다.

물론 제한된 포인터를 매개변수로 갖는 함수에 겹쳐진 배열 대상체를 전달하는 행위는 이제 불법이 되며 앞서 살펴본 에일리어징과 관련된 예처럼 예상치 못한 결과를 얻을 수 있는 원인이 될 수도 있다. 참고로 restrict는 레지스터 처럼 컴파일러에게 최적화를 위해 프로그래머가 제공해 주는 일종의 힌트일 뿐이다. 따라서 restrict를 통해 이룰 수 있는 최적화와 무관한 환경(혹은 그러한 최적화가 존재하지만 컴파일러 제작자가 무능력하거나 게으른 경우)에서는 컴파일러가 간단히 restrict를 무시해 버릴 수도 있다.

제한된 포인터가 함수 매개변수에서 사용되면 결국 해당 포인터가 가리키는 대상체가 서로 겹치지 않았음을 의미하기 때문에 서로 겹쳐진 대상체를 인자로 주어서는 안 되는 기존의 표준 라이브러리 함수를 기술하는 방법도 훨씬 수월해졌다. 예를 들어 표준은 메모리의 블럭 단위 복사에 대해 특별히 효율적인 연산을 제공하는 환경을 고려해 메모리 복사 함수를 memcpy()와 memmove()로 나눠 제공하고 있다.

겹쳐진 메모리 공간에서도 올바른 복사가 이루어지도록 하기 위해 두 메모리 공간이 겹쳐 있음을 확인하는 과정 자체가 무시 못할 오버헤드가 되기 때문에 프로그래머는 메모리가 겹쳐 있지 않음을 확신하는 경우 memcpy()를 사용해 잠재적으로 좋은 성능을 기대할 수 있다. 겹쳐 있을 가능성이 있는 경우 약간의 오버헤드를 감수하고 안전하게 memmove()를 사용할 수 있는 것이다. 따라서 C90에서는 memcpy()의 원형을 다음과 같이 선언한다.


memcpy(void *, const void *, size_t);


말로써 겹쳐진 메모리 영역 사이의 복사를 금지했지만, 제한된 포인터의 도입으로 이제 C99에서는 memcpy()와 memmove()가 서로 다른 형태의 원형을 갖고 있음을 확인할 수 있다.


memcpy(void *restrict, const void *restrict, size_t);
memmove(void *, const void *, size_t);


유연한 배열 멤버[Lang][Open]
구조체를 선언하되 구조체의 멤버 중 하나가 배열이고, 또 이 배열의 크기를 동적 할당을 통해 늘리거나 줄이고 싶다면 보통 다음 중 한 가지 방법을 통해 자료 구조를 구현하는 것이 일반적이다(이를 struct hack이라고 부른다).


struct foo {
  int number;
  double bar[100];
} *flexible;

flexible = malloc(
  sizeof(struct foo)
  - sizeof(double) * 100
  + sizeof(double) * n);

flexible->number = n;
flexible->bar[n-1] = 0;  // wrong

------------------------------------

struct foo {
  int number;
  double bar[1];
} *flexible;

flexible = malloc(
  sizeof(struct foo) +
  sizeof(double) * (n-1));


flexible->number = n;
flexible->bar[n-1] = 0;  // wrong


이와 같은 프로그램 구조는 상당히 긴 기간 동안 다양한 C 프로그램에서 빈번하게 사용되어온 구조임에도 불구하고, 표준 C 언어를 엄격한 환경에도 무리 없이 적용할 수 있도록 허용하기 위해 모두 잘못된 구조로 규정했다. 위원회는 좌측 구조가 잘못된 이유를 일부 환경에서 구조체에 접근할 때 선언된 구조체형의 메모리 전체(bar[n]이 아닌 bar[100] 전부)를 요구할 수 있기 때문이며, 우측 구조가 잘못된 이유는 멤버 bar를 통해 일어나는 포인터 연산을 선언된 구조체형(bar[n]이 아닌 bar[1])에 맞춰 제한할 수 있기 때문이라고 설명하고 있다.

결국 C99 이전에 위와 같은 형태의 자료 구조를 구성하는 유일한 적법한 방법은 다음과 같이 포인터를 사용해 번거로운 메모리 할당 과정을 거치는 것뿐이었다. 메모리 할당 과정이 번거롭다는 것은 그렇게 할당받은 메모리를 해제할 때도 동일하게 번거로움을 의미한다.


struct foo {
  int number;
  double *bar;
} *flexible;

flexible = malloc(sizeof(struct foo));
flexible->bar = malloc(sizeof(double) * n);
flexible->bar[n-1] = 0;


하지만 표준화 위원회 역시 유연한 배열 멤버를 갖는 구조체를 처음 보인 것처럼 간단한 메모리 할당으로 구성될 수 있도록 할 필요가 있음을 동감했기에 C99에서 서둘러 다음과 같은 적법한 형태를 도입하게 된다.


struct foo {
  int number;
  double bar[];  // flexible array member
} *flexible;

flexible = malloc(sizeof(struct foo) + sizeof(double)*n);
flexible->bar[n-1] = 0;


비록 유연한 배열 멤버는 구조체의 마지막 멤버로서만 존재할 수 있다는 등의 다소 엄격한 제약과 유연한 배열 멤버를 갖는 구조체형에 적용되는 sizeof 연산자의 결과 등을 따로 정의하기 위해 언어를 이해하기가 다소 어려워졌지만, 앞서 보인 간단한 예를 통해 확인할 수 있듯이 새로 도입된 기술은 그 의도가 프로그램 상에서 분명히 드러나고 사용하기도 충분히 편리함을 확인할 수 있다.

암시적인 int 제거[Lang]
C 언어는 그 뿌리를 데이터형을 갖지 않는 언어(typeless language)인 BCPL과 B에 두고 있다. 따라서 알게 모르게 그 언어들의 특성을 물려받게 됐다. 그 중 가장 대표적인 것으로 꼽을 수 있는 것이 바로 암시적인 int(implicit int)이다. 이는 말 그대로 문법상 형 지정자(type specifier)가 나와야 하는 일부 문맥에 아무 것도 주어지지 않으면 기본적으로 int 형으로 가정됨을 의미한다. 우선 암시적 int의 몇 가지 용례를 살펴보도록 하자.


foo(void)  /* int func(void)와 동일한 의미 */
{
  return 1;
}

bar(a, b)  /* 고전적인 함수 정의 방식, int bar(a, b)와 동일한 의미 */
/* 이 위치에 int a, b;가 있는 것과 동일한 의미 */
{
  return a + b;
}

void foobar(const i);  /* void foobar(const int i);와 동일한 의미 */


C 언어는 분명 그 선조 언어와는 달리 데이터형을 지원하기에 마치 데이터형이 없는 언어인 것처럼 형 지정자를 생략해 int 형을 지정하는 방법은 사라져야 마땅했다. 하지만 처음 C 표준화가 이루어질 당시 존재하던 적지 않은 수의 프로그램들이 이 기술에 의존하고 있었기에 하위 호환성(backward compatibility)을 신중하게 고려해 표준에서 제거하지 못했다.

반대로 C99에서는 너무나 과감하게 이 기술이 제거됐다. 언어 표준에 가해지는 급격한 변화를 막기 위해 표준은 구식 기술(obsolete feature)이라는 개념을 사용한다. 즉 하위 호환성을 위해 해당 기술의 금지를 일단은 유보하지만 그 기술이 결코 바람직하지 않다고 판단되는 경우, 이를 표준에 명시적으로 구식 기술로 기록해 프로그래머들이 프로그램을 수정하거나 새 프로그램을 개발할 때 그 기술의 사용을 꺼리도록 만드는 것이다(대표적인 예로, 함수 선언시에 int func(); 처럼 매개변수 리스트에 아무 것도 적어주지 않는 고전적인 선언 방식은 C90 시절부터 지금까지 구식 기술로 지정되어 있다).

이렇게 구식으로 지정된 기술이 오랜 시간을 거쳐 중요한 코드에서 사용되지 않으면 그때 비로소 표준에서 안전하게 제거된다. 하지만 이번 암시적 int를 제거하는 과정은 이러한 구식 기술로의 지정 없이 바로 이루어졌다는 점에서 다소 파격적이라고 볼 수 있다. C 언어의 많은 부분은 서로 연관되어 있기에 이 암시적인 int의 제거는 곧 언어의 다른 부분에 또 다른 변화를 가져오게 된다. 이는 다음 회에서 return 문과 관련된 변화에서 자세히 살펴보게 될 것이다.

마치며
제한된 지면으로 친절한 설명을 전달하지 못한 아쉬움이 남지만, 시작인만큼 적은 수의 C99 기술을 소개하면서 자세한 이야기를 담으려 노력했다. 다음부터는 더 많은 기술을 다루어야 하기에 독자들이 이번 첫 원고를 통해 흐름에 익숙해졌다고 가정하고 좀더 짧고 명확한 설명을 전달할 수 있도록 노력하겠다. 다음 호에도 C99의 새 기술에 대한 소개가 이어지고, 추가로 소개되는 기술을 실제 어떤 컴파일러를 통해 사용할 수 있는지도 간단히 알아보겠다.

이번 원고에 대한 어떠한 지적이나 질문, 기타 의견도 환영이다. 필자의 메일이나 홈페이지 게시판을 통해 알려주면 자세한 답변을 드릴 것을 약속한다. 참고로 이 연재는 두 차례에 걸쳐 KLDP 세미나를 통해 발표했던 내용을 보강 정리한 것이다. 이에 대한 자세한 내용은 http://doc.kldp.org/wiki.php/KLDPConf/20031011 http://doc.kldp.org/wiki.php/KLDPConf/20040118에서 만날 수 있다.