레이블이 C언어인 게시물을 표시합니다. 모든 게시물 표시
레이블이 C언어인 게시물을 표시합니다. 모든 게시물 표시

2011년 4월 17일 일요일

C언어 - 함수 포인터(Function Pointer)와 배열 파라미터 (Call by reference)

지난번 내용을 상기해보며

  지난번 내용은 잘 기억하고 있는지요? 다시 한 번 요약해 보자면, "어떠한 함수가 특정한 타입의 변수/배열의 값을 바꾸려면 함수의 인자는 반드시 타입을 가리키는 포인터 형을 이용해야 한다!" 였습니다. 사실, 이 문장이 이해가 잘 되지 않았던 분들이 있으리라 생각됩니다. 하지만, 이번 강좌를 보고 난다면 이 문장의 의미를 정확하게 파악할 수 있을 것입니다.


/* 눈 돌아가는 예제. 포인터가 가리키는 변수를 서로 바꾼다.  */
#include <stdio.h>

int pswap(int **pa, int **pb);
int main()
{
    int a,b;
    int *pa, *pb;

    pa = &a;
    pb = &b;

    printf("pa 가 가리키는 변수의 주소값 : %x \n", pa);
    printf("pa 의 주소값 : %x \n \n", &pa);
    printf("pb 가 가리키는 변수의 주소값 : %x \n", pb);
    printf("pb 의 주소값 : %x \n", &pb);

    printf(" ------------- 호출 -------------- \n");
    pswap(&pa, &pb);
    printf(" ------------- 호출끝 -------------- \n");

    printf("pa 가 가리키는 변수의 주소값 : %x \n", pa);
    printf("pa 의 주소값 : %x \n \n", &pa);
    printf("pb 가 가리키는 변수의 주소값 : %x \n", pb);
    printf("pb 의 주소값 : %x \n", &pb);
    return 0;

}
int pswap(int **ppa, int **ppb)
{
    int *temp = *ppa;

    printf("ppa 가 가리키는 변수의 주소값 : %x \n", ppa);
    printf("ppb 가 가리키는 변수의 주소값 : %x \n", ppb);

    *ppa = *ppb;
    *ppb = temp;

    return 0;
}

  성공적으로 컴파일 하면


  일단, 붉은색으로 박스 친 부분을 잘 살펴보기 바랍니다. pa 가 가리키는 변수의 주소값은 (즉, pa 의 값이지요) 31FBCC 였습니다. 물론, 여러분이 실행했을 때 에는 결과가 다르게 나올 것입니다. (거의 99% 확률로 다르게 나옵니다) pb 가 가리키는 변수의 주소값은 31FBC0 이였습니다. 그런데 말이죠. pswap 함수를 호출하고 나니, pa 가 가리키는 변수의 주소값은 31FBC0 이 되고, pb 가 가리키는 변수의 주소값은 31FBCC 가 되었습니다. 즉, 두 포인터가 가리키는 변수가 서로 뒤바뀐 것이지요.

  이 때, 우리는 이와 같은 함수를 만들기 위해서, 인자를 어떤 형식으로 취해야 될까요? 앞서 말했듯이, 특정한 타입의 변수의 값을 바꾸려면, 특정한 타입을 가리키는 포인터로 인자를 취해야 된다고 했습니다. 그런데, 이 예제의 경우, 특정한 타입은 int* 타입입니다. 그렇다면 int* 타입을 가리키는 포인터의 타입은? 음. 강좌를 잘 복습하였다면 int** 타입 이라고 말할 수 있겠지요. (잘 모르겠다면 12-3 강, 포인터는 영희이다! 를 보세요)

  따라서, 우리는 위 이야기를 토대로 아래와 같이 함수를 정의하였습니다.

int pswap(int **ppa, int **ppb)

  상당히, 잘한 것이지요. 이제, 함수의 몸체를 봐봅시다.


int pswap(int **ppa, int **ppb)
{
    int *temp = *ppa;

    printf("ppa 가 가리키는 변수의 주소값 : %x \n", ppa);
    printf("ppb 가 가리키는 변수의 주소값 : %x \n", ppb);

    *ppa = *ppb;
    *ppb = temp;

    return 0;
}

  일단, int* 형의 temp 변수를 만들어서 *ppa 의 값을 저장하고 있습니다. 그런데, *ppa 의 값은 무엇일까요? 만일 우리가 위 예제 처럼 pswap 함수를 호출하였다고 하면, ppa 는 pa 를 가리키고 있고, ppb 는 pb 를 가리키고 있겠지요. 따라서, *ppa 라 하면 pa 의 값을 이야기 합니다. 그런데, pa 는 int* 형이므로, pa 의 값을 보관하는 변수는 반드시 int* 이여야 되겠지요. 따라서, 우리는 int* 형의 temp 변수를 정의하였습니다. 그 아래의 내용은 이전에 만들어 보았던 swap 함수와 동일합니다.


    printf("ppa 가 가리키는 변수의 주소값 : %x \n", ppa);
    printf("ppb 가 가리키는 변수의 주소값 : %x \n", ppb);

  그렇다면 우리는 위 두개의 printf 문장에서 어떤 결과가 출력될 지 예측 가능합니다. 위 예제에서 ppa 가 pa 를 가리키고 있으므로 ppa 의 값을 출력하면 pa 의 주소값이 나오고, ppb 도 마찬가지로 나오겠죠. 위 출력결과에서 실제로 같다는 것을 확인할 수 있습니다. 어때요. pswap 함수가 이해가 되나요?

  위 과정을 그림으로 표현하면 아래와 같습니다.



  그렇다면, 이번에는 이차원 배열을 인자로 받는 함수에 대해서 생각해 보도록 합시다.


/* 2 차원 배열의 각 원소를 1 씩 증가시키는 함수 */
#include <stdio.h>
/* 열의 개수가 2 개인 이차원 배열과, 총 행의 수를 인자로 받는다. */
int add1_element(int (*arr)[2], int row);
int main()
{
    int arr[3][2];
    int i,j;

    for(i=0;i<3;i++)
    {
        for(j=0;j<2;j++)
        {
            scanf("%d", &arr[i][j]);
        }
    }

    add1_element(arr, 3);

    for(i=0;i<3;i++)
    {
        for(j=0;j<2;j++)
        {
            printf("arr[%d][%d] : %d \n", i,j,arr[i][j]);
        }
    }
    return 0;

}
int add1_element(int (*arr)[2], int row)
{
    int i,j;
    for(i=0;i<row;i++)
    {
        for(j=0;j<2;j++)
        {
            arr[i][j]++;
        }
    }

    return 0;
}

  성공적으로 컴파일 하였다면


역시 잘 실행되는군요. 일단, 함수의 정의부분 부터 살펴봅시다.


int add1_element(int (*arr)[2], int row)
{
    int i,j;
    for(i=0;i<row;i++)
    {
        for(j=0;j<2;j++)
        {
            arr[i][j]++;
        }
    }

    return 0;
}

  이 함수는 인자를 두 개 받고 있는데 하나는 열의 개수가 2 개인 이차원 배열을 가리키는 포인터 이고, 하나는 함수의 행의 수를 받는 인자입니다.


    for(i=0;i<row;i++)
    {
        for(j=0;j<2;j++)
        {
            arr[i][j]++;
        }
    }

 우리는 row 를 통해 이 이차원배열의 행의 개수를 알 수 있고, 열의 개수는 이미 알고 있으므로 (배열 포인터에서) 각 원소를 1 씩 증가시키는 작업을 시행할 수 있게됩니다. 위와 같이 말이죠. 우리는 포인터를 잘 배워서 헷깔릴 문제는 없지만 많은 사람들에게 다음과 같이 인자를 받는것이 어렵게 느껴집니다.

int add1_element(int (*arr)[2], int row)

  그래서, 오직 함수의 인자의 경우에서만 위 형태의 인자를 다음과 같이도 표현할 수 있습니다.


int add1_element(int arr[][2], int row)

  이는 오직 함수의 인자에서만 적용되는 것입니다. 만일

    int parr[][3] = arr;

 와 같은 문장을 이용했더라면 컴퓨터는 parr 을 '열의 개수가 3 개이고 행의 개수는 정해지지 않는 배열' 이라 생각해서 오류를 내게 됩니다. (만일 행의 개수를 생략했다면 배열을 정의시 초기화도 해주어야 되는데는 위는 그러지 않으므로) 암튼, 함수의 인자에서만 가능한 형태라는 것을 기억해 주시기 바랍니다.

  덧붙여서 응용력을 살짝 이용하면 다차원 배열의 인자도 정의할 수 있습니다. 예를 들어서


int multi(int (*arr)[3][2][5])
{
    arr[1][1][1][1] = 1;
    return 0;
}

  혹은


int multi(int arr[][3][2][5])
{
    arr[1][1][1][1] = 1;
    return 0;
}

  로 하면 됩니다.

 상수인 인자


/* 상수를 인자로 받아들이기 */

#include <stdio.h>

