포인터가 어렵나요? (네)

0. 서론

C언어를 배우는 사람들에게 C언어의 어떤것이 어렵냐고 물어보면 열에 아홉은 포인터가 어렵다고 합니다.

그런데 포인터가 정말 어려운 개념일까요? (네)

그렇다면 지금부터 포인터가 무엇인지, 어떻게 사용해야하는지 알아봅시다!


1. 자료형

포인터를 이해함에 있어서 반드시 선행되어야 할 것이 자료형과 변수에 대한 이해입니다.

물론 이곳 분들은 다 알고 계시겠지만, 그래도 한 번 짚고 넘어가 보겠습니다.

C에서는 다음의 기본 자료형을 제공합니다.

자료형

long 형은 32비트 운영체제에서 4 바이트, 64비트 운영체제에서 8 바이트로 동작합니다. 또한 실수형에서 long double 형이란것도 존재합니다.

여기서 중요한것은, 모든 자료형이 각자 일정한 크기를 가지고 있다는 것입니다. 이것은 구조체도, 배열도 마찬가지입니다.

모든 자료형에 크기가 존재한다는것은, 필연적으로 다음의 사실을 알려줍니다.

자료형을 사용하는 변수는 메모리의 어딘가에서 그 변수의 자료형에 해당하는 크기만큼을 사용한다.

변수의 메모리 주소를 확인하는 방법은 변수명 앞에 &기호를 붙이면 되고, 변수의 크기를 확인하는 방법은 sizeof 연산자를 이용하면 됩니다.

자, 지금까지 배운 내용으로 다음 문제를 한 번 풀어볼까요?

//문제 : 다음 코드에서 선언된 변수의 자료형과 자료형의 크기를 말해보시오.
char a;
int b;
double c;

char d[10];
int e[5][10];

다 풀어 보셨나요? 그렇다면 정답을 확인해 봅시다. 정답은 아래에 나와있습니다.

char a; // char형, 1바이트
int b; // int형, 4바이트
double c; // double형, 8바이트

char d[10]; // char [10]형, 10바이트
int e[5][10]; // int [5][10]형, 200바이트

참 쉽죠?(네)

a, b, c는 앞에 나와있는대로 자료형의 종류와 크기를 적으면 됩니다.

그런데 d, e를 봅시다. 뭔가 자료형이 이상하네요? 그냥 char형, double형의 배열인것이 아닌가요?

아닙니다. 배열 또한 자료형입니다. 그리고 배열의 크기가 다르면 다른 배열입니다.

예를들어 char [10] 자료형과 char [11] 자료형은 엄연히 다른 자료형입니다. 전자는 10바이트 크기의 자료형이고, 후자는 11바이트 크기의 자료형이죠.

물론 크기가 같아도 적힌 숫자가 다르면 다른 자료형입니다. (ex, char [5][10], char [10][5])

여기까지가 자료형에 대한 설명입니다. 포인터를 알아보기 전에 위 내용을 모두 숙지하도록 합시다.


2. 포인터

포인터는 메모리 주소를 저장하고, 특정 자료형으로 그 주소에 접근하기 위한 자료형입니다.

이것이 의미하는것은, 포인터 변수에는 메모리 주소를 담을 수 있는 크기와, 그 주소를 접근하기 위한 자료형이 필요하다는 것입니다.

실제로 포인터 변수는 항상 4 / 8 바이트의 정해진 크기를 갖습니다. 여기서 4 / 8 바이트는 long형과 마찬가지로 32비트 운영체제에서 4바이트, 64비트 운영체제에서 8바이트를 의미합니다.

지금부터는 편의상 8바이트라고 가정하겠습니다.

포인터 변수를 선언하는 방법은 아래와 같습니다.

// 자료형 *변수명;
char *pa;
int *pb;
double *pc;

int a = 0;
pb = &a;

변수 pb는 int * 자료형 이며, 크기는 메모리 주소를 담기 때문에 8바이트 입니다. pb는 값으로 변수 a의 주소를 가지고 있습니다.

