C언어의 자료형

C언어의 자료형으로는 기본적으로 다음과 같이 있다.

  • char
  • short
  • int
  • long
  • long long
  • float
  • double
  • long double

실수형을 제외한 각 정수 자료형에 signed/unsigned도 붙을 수 있으나 여기까지만 보면 뭐 그럭저럭 귀여운 정도다. 하지만 이 자료형에 배열, 포인터 및 형식 한정자(type qualifier) 등이 덕지덕지 붙기 시작하면 헬게이트가 열리게 된다.

이 글에서는 그러한 자료형을 쉽게 해석하는 방법에 대해 알아볼 것이다.

const로 몸 풀고 가자

우선 const 형식 한정자부터 살펴보자. 형식 한정자 중에 const 말고 volatile도 있지만, volatile은 그 변수에 대해 컴파일러 최적화를 적용시키지 않는 의미이므로 한 번 쓰고나면 그걸로 끝이다. 하지만 const는 그렇지 않다.

const int SIZE = 10;
int const SIZE = 10;

우선 가장 기초적인 쓰임새다. SIZE라는 int형 변수를 10으로 초기화하되, 더 이상 수정이 불가능하게 const 형식 한정자를 붙였다. 이때, const int나 int const나 동일한 의미인 점을 염두에 두어라. 가끔 가다보면 이게 혼동되어서 서로 다른 뜻으로 착각하고 어떻게 써야 하는지 모르는 사람들이 종종 보인다. const의 의미가 달라지는 부분은 포인터(*) 기호와 같이 쓰일 때다.

int a = 3, b = 7;
const int *p = &a;
int *const q = &b;

바로 이처럼 말이다. int*형으로 선언한 p와 q에 각각 &a와 &b를 넣었다. 다만, p는 const가 *의 왼쪽에 있고 q는 *의 오른쪽에 있다. 이렇게 하면 다음과 같은 차이점이 있다.

*p = 5; // ERROR! p가 가리키는 a 변수의 값을 수정할 수 없음
p = &b; // OK, p가 b를 가리키게 수정함

*q = 1; // OK, q가 가리키는 b 변수의 값을 1로 수정함
q = &a; // ERROR! q가 다른 변수를 가리키게 수정할 수 없음

슬슬 헷갈리기 시작할 수도 있는데, "선언 시 const의 오른쪽에 있는 것을 수정할 수 없다"하고 기억하면 매우 직관적이고 쉬워진다. p의 경우, const의 오른쪽에 *p가 있다. 따라서, *p는 수정할 수 없지만 p는 수정할 수 있다. q의 경우, const의 오른쪽에 q가 있다. 따라서, q는 수정할 수 없지만 *q는 수정할 수 있다.

const int *const r = &a;

이렇게 쓰면 r도 const, *r도 const이므로 모두 수정할 수 없게 된다.

이젠 배열을 확인해 보자

아직 배열과 포인터의 관계에 대해서 헷갈리는 사람도 있을 것이다. 이 둘은 명백히 다르다. 똑같다고 말하는 사람은 뚝배기 깨버려야 한다. 다시 강조하지만, 배열과 포인터는 서로 다른 자료형이다.

int arr[10];
int *p = arr;

배열과 포인터가 똑같다고 말하는 사람들은 분명 쓰임새가 비슷해서 그럴 것이다. 이렇게 하면 arr[idx] 대신 p[idx]으로 사용해도 전혀 문제가 없으므로, arr와 p는 같다고 하는 것이다. 하지만, 그들의 주장은 sizeof 연산자의 확인으로 매우 간단하게 반박할 수 있다.

printf("%zu\n", sizeof(arr));
printf("%d\n", (int)sizeof p);

나는 한 글에 여러가지 정보를 넣는 것을 참 좋아하는데, 위의 짧은 코드에도 매우 많은 정보를 욱여넣었다. sizeof 연산자의 반환 타입이 size_t이므로 이에 알맞은 format specifier는 %zu이다. 이는 C99 표준에 등장한다. 만약 ANSI C를 쓰고 있는 사람이 있다면 아래처럼 int로 형변환하여 %d로 출력할 수도 있다. 또한, sizeof는 함수가 아닌 연산자이기 때문에 괄호를 안 써도 사용 가능하다.

어쨌든, 위 코드를 돌려보면 각각 40, 4가 출력될 것이다. 물론, 플랫폼에 따라 4 대신 8이 출력될 수도 있고(x86과 x64의 차이), int의 크기가 2라면 40 대신 20이 출력될 수도 있다. 여기서 중요한 건 정확한 값이 아니라 둘이 다르다는 점이다. 재차 강조한다. 배열과 포인터는 다르다.