int read_val(const int val);
int main()
{
    int a;
    scanf("%d", &a);

    read_val(a);
    return 0;
}
int read_val(const int val)
{
    val = 5; // 허용되지 않는다.

    return 0;
}

  컴파일 하게 되면 아래와 같은 오류를 만나게 됩니다.

 error C2166: l-value가 const 개체를 지정합니다.

  흠.. 이건 우리가 이전에 상수의 값을 변경하려고 했었을 때 만났던 오류 인것 같습니다. 맞습니다. 우리가 val 을 const int 로 선언하였기 때문에 함수를 호출 할 때, val 의 값은 인자로 전달된 값으로 초기화 되고 결코 바뀌지 않습니다. 즉, val 은 a 의 값으로 상수로 초기화 된 것입니다. 따라서, 함수 내부에서 val = 5 와 같이 val 의 값을 바꾸려 한다면 오류가 나겠지요. 왜냐하면 val 은 상수이니까요.

  상수로 인자를 받아들이는 경우 대부분은 함수를 호출 해도 그 인자의 값이 바뀌지 않는 경우에 자주 사용합니다만, 자세한 내용은 나중에 좀더 다루도록 하겠습니다.

 함수 포인터


  아마, '함수 포인터' 라는 말을 들었을 때는 조금 의아하는 감이 있지 않을까 합니다. 함수 포인터라니, 함수를 가리킨다는 것인가? 그럼, 함수가 메모리 상에 있다는 거야? 네. 맞습니다. 사실, 프로그램의 코드 자체가 메모리 상에 존재합니다. 우리는 이전에 컴파일러가 하는 작업이 바로 우리가 '인간에 친숙한 언어' 로 쓰여진 프로그램 코드를 '컴퓨터에 친숙한 언어, 즉 수 데이터들' 로 바꿔주어 실행 파일을 생성한다고 배웠습니다. 이렇게, 바뀐 실행 파일을 실행하게 되면 프로그램의 수 코드가 메모리 상에 올라가게 됩니다. 다시말해, 메모리 상에 함수의 코드가 들어간다는 것입니다. 이 때, 변수를 가리키는 포인터 처럼 함수 포인터는 메모리 상에 올라간 함수의 시작 주소를 가리키는 역할을 하게 됩니다.
 
  그렇다면, 함수 포인터가 함수를 가리키기 위해서는 그 함수의 시작 주소값을 알아야 합니다. 그런데, 배열과 마찬가지로 함수의 이름이 바로 함수의 시작 주소값을 나타냅니다.


/* 함수 포인터 */
#include <stdio.h>

int max(int a, int b);
int main()
{
    int a, b;
    int (*pmax)(int, int);
    pmax = max;

    scanf("%d %d", &a, &b);
    printf("max(a,b) : %d \n", max(a,b));
    printf("pmax(a,b) : %d \n", pmax(a,b));

    return 0;
}
int max(int a, int b)
{
    if(a>b)
        return a;
    else
        return b;

    return 0;
}

  성공적으로 컴파일 했다면


  역시 우리가 예상했던 데로 잘 흘러가는 것 같습니다. 함수 포인터는 어떻게 정의하는지 살펴봅시다.


    int (*pmax)(int, int);

  일단, 위는 함수 포인터 pmax 의 정의 입니다. 위 정의를 보고 다음과 같은 사실을 알 수 있습니다. '이 함수 포인터 pmax 는 함수의 리턴값이 int 형이고, 인자 두 개가 각각 int 인 함수를 가리키는구나!'. 따라서, 우리는 pmax 함수 포인터로 특정한 함수를 가리킬 때, 그 함수는 반드시 pmax 의 정의와 일치해야 합니다. 함수 포인터의 일반적인 정의는 다음과 같습니다.


(함수의 리턴형) (*포인터 이름)(첫번째 인자 타입, 두번째 인자 타입,....)
// 만일 인자가 없다면 그냥 괄호 안을 비워두면 된다. 즉, int (*a)() 와 같이 하면 된다

  이제 pmax 가 max 를 가리키게 되는 부분을 봅시다.


    pmax = max;

  max 함수를 살펴보면 pmax 의 정의와 일치하므로, max 함수의 시작 주소값을 pmax 에 대입할 수 있게 됩니다. 이 때, 앞에서도 말했듯이 특정한 함수의 시작 주소값을 알려면 그냥 함수 이름을 넣어주면 됩니다. pmax = &max 와 같은 형식은 틀린 것입니다.


    printf("max(a,b) : %d \n", max(a,b));
    printf("pmax(a,b) : %d \n", pmax(a,b));

  pmax 는 이제 max 함수를 가리키므로 pmax 를 통해 max 함수가 할 수 있었던 모든 작업들을 할 수 있게 됩니다. 이때도 역시 그냥 pmax 를 max 처럼 이용하면 됩니다. 이는 배열에서

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

    arr[2]; // p[2] 와 정확히 일치
    p[2];

  와 같이 arr[2] 와 p[2] 가 동일한 것과 같습니다. 아무튼 max(a,b) 를 하나 pmax(a,b) 를 하나 결과는 똑같이 나오게 됩니다.


/* 함수 포인터 */
#include <stdio.h>

int max(int a, int b);
int donothing(int c, int k);
int main()
{
    int a, b;
    int (*pfunc)(int, int);
    pfunc = max;

    scanf("%d %d", &a, &b);
    printf("max(a,b) : %d \n", max(a,b));
    printf("pfunc(a,b) : %d \n", pfunc(a,b));

    pfunc = donothing;

    printf("donothing(1,1) : %d \n", donothing(1,1));
    printf("pfunc(1,1) : %d \n", pfunc(1,1));
    return 0;
}
int max(int a, int b)
{
    if(a>b)
        return a;
    else
        return b;

    return 0;
}
int donothing(int c, int k)
{
    return 1;
}

  성공적으로 컴파일 했다면


  일단, 우리는 이전의 예제와 동일한 형태의 함수 포인터 pfunc 을 정의하였습니다.


    int (*pfunc)(int, int);


  이는 '리턴형이 int 이고 두 개의 인자 각각의 포인터 형이 int 인 함수를 가리킵니다. 그런데, donothing 함수와 max 함수 모두 이 조건을 만족하고 있습니다. 즉, 이들은 인자의 변수들도 다루고 하는 일도 다르지만 리턴값이 int 로 같고 두 개의 인자 모두 int 이므로 pfunc 이 이 두개의 함수를 가리킬 수 있는 것입니다. 


    pfunc = max;

    scanf("%d %d", &a, &b);
    printf("max(a,b) : %d \n", max(a,b));
    printf("pfunc(a,b) : %d \n", pfunc(a,b));

    pfunc = donothing;

    printf("donothing(1,1) : %d \n", donothing(1,1));
    printf("pfunc(1,1) : %d \n", pfunc(1,1));

  따라서, 위와 같이 했을 때 pfunc 이, 자기가 가리키는 함수의 역할을 제대로 하고 있다는 것을 알 수 있습니다. 그런데 말이죠. 함수 포인터를 만들 때, 인자의 형이 무엇인지 알기 힘든 경우가 종종 있습니다. 예를 들어 아래와 같은 함수의 원형을 봅시다.

int increase(int (*arr)[3], int row)

  흠... 두 번째 인자의 형은 int 라는 것은 알겠는데 첫번째 인자의 형은 도대체 뭘까요? 사실, 간단합니다. 특정한 타입의 인자를 판별하는 일은 단순히 변수의 이름만을 빼버리면 됩니다. 따라서, 첫번째 인자의 형은 int (*)[3] 입니다. 즉, increase 함수를 가리키는 함수 포인터의 원형은 아래와 같습니다.


    int (*pfunc)(int (*)[3], int);

  간단하지요? 이것을 이전에 이차원 배열을 인자로 받았던 함수에 적용시켜 보면 정확히 작동한다는 것을 알 수 있습니다.

그럼, 이번 강좌는 여기에서 끝을 내도록 하겠습니다. 함수에 관한 강좌는 여기서 막을 내리게 됩니다. 사실, 아직까지도 C 언어를 배우면서 정말로 무언가 할 수 있는 실용적인 프로그램을 만들지 못해서 안타깝습니다. 그래서 이번에 생각해보기로 여러 재미있는 과제들을 내보도록 하죠.





여러분의 출력결과는 위 사진과 다를 수 있습니다.

C언어 - 함수와 포인터 인자 (Call by reference)

안녕하세요 여러분. 이전에 함수에 대해선 잘 이해하셨는지요? 그리고, 마지막에 던진 의미 심장한(?) 질문에는 답을 구하셨나요? 우리는 이전 12 강에서 포인터에 대해서 다루어왔습니다. 그 때 동안 늘 머리속에 맴돌았던 생각은 "도대체 이거 어따가 써먹는거야?" 였죠. 하지만, 이번 강좌에서 그 질문에 대한 해답을 찾을 수 있기 바랍니다.

  일단, 간단히 이전에 포인터에 대해서 배웠던 내용을 리뷰 하자면

포인터는 특정한 변수의 메모리 상의 주소값을 저장하는 변수로, int 형 변수의 주소값을 저장하면 int*, char 이면 char* 형태로 선언된다. 또한 * 단항 연산자를 이용하여, 자신이 가리키는 변수를 지칭할 수 있으며 & 연산자를 이용하여 특정한 변수의 주소값을 알아낼 수 있다.

  만일 위 내용중에 한 마디라도 이해가 안되는 부분이 있다면 12 강 포인터 강좌를 다시 읽어 보시기를 강력하게 권합니다. 그렇지 않다면 아래의 내용을 계속 읽어가도록 하죠. 우리는 지난 강좌에서 다음과 같이 단순한 형태로는 다른 함수에서 정의된 변수의 값을 바꿀 수 없다고 했습니다.


