[OS]/Embedded&Linux

C99 이해를 위한 배경 지식과 새 기술 소개 - 3

하늘을닮은호수M 2007. 8. 6. 10:42
반응형

출처 : http://hawkshim.tistory.com/entry/펌-C99-이해를-위한-배경-지식과-새-기술-소개-3

전웅

제한된 포인터 [Lang][Lib][Open]
하드웨어적으로 병렬 처리를 지원하는 환경(예를 들면 벡터 프로세서)에서 제공하는 병렬화 기능을 제대로 활용하기 위해서는 기본적으로 특정 연산이 반복해서 적용되는 두 배열 대상체가 서로 무관해야 한다. 즉 그와 같은 환경에서 제공하는 성능 좋은 최적화 기능을 십분 활용하기 위해서는 특정 연산을 수행하는 함수에 매개변수를 통해 주어지는 두 배열의 모든 요소들이 서로 에일리어징되어서는 안 된다는 의미이다. C 언어를 처음 표준화하던 시기에 컴파일러에게 두 대상체가 서로 에일리어징되지 않았음을 확신시키기 위한 방법으로 noalias라는 형 한정어(type qualifier)를 도입하려 했다. 하지만 이 형 한정어는 상당히 엄격하고 복잡한 의미를 가지고 있어 제대로 기술하기도 쉽지 않았고, 이를 도입할 경우 언어에 심각한 오점을 만들 가능성도 있기에 심한 반대에 부딪혀 결국 표준에 입성하지 못했다(http://www.lysator.liu.se/c/dmr-on-noalias.html 참고). 다만 C90의 끝자락에 서로 겹쳐진 두 배열 대상체를 함수에 전달하는 것은 병렬 환경에서의 최적화를 고려해 별로 바람직하지 않다는 일종의 충고만이 자리잡고 있을 뿐이었다(<그림 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에서 만날 수 있다.

정리 | 김세미 | semsem@korea.cnet.com



[ 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를 참조하면서 조금이나마 해결될 것으로 기대한다.

반응형