포인터 변수가 가지고 있는 주소에 접근하는 방법은, 변수앞에 *를 붙이는 것입니다.

*pb = 3;
printf("%d %d", a, *pb); // 3 3 출력
sizeof(pb); // 포인터 변수이므로 8 바이트
sizeof(*pb) // int형으로 접근하였으므로 4바이트

또한 배열 처럼 인덱스로 접근할 수 있습니다.

pb[0] = 3;
printf("%d %d", a, pb[0]); // 3 3 출력

이것이 가능한 이유는, 포인터 변수가 접근할 주소와, 접근할 크기를 가지고 있기 때문입니다.

접근할 주소는 포인터 변수의 값이되고, 접근할 크기는 사용하고자 하는 자료형의 크기가 됩니다.

int형 포인터라면 접근할 크기는 int형의 크기인 4바이트가 되겠죠.

여기까지 이해하셨다면, 사실 &기호도 포인터라는것을 이해할 수 있을것입니다.

&기호는 그 변수의 포인터형을 반환해줍니다. 즉, 위 코드에서 &a 는 int형 포인터이고, 크기는 8바이트이며, 변수 a의 주소를 나타내고 있죠.

여기까지가 포인터에 대한 대략적인 설명입니다. 이해하기 어렵지 않죠? (네)

이 다음부터는 포인터끼리의 형변환과, 배열의 포인터, 동적할당에 대해 다뤄볼 것입니다.

P.S. 앞에서 포인터를 특정 자료형을 통해 메모리 주소를 접근하기 위해 사용된다고 하였는데, 특정 자료형을 지정하지 않고 메모리 주소만 저장하는 방법도 있습니다. 그것이 바로 void형 포인터입니다.


3. 배열의 포인터

배열도 자료형이기 때문에 당연히 배열을 위한 포인터 또한 존재합니다.

예제를 통해 알아보도록 합시다.


int a[10]; // int [10] 자료형 변수 a. 크기는 40 바이트
int (*p)[10] = &a; // int [10] 자료형의 포인터 변수 p. p의 자료형은 int (*)[10]

(*p)[3] = 3; // a[3] = 3 과 같음
*p[3] = 3; // a[30] = 3 과 같음. 오버플로우!

int *pa = a; // int 자료형의 포인터 변수 pa. 
pa[3] = 3; // a[3] = 3과 같음

변수 p는 변수 a의 주소를 담고 있습니다. 여기서 p의 크기는 포인터 변수이기 때문에 8바이트 입니다.

p를 통해 a에 접근할 때는 *p 또는 p[0] 을 적어주면 됩니다. 이 때의 자료형은 int [10] 이며, 크기는 40 바이트 입니다.

*p 는 배열을 가리키기 때문에 배열안에 원소로 접근할 수 있습니다. 이 경우는 (*p)[3]과 같은 방법으로 접근이 가능합니다.

간혹 *p[3] 과 같이 접근하는 경우가 있는데, 이는 p[3][0]과 같기 때문에 위 코드에서는 오버플로우가 됩니다.

이는 * 연산자의 우선순위가 낮기 때문에 발생하는 문제이므로 사용에 주의합시다.

그런데 C언어 강의 시간에는 배열을 저렇게 담지 않고, 변수 pa와 같이 단순히 int형 포인터를 통해 배열을 담았을 것입니다.

사실 이 방법은 엄밀하게 말하면 배열의 주소를 포인터변수로 담은것이 아닙니다. a[0]의 주소를 포인터로 담은 것이지요.

이것은 배열의 성질에 의한것으로, a는 int [10] 자료형이지만 a + 0 은 int * 자료형이 됩니다.

마찬가지로 sizeof(a) 는 40 바이트이고, sizeof(a + 0) 은 8바이트가 되죠.

변수 pa의 경우는 후자처럼 처리가 된 것이고, 이것을 강의시간에는 배열의 이름은 배열의 첫 번째 원소의 주소를 가리킨다고 배웠을 것입니다.

다차원 배열의 경우도 위와 같은 방법으로 표시할 수 있습니다.