/* 이상한 짓 */
#include <stdio.h>
int change_val(int i)
{
    i = 3;
    return 0;
}
int main()
{
    int i = 0;

    printf("호출 이전 i 의 값 : %d \n", i);
    change_val(i);
    printf("호출 이후 i 의 값 : %d \n", i);

    return 0;
}


  왜 main 함수 안에서 정의된 i 의 값이 바뀌지 않는지는 잘 아시겠지만 그래도 한 번 확인해봅시다.


    i 의 값이 전혀 바뀌지 않았음을 알 수 있습니다. 그 이유는 함수 change_val 을 호출 할 때, change_val 함수 안에서 정의된 변수 i 는 main 함수의 i 의 값을 전달 받은 후에, change_val 함수 안에서 정의된 변수 i 의 값을 3 으로 변경하게 됩니다. 여기서 중요한 점은 'main 함수의 i 가 아닌 change_val 함수 안에서 정의된 변수 i 의 값이 3 으로 변경' 된다는 것이지요. 결론적으로 main 함수의 i 의 값에는 아무런 영향도 미치지 못하고 위와 같은 현상이 벌어지는 것입니다.

  위 과정을 그림으로 표현하면 아래와 같습니다.


  하지만, 여러분은 지난 3 개의 강좌를 통해 포인터에 대해 귀가 아플 만큼 들어 보았을 것입니다. 그리고, 여기에서 그 아이디어를 적극적으로 활용하고자 합니다. 이전의 방법을 통해서 다른 함수에 정의된 변수들의 값을 변경할 때 직면했던 문제는 바로 각 함수는 다른 함수의 변수들에 대해 아는 것이 아무것도 없다는 것이였습니다. 즉 A 라는 함수에서 i 라는 변수를 이용한다면 컴파일러는 이 변수 i 가 오직 A 함수에서만 정의되었다고 생각하지 다른 함수에서 정의되었는지는 상관하지 않다는 것입니다.

  그렇지만 궁여지책으로 유일하게 가능했던 것은 인자를 이용해서 다른 함수에 정의된 변수들의 '값' 을 전달하는 것이였습니다. 하지만 그렇게 해도 여전히 불가능해 보였습니다.
 



/* 드디어 써먹는 포인터 */
#include <stdio.h>
int change_val(int *pi)
{
    printf("----- chage_val 함수 안에서 -----\n");
    printf("pi 의 값 : %d \n", pi);
    printf("pi 가 가리키는 것의 값 : %d \n", *pi);

    *pi = 3;

    printf("----- change_val 함수 끝~~ -----\n");
    return 0;
}
int main()
{
    int i = 0;

    printf("i 변수의 주소값 : %d \n", &i);
    printf("호출 이전 i 의 값 : %d \n", i);
    change_val(&i);
    printf("호출 이후 i 의 값 : %d \n", i);

    return 0;
}


  성공적으로 컴파일 하면


  헉! 눈으로 보고도 믿기지 않으십니까? 호출 이후의 i 의 값이 0 에서 3 으로 바뀌었습니다~~ 이게 무슨일 입니까? 이건 우리가 여태까지 꼭 하고야 말겠던 바로 그 작업 아닙니까. 바로 다른 함수에서 정의된 변수의 값을 바꾸는 것 말이죠. 그런데, 위 코드를 조금씩 뜯어 들여보다 보면 방법은 매우 간단하다는 것을 알 수 있습니다. 물론, 이 강의를 보고 계시는 일부 똑똑한 독자들은 이미 짐작 했을 것이지만요.


여러분의 출력결과와 다를 수 있습니다.

int change_val(int *pi)


  일단, 함수의 정의부분을 살펴보자면 int 형의 변수를 가리키는 pi 라는 이름의 포인터로 인자를 받고 있습니다. 그리고 main 함수에서 이 함수를 어떻게 호출했는지 보면


    change_val(&i);

  즉, 인자에 main 함수에서 정의된 i 라는 변수의 '주소값' 을 인자로 전달하고[각주:1] 있습니다. 따라서  change_val 함수를 호출하였을 때 pi 에는 i 의 주소'값'이 들어가게 됩니다. 즉, pi 는 i 를 가리키게 됩니다.


{
    printf("----- chage_val 함수 안에서 -----\n");
    printf("pi 의 값 : %d \n", pi);
    printf("pi 가 가리키는 것의 값 : %d \n", *pi);

    *pi = 3;

    printf("----- change_val 함수 끝~~ -----\n");
    return 0;
}

  pi 가 i 의 주소값을 가지고 있으므로 pi 를 출력했을 때 그 값은 i 의 주소값과 같을 수 밖에 없습니다. 이는 두 번째 printf 문장에서 확인할 수 있습니다. 또한 그 아래 *pi 를 통해서 i 를 간접적으로 접근할 수 있습니다. 왜냐하면 * 라는 단항 연산자의 의미가 '내가 가지는 주소값에 해당하는 변수를 의미해라' 이기 때문에 *pi 는 pi 가 가리키고 있는 변수인 i 를 의미할 수 있게 됩니다. 즉, pi 를 통해서 굳게 떨어져 있던 main 과 change_val 함수의 세계 사이에 다리가 놓이게 되는 것이지요.

  간혹 pi 가 main 함수에서 정의된 것이라고 착각하는 분들이 있는데, pi 역시 change_val 함수 내에서 정의된 변수 입니다.

  또한 *pi = 3 을 통해 'pi 가 가리키고 있는 변수' 의 값을 3 으로 변경할 수 있습니다. 여기서 pi 가 i 를 가리키므로 i 의 값을 3 으로 변경할 수 있겠네요. 따라서,


    printf("호출 이후 i 의 값 : %d \n", i);

에는 i 의 값이 성공적으로 변경되어 3 이 출력되는 것입니다. 위 과정을 그림으로 나타내면 아래와 같습니다.




 /* 두 변수의 값을 교환하는 함수 */
#include <stdio.h>
int swap(int a, int b)
{
    int temp = a;
   
    a = b;
    b = temp;

    return 0;
}
int main()
{
    int i, j;

    i = 3;
    j = 5;

    printf("SWAP 이전 : i : %d, j : %d \n", i, j);

    swap(i, j); // swap 함수 호출~~

    printf("SWAP 이후 : i : %d, j : %d \n", i, j);

    return 0;
}

  성공적으로 컴파일 했으면


  흠.. 일단 우리가 원하던 결과가 나오지 않았습니다. 소스 상단의 주석에서도 볼 수 있듯이 swap 함수는 두 변수의 값을 교환해 주는 함수 입니다. 우리가 원하던 것은 SWAP 이후에  i 에는 5 가 j 에는 3 이 들어 있는 것인데 전혀 바뀌지 않았습니다. 이에 대해 이야기 하기 전에 소스 코드에서 보이는 새로운 것들에 대해 이야기 해봅시다.



int swap(int a, int b)

  swap 함수의 정의를 보면 직관적으로 인자가 2 개나 있다는 것을 알 수 있습니다. 맞습니다. 이 swap 함수는 호출시 2 개의 인자를 전달해주어야 합니다. 물론 인자가 더 늘어난다면 반점(,) 을 이용해서 계속 늘려나가면 됩니다. 예를 들어서

int this_function_has_many_argumenets(int a, char b, int* k, long d, double c, int aa, char bb, int* kk, double cc) 

  와 같이요. 아무튼, swap 함수를 살펴 보면


int swap(int a, int b)
{
    int temp = a;
   
    a = b;
    b = temp;

    return 0;
}

  로 두 개의 int 형 인자를 받아 들이고 있습니다. 이 때, 내부를 보면 temp 라는 변수에 a 의 값을 저장합니다. 그리고 변수 a 에 변수 b 의 값을 넣습니다. 이제, 변수 b 에 a 의 값을 넣어야 하는데, 현재 변수 a 에는 b 의 값이 이미 들어가 있으므로 이전에 저장하였던 a 의 값인 temp 변수의 값을 b 에 넣으면 됩니다. 일단, 내용상으로는 전혀 하자가 없어 보입니다.

    printf("SWAP 이전 : i : %d, j : %d \n", i, j);

    swap(i, j); // swap 함수 호출~~

    printf("SWAP 이후 : i : %d, j : %d \n", i, j);

  그런데, 말이죠. main 함수에서 i,j 의 값을 바꾸려고 swap 함수를 호출하였더니 전혀 뒤바뀌지 않은 채로 출력되었습니다. 도대체 왜 그런가요? 물론, 여러분은 다 알고 있겠지요. swap 함수의 변수 a,b 가 모두 swap 함수 내부에서 선언된 변수들이란 것입니다. 다시말해 변수 a 와 b 는 i 와 j 와 어떠한 연관도 없습니다. 다만, a 와 b 의 초기값이 i, j 와 동일하였다는 것만 빼고요.
  이는 마치 아래의 작업을 한 것과 같습니다.


    int i, j;
    int temp, a, b;
    /* 함수를 호출하여 함수의 인자를 전달하는 부분 */
    a = i;
    b = j;
    /* 함수 몸체의 내용을 실행 */
    temp = a;
    a = b;
    b = temp;

  그러니 i 나 j 의 값이 바뀔리 만무하죠. 아무튼, 위 함수가 호출되는 과정을 그림으로 표현하면 아래와 같습니다.


  그렇다면 어떻게 해야 할까요? 다 알고 있겠죠? 포인터를 이용합시다~~



