C 프로그래밍 입문/포인터

위키책, 위키책

포인터[+/-]

포인터(pointer)는 C가 갖는 가장 강력한 힘이자 C를 배우고자 하는 사람들이 넘어야 하는 가장 높은 벽 이기도 하다. 대부분의 C 책들을 들여다 보면 전체 책의 2/3가량이 포인터에 관한 설명으로 이루어져 있는 것을 쉽게 볼 수 있다. 그만큼 활용도가 높고 강력하며 이해하기 까지 시간이 드는 개념이기도 하다.

포인터에 대해 설명하기 전에, 이전에 다루었던 개념을 다시 한 번 되새겨 보도록 하자. 변수(variable)가 무엇이었는가? 변수는 메모리 공간이다. 컴퓨터에 꽃혀있는 RAM내 어딘가를 의미한다. 그리고, RAM에 있는 메모리 공간에는 일련번호가 붙어있고, 모든 변수는 일련번호와 크기가 정해져 있다. 단지 일련번호를 외워가며 프로그램을 작성할 수 없으니 일련번호와 이름을 비교하는 비교표를 컴파일러가 작성하고, 프로그래머는 그 비교표에 있는 이름을 외워서 프로그램을 작성하면 컴파일러가 나중에 이름을 일련번호로 변환하는 작업을 해준다.

포인터 역시 위에서 재언급한 변수의 한 종류이다. 다른 변수와의 차이점 이라면 위에서 말한 '메모리 공간의 일련번호'를 저장할 수 있는 변수이다. 정수형 변수에는 정수값이, 부동소수형 변수에는 부동소수 값이, 그리고 포인터형 변수에는 '메모리 공간의 일련번호'가 저장된다. 조금 헛갈린다면 '포인터 == 메모리 공간의 일련번호'라고 이해해도 된다.

계속해서 '메모리 공간의 일련번호' 라는 용어를 쓰기엔 어려우니, 앞으로는 일반적으로 사용되는 표현을 사용하도록 하겠다. 바로 주소(address)다..

그럼 메모리 공간의 일련번호 -- 주소 따위를 저장해서 무엇에 쓰느냐 라는 문제가 대두된다. 컴파일러가 알아서 이름을 주소로 바꾸어 주는데 굳이 주소를 다루어야 할 필요가 있겠는가? 답은 보나마나 '물론'이다. 처음에 C 라는 언어는 운영체제를 만들기 위해 만들어진 언어이며 하드웨어를 다루는데 가장 유리한 언어라고 했던 것을 기억하고 있길 바란다. 포인터가 바로 그 '하드웨어를 다루는' 힘이다.

기본 포인터[+/-]

'변수 = 메모리 공간'이고 '포인터 = 메모리 공간의 주소를 저장하는 변수'이었으니, 결국 포인터는 '변수의 위치를 저장하는 변수'라는 의미가 된다. 그럼 아래 코드를 잠시 보자:

#include <stdio.h>
int main (int argc, char * argv[])
{
    int var = 10;
    int *vp = &var;

    printf("The value of pointer: %x\n", vp);
    printf("The value before: %d, %d\n", var, *vp);
    var = 20;
    printf("The value after: %d, %d\n", var, *vp);
    *vp = 30;
    printf("The value then: %d, %d\n", var, *vp);
    return 0;
}

5번 라인에서 vp라는 변수를 선언하면서 변수의 이름 앞에 * (별표; asterisk)가 붙어있는 걸 볼 수 있다. 실상 이 별표는 타입의 일부로 'int *' 가 하나의 타입이 된다. 'int *'의 의미는 '정수값을 저장하는 메모리 공간의 주소를 저장할 수 있는 변수'이다. 다음과 같은 이유에 의해 '주소가 저장되는 변수'임에도 불구하고 일반적인 변수의 타입이 추가로 붙는다.

  1. 변수에 타입이 있는 이유는 변수에 저장되어있는 데이터를 처리할 방법을 컴파일러에게 알려주기 위함이다.
  2. 포인터 변수에 주소를 넣는 이유는 그 주소 자체가 필요 해서가 아니라 주소에 해당되는 메모리 공간에 저장되어있는 데이터가 필요해서 이다.
  3. 결국 포인터 변수에 타입이 존재하는 이유는 포인터 변수에 저장되어있는 주소위치에 있는 데이터를 처리할 방법을 컴파일러에게 알려주기 위함이다.