4. 포인터의 포인터

포인터 또한 자료형이기 때문에 마찬가지로 포인터를 위한 포인터도 정의할 수 있습니다.

이것 역시 예제를 통해 알아보도록 합시다.


int a; // int형 변수 a. 크기는 4 바이트
int *pa = &a; // int * 자료형 pa. 크기는 8 바이트
int **ppa = &pa; // int ** 자료형 ppa. 크기는 8 바이트

int b[10]; // int [10] 자료형 b. 크기는 40 바이트
int (*pb)[10] = &b; // int (*)[10] 자료형 pb. 크기는 8 바이트
int (**ppb)[10] = &pb; int (**)[10] 자료형 ppb. 크기는 8 바이트

int *c[10]; // int *[10] 자료형 c. 크기는 80 바이트
int *(*pc)[10] = &c; // int *(*)[10] 자료형 pc. 크기는 8 바이트

어렵지 않죠? (네)

배열의 경우와 크게 다르지 않습니다. 만약 변수 ppa를 통해 변수 a에 접근하려면 **ppa 또는 ppa[0][0] 과 같이 적어주면 됩니다.

변수 ppb는 int [10] 자료형을 가리키는 포인터 int (*)[10] 을 가리키는 포인터입니다. 괄호와 *의 위치를 기억해둡시다.

변수 c는 int *의 10개 짜리 배열입니다. 따라서 크기는 80 바이트가 됩니다. 변수 pc는 변수 c의 자료형을 가리킵니다.


5. 포인터의 형변환

포인터는 메모리 주소를 담는 자료형이고, 메모리 주소를 어떤 자료형으로 접근한다고 해도 메모리 주소가 바뀌지는 않습니다.

즉, 포인터 변수를 다른 포인터 변수로 형변환 한다고 해도, 그 포인터 변수의 값인 메모리 주소는 변하지 않게 됩니다.

그렇기 때문에 포인터의 형변환은 자유롭게 이루어질 수 있습니다. 값도 크기도 고정되고 목적만 바뀌는것이니까요!

다음 예제를 살펴봅시다.


char a[8] = "hello!!";

// a 배열 null 로 초기화 하기 1
for(int i=0; i < 8; i++)
    a[i] = '\0';

// 2
*(long long *)&a = 0;

배열 a의 자료형은 char [8] 입니다. 크기는 8바이트죠.

2번 코드는 long long도 똑같이 8바이트 자료형이라는것을 이용하여 초기화한 것입니다.

&a로 인해 자료형은 char (*)[8] 이 되었고, 형변환을 통해 long long * 자료형이 되어 long long으로 접근하여 0으로 초기화 한 코드입니다.

C언어의 레퍼런스 함수인 memset 도 위와 비슷한 방법을 이용하여 초기화 해줍니다.

P.S. 위와 같은 형변환을 할 시엔 접근하려는 자료형의 크기가 다르게 될 경우의 오버플로우 문제에 주의합시다. 만약 a 배열이 char [4] 자료형이었다면 위 코드는 오버플로우가 될 것입니다.

포인터의 형변환을 이용하면 다음과 같은것도 할 수 있습니다.


// 1차원을 2차원으로 확장하기

int a[100]; // int [100] 자료형 a. 크기는 400 바이트

int (*p)[10] = (int (*)[10])a; // int (*)[10] 자료형 p. 크기는 8 바이트

p[3][1] = 3; // a[31] = 3 과 동일

1차원 배열을 2차원 배열로 생각하는것을 포인터를 이용하여 간단하게 나타낸 코드입니다.

이렇게 한다면 배열 복사를 통하지 않아 시간도 절약되고, 배열을 따로 선언하지 않기 때문에 메모리도 아낄 수 있죠.


6. 동적할당

동적할당은 프로세스의 실행중에 메모리를 할당하는 것을 말합니다. 배열의 크기를 변수에 의해 결정할 때 자주 사용됩니다.

C언어에서는 malloc 이라는 함수를 통해 동적할당을 수행할 수 있습니다.