/* 올바른 swap 함수 */
#include <stdio.h>
int swap(int *a, int *b)
{
    int temp = *a;
   
    *a = *b;
    *b = temp;

    return 0;
}
int main()
{
    int i, j;

    i = 3;
    j = 5;

    printf("SWAP 이전 : i : %d, j : %d \n", i, j);

    swap(&i, &j);

    printf("SWAP 이후 : i : %d, j : %d \n", i, j);

    return 0;
}

  성공적으로 컴파일 하면


  오오오.. 드디어 우리가 원하던 것이 이루어졌습니다. 바로 i 와 j 의 값이 서로 뒤바뀐(swap) 것이지요. 아.. 정말 기쁩니다. 그런데, 이전에 이야기 하였던 내용을 잘 숙지하였더라면 위 함수가 왜 제대로 호출하는지 쉽게 알 수 있습니다.


int swap(int *a, int *b)
{
    int temp = *a;
   
    *a = *b;
    *b = temp;

    return 0;
}

  먼저 swap 함수를 살펴 봅시다. 이는 int 형을 가리키는 포인터 변수를 인자로 가지고 있습니다. 일단, swap 함수 내에서 두 변수를 교환하는 과정은 위와 동일하니 이에 대해서는 이야기 하지 않도록 하겠습니다. 이 때, main 함수에서는 swap 함수를 아래와 같이 호출합니다.


    printf("SWAP 이전 : i : %d, j : %d \n", i, j);

    swap(&i, &j); // 호출

    printf("SWAP 이후 : i : %d, j : %d \n", i, j);

  바로 a 와 b 에 i 와 j 의 주소값을 전달하여 a 와 b 로 하여금 i 와 j 를 가리키게 만든 것입니다. 따라서, swap 함수 내부에서는 a 와 b 의 값을 교환하는 것이 아니라 a 와 b 가 가리키는 두 변수의 값을 교환했으므로 (*a, *b) 결과적으로 i 와 j 의 값이 바뀌게 된 것입니다. 어때요, 간단하지요?

  결론적으로 정리하자면

어떠한 함수가 특정한 타입의 변수/배열의 값을 바꾸려면 함수의 인자는 반드시 그 타입을 가리키는 포인터를 이용해야 한다!

  포인터를 인자로 받은 함수에 대해선 지속적으로 이야기 할 것이므로 지금 막상 이해가 잘 안된다고 해도 큰 걱정할 필요는 없습니다.
 
  함수의 원형
 

  우리가 여태까지 사용하였던 함수들은 모두 main 함수 위에서 정의되고 있었습니다. 그러면, 그 정의를 main 함수 아래에서 한다면 어떻게 될까요? 사실, 대부분의 사람들의 경우 main 함수를 제일 위에 놓고 나머지 함수들은 main 함수 뒤에 정의하게 됩니다. 아무튼, 위의 코드를 살짝 바꿔보면 아래와 같습니다.

/* 될까? */
#include <stdio.h>
int main()
{
    int i, j;
    i = 3;
    j = 5;
    printf("SWAP 이전 : i : %d, j : %d \n", i, j);
    swap(&i, &j);
    printf("SWAP 이후 : i : %d, j : %d \n", i, j);

    return 0;
}
int swap(int *a, int *b)
{
    int temp = *a;
   
    *a = *b;
    *b = temp;

    return 0;
}

  컴파일 하게 되면 아래와 같은 경고 창을 볼 수 있습니다.

warning C4013: 'swap'이(가) 정의되지 않았습니다. extern은 int형을 반환하는 것으로 간주합니다.

  흠, 일단은 무시하고 실행해 보도록 하죠.


 어랏, 잘만 됩니다. 그렇다면 아무런 문제가 없는 것일까요? 사실, 함수를 적절히 잘 이용하기만 하면 큰 문제는 발생하지 않습니다. 그런데 말이죠. 사람도 역시 사람인지라, 프로그래밍 하다가 실수로 인자의 개수를 부족하게 쓰거나, 올바르지 않는 타입의 변수 (예를 들어 인자가 int* 인데, int 변수를 썼다든지) 를 사용하는 수가 발생하게 됩니다. 더군다나, 우리의 예제에서는 함수가 겨우 한 개 밖에 없었지만 실제 프로그래밍 시에는 수십개의 함수를 이용하기 때문이죠. 그렇다면, 여러분이 완벽한 인간이 아니라는 가정 하에 인자 하나를 누락시켜 봅시다.

  위 코드의 함수 호출 부분을

    swap(&i, &j); 에서 swap(&i); 로 변경해봅시다.

 컴파일 하면 여전히 위와 동일한 경고가 나오는데 특별히 내가 인자를 누락 했다는 말은 하지 않습니다. 그리고, 실행해보면


  허걱! 컴파일시 아무런 오류 메세지도 없었는데 실행해 보면 위와 같이 덜컹 오류가 나타납니다. 이런 해괴한 일이 아닐 수 없군요. 게다가, 컴파일러는 내가 인자를 어디서 인자를 누락했는지 조차 표시해주지 않기 때문에 오류를 찾기 힘들어 질 수 밖에 없습니다. 물론, 우리의 예제는 짧기 때문에 찾기 쉽지만 진짜 같은 프로그램을 제작하면 코드가 보통 수천~수만 줄에 달한다는 것만을 기억하세요.

  이번에는 swap 함수 호출 부분을

    swap(&i, j); 로 변경해보세요.


 뜨아아. 역시 이번에도 동일한 형태의 프로그램 오류가 컴파일시 오류 하나 없었는데도 불구하고 나타났습니다. 이 역시 포인터 전달 해야 되는데, 그냥 정수값을 전달해서 포인터 b 가 메모리의 올바르지 않은 공간에 접근하여 발생한 일입니다. 참으로 곤욕스러운 일이 아닐 수 없습니다. 우리가 아무리 대단하다고 해도 실수를 할 수 있는 법인데, 컴파일러는 이러한 실수를 하나도 잡아내지 못하고 있습니다.

  그러나, 우리의 C 언어가 이를 용납할 수 있나요? C 언어에서는 멋진 해결책이 있습니다. 바로, 함수의 원형(prototype) 를 이용하는 것입니다.

/* 함수의 원형 */
#include <stdio.h>
int swap(int *a, int *b); // 이 것이 바로 함수의 원형
int main()
{
    int i, j;
    i = 3;
    j = 5;
    printf("SWAP 이전 : i : %d, j : %d \n", i, j);
    swap(&i,&j);
    printf("SWAP 이후 : i : %d, j : %d \n", i, j);

    return 0;
}
int swap(int *a, int *b)
{
    int temp = *a;
   
    *a = *b;
    *b = temp;

    return 0;
}

  성공적으로 컴파일 하면


  오, 역시 잘 출력됩니다. 이번에는 컴파일시 경고나 오류의 흔적 조차 찾아볼 수 없었습니다.