또한 5번 라인에서 vp에 값을 대입할 때 & 연산자를 사용하는 것을 볼 수 있다. &는 레퍼런스 연산자(reference operator)라 부르며, 변수의 주소를 알아내는데 사용된다. 결국 5번라인은 '정수형 포인터 vp를 만들고, var의 주소를 알아내서 vp에 저장한다' 라는 의미가 된다.

반복해서 설명 되었듯이 포인터는 메모리 주소 - 메모리 공간의 일련번호 이다. 결국 vp에 저장되는 값은 실제로는 정수값 이다. 그렇기 때문에 7번라인에 보는 것 과 같이 그 값을 인쇄해 보면 정수값으로 인쇄된다.

8, 10, 12번 라인에 걸쳐서 변수 vp 앞에 * 연산자가 붙어있는 것을 볼 수 있다.[1] * 연산자는 포인터 연산자라고 하며, 포인터변수에 저장되어있는 메모리 주소에 해당되는 메모리 공간에 저장되어있는 값을 액세스 하려는 경우에 사용된다. 변수 vp에는 변수 var의 주소가 저장되어 있으므로 결국 *vp를 통해 액세스되는 데이터는 var를 통해 액세스 되는 데이터와 동일하다. 9번과 11번 에서보면 var를 수정해서 보여지는 결과난 *vp를 통해서 수정해서 보여지는 결과나 같은 것을 보면 유추가 가능할 것 이다.

배열 포인터[+/-]
구조체 포인터[+/-]
함수 포인터[+/-]

종종 우리는 그 자체가 포인터인 인수로 함수를 호출해야한다. 많은 경우에 변수 자체는 현재 함수에 대한 매개 변수이며 어떤 유형의 구조에 대한 포인터일 수 있다. 변수자체가 포인터인 경우, 포인터 값을 얻기 위해 앰퍼샌드 문자가 필요하지 않다. 아래의 예에서, 포인터인 변수 pStruct는 FunctTwo 함수의 매개변수이며 FunctOne에 인수로 전달된다.

FunctOne 함수의 두 번째 매개 변수는 int이다. 함수 FunctTwo에서 mValue는 int에 대한 포인터이므로, 포인터는 먼저 *연산자를 사용하여 참조를 해제해야 하며, 호출에서 두 번째 인수는 *mValue이다. FunctOne 함수의 세 번째 매개 변수는 long이다. pAA가 그자체로 long 포인터이기 때문에, 함수에서 세 번째 인수로 사용될때 &연산자가 필요하지 않다.

int FunctOne(struct someStruct *pValue, int iValue, long *lValue)
{
    /*  do some stuff ... */
    return 0;
}

int FunctTwo(struct someStruct *pStruct, int *mValue)
{
    int j;
    long  AnArray[25];
    long *pAA;
     
    pAA = &AnArray[13];
    j = FunctOne( pStruct, *mValue, pAA ); /* pStruct already holds the address that the pointer will point to; there is no need to get the address of anything.*/
    return j;
}

C 는 또한 함수에 대한 포인터를 만들수 있도록 허용한다. 함수 포인터는 다소 복잡하다. 예시로, 다음의 함수를 살펴보자:

static int Z = 0;

int *pointer_to_Z(int x){
    /* function returning integer pointer, not pointer to function */
    return &Z;
}
int get_Z(int x){
    return Z;
}

int (*function_pointer_to_Z)(int); //pointer to function taking an int as argument and returning an int
function_pointer_to_Z = &get_Z;

