이 문서는 2015년 초에 썼지만, 어디에 내보내지는 않았습니다. 초안 폴더에 쳐박아둔채 아무에게도 보여주지 않았기 때문에 거의 다듬어지지 않은 글입니다. 딱 하나, 2015만 2016으로 바꾸어 내보냈습니다.
수정/개선/불만 무엇이든 편하게 말씀해주세요. - Matt
Keith Thompson이 써준 수정 사항들과 다른 의견들은 howto-c-response에서 확인하세요.
이제 글 시작합니다.
C 첫 번째 규칙은, 안쓸 수 있다면 쓰지 말라는 것입니다.
꼭, C로 프로그램을 작성해야한다면, 현대의 규칙을 따르세요.
C는 1970년대 초부터 쓰여왔습니다. 사람들은 C가 진화하며 거쳐온 어느 지점에서 "C를 배웠"고, 한 번 배운 후에는 그 지식이 잘 업데이트 되지 않았습니다. 그래서 각자가 처음 배웠을 시절의 C에 기반하여 모두 다른 생각들을 갖게 되었습니다.
C 개발에 대하여 "내가 8~90년대에 배운 것"에 갇히지 않는 것이 중요합니다.
이 글에서는 독자가 현대의 표준을 지원하는 현대적인 플랫폼 위에서 작업을 한다고 가정합니다. 오래된 레거시 호환에 대한 요구사항은 없다고 말이지요. 몇몇 회사가 20년된 시스템을 업그레이드하기 싫어한다고 해서, 전세계 모두가 고대의 표준에만 얽매여있을 수는 없습니다.
c99 표준 (c99는 "1999년에 만들어진 C 표준"을 의미합니다. c11은 "2011년에 만들어진 C 표준"입니다. 즉 11이 99보다 최신입니다).
- clang, 기본
- clang은 기본으로 C11의 확장된 버전(
GNU C11 모드
)을 사용합니다. 따라서 현대적인 기능을 사용하는데 아무 옵션도 필요없습니다. - C11 표준을 사용하려면,
-std=c11
을 주면 됩니다. C99 표준을 쓰고 싶다면,-std=c99
를 주면 됩니다. - clang은 gcc보다 컴파일이 더 빠릅니다.
- clang은 기본으로 C11의 확장된 버전(
- gcc는
-std=c99
나-std=c11
를 주어야합니다.- gcc는 clang보다 컴파일은 느리지만, 가끔씩 더 빠른 코드를 만들어냅니다. 성능비교와 회귀 테스트(Regression test)가 중요합니다.
- gcc-5는 (clang과 동일하게)
GNU C11 모드
가 기본입니다. 정확한 C11이나 C99 표준을 사용하고 싶다면,-std=c11
이나-std=c99
를 주면 됩니다.
최적화
- -O2, -O3
- 일반적으로는
-O2
를 쓰면 됩니다. 때로는-O3
가 필요할 수도 있습니다. 각 레벨(과 각 컴파일러)로 측정해보고 성능이 가장 좋은 버전을 사용하세요.
- 일반적으로는
- -Os
- (당연하지만) 캐시효율이 중요하다면,
-Os
가 도움이 됩니다.
- (당연하지만) 캐시효율이 중요하다면,
경고
-Wall -Wextra -pedantic
- 최신 컴파일러 버전에서는 -Wpedantic을 사용해야합니다. 하지만 보다 넓은 하위 호환성을 위하여 -pedantic도 여전히 지원하고 있습니다.
- 테스트 중에는 모든 플랫폼에서
-Werror
와-Wshadow
를 추가하세요.- 실제 배포용 소스에
-Werror
를 사용하는 것은 조금 까다롭습니다. 플랫폼과 컴파일러, 라이브러리에 따라서 다른 경고를 낼 수 있기 때문입니다. 어떤 플랫폼의 GCC가 이전에 경험하지 못한 새롭고 놀라운 방법으로 경고를 한다고 해서, 전체 빌드가 실패하도록 만들고 싶지는 않을 테니까요.
- 실제 배포용 소스에
- extra fancy options include
-Wstrict-overflow
-fno-strict-aliasing
-fno-strict-aliasing
를 사용하지 않으면, 객체를 정확하게 생성한 타입으로만 사용해야합니다. 이미 존재하는 C 코드들에서 타입을 바꿔가며 사용하는 경우가 많으므로,-fno-strict-aliasing
을 사용하는 것이 훨씬 안전한 선택입니다. 소스 코드 전체를 통제할 수 있는 상황이 아니라면요.
- 현재, Clang이 적법한 문법에 대하여 경고를 내고 있습니다. 따라서
-Wno-missing-field-initializers
를 사용해야 합니다.- GCC는 4.7.0 이후 버전에서는 이 경고가 나오지 않습니다.
빌드
- 컴파일 단위
- 가장 일반적인 C 프로젝트 빌드 방법은, 각 소스 파일을 각각 오브젝트 파일로 바꾼 후, 마지막에 이 오브젝트 파일들을 하나로 링크하는 방식입니다. 이 방법은 점진적 개발에는 좋지만, 성능이나 최적화에는 약간 안좋은 부분이 있습니다. 컴파일러가 여러 파일에 걸쳐있는 최적화 가능한 요소를 찾아서 최적화할 수 없기 때문입니다.
- LTO(Link Time Optimization: 링크 타임 최적화)
- LTO는 이 "컴파일 단위간 소스코드 분석과 최적화 문제"를 해결합니다. LTO는 오브젝트 파일에 중간단계 결과물을 함께 저장합니다. 그래서 링크 시에 컴파일 단위 사이에 걸쳐있는 소스코드 최적화가 가능합니다. (이 때문에 링크 속도가 느려질텐데,
make -j
를 사용하면 도움이 됩니다.) - clang LTO (guide)
- gcc LTO
- 2016년 현재, clang과 gcc는
-flto
옵션만 오브젝트 파일 컴파일과 최종 라이브러리/프로그램 링크시에 추가해주면 LTO가 동작합니다. LTO
는 아직 더 발전해야합니다. 프로그램에서 직접 사용되지는 않지만 다른 추가 라이브러리에서 사용할 수도 있는 코드가 있을 수 있습니다. LTO는 최종 링크를 할 때 사용되지 않거나, 사용할 수 없는, 그리고 최종 링크 결과물에 포함시킬 필요가 없는 함수나 코드를 찾아서 제거해버릴 수 있을 겁니다.
- LTO는 이 "컴파일 단위간 소스코드 분석과 최적화 문제"를 해결합니다. LTO는 오브젝트 파일에 중간단계 결과물을 함께 저장합니다. 그래서 링크 시에 컴파일 단위 사이에 걸쳐있는 소스코드 최적화가 가능합니다. (이 때문에 링크 속도가 느려질텐데,
아키텍처
-march=native
- 컴파일러가 CPU의 전체 기능을 사용할 수 있도록 합니다.
- 다시 한 번, 성능 테스트와 회귀 테스트가 중요합니다. 수행 결과를 여러 컴파일러와 컴파일러 버전에 따라 비교도 해보아야합니다. 활성화시킨 최적화로 인해 나쁜 부수효과가 생겼을 수도 있으니까요.
- 프로그램을 빌드하는 기기와 실행하는 기기가 다르다면,
-msse2
와-msse4.2
도 유용할 수 있습니다.
새로 작성하는 코드에 char
나 int
, short
, long
, unsigned
같은 것들을 쓰고 있다면, 잘못하고 있는 겁니다.
현대적인 프로그램이라면, #include <stdint.h>
를 넣고, 표준 타입들을 사용해야 합니다.
더 자세한 내용은 stdint.h 명세를 참고하세요.
공통 표준 타입들은 다음과 같습니다:
int8_t
,int16_t
,int32_t
,int64_t
— 부호가 있는 정수uint8_t
,uint16_t
,uint32_t
,uint64_t
— 부호가 없는 정수float
— 표준 32비트 부동소수점double
- 표준 64비트 부동소수점
더 이상 char
는 쓰지 않습니다. C에서 char
는 이름을 잘못 붙였고, 잘못 사용되었습니다.
개발자들은 부호가 없는 바이트 조작을 할 때도 일상적으로 char
를 "바이트"로 오용해왔습니다. 부호가 없는 한 바이트/8비트 값이라면 uint8_t
를, 연속한 여러 바이트/8비트 값이라면 uint8_t *
를 쓰는 것이 훨씬 깔끔합니다.
몇몇 독자들이 그들은 정말로 int
를 사랑해서, 손에 인이 박혀있다고 전해왔습니다. 하지만, 당신의 손에서 벗어난 후에 타입의 크기가 바뀔 수 있다면, 프로그램을 정확하게 작성하기가 기술적으로 불가능하다는 점을 지적하고 싶습니다.
또한, inttypes.h의 RATIONALE(근거) 부분에서 크기가 고정되지 않은 타입의 위험성을 읽어보세요. 만약 당신이 정말로 영리해서 개발하는 동안 int
가 어느 플랫폼에서는 16비트이고, 어느 플랫폼에서는 32비트라는 것을 잊지 않을 수 있다면, 그리고 또 정말 꼼꼼해서 int
를 사용한 모든 곳에서 모든 16비트와 32비트의 경계 케이스까지 전부 테스트할 수 있다면, int
를 써도 됩니다.
fizzbuzz(한국의 369게임과 비슷한 놀이) 프로그램을 짜는동안 전체 플랫폼들의 각 경우에 어떻게 코딩해야하는지를 모두 머리에 담고 있을 수 없는 우리 일반인들은 고정 크기 타입을 사용합시다. 정확한 코드를 작성하면서도 머리도 덜 아프고, 테스트도 덜 해도 될겁니다.
아니면, 명시적으로 써 놓아도 좋겠네요: "ISO C 표준 정수의 변환 규칙에 따라 동작이 이유없이 바뀔 수 있습니다."
행운을 빌어요.
2016년에 유일하게 char
를 쓸 수 있는 경우는, 이미 존재하는 API가 char
를 필요로 하는 곳입니다. 예를 들어 strncat
이나 "%s"를 printf에 쓸 때 등이 있습니다. 또는 읽기만 하는 문자열을 선언할 때입니다. 예를 들어 const char *hello = "hello"
. C에서 문자열("hello")은 char [] 타입이기 때문입니다.
또한 C11에서는 유니코드를 기본 지원하는데, UTF-8 문자열 표현의 타입도 여전히 char *입니다. const char *abcgrr = u8"abc😬";
같은 멀티바이트 문자열이더라도요.
위 타입들을 반환하거나, 인자로 받는 함수를 사용하는 경우, 함수 형식이나 API명세에 나오는 타입을 사용하세요.
이제 unsigned
라는 단어를 입력할 일은 없습니다. 가독성도, 활용도도 떨어지는 여러 단어로 된 못생긴 C 문법 없이도 코드를 쓸 수 있습니다. uint64_t
라고 쓰면 되는데 왜 unsigned
long
long
라고 쓰나요? <stdint.h>
의 타입들이 더 분명하고, 의미도 명확하며, 의도를 잘 전달합니다. 인쇄하거나 읽기에도 더 간단하고요.
"짜증나는 포인터 연산 때문에 포인터를 long
으로 캐스팅해야한다구요!"라고 말할 수도 있는데요.
하지만 당신은 틀렸습니다.
포인터 연산에 사용하는 정확한 타입은 <stdint.h>
에 정의된 uintptr_t
입니다. stddef.h에 정의된 ptrdiff_t
도 유용합니다.
아래와 같이 쓰는 대신에
long diff = (long)ptrOld - (long)ptrNew;
다음과 같이 쓰세요.
ptrdiff_t diff = (uintptr_t)ptrOld - (uintptr_t)ptrNew;
또 다음과 같이 쓰세요.
printf("%p is unaligned by %" PRIuPTR " bytes.\n", (void *)p, ((uintptr_t)somePtr & (sizeof(void *) - 1)));
"나는 32비트 플랫폼에서는 32비트를, 64비트 플랫폼에서는 64비트를 쓰고 싶다구요!"라고 할 수도 있겠지요.
왜 플랫폼에 따라 다른 크기를 사용함으로써 고생을 사서 하는지는 모르겠지만, 아마 그렇더라도 long
을 사용하고 싶은 것은 아닐 겁니다.
이런 상황이라면, intptr_t
를 쓰세요. 현재 플랫폼의 워드 크기에 해당하는 정수 타입입니다.
32비트 플랫폼에서는 intptr_t
가 int32_t
입니다.
64비트 플랫폼에서는 intptr_t
가 int64_t
입니다.
intptr_t
과 함께 uintptr_t
도 있습니다.
포인터간 거리를 저장하는 용도로는, ptrdiff_t
라는 적절한 이름을 가진 녀석이 포인터 간 뺄셈 결과를 저장하기에 적절합니다.
시스템에서 사용가능한 가장 큰 정수를 표현할 타입이 필요하신가요?
이런 때 자신이 아는 가장 큰 타입을 그냥 사용하는 경우가 많지요. 작은 부호가 없는 타입을 uint64_t
로 캐스팅한다든가 합니다. 하지만 어느 값이든 저장할 수 있음을 보장하는 기술적으로 더 나은 방법이 있습니다.
정수를 저장하는 가장 안전한 타입은 intmax_t
(와 uintmax_t
)입니다. 모든 부호가 있는 정수는 intmax_t
로 손실없이 대입하거나 캐스팅할 수 있습니다. 부호가 없는 정수는 uintmax_t
로 손실없이 대입하거나 캐스팅할 수 있고요.
가장 널리 쓰인 시스템 의존적인 타입은 stddef.h에 정의된 size_t
입니다.
size_t
는 기본적으로 "가장 큰 배열 인덱스를 담을 수 있는 정수"입니다. 따라서 프로그램에서 가장 큰 메모리 주소를 담을 수 있다는 이야기도 됩니다.
실제 사용에 있어서 size_t
는 sizeof
연산자의 반환 타입입니다.
어떤 경우든, size_t
는 실질적으로 모든 현대 플랫폼에서 uintptr_t
와 같은 타입으로 정의됩니다. 32비트 플랫폼에서는 uint32_t
이고, 64비트 플랫폼에서는 uint64_t
입니다.
에러가 발생하면 -1을 반환하는 라이브러리 함수에서 쓰는 부호가 있는 size_t
타입인 ssize_t
도 있습니다. (주의: ssize_t
는 POSIX에 정의된 타입으로, 윈도 인터페이스에는 해당하지 않습니다.)
자, 그러면 size_t
를 함수 파라미터의 임의의 시스템 의존적인 크기로 사용해도 될까요? 기술적으로는 size_t
가 sizeof
의 반환 타입이므로, 바이트 수를 나타내는 크기 값을 받는 함수라면, size_t
를 써도 됩니다.
다른 용례를 보자면, size_t
는 malloc()
의 인자 타입입니다. 그리고 ssize_t
는 read()
와 write()
의 반환타입이기도 합니다.(윈도에서는 ssize_t
가 없으므로 그냥 int
를 반환합니다.)
출력을 위해 캐스팅을 해서는 안됩니다.
inttypes.h에 정의된 타입 지시자를 정확히 사용하세요.
예를 들어 다음과 같은 것들이 있습니다.
size_t
-%zu
ssize_t
-%zd
ptrdiff_t
-%td
- 포인터 그 자체의 값 -
%p (현대 컴파일러에서는 16진수를 출력합니다. 포인터를 먼저 (void *)로 캐스팅하세요)
- 64비트 타입은
PRIu64
(부호 없음)와 PRId64(부호 있음)을 사용하여 출력해야합니다.- 어떤 플랫폼에서 64비트 값은
long
이지만, 어떤 플랫폼에서는long long
입니다. - 이 포맷 매크로 없이 어느 플랫폼에서나 정확히 동작하는 포맷문자열을 만들기는 사실상 불가능합니다. 타입의 크기가 바뀌는 것을 제어할 수 없기 때문입니다. (또한 값을 출력하기 전 캐스팅하는 것은 안전하지도 않고, 논리적으로도 안 맞습니다.)
- 어떤 플랫폼에서 64비트 값은
intptr_t
—"%" PRIdPTR
uintptr_t
—"%" PRIuPTR
intmax_t
—"%" PRIdMAX
uintmax_t
—"%" PRIuMAX
PRI*
지시자에 대하여 부연 설명을 드리자면, 이것들은 매크로이고, 플랫폼에 따라 적절한 printf 타입 지시자로 바뀝니다. 즉, 다음과 같이 하는 게 아니라,
printf("Local number: %PRIdPTR\n\n", someIntPtr);
다음과 같이 매크로로 사용해야합니다.
printf("Local number: %" PRIdPTR "\n\n", someIntPtr);
%
는 문자열 안에 넣지만, 타입 지시자는 문자열 바깥에 넣는다는 점 기억하세요. C 전처리기가 인접한 문자열들을 모두 하나의 문자열로 이어붙여줍니다.
다음과 같이 하지 마세요.
void test(uint8_t input) {
uint32_t b;
if (input > 3) {
return;
}
b = input;
}
다음과 같이 하세요.
void test(uint8_t input) {
if (input > 3) {
return;
}
uint32_t b = input;
}
경고: 성능이 매우 중요한 반복문이라면, 초기화를 어디서 하는게 좋은지 테스트 해보세요. 때로는 선언을 여기저기 나누어놓는 것이 속도를 느리게 만들 수도 있습니다. 성능이 매우 중요하지는 않은 일반적인 코드(세상의 대부분을 구성하는 것이지요)에서는, 코드가 명확한 편이 낫습니다. 초기화하는 곳에서 타입을 정의하면 읽기에 훨씬 좋지요.
다음과 같이 하지 마세요.
uint32_t i;
for (i = 0; i < 10; i++)
다음과 같이 하세요.
for (uint32_t i = 0; i < 10; i++)
한 가지 예외: 반복문이 끝난 후에 변수의 값을 사용하고 싶다면, 위와 같이 루프 안에서만 사용할 수 있는 변수를 선언해서는 안됩니다.
다음과 같이 하지 마세요.
#ifndef PROJECT_HEADERNAME
#define PROJECT_HEADERNAME
.
.
.
#endif /* PROJECT_HEADERNAME */
다음과 같이 하세요.
#pragma once
#pragma once
는 컴파일러에게 헤더파일을 한 번만 포함시키라고 지시합니다. 더 이상 세 줄의 헤더 가드가 필요치 않습니다. 이 프라그마는 모든 플랫폼의 모든 컴파일러에서 광범위하게 지원되며, 헤더 가드 보다 더 권장되는 방식입니다.
더 자세한 내용 및 지원하는 컴파일러 목록은 링크에서 확인하세요.