#include <stdio.h>
int swap(int *a, int *b); // 이것이 바로 함수의 원형
int main()
{
    int i, j;
    i = 3;
    j = 5;
... (생략)

  소스 코드의 제일 윗부분을 보면 위와 같이 한 줄이 추가된 것을 볼 수 있습니다. 이는 바로 '함수의 원형' 이라 부르는 것입니다. 이는 사실 함수의 정의 부분을 한 번 더 써준 것 뿐입니다 (주의할 점은 함수의 원형에는 정의와는 달리 뒤에 ; 를 붙인 다는 것입니다). 그런데, 이 한줄이 컴파일러에게 다음과 같은 사실을 알려줍니다.

  "야, 이 소스코드에 이러 이러한 함수가 정의되어 있으니까 잘 살펴봐"

  다시말해, 컴파일러에게 이 소스코드에 사용되는 함수에 대한 정보를 제공하는 것입니다. 다시 말해 실제 프로그램에는 전혀 반영되지 않는 정보지요. 그렇지만, 우리가 앞서 하였던 실수들을 하지 않도록 도와줍니다. 만일, 위와 같이 함수의 원형을 삽입한 상태에서 인자를 &i 하나로 지워 봅시다. 즉, swap(&i, &j) 를 swap(&i); 로 변경해봅시다.

  그럼 컴파일 시 아래와 같은 오류를 만나게 됩니다.

 error C2198: 'swap' : 호출에 매개 변수가 너무 적습니다.

  와우! 우리가 앞서 함수의 원형을 집어 넣지 않았을 때 에는 인자(매개 변수)를 하나 줄여도 아무말 하지 않던 컴파일러가 원형을 삽입하고 나니 위와 같이 정확한 위치에 내가 어딜 잘못했는지 잡아냅니다. 이것이 가능한 이유가 바로 컴파일러에게 내가 무슨 무슨 함수를 이용할 것인지 함수의 원형을 통해 이야기 하였기 때문입니다. 내가, int swap(int *a, int *b) 라는 함수가 있다는 것을 원형을 이용해 알려주었기 때문에 컴파일러는 우리가 swap 함수를 사용하면 꼭 2 개의 인자를 이용한다는 사실을 알게 되어 내가 인자를 하나만 적었을 때 틀렸다고 알려 준 것입니다.

  그렇다면 swap(&i, &j) 를 swap(&i, j) 로 바꿔보면 어떻게 될까요?

warning C4047: '함수' : 'int *'의 간접 참조 수준이 'int'과(와) 다릅니다.
warning C4024: 'swap' : 형식 및 실제 매개 변수 2의 형식이 서로 다릅니다.

  실질적인 오류는 발생하지 않았지만 일단, 내가 잘못하였다는 것을 알려줍니다. 컴파일러는 역시 원형을 통해 두 번째 매개 변수의 타입이 무엇인지 알고 있기에 그냥 int 를 사용하면 함수의 두번째 매개변수와 내가 인자에 전달하는 변수의 형과 다르다는 사실을 알려 줍니다. 다만, 여기서 아까와 같이 오류가 출력되지 않는 이유는 int* 도 사실 int 형 데이터 이기 때문에 j 가 (int *) 로 캐스팅 되어 전달되므로, 아까와 같은 강한 오류 메세지는 출력되지 않습니다. 그러나, 여전히 프로그래머의 잘못을 지적하고 있습니다.

  이러한 연유에서, 함수의 원형을 집어넣는 일은 여러분들이 '반드시' 하셔야 되는 일입니다. 물론, main 함수 위에 함수를 정의하면 상관 없지만 사실 99.9% 의 프로그래머들은 함수를 main 함수의 뒤에 정의하고 원형을 앞에 추가하는 것을 선호하니 여러분들도 트렌드를 따르시기 바랍니다.

  배열을 인자로 받기 

  이번에는 배열을 인자로 받아 들이는 함수에 대해서 생각해봅시다. 이번 예제에서 우리가 만들게 된 함수는 바로, 배열을 인자로 받아서 그 배열의 각 원소의 값을 1 씩 증가시키는 함수 입니다.

#include <stdio.h>

int add_number(int *parr);
int main()
{
    int arr[3];
    int i;

    /* 사용자로 부터 3 개의 원소를 입력 받는다. */
    for(i=0;i<3;i++)
    {
        scanf("%d", &arr[i]);
    }

    add_number(arr);

    printf("배열의 각 원소 : %d, %d, %d", arr[0], arr[1], arr[2]);

    return 0;

}
int add_number(int *parr)
{
    int i;
    for(i=0;i<3;i++)
    {
        parr[i]++;
    }
    return 0;
}

  성공적으로 컴파일 했으면


  음, 역시 함수가 잘 작동하는 군요. 우리가 10, 11, 15 를 입력했을 때, 함수를 통해서 각 원소가 1 씩 증가하여 11, 12, 16 이 되었습니다. 일단, add_number 함수 부터 살펴 보도록 하죠.

int add_number(int *parr)

  우리가, 앞서 말한 내용에 따르면 '특정한 타입의 값을 변경하는 함수를 제작하려면, 반드시 그 타입을 가리키는 포인터를 인자로 가져야 한다' 라고 했습니다. 그렇다면, 우리가 arr 이라는 배열을 가리키는 포인터가 바로 add_number 의 인자로 와야 하는데, 우리가 12 - 3 강에서 배운 내용에 따르면 int arr[3] 와 같은 일차원 배열을 가리키는 포인터는 바로 int* 형이라 했습니다. (잘 모르겠다면 여기를 눌러서 강의를 다시 보시기 바랍니다)

  따라서, add_number(int *parr) 이라 하면 arr 을 가리키도록 인자를 받을 수 있습니다. 함수를 호출 할 때 아래와 같이 하였습니다.

    add_number(arr);

  그런데, 우리가 이전에 배운 바에 따르면 arr 은 배열의 시작 주소 값을 가지고 있다고 하였습니다. 즉, arr = &arr[0] 인 것이지요. 따라서, parr 에는 arr 배열의 시작 주소, 즉 배열 arr 을 가리키게 됩니다.

{
    int i;
    for(i=0;i<3;i++)
    {
        parr[i]++;
    }
    return 0;
}

  마지막으로 함수의 몸체를 살펴봅시다. parr[i] 를 통해 parr 이 가리키는 배열의 (i + 1) 번째 원소에 접근할 수 있습니다 (arr[1] 이 배열의 두 번째 원소 이므로). 따라서, parr[i]++ 을 통해서 배열의 각 원소들의 크기를 모두 1 씩 증가시키게 됩니다. 사실, 위 함수가 어떻게 돌아가는지 잘 이해하기 위해서는 포인터와 배열에 대한 거의 완벽한 이해를 필요로 합니다. 만약 그러지 않는다면 모래사장에 빌딩 짓는 것처럼, C 언어에 대한 개념을 완전히 잊어버릴 수 있으니 모른 다면 꼭 뒤로 가기를 하여 복습을 하시기 바랍니다.

/* 입력 받은 배열의 10 개의 원소들 중 최대값을 출력 */
#include <stdio.h>
/* max_number : 인자로 전달받은 크기 10 인 배열로 부터 최대값을 구하는 함수 */
int max_number(int *parr);
int main()
{
    int arr[10];
    int i;

    /* 사용자로 부터 원소를 입력 받는다. */
    for(i=0;i<10;i++)
    {
        scanf("%d", &arr[i]);
    }

    printf("입력한 배열 중 가장 큰 수 : %d \n", max_number(arr));
    return 0;

}
int max_number(int *parr)
{
    int i;
    int max = parr[0];

    for(i=1;i<10;i++)
    {
        if(parr[i] > max)
        {
            max = parr[i];
        }
    }

    return max;
}

  성공적으로 컴파일 한다면


    이번 예제는 사용자들로 부터 정수 10 개를 입력 받아서, 그 수들 중 가장 큰 수를 뽑아내는 프로그램 입니다. 먼저 max_number 함수 부터 살펴봅시다.

int max_number(int *parr)
{
    int i;
    int max = parr[0];

    for(i=1;i<10;i++)
    {
        if(parr[i] > max)
        {
            max = parr[i];
        }
    }

    return max;
}


  음.. 일단, 소스 코드를 해석하는데에는 큰 어려움이 없을 것 같네요. 일단 처음 max 에 parr 이 가리키는 배열의 [0], 즉 첫번째 원소의 값을 넣었습니다. 그리고 아래 for 문에서 만약 parr[i] 가 max 보다 크면 max 의 값을 parr[i] 로 대체하고 있군요. 결과적으로 i 값이 9 까지 되었을 때에는 max 에 parr 중 가장 큰 원소의 값이 들어가게 됩니다. 만일, max 보다 더 큰 원소가 parr 에 있다면 max 의 값은 그 큰 원소의 값으로 바뀌었기 때문에 모순이지요.

  결과적으로 우리가 입력한 10 개의 원소들 중 가장 큰 원소가 출력됩니다.

  함수 사용 연습하기 

  사실, 아직까지 함수가 왜 이리 중요한 것인지 감이 잘 오지 않는 분들이 있을 것입니다. 그래서, 그러하신 분들을 위해 함수의 중요성을 절실히 느낄 수 있는 예제를 준비하였습니다.
 
  다음의 두 소스 코드를 비교해 보면서 어떤 것이 나은지 생각해보세요

/* 함수를 이용하지 않은 버전 */
#include <stdio.h>
int main()
{
    char input;
   
    scanf("%c", &input);

    if(48 <= input && input <= 57)
    {
        printf("%c 는 숫자 입니다 \n", input);
    }
    else
    {
        printf("%c 는 숫자가 아닙니다 \n", input);
    }

    return 0;
}


/* 함수를 이용한 버전 */
#include <stdio.h>
int isdigit(char c); // c 가 숫자인지 아닌지 판별하는 함수
int main()
{
    char input;
   
    scanf("%c", &input);

    if(isdigit(input))
    {
        printf("%c 는 숫자 입니다 \n", input);
    }
    else
    {
        printf("%c 는 숫자가 아닙니다 \n", input);
    }

    return 0;

}
int isdigit(char c)
{
    if(48 <= c && c <= 57)
    {
        return 1;
    }
    else
        return 0;
}
 일단, 첫번째 소스의 경우 길이가 짧습니다. 다만 이해하기가 힘듧니다.

    if(48 <= input && input <= 57)
    {
    }

  printf 문이 없다고 했을 때 위 코드가 input 이 숫자인지 아닌지 판별하는지 쉽게 구분이 가나요? 이는 특별히 주석을 넣지 않는 한 매우 어렵습니다. 사실, 숫자의 경우 아스키 코드의 값이 48 에서 57 이기 때문에 위 코드를 사용하였는데 아스키 코드표를 외우고 다니지 않는 한 이해하기 상당히 어렵습니다.

  그렇다면 함수를 이용한 버전을 살펴 봅시다.  

    if(isdigit(input))
    {
    }

  일단 isdigit 라는 이름만 보고도 이 함수는 input 이 숫자 인지 아닌지 (is digit? ) 판별하는 함수 임을 알 수 있습니다. 물론, isdigit 함수 내부에도 첫번째 소스와 동일한 과정이 진행되지만 이 함수가 무슨 작업을 하는지 알기 때문에 소스를 이해하기 훨씬 쉬워집니다. 뿐만 아니라, 어떠한 문자가 숫자인지 반복해서 확인하는 경우에도 함수를 이용하면 편히 사용할 수 있습니다.

  아직까지도 왜 함수를 써야 하는지 모르겠다고 해서 큰 문제는 아닙니다. 나중에 가면 자연스럽게 깨닫게 될 것입니다. 그럼, 이번 강의는 여기까지에서 줄이겠습니다.

C언어 - 포인터 (3)

1 차원 배열 가리키기 

  일단, 강의의 시작은 간단한 것으로 해보겠습니다. 이전해도 말했듯이 (벌써 몇 번째 반복하고 있는지는 모르겠지만 그 만큼 중요하니까) int arr[10]; 이라는 배열을 만든다면 arr 이 arr[0] 을 가리킨다고 했습니다. 그렇다면 다른 int* 포인터가 이 배열을 가리킬 수 있지 않을까요? 한 번 프로그램을 짜봅시다.

#include <stdio.h>
int main()
{
    int arr[3] = {1,2,3};
    int *parr;

    parr = arr;
    /* parr = &arr[0]; 도 동일하다! */

    printf("arr[1] : %d \n", arr[1]);
    printf("parr[1] : %d \n", parr[1]);
    return 0;
}

성공적으로 컴파일 한다면


 일단, 중점적으로 볼 부분은 아래와 같습니다.

    parr = arr;

  바로 arr 에 저장되어 있는 값을 parr 에 대입하는 부분이지요. 앞에서 말했듯이 arr 은 int 를 가리키는 포인터 입니다. 이 때, arr 에 저장된 값, 즉 배열의 첫 번째 원소의 주소를 parr 에 대입하고 있습니다. 다시 말해 위 문장은 주석에도 잘 나와 있듯이 아래와 같은 문장이 됩니다.

    parr = &arr[0]

  따라서, parr 을 통해서 arr 을 이용했을 때와 동일하게 배열의 원소에 마음껏 접근할 수 있게 되는 것이 됩니다. 위 모습을 한 번 그림으로 나타내보면 (아마도 여러분들은 지금 수준이라면 머리속으로 다 그릴 수 있어야 할 것입니다)

참고적으로 한 방의 크기는 그림의 단순화를 위해 4 바이트로 하였습니다.

/* 포인터 이용하기 */
#include <stdio.h>
int main()
{
    int arr[10] = {100,98,97,95,89,76,92,96,100,99};

    int* parr = arr;
    int sum = 0;

    while(parr - arr <= 9)
    {
        sum += (*parr);
        parr ++;
    }

    printf("내 시험 점수 평균 : %d \n", sum/10);
    return 0;
}

  성공적으로 컴파일 하면


  일단, 포인터를 이용한 간단한 예제를 다루어보겠습니다.
 
    int* parr = arr;

  먼저, int 형 1 차원 배열을 가리킬 수 있는 int* 포인터를 정의하였습니다. 그리고, 이 parr 은 배열 arr 을 가리키게 됩니다.

    while(parr - arr <= 9)
    {
        sum += (*parr);
        parr ++;
    }

  그 다음 while 문을 살펴봅시다. while 문을 오래전에 배워서 기억이 안난다면 다시 뒤로 돌아가세요! 이 while 문은 parr - arr 이 9 이하일 동안 돌아가게 됩니다. sum 에 parr 이 가리키는 원소의 값을 더했습니다. += 연산자의 의미는 아시죠? sum += (*parr); 문장은 sum = sum + *parr 와 같다는 것 알고 계시지요?

        parr ++;

  parr 을 1 증가시켰습니다. 이전 강좌에서도 이야기 하였지만 포인터 연산에서 1 증가시킨다면, parr 에 저장된 주소값에 1 이 더해지는 것이 아니라 1 * (포인터가 가리키는 타입의 크기) 가 더해진다는 것이지요. 즉, int 형 포인터 이므로 4 가 더해지므로, 결과적으로 배열의 그 다음 원소를 가리킬 수 있게 됩니다. 암튼, 위 작업을 반복하면 arr 배열의 모든 원소들의 합을 구하게 됩니다. while 문에서 9 이하일 동안만 반복하는 이유는, parr - arr >= 10 이 된다면 parr[10 이상의 값] 을 접근하게 되므로 오류를 뿜게 됩니다.

  여기서 궁금한 것이 없나요? 우리가 왜 굳이 parr 을 따로 선언하였을까요? 우리는 arr 이 arr[0] 을 가리킨다는 사실을 알고 있으므로 arr 을 증가시켜서 *(arr) 으로 접근해도 되지 않을까요? 한 번, arr 의 값을 변경할 수 있는지 없는지 살펴봅시다.

/* 배열명 */
#include <stdio.h>
int main()
{
    int arr[10] = {100,98,97,95,89,76,92,96,100,99};
   
    arr ++; // 오류!! 배열 이름은 const
    return 0;
}


  컴파일 해보면

error C2105: '++'에 l-value가 필요합니다.

  와 같은 오류를 만나게 됩니다. 이 말은 arr 이 상수이므로 연산할 수 없다는 말이 됩니다. 배열이름이 상수라고요? 맞습니다. 물론 *arr = 10; 과 같이 arr[0] 의 값은 바꿀 수 있지만 arr++ 와 같은 연산(즉, arr 이 다른 것을 가리키는 것)은 허용되지 않는 것을 보아서 const 키워드가 어느쪽에 붙은 포인터인지 짐작할 수 있을 것 입니다. 즉, 배열의 이름은

  (뭐시기 뭐시기) *const (배열 이름)

 과 같은 형태임을 알 수 있습니다. 결과적으로 배열의 이름은 죽어다 깨어나도 언제나 배열의 첫번째 원소를 가리키게 됩니다. 배열의 이름이 결코 다른 값들을 가리킬 수 없도록 C 에서 제한을 두는 이유는, 제 생각에 프로그래머가 실수로 배열의 이름이 가리키는 값을 잘못 바꿔서 배열을 '아무도 가리키지 않는 메모리 상의 미아'가 됨을 막으려고 한 것이 아닐까요?

  포인터의 포인터 

  똑똑한 분들이라면 이러한 것들에 대해서도 생각해 보신 적이 있을 것입니다. 물론, 안하셔도 상관 없고요.. 저의 경우 포인터 처음 배울 때 그것 마저 이해하기도 힘들어서 한참 버벅거렸습니다 :) 아무튼. 지금 머리속으로 예상하시는 대로 포인터의 포인터는 다음과 같이 정의합니다.

      int **p;

  위는 'int 를 가리키는 포인터를 가리키는 포인터' 라고 할 수 있습니다. 쉽게 머리에 와닿지 않죠? 당연합니다. 이전 강좌의 내용도 어려워 죽겠는데 위 내용까지 머리속에 쑤셔 넣으려면 얼마나 힘들겠어요? 그래서, 한 번 예제를 봅시다.