배열과 포인터는 다른데 왜 arr[idx]와 p[idx]는 서로 같을까? 이는 포인터와 정수 산술연산에 대해 설명할 필요가 있겠다. arr는 배열의 이름으로, 이는 &arr[0]와 동일하다(사실 값이 동일한 뿐이지, 타입까지 정확히 같은 것은 아니다. &arr[0]은 int*이고, arr는 int[]이다). arr[idx]는 *(arr + idx)라는 표현의 syntactic sugar로, 다시 말하면 같은 표현이다. arr에 idx라는 정수를 더하면 실제 주소를 계산할 때는 sizeof(*arr)의 크기만큼 idx에 곱해져서 더해진다. arr가 int배열이고 int 크기를 4라고 한다면, arr[3]은 arr라는 주소에 4 * 3만큼 더하고, 그 주소에 역참조하여 데이터에 접근하는 것이다.

그럼 이제 같은 이유를 설명할 수 있다. p에 arr[0]의 주소를 넣음으로써 p와 arr는 같은 주소를 가리킨다. 또한, *p와 *arr의 타입도 같다. 따라서, p[idx]는 arr[idx]와 완벽히 동일한 주소에 접근해서 역참조를 하기 때문에 같은 것이다.

그리고 여담이지만, arr[idx]라는 표현이 *(arr + idx)와 동일하므로 덧셈의 교환법칙에 의해 *(idx + arr)로 써도 상관이 없고, 이는 곧 idx[arr]라고 써도 전혀 문제될 것이 없음을 뜻한다. 이는 다차원 배열로 확장하면 arr[a][b][c]라는 표현은 c[b[arr][a]]] 등으로 바꿔쓸 수 있으며 난독화의 효과도 얻을 수 있고 보는 이를 경악에 빠지게 하며 퇴사를 하게될 수도 있다.

배열과 포인터를 섞어 보자

그럼 이제 둘을 합쳐보자. 여기서부터 심한 뇌절을 유발할 수 있으므로 높은 집중력이 요구된다.

int *a[10];
int (*b)[10];

a는 int*형을 10칸 담을 수 있는 포인터 배열이다.
b는 int형을 10칸 담을 수 있는 배열을 가리키는 배열 포인터이다.

그렇다. 포인터는 배열도 가리킬 수 있다. int형만 가리킬 수 있다고 생각하면 큰 오산이다. int[]도 가리킬 수 있다는 얘기다. 그럼 배열 포인터는 일반 포인터와 뭐가 다른데?

아까 위에서 포인터와 정수의 산술연산을 얘기할 때, 정수만큼 더하면 그 정수에 포인터가 가리키는 타입의 크기만큼 곱해져서 더해진다는 설명을 봤을 것이다. 따라서, b+1을 하면 b가 가리키는 타입인 int[10]의 크기 40만큼 더해지는 것이다. 그럼 b에는 뭘 저장할까?

int arr[50][10];
int (*b)[10] = arr;

바로 이렇게 된다. arr는 &arr[0]과 동일한데, arr[0]은 int[10]이 된다. 여기에 주소를 취하면 int(*)[10]이 되므로 이를 같은 타입인 b에 대입하는 것은 지극히 자연스럽다.

참고로, 이는 등가 포인터와도 관련되는 매우 중요한 얘기다. 이것까지 설명하면 글이 삼천포로 빠지기 때문에 따로 글을 쓰겠다. 물론 귀찮아서 안 쓸 수도 있다. 지금까지의 설명은 잘 따라왔는가? 그럼 다음의 코드를 보자.

int (**(***(*(**(*ptr[5][5])[5]))[5][5]))[5];

저렇게 코딩하는 사람이 있을진 모르겠지만, 그런 건 둘째 치고 일단 저 코드를 해석해보자. 우리는 배열과 포인터를 배웠으므로 분명 해석할 수 있다. 그리고, 여기서 이 글의 핵심 내용을 소개한다.

시계방향 나선 규칙

위에서 본 저런 끔찍한 선언을 쉽게 해석하기 위해 "시계방향 나선 규칙"을 사용한다. 이름에서도 알 수 있듯이, 시계방향으로 나선을 그리며 뱅글뱅글 돌면 된다. 그럼 시작하자.

가장 먼저 할 일은 식별자를 찾는 것이다. 식별자가 뭐냐고? 변수명이다. 위의 코드에선 ptr이다. 그래, 이걸 찾는 건 쉽지. 그럼 그 다음은?

그 후 위로 뻗어나와 시계방향으로 회전한다. 오른쪽을 봤더니 [5][5]가 보인다. 따라서, ptr은 배열이다! 그것도 2차원 배열! 크기는 5행 5열! 그렇다. ptr의 정체는 2차원 배열이다. 그럼 무엇을 담는 2차원 배열일까?

진행방향 그대로 유지하며 시계방향으로 뱅글 회전하자 *가 나왔다. 그렇다! 포인터다! 즉, 포인터를 담는 2차원 배열이었던 것이다. 차근차근 까보니까 의외로 별 거 없다. 그럼 이 포인터는 무엇을 가리키는 포인터일까?

다시 시계방향으로 회전하니까 [5]가 나왔다. 즉, 그 포인터는 1차원 배열을 가리키는 포인터였다. 그것도 5개의 요소를 담는 1차원 배열이다. 그럼 이 1차원 배열은 무엇을 담을까?