// int 형 변수 동적할당
int *a = malloc(sizeof(int));

// 1차원 배열의 동적할당
int *b = (int *)malloc(sizeof(int) * N);

1차원 배열까지는 비교적 간단한 방법으로 동적할당이 가능합니다.

주의할 점은 배열에 대한 동적할당이라 해도, 변수 b는 int * 자료형이기 때문에 크기는 8 바이트 입니다.

2차원 배열부터는 어떻게 해야 할까요?

바로 더블 포인터를 이용하면 됩니다.


// 2차원 배열 N * M의 동적할당

int **a = (int **)malloc(sizeof(int *) * N);
for(int i=0; i < N; i++)
    a[i] = (int *)malloc(sizeof(int) * M);

변수 a는 int ** 자료형이지만, 동적할당을 통해 int *의 배열로 사용되었습니다.

같은 원리로 변수 a[i] 도 int * 자료형이지만, int 형의 배열로 사용됩니다.

만약 M이 상수라면, 아래와 같이 할당받을 수 있습니다.


// 2차원 배열 N * 30의 동적할당

int (*a)[30] = (int (*)[30])malloc(sizeof(int [30]) * N);

여기서 잠깐!

더블 포인터를 이용해 2차원 배열을 만드는 방법을 소개했지만, 이 점을 오해해서는 안됩니다. 바로 아래와 같은 상황입니다.


int func(int **a)
{
    return a[3][2];
}

int main()
{
    int a[10][20] = {};
    a[3][2] = 3;
    printf("%d", func(a));
}

위 코드의 어느부분에 문제가 있을까요?

바로 func 함수의 인자로 int ** 자료형이 아닌, int (*)[20] 자료형을 넘긴것 입니다.

두 자료형이 서로 다르기 때문에 func 함수 안의 a[3][2] 와 main 함수 안의 a[3][2]는 서로 다른 주소를 갖게 됩니다.

계산해볼까요?

변수 a의 주소를 1000이라고 가정해 봅시다.

main 함수에서의 a[3][2] 는 a[3] 까지 접근했을 때 메모리 주소는 1000 + 20 * 4 * 3 = 1240이 되고,

a[3][2] 까지 접근하면 메모리 주소는 1240 + 4 * 2 = 1248이 됩니다.

func 함수에서의 a[3][2] 는 a[3] 까지 접근했을 때 메모리 주소는 1000 + 8 * 3 = 1024가 되고,

a[3] 의 자료형은 int의 포인터기 때문에 배열 인덱스로 접근할 때는 a[3]의 값으로 메모리 주소를 계산하게 됩니다!

메모리 주소 1024에 들어있는 값은 변수 a를 0으로 초기화 했기 때문에 0이 되고, 따라서 a[3][2] 는 메모리 주소가 0 + 4 * 2 = 8이 됩니다.

이처럼 둘은 완전히 다른 자료형이니 사용에 주의해야 합니다.

그렇다면 제대로 동작하는 함수는 어떻게 만들어야 할까요?

답은 아래처럼 구현하면 됩니다.


// 방법 1
int func(int (*a)[20])
{
    return a[3][2];
}

// 방법 2
int func(int a[][20]) // 이 표현은 함수의 인자로 받을때 사용할 수 있습니다.
{
    return a[3][2];
}

int main()
{
    int a[10][20] = {};
    a[3][2] = 3;
    printf("%d", func(a));
}

7. 결론

포인터는 자료형과 크기를 계산할 줄 알면 어렵지 않습니다!

다만 계산하는게 헷갈릴 뿐이지요 ㅎㅎ...

시간상 이정도로 마무리 하고 나중에 함수 포인터에 대한 내용을 추가하는것으로 포스트를 마치겠습니다.

감사합니다.

댓글 (5개) 댓글 쓰기


movie_jo 10달 전

역시 갓....


takeru3 9달 전

정독하고 갑니다. 감사합니다


lilynys113 9달 전

함수 포인터 기대됩니다 ^_^


gnujoow 8달 전

이 포스팅을 줍줍

sujin 8달 전

갓....