/* 포인터의 포인터 */
#include <stdio.h>
int main()
{
    int a;
    int *pa;
    int **ppa;

    pa = &a;
    ppa = &pa;

    a = 3;

    printf("a : %d // *pa : %d // **ppa : %d \n", a, *pa, **ppa);
    printf("&a : %d // pa : %d // *ppa : %d \n", &a, pa, *ppa);
    printf("&pa : %d // ppa : %d \n", &pa, ppa);
   
    return 0;
}

성공적으로 컴파일 했다면

여러분의 결과는 약간 다를 수 있습니다. 다만, 같은 행에 있는 값들이 모두 같음을 주목하세요

  일단 위에 보시다 싶이 같은 행에 있는 값들은 모두 같습니다. 사실, 위 예제는 그리 어려운 것이 아닙니다. 포인터에 제대로 이해만 했다면 말이죠. 일단 ppa 는 int* 를 가리키는 포인터 이기 때문에

    ppa = &pa;

 와 같이 이전의 포인터에서 했던 것 처럼 똑같이 해주면 됩니다. ppa 에는 pa 의 주소값이 들어가게 되죠.
 
    printf("&pa : %d // ppa : %d \n", &pa, ppa);

  따라서 우리는 위의 문장이 같은 값을 출력함을 알 수 있습니다. 위의 실행한 결과를 보아도 둘다 1636564 를 출력했잖아요.

    printf("&a : %d // pa : %d // *ppa : %d \n", &a, pa, *ppa);

그리고 이제 아래에서 두 번째 문장을 봐 봅시다. pa 가 a 를 가리키고 있으므로 pa 에는 a 의 주소값이 들어갑니다. 따라서, &a 와 pa 는 같은 값이 되겠지요. 그러면 *ppa 는 어떨까요? ppa 가 pa 를 가리키고 있으므로 *ppa 를 하면 pa 를 지칭하는 것이 됩니다. 따라서 역시 pa 의 값, 즉 &a 의 값이 출력되게 됩니다.

    printf("a : %d // *pa : %d // **ppa : %d \n", a, *pa, **ppa);

마지막으로 위의 문장을 살펴 봅시다. pa 가 a 를 가리키고 있으므로 *pa 를 하면 a 를 지칭하는 것이 되어 a 의 값이 출력됩니다. 그렇다면 **ppa 는 어떨까요? 이를 다시 써 보면 *(*ppa) 가 되는데, *ppa 는 pa 를 지칭하는 것이기 때문에 *pa 가 되서, 결국 a 를 지칭하는 것이 됩니다. 따라서, 역시 a 의 값이 출력되겠지요. 어때요? 간단하죠?