이번엔 **를 만났다. 즉, 이중 포인터이다. 이중 포인터는 포인터 변수의 주소를 담는 녀석이다. 따라서, 위에서 본 1차원 배열은 이중 포인터의 배열이다. 그리고 그 다음으로 회전하려고 봤더니... 현재 매칭되는 괄호 오른쪽에 붙은 것 없이 바로 괄호가 닫혔다.

이 경우는 괄호를 떼버려도 된다. 괄호를 떼고나면 *와 **가 합쳐져서 삼중 포인터가 된다. 이런, 낚였다! 이중 포인터도 아니고 삼중 포인터였다. 따라서, 아까의 1차원 배열은 이중 포인터 배열이 아닌 삼중 포인터의 배열이었다. 마음을 가다듬고 해석을 진행하자. 거의 다 왔다.

그 3중 포인터는 다시 2차원 배열을 가리키는 것이 밝혀졌다. 그럼 이 2차원 배열은 무엇을 담을지 보자.

삼중 포인터가 있다. 그럼 삼중 포인터를 담는 2차원 배열이었을까? 우리는 한 번 당했으면 학습을 해야한다. 오른쪽을 보니 다시 괄호가 2번 닫힌다. 즉, 괄호 하나를 다시 없앨 수 있고, 그러면 삼중 포인터가 아닌 5중 포인터가 된다. 누가 이딴 코드를 쓴거야?

그 5중 포인터는 다시 1차원 배열을 가리킨다. 이제 피날레를 장식하자.

그 1차원 배열은 int를 담는다. 해석이 완료되었다! 그럼 ptr의 정체를 정리해보자.

ptr은 int형 1차원 배열을 가리키는 5중 포인터를 담는 2차원 배열을 가리키는 3중 포인터를 담는 1차원 배열을 가리키는 포인터를 담는 2차원 배열

아주 지랄을 하는 자료형이지만, 어쨌든 문법적으론 틀린 게 없는 엄연한 코드다. 여기까지 배열과 포인터의 콜라보를 잘 봤다. 하지만 아직 남은 게 있다. 바로 함수 포인터다.

함수를 가리키는 함수 포인터

포인터는 int나 배열을 가리킬 수 있을 뿐만 아니라, 함수마저 가리킨다. 이를 함수 포인터라고 한다. 사용법을 살펴 보자.

int add(int a, int b) {
    return a + b;
}

int (*fp)(int, int) = add;

나선 규칙으로 짤막하게 해석해보면, fp는 포인터이며 그 포인터는 int 두 개를 인자로 받고 int를 리턴하는 함수를 가리킨다고 해석이 가능하다. 그리고 이 fp에 add를 집어넣음으로써, add(3, 7) 으로 쓰는 대신에 fp(3, 7)이라고 써도 동일한 결과를 얻을 수 있다.

Q. 어, 그러면 add와 fp는 같겠네요?
A. 같겠냐?

다르다. 하나는 함수고, 하나는 포인터다. 한 가지 재밌는 코드를 살펴보자.

printf("%d\n", add(3, 7));
printf("%d\n", (&add)(3, 7));
printf("%d\n", (*add)(3, 7));
printf("%d\n", (**add)(3, 7));
printf("%d\n", (*****add)(3, 7));
printf("%d\n", (*******************************add)(3, 7));

재밌게도, 모두 정상적으로 동작하는 코드이며 동일하게 10이 출력된다. 참고로 (&add)(3, 7)이 되면 (&&add)(3, 7)도 되지 않을까 하고 생각하는 사람이 있을지 모르겠는데, 이렇게 쓰면 &&가 논리 and 연산자로 파싱이 되며 문법 오류가 된다. 위 코드에서 add를 fp로 바꾸면 (&fp)(3, 7) 말고는 모두 정상적으로 동작한다. 즉, 함수와 포인터는 차이가 있다.

참고로, C언어에서는 int (*fp)()로 선언하면 int를 반환하는 모든 함수를 받을 수 있다. 인자에 아무것도 쓰지 않으면 이는 '인자가 있을 수도 있고, 없을 수도 있음'을 의미하기 때문이다. C++에서는 이와 다르게 '인자가 없음'을 뜻한다. 따라서, C++에서 int (*fp)() = add; 하고 쓴다면 컴파일 에러가 발생한다. 그럼 C에서 '인자가 없음'을 나타내려면 어떻게 하냐고? int (*fp)(void)라고 써주면 된다.

그럼 한번 다음의 코드를 해석해 보자.

void (*signal(int, void (*)(int)))(int);

식별자는 signal이다. 오른쪽을 봤더니 여는 괄호가 있다. 즉, signal은 함수다. 이 함수는 int와 void(*)(int)라는 함수를 인자로 받는다. 그리고 포인터를 리턴한다. 이 포인터는 int를 인자로 받고 반환형이 void인 함수를 가리킨다.

정말 간단하지 않은가? 당신은 이제 C언어의 어떤 선언문을 봐도 겁먹지 않고 냉정하게 해석할 수 있게 되었다. 축하한다!

Reference