printf("pointer_to_Z output: %d\n", *pointer_to_Z(3));
printf("function_pointer_to_Z output: %d", (*function_pointer_to_Z)(3));

함수 포인터에 typedef를 선언하면 일반적으로 코드가 명확해진다. 다음은 함수 포인터와 * 포인터를 사용하여 콜백으로 알려진 것을 실행하는 예이다. DoSomethingNice 함수는 caller 데이터와 함께 caller 제공 함수 TalkJive를 호출한다. DoSomethingNice는 데이터포인터가 무엇을 참조하는지 전혀 알지 못한다.

typedef  int (*MyFunctionType)( int, void *);      /* a typedef for a function pointer */

#define THE_BIGGEST 100

int DoSomethingNice( int aVariable, MyFunctionType aFunction, void *dataPointer )
{
    int rv = 0;
    if (aVariable < THE_BIGGEST) {
       /* invoke function through function pointer (old style) */
       rv = (*aFunction)(aVariable, dataPointer );
     } else {
         /* invoke function through function pointer (new style) */
       rv = aFunction(aVariable, dataPointer );
    };
    return rv;
}

typedef struct {
    int    colorSpec;
    char   *phrase;
} DataINeed;
 
int TalkJive( int myNumber, void *someStuff )
{
    /* recast void * to pointer type specifically needed for this function */
    DataINeed *myData = someStuff;
    /* talk jive. */
    return 5;
}
 
static DataINeed sillyStuff = { BLUE, "Whatcha talkin 'bout Willis?" };
  
DoSomethingNice( 41, &TalkJive,  &sillyStuff );

일부 C의 버전에서는 DoSomethingNice의 TalkJive argument 앞에 &연산자가 필요하지 않을 수 있다. 비록, 함수의 signature가 tpyedef의 signature와 정확히 일치하더라도, 일부 구현에서 MyFunctionType에 대한 인수를 casting 하는 것을 요구할지도 모른다.

함수 포인터는 C에서 다형성을 구현하는데 유용하다. 첫번째는 다형성을 위해서 요소로써 함수 포인터를 갖는 구조체를 정의한다. 두번째로 이전 구조체에 대한 포인터를 포함하는 base object구조체가 또한 정의된다. 클래스는 클래스에 특정한 데이터로 두 번째 구조를 확장함으로써 정의된다. 그리고 클래스와 연관된 함수의 주소를 포함하는 첫 번째 구조 유형의 정적 변수에 의해 정의된다.

이러한 유형의 다형성은 파일 I/O 함수를 호출할 때 표준 라이브러리에서 사용된다. 또한 유사한 메커니즘이 C에서 상태기계를 구현하기 위해 사용될 수 있다. 상태 내에서 발생할 수 있는 이벤트를 처리하기 위한 함수 포인터를 포함하는 구조가 정의되며, 상태 진입 및 종료 시 함수를 호출할 수 있다. 이러한 구조의 인스턴스는 상태에 해당한다. 각 상태는 상태에 적합한 기능에 대해 포인터로 초기화 된다. 상태 시스템의 현재 상태는 실제로 이러한 상태 중 하나에 대한 포인터이다. 현재 상태 포인터의 값을 변경하면 현재 상태가 효과적으로 변경된다. 일부 이벤트가 발생하면 현재 상태의 함수 포인터를 통해 해당 함수를 호출한다.

확장된 포인터 개념[+/-]
특별한 데이터 타입[+/-]
  • 자기참조 공용체
union toto {
	union toto* a;
	unsigned int b;
};

[2]

주석 및 참고자료[+/-]

  1. 이것은 비록 같은 문자를 사용하지만 5번 라인에서 사용한 타입 한정자인 * 와는 전혀 다릅니다. 당연히 곱셈 연산자도 아닙니다 :).
  2. https://stackoverflow.com/questions/8112085/can-a-union-be-self-referenced-in-c