위 관계를 그림으로 그리면 다음과 같습니다.


  2 차원 배열의 [] 연산자 

  2 차원 배열의 [] 연산자에 대해선 제가 지난번 강좌에서 '생각 해보기' 문제로 내었던 것 같은데, 생각해보셨는지요? 일단 이전의 기억을 더듬에서 다음과 같은 배열이 컴퓨터 메모리 상에 어떻게 표현되는지 생각 해보도록 합시다.

    int a[2][3];


  물론, 이 배열은 2 차원 배열이므로 평면위에 표시된다고 생각할 수 도 있지만, 컴퓨터 메모리 구조는 1 차원 적이기 때문에 1 차원으로 바꿔서 생각해봅시다. 되었나요? 그렇다면, 제가 그림을 보여드리죠.

실제로 프로그램을 짜서 실행해 보면 메모리 상에 위와 같이 나타남을 알 수 있습니다. 한 번 해보세요~

  일단, 위 그림에서 왼쪽에 메모리 상의 배열의 모습이 표현된 것은 여러분이 쉽게 이해하실 수 있스리라 믿습니다. 다만, 제가 설명해야 할 부분은 오른쪽에 큼지막하게 화살표로 가리키고 있는 부분이지요. 먼저 아래의 예제를 봅시다.

/* 정말로? */
#include <stdio.h>
int main()
{
    int arr[2][3];
   
    printf("arr[0] : %x \n", arr[0]);
    printf("&arr[0][0] : %x \n", &arr[0][0]);

    printf("arr[1] : %x \n", arr[1]);
    printf("&arr[1][0] : %x \n", &arr[1][0]);

    return 0;
}

성공적으로 컴파일 했다면

표현된 주소값은 여러분과 다를 수 있습니다.

 오옷.. arr[0] 에 저장되어 있는 값이 arr[0][0] 의 주소값과 같고, arr[1] 에 저장되어 있는 값이 arr[1][0]의 주소값과 같습니다. 이것을 통해 알 수 있는 사실은..? 아마도 다 알겠지만, arr[0] 은 arr[0][0] 을 가리키는 포인터 이고, arr[1] 은 arr[1][0] 을 가리키는 포인터라는 뜻이 되겠지요. 이 때, arr[0][0] 의 형이 int 이므로 arr[0] 은 int* 형이 되겠고, 마찬가지로 arr[1] 도 int* 형이 되겠지요.

/* a? */
#include <stdio.h>
int main()
{
    int arr[2][3];
   
    printf("&arr[0] : %x \n", &arr[0]);
    printf("arr : %x \n", arr);

    return 0;
}

성공적으로 컴파일 했다면

출력 결과가 여러분의 출력결과와 다를 수 있지만 값이 같음을 주목하세요

    흠.. 이번에는 arr[0] 의 주소값과 arr 에 저장된 값이 같군요.. 근데요, 이러한 결과는 어디서 본 것 같지 않으세요? 맞아요. 실제로 우리가 만들었던 1 차원 int 배열에서도 배열의 이름이 배열의 첫 번째 원소를 가리키고 있었습니다. 그런데 위 2차원 배열의 경우에도 arr 이 arr[0] 을 가리키고 있으므로 동일하다고 볼 수 있습니다. 그렇다면, arr[0] 이 int* 형 이므로 arr[0] 을 가리키는 arr 은 int** 형 일까요?  아마 여러분 머리속엔 지금 이러한 생각이 떠오를 것입니다.

"당연하지.. 그걸 말이라 묻냐? 니가 위에서 설명했잖아. int* 를 가리키는 포인터는 int** 이라고 "

  그런데 생각을 해보세요. 그렇게 당연하다면 제가 질문을 했겠습니까? (^^) 답변은 "아니오" 입니다.

아마 여러분은 '내가 위에서 뭘 배웠지?' 라는 생각이 들면서 혼돈에 빠질 것입니다. 당연하지요.. 제가 이야기를 안한 부분이니까요. 저도 처음에 이 부분을 배웠을 땐 이전까지 배웠던 사실이 쓰나미 처럼 밀려나가고 머리속에 하애지는 현상을 겪었습니다. 아무튼.. 왜 그런지 설명해보죠.

  포인터의 형(type) 을 결정짓는 두 가지 요소   

먼저 포인터의 형을 결정하는 두 가지 요소에 대해 이야기 하기 전에, 위에서 배열의 이름이 왜 int** 형이 될 수 없는지에 대해 먼저 이야기 해봅시다. 만일 int** 형이 될 수 있다면 맨 위에서 했던 것 처럼 int** 포인터가 배열의 이름을 가리킨다면 배열의 원소에 자유롭게 접근할 수 있어야만 할 것입니다.

/* 과연 될까? */
#include <stdio.h>
int main()
{
    int arr[2][3]={{1,2,3},{4,5,6}};
    int **parr;

    parr = arr;

    printf("arr[1][1] : %d \n", arr[1][1]);
    printf("parr[1][1] : %d \n", parr[1][1]);

    return 0;
}

  그런데 컴파일 시에 아래와 같은 경고가 기분을 나쁘게 하네요.

  warning C4047: '=' : 'int **'의 간접 참조 수준이 'int (*)[3]'과(와) 다릅니다.

  아무튼, 무시하고 실행해봅시다.

  헉! 예전에 보았던 친근한 오류가 뜹니다. 무슨 뜻일까요? 예전에 배열에 대해 공부하였을 때(11-1 강) '초기화 되지 않은 값' 에 대해서 이야기한 적이 있었을 것입니다. 이 때, int arr[3]; 이라 했는데 arr[10]= 2; 와 같이 허가되지 않은 공간에 접근하기만 해도 위와 같은 오류가 발생한다고 했습니다.
 
  위 예제의 경우도 마찬가지 입니다. parr[1][1] 에서 이상한 메모리 공간의 값에 접근하였기에 발생한 일이지요. 그렇다면 왜? 왜? 이상한 공간에 접근하였을까요?

  먼저, 일차원 배열에서 배열의 형과, 시작 주소값을 안다고 칠 때, n 번째 원소의 시작 주소값을 알아내는 공식을 생각해봅시다. 만일 이 배열의 형을 int 로 가정하고, 시작 주소를 x 라고 할 때, (참고적으로 다 아시겠지만 int 는 4 바이트[각주:1]) n 번째 원소에 접근한다면


  와 같이 나타낼 수 있습니다. 왜냐구요? 아마, 여러분들이 스스로 생각해보세요 :)

  이번에는 이차원 배열을 나타내봅시다. 이 이차원 배열이 int arr[a][b]; 라고 선언되었다면 (여기서 a 와 b 는 당연히 정수겠죠) 아래와 같이 2차원 평면에 놓여 있다고 생각할 수 있습니다.
 
참고적으로 행은 '가로' 이고, 열은 '세로' 입니다.

  메모리는 선형(1차원) 이므로 절대로 위와 같이 배열될 일은 없겠지요. 위 이차원 배열을 메모리에 나타내기 위해서는 각 행 부터 읽어주면 됩니다. 즉, 위 배열은 아래와 같이 메모리에 배열됩니다.

사실 위에서도 비슷한 그림이 나오지만 또 그린 이유는 머리에 완전히 박아 두라는 의미 입니다.
  즉, arr[0][0] 부터 arr[0][1] .... arr[a-1][b-1] 순으로 저장되게 되지요. 그렇다면 위 배열의 시작주소를 x 라 하고, int 형 배열이고, arr[c][d] 라는 원소에 접근한다고 칩시다. 그렇다면 이 원소의 주소값은 어떻게 계산될까요?

  일단, 위 원소는 (c+1) 번째 행의 (d+1) 번째 열에 위치해 있다고 생각할 수 있습니다. (예를 들어서 arr[0][2] 는 1 번째 행의 3 번째 열에 위치해 있다) 그러면, 먼저 (c+1) 행의 시작 주소를 계산해봅시다. 간단히 생각해보아도 x + c * b * 4 라는 사실을 알 수 있습니다. 왜냐하면 4 를 곱해준 것은 int 이기 때문이고, (c+1) 행 앞에 c 개의 행들이 있는데, 각 행들의 길이가 b 이기 때문이죠.
 
  그리고 이 원소가 (d+1) 번째에 있다는 사실을 적용하면 ((c+1) 행 시작주소) + d * 4 라고 계산될 수 있습니다. 결과적으로


  가 됩니다. 참고적으로 이야기 하자면, 수학에서 곱하기 기호가 매우 자주 등장하므로 생각하는 경향이 있는데, 저도 매번 곱하기 기호를 쓰기 불편하므로 생략하도록 하겠습니다. 위 식은 아래의 식과 동일합니다.


  주목할 점은 식에 b 가 들어간다는 것입니다. (1 차원 배열에서는 배열의 크기에 관한 정보가 없어도 배열의 원소에 접근할 수 있었는데 말이죠) 다시 말해, 처음 배열 arr[a][b] 를 정의했을 때의 b 가 원소의 주소값을 계산하기 위해 필요하다는 것입니다. 우리는 이전의 예제에서 int** 로 배열의 이름을 나타낼 수 있다고 생각하였습니다. 하지만 이렇게 선언된 parr 으로 컴퓨터가 parr[1][1] 원소를 참조하려고 하면 컴퓨터는 b 값을 알 수 없기 때문에 제대로된 연산을 수행할 수 없게됩니다.
  따라서, 이차원 배열을 가리키는 포인터는 반드시 b 값에 대한 정보를 포함하고 있어야 합니다.

  결론적으로 포인터 형을 결정하는 것은 다음 두 가지로 요약할 수 있습니다.
  1. 가리키는 것에 대한 정보 (예를 들어, int* 이면 int 를 가리킨다, char** 이면 char* 을 가리킨다 등등)
  2. 1 증가시 커지는 크기 (2 차원 배열에서는 b * (형의 크기) 를 의미한다)
  여기서 1 증가시 커지는 크기가 2 차원 배열에서는 b * (형의 크기) 를 의미하는지 궁금한 사람들이 있을 것입니다. 한 번 해봅시다.

/* 1 증가하면 ? */
#include <stdio.h>
int main()
{
    int arr[2][3]={{1,2,3},{4,5,6}};
   
    printf("arr : %x , arr + 1 : %x \n", arr, arr+1);

    return 0;
}

  성공적으로 컴파일 한다면

 
  16 진수의 연산과 친숙하지 않더라도 0x2CF7D8 - 0x2CF7CC (참고로 예전에도 이야기 했듯이 제 강좌에서 16 진수로 나타내었다는 사실을 명시하기 위해 앞에 0x 를 붙인다고 했습니다.) 를 계산해 보면 0xC 가 나옵니다. 0xC , 즉 십진수로 12 입니다. 근데, 위 배열의 b 값은 3 이고 int 의 크기는 4 바이트 이므로, 3 * 4 = 12 가 딱 맞게 되는 것이지요.

  왜 그럴까요? 사실, 그 이유는 단순합니다. 거의 맨 위의 그림을 보면 이차원 배열에서 a 가 a[0] 을 가리키고 있는 그림을 볼 수 있습니다. 만일 1 차원 배열 b[3] 이 있을 때 b + 1 을 하면 b[1] 을 가리키잖아요? 2 차원 배열도 동일하게 a 가 1 증가하면 a[1] 을 가리키게 됩니다. 다시말해 두 번째 행의 시작 주소값을 가리키는 포인터[각주:2]를 가리키게 된다는 것이지요.

/* 드디어! 배우는 배열의 포인터 */
#include <stdio.h>
int main()
{
    int arr[2][3]={{1,2,3},{4,5,6}};
    int (*parr)[3];  // 괄호를 꼭 붙이세요

    parr = arr; // parr 이 arr 을 가리키게 한다.

    printf("parr[1][2] : %d , arr[1][2] : %d \n", parr[1][2], arr[1][2]);

    return 0;
}

  성공적으로 컴파일 한다면


    드디어, 2 차원 배열을 가리키는 포인터에 대해 이야기 하겠습니다. 1 차원 배열을 가리키는 포인터는 간단합니다. (아마 아시겠죠?) 그런데, 2 차원 배열을 가리키는 포인터는 배열의 크기에 관한 정보가 있어야 한다고 했습니다. 2 차원 배열을 가리키는 포인터는 아래와 같이 써주면 됩니다.

  (배열의 형) ( *(포인터 이름) )[2 차원 배열의 열 개수] ;

  이렇게 포인터를 정의하였을 때 앞서 이야기한 포인터의 조건을 잘 만족하는지 보도록 합시다. 일단, (배열의 형) 을 통해서 원소의 크기에 대한 정보를 알 수 있습니다. 즉, 가리키는 것에 대한 정보를 알 수 있게 됩니다. (조건 1 만족). 또한, [2 차원 배열의 열 개수] 를 통해서 1 증가시 커지는 크기도 알게 됩니다. 바로 (배열의 형 크기 - 예를 들어 int 는 4, char 은 1) * (2 차원 배열의 열 개수) 만큼 커지게 됩니다.

    int (*parr)[3]; 

  위와 같이 정의한 포인터 parr 을 해석해 보면, int 형 이차원 배열을 가리키는데, 그 배열의 열의 개수가 3 개 이군요! 라는 사실을 알 수 있습니다 (정확히 말하면, int* 를 가리키는데, 1 증가시 3 이 커진다 라는 의미입니다) .

  주의할 점은 괄호로 꼭 묶어주어야 한다는 것입니다. 만일 괄호로 묶지 않는다면 다른 결과가 나오니 주의하세요.

/* 배열 포인터 */
#include <stdio.h>
int main()
{
    int arr[2][3]={{1,2,3},{4,5,6}};
    int brr[10][3];
    int crr[2][5];

    int (*parr)[3];

    parr = arr; // O.K
    parr = brr; // O.K
    parr = crr; // 오류!!!!

    return 0;
}

  앞서, 2 차원 배열에서 원소의 주소값을 계산하는 식을 기억하시는지요? 다시, 불러오면


  여기서 보아야 할 점은 a 의 값이 필요 없다는 것입니다. 다시말해, b 값만 알고 있다면 a 값과 무관하게 원소에 접근할 수 있다는 것이지요. 위 예제에서도 보시다 싶이, parr 이 arr[2][3] 과 brr[10][3] 을 가리킬 수 있습니다. 왜냐하면 b 값, 즉 열의 개수가 동일한 이차원 배열이기 때문이죠. 반면에 carr 은 arr 과 b 값, 즉 3 과 5 가 다르기 때문에 parr 에 crr 을 대입하면 오류가 생깁니다. 만일 crr 을 parr 에 대입했다고 쳐도, 컴퓨터는 parr 을 이용하여 carr 을 참조할 때, 열의 개수가 3 인 배열로 생각하기 때문에 이상한 결과가 나타납니다.

  포인터 배열 

  포인터 배열, 말그대로 '포인터들의 배열' 입니다. 위에서 설명한 배열 포인터는 '배열을 가리키는 포인터' 였죠. 두 용어가 상당히 헷깔리는데, 그냥 '언제나 진짜는 뒷부분' 이라고 생각하시면 됩니다. 즉, 포인터 배열은 정말로 배열이고, 배열 포인터는 정말로 포인터 였죠.

/* 포인터 배열 */
#include <stdio.h>
int main()
{
    int *arr[3];
    int a = 1, b = 2, c = 3;
    arr[0] = &a;
    arr[1] = &b;
    arr[2] = &c;

    printf("a : %d, *arr[0] : %d \n", a, *arr[0]);
    printf("b : %d, *arr[1] : %d \n", b, *arr[1]);
    printf("b : %d, *arr[2] : %d \n", c, *arr[2]);

    printf("&a : %d, arr[0] : %d \n", &a, arr[0]);
    return 0;
}

  성공적으로 컴파일 한다면
 
마지막 출력결과는 여러분과 상이할 수 있으나 두 값이 같음을 주목하세요

  일단, arr 배열의 정의 부분을 봐봅시다.

    int *arr[3];

  위 정의가 마음에 와닿나요? 사실, 저는 처음에 배울 때 별로 와닿지 않았습니다. 사실, 이전에도 말했듯이 위 정의는 아래의 정의와 동일합니다.

    int* arr[3];

  이제, 이해가 되시는지요? 우리가 배열의 형을 int, char 등등으로 하듯이, 배열의 형을 역시 int* 으로도 할 수 있습니다. 다시말해, 배열의 각각의 원소는 'int 를 가리키는 포인터' 형으로 선언된 것입니다. 따라서, int 배열에서 각각의 원소를 int 형 변수로 취급했던 것처럼 int* 배열에서 각각의 원소를 포인터로 취급할 수 있습니다. 마치, 아래처럼 말이지요.

    arr[0] = &a;
    arr[1] = &b;
    arr[2] = &c;

  각각의 원소는 각각 int 형 변수 a,b,c 를 가리키게 됩니다. 이를 그림으로 표현하면 아래와 같습니다.


  arr[0] 에는 변수 a 의 주소가, arr[1] 에는 변수 b 의 주소, arr[2] 에는 변수 c 의 주소가 각각 들어가게 됩니다. 이는 마지막 printf 문장에서도 출력된 결과로 확인 할 수 있습니다.

  사실, 포인터 배열에 관한 내용은 짧게 끝냈습니다. 하지만, C 언어에서 상당히 중요하게 다루어지는 개념입니다. 아직 여러분이 그 부분에 대해 이야기할 단계가 되지 않았다고 보아, 기본적인 개념만 알려 드린 것입니다. 꼭 잊지 마시길 바랍니다.

  자. 이제 배열을 향한 대장정이 끝이 났습니다. 여기까지 부담없이 이해하셨다면 여러분은 C 언어의 성지를 넘게 된 것입니다! 사실, 여러분은 이 포인터를 무려 3 강의를 연달아 들으면서 '도대체 이걸 왜하냐?' 라는 생각이 머리속에 끝없이 멤돌았을 것입니다. 물론, 앞에서도 이야기 했지만 포인터는 다음 단계에서 배울 내용에 필수적인 존재입니다. 사실, 지금은 아무짝에도 쓸모 없는 것 같지만...

  여기까지 스크롤을 내리면서도 마음 한 구석에 응어리가 있는 분들은 과감하게 포인터 강좌를 처음부터 읽어 보세요. 저의 경우 포인터만 책 수십권을 찾아보고 인터넷에서 수십개의 자료를 찾아가며 익혔습니다. 그래도 궁금한 내용들은 꼬오옥 댓글을 달아주세요. 저는 정말 아무리 이상하고 괴상한 질문도 환영하니.. 꼭 궁금한 내용을 물어봐주세요 :)