관리 메뉴

Kim's Programming

C언어 - 포인터(Pointer)(1/3) 본문

Programming/C

C언어 - 포인터(Pointer)(1/3)

Programmer. 2015. 8. 16. 16:46

포인터 연산


포인터란 메모리의 한 지점, 간단히 말해서 번지값을 가지는 변수 입니다. register형 변수를 제외한 어떤 형태의 변수든 반드시 메모리에 보관되며 모든 메모리는 번지를 가지고 있습니다. 따라서 이 변수의 번지를 가리키는 포인터 변수를 항상 선언 할 수 있습니다. 한 문장으로 만들면 다음과 같습니다.

임의의 타입 A가 있을 때 A형의 포인터 변수를 선언할 수 있다.

int, char, double 등의 기본적인 데이터 타입에 대해 int *, char *, double *형의 변수를 선언할 수 있음은 물론이고 구조체, 공용체, 배열에 대해서도 포인터형을 만들 수 있습니다. 사용자가 직접 만든 타입(typedef)에 대해서도 포인터형 변수를 선언할 수 있으며 심지어는 포인터 타입에 대한 포인터 또한 선언 할 수 있습니다. 다음 각 * 기호는 프로그램 요소 7가지 중 어떤 것인지 알아보겠습니다.
1
2
3
i=3*5;
printf("%d",*pi);
int *pi;
cs
첫번 째 *은 다 알다시피 곱하기 연산자 이며 3과 5를 곱하는 역할을 합니다. 두 번째 *은 포인터 변수가 가리키는 메모리 번지의 내용을 읽어내는 포인터 연산자 입니다. 포인터 연산자와 포인터 연산자는 모양은 같지만 취하는 피연산자의 갯수가 다르기 떄문에 비교적 쉽게 구분이 가능합니다. 곱하기 연산자는 이항 연산자이며 포인터 연산자는 단항 연산자 인데 C언어에는 이러한 모양은 같지만 피연산자 개수가 다른 연산자가 몇개 있습니다. 세 번째의 *의 경우는 곱하기 연산자도 포인터 연산자도 아닙니다. 이 *기호는 포인터를 선언할 때 사용하는 구두점입니다. A 형 변수는 다음과 같이 선언합니다.
1
A variable;
cs
타입 이름 A 다음 원하는 변수명을 적으면 이 변수는 A형을 가지는 변수가 되는데 가장 기본적으로 알 수 있는 것이 int i; 입니다. 이 선언 문의 변수 명 앞에 * 구두점을 찍붙여서 A *variable 이라고 선언을 해주면 A형 포인터 변수가 되게됩니다. A형 변수를 선언할 수 있으면 A *형은 항상 선언이 가능하므로 A가 어떤 타입이든지 상관없이 변수 이름 앞에 *구두점만 붙이며 A형 포인터 변수를 선언할 수 있습니다. * 구두점은 다음 두가지 형식으로 표기가 가능합니다.(띄어 쓰기에 주목하세요.)
1
2
int *pi
int* pi
cs
타입명 int 다음에 한 칸을 띄우고 *와 변수명을 쓸 수도 있으며 int 다음에 바로 붙여서 *을 쓰고 한칸 띄운 후 변수명을 쓸 수도 있습니다. C언어는 프리포맷을 지원하기때문에 공백이 어디에 있는지는 중요하게 생각되지 않습니다. 그래서 int *pi도 int* pi도 int * pi도 되지만 구분을 위해 주로 위에 있는 2형태를 많이 쓰게됩니다. 특별히 2개의 차이를 구분하자면 int*를 하나의 타입으로 볼 때는 2번쨰를 변수가 포인터임을 강조할 때는 1번째를 사용하여 포인터를 선언합니다. 지금 볼 떄는 일반 변수와 비슷해 보이는데 선언 하는 방법도 한번 보겠습니다.
1
2
3
int *i,j;        //i는 포인터, j는 정수형
int* i,j;        //i는 포인터, j는 정수형
int *i,*j;        //i와 j 모두 포인터
cs

1번 2번 째 줄은 i를 정수형 포인터로 , j를 정수형 변수로 선언 되는데 *의 위치가 변수에 붙던 타입명에 붙던간에 일반 변수 선언처럼 한번에 다 포인터형으로 인정해 주는 것은 아닙니다.


포인터의 타입


포인터가 가르키는 번지에 들어있는 값, 즉 포인터가 가르키는 실체를 대상체(object)라고 합니다. 예를 들어 정수형 포인터의 대상체는 정수형 변수이며 실수형 포인터의 대상체는 실수형 변수입니다. *연산자로 포인터가 가리키는 곳을 읽으면 포인터의 대상체의 값이 읽혀집니다. 포인터 변수를 선언할 때는 가리키고자 하는 대상체의 타입을 바드시 명시해야 합니다. 대상체의 타입을 포인터의 타입이라고 합니다. 그래서 포인터형 변수의 타입은 대상체의 타입을 따라 ~에대한 포인터형(pointer to ~)이라고 표현을 합니다. 예를 들어 정수형 포인터 pi를 선언 했다고 생각해 보겠습니다.

1
int *pi;
cs

pi 변수는 포인터 형이지만 정확한 명칭은 "정수에 대한 포인터형"이라고 하는 것이 더 정확합니다. int *라는 타입이 정수형 변수를 가리키는 포인터형이라는 뜻입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<cstdio>
 
void main()
{
    int i = 1234;
    int *pi;
    double d = 3.14;
    double *pd;
 
    pi = &i;
    pd = &d;
    printf("정수형 = %d\n"*pi);
    printf("실수형 = %f\n"*pd);
 
    pi = (int *)&d;
    printf("pi로 읽은 d번지의 값 = %d\n", pi);
}
cs

다음 소스의 결과는 다음과 같습니다.

정수형 포인터 pi는 정수형 변수 i의 번지를 가리키며 실수형 포인터 pd는 실수형 변수 d의 번지를 가리키고 있습니다. 이 상태에서 *pi, *pd로 이 포인터들이 가리키는 곳의 대상체를 읽어서 화면으로 출력했습니다. 정수형 포인터로 대상체를 읽으면 정수가 제대로 읽혀지고 실수형 포인터로 실수를 읽어도 제대로 읽혀집니다.


*연산자는 포인터가 가리키고 있는 곳의 대상체를 읽는 연산자입니다. 이 연산자가 제대로 값을 읽기 위해서는 대상의 타입을 정확하게 알고 있어야 가능합니다. pi가 정수형 변수를 가리키는 포인터 변수이므로 *pi연산문은 pi가 가리키는 번지에서부터 4바이트를 읽어냅니다. 그리고 이 대상체가 부호있는 정수형이라는 것을 알고 있으므로 제일 앞쪽 비트(MSB)를 부호 비트로 해석하고 나머지 비트는 절대값으로 평가 할 것입니다. 이에 비해서 *pd 연산문은 pd 번지로부터 8바이트를 읽은 후 이 값을 부호, 가수, 지수로 분리한후 정확한 실수 값을 얻게됩니다. pi나 pd나 똑같이 메모리의 한 지점을 가리키는 포인터형 변수이지만 선언할 때 대상체의 타입을 명시했기 떄문에 *연산자가 이 포인터들로부터 읽는 값이 달라질 수 있습니다. 만약pi, pd가 어떤 대상체를 가리키고 있는지 모른다면 *연산자는 이 포인터들이 가리키는 번지에 들어있는 값의 길이와 비트 해석 방법을 알지 못할 것입니다. 메모리의 위치만 가지고는 정보가 부족하기 떄문에 대상체를 제대로 읽을 수 없습니다.

 

위의 소스에서는 정수형 포인터 변수 pi에 실수형 변수 d의 번지를 대입하여 이 값을 읽어 보았습니다. 포인터의 타입과 대상체의 타입이 맞지 않기 떄문에 &d, 즉 d의 변지를 pi에 대입하려면 반드시 이 번지값을 (int *)로 캐스팅 해야합니다. pi=&d;연산문으로 바로 대입하면 타입이 맞지 않아서 대입할 수 없다는 에러로 처리됩니다. C++에서는 C보다는 덜 엄격하게 판단하기 떄문에 에러로 처리합니다. pi가 번지값을 가리키는 포인터이고 &pd도 번지값이므로 캐스팅만 하면 강제로 대입할 수는 있습니다. 이 상태에서 *pi로 대상체의 값을 읽어 보면 엉뚱한 값이 출력 됩니다. pi가 가리키는 메모리에는 8바이트 길이의 3.14라는 실수값이 들어 있지만 *연산자는 pi가 정수형 포인터이므로 이 번지에서 4바이트만 취해 정수 값을 읽기 떄문이다. 읽어야 할 값의 길이도 맞지 않지만 정수와 실수의 비트 패턴이 다르므로 3.14도 아니고 3도 아닌 완전히 엉뚱한 값이 읽혀지게 됩니다.

 

변수가 저장되는 장소인 메모리에는 정수형, 실수형 따위의 별스러운 표식이 붙어있는 것이 아닙니다. 모든 메모리는 8비트로 구성되어 있고 여덟자리의 이진수를 기억할 수 있다는 점에서 동질적입니다. 그래서 모든 메모리 위치를 가리키는 포인터는 자신이 가리키고 있는 번지에 저장된 값이 어떤 종류인지를 기억하고 있어야 하며 이런 이유로 포인터를 선언할 때 대상체의 타입을 밝혀야 합니다. 마치 변수가 타입을 가져야 하는 이유와 같은 의미와 비슷합니다. 포인터가 대상체의 타입을 요구하는 두 번쨰 이유는 인접한 다른 대상체로 이동할 때 이동 거리를 알기 위해서 입니다. 이동 거리란 곧 대상체의 크기에 대한 정보를 의미합니다. 번지를 가르키는 포인터로 일종의 변수이므로 실행 중 다른 번지를 가르키도록 변경이 가능합니다. 이때는 보통 증감 연산자를 사용하는데 현재 위치에서 앞뒤로 이동함으로써 인접한 대상체로 인동합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<cstdio>
 
void main()
{
    int ar[] = { 211233124435 };
    int *pi;
 
    pi = ar;
    printf("------------->  %d\n", pi);
    printf("첫 번째 요소 :  %d\n"*pi);
    pi++;
    printf("------------->  %d\n", pi);
    printf("두 번째 요소 :  %d\n"*pi);
    pi++;
    printf("------------->  %d\n", pi);
    printf("세 번째 요소 :  %d\n"*pi);
}
cs

 

 결과는 다음과 같습니다.

 

크기 5의 ar 배열을 정의하고 pi=ar 대입문으로 pi가 ar 배열의 선두 번지를 가리키도록 했습니다. 배열명 자체는 배열의 시작번지를 가리키는 포인터 상수이므로 pi가 이 포인터 상수를 대입받을 수 있습니다. pi = &ar이 아니며 이렇게 대입해봐야 대입되지 않음을 주의하도록 해야합니다. pi가 ar배열의 시작번지 ar[0]의 번지를 가리키고 있는 황입니다. 이 상태에서 *pi를 읽으면 pi위치에서 4바이트를 읽을 것이며 이 것을 출력하면 첫 번째 요소인 21값을 읽게 됩니다. 그리고 pi++ 연산문으로 pi값을 증가시켜서 ar 배열의 다음 요소로 이동했으며 이 상태에서 *pi를 읽으면 ar[1]의 값이 출력되고 또 pi값을 증가시키면 ar[2]값이 출력되게 됩니다. 또한 출력을 시켜보면 메모리 값의 이동이 pi++의 연산으로 인해 메모리가 1씩 이동하는 것이 아니라 4씩 이동하는 것을 확인 할 수 있을 겁입니다. 하지만 상식적으로 727372라고 해당된 메모리번지에서(출력 결과 사진으로)1을 더하면 727373이 되야 하는게 당연한거라고 생각을 할 수 있습니다. 하지만 727273은 의미가 없으며 두 요소 사이에 있는 애매한 번지일 뿐입니다. 이런 애매한 번지의 값을 읽어서는 ar[1]의 값을 구할 수 없음을 물론이고 이것도 저것도 아닌 이상한 값이 읽혀질 것입니다. 그래서 C는 포인터에 대한 증감 연산을 산술 연산과는 달리 아주 특수하게 수행합니다.

 

A형 포인터 변수에 px에 정수 i를 더하면 px = px + (i*sizeof(T))가 됩니다.

-> 즉 하나를 증가 시키면 배열 요소 하나 크기만큼 포인터 주소가 이동하게됩니다.

 

pi의 경우 정수형 포인터 변수이므로 pi++은 sizeof(int)만큼인 4바이트 증가하게 될 것입니다. pi가 1000인 상태에서 pi++연산문은 pi를 1004로 만들며 이 상태에서 *pi를 읽으면 ar[1]의 값을 구할 수 있습니다. 이 렇게 되어야 배열을 가리키는 포인터가 증감시켜서 배열의 다른 요소들에 자유롭게 이동할 수 있을 것입니다. 컴파일러가 포인터 연산에 대한 대상체의 크기만큼 앞 뒤로 이동시킬 수 있으려면 이 포인터가  가리키는 대상체의 타입이 무엇인가를 알아야합니다. 그래서 포인터 변수를 선어할 때 가리킬 대상의 타입을 명시하는 것입니다. 포인터 형  변수의 타입은 포인터가 가리키는 대상체에 대한 타입을 명시하여 "정수형 변수에 대한 포인터"식으로 표현해야 하나 간단히  줄여서 "정수형 포인터"라고 칭합니다.

 

포인터의 연산

 

포인터 연산이란 피연산자 중의 하나가 포인터인 연산입니다. pi++과 같은 포인터형 변수에 대한 연산, pi1-pi2같이 포인터끼리의 연산이나 ar-pi,pi+3들과 같이 포인터 변수나 포인터 상수가 피연산자 중에 하나라도 있으면 이런 연산을 포인터 연산이라고 합니다. 포인터라는 타입이 정수나 실수 같은 수치들과는 다른 독특한 타입이기 때문에 포인터 연산도 일반적인 산술연산과는 다른 규칙이 적용됩니다. 

 

    1. 포인터끼리는 더할 수 없다.

      덧셈은 가장 기본적인 연산이긴 하지만 포인터끼리의 덧셈은 허용되지 않습니다. 왜냐하면 번지값 끼리 더한다는 것은 아무런 의미가 없기 떄문입니다. 변수 x,y를 포인터 변수 px, py가 각각 가리키고 있다고 하고 x는 100번지 y는 150번지에 있따고 하면 이때 포인터 끼리 더하는 px+py의 연산은 허용되지 않습니다. 번지라는 타입은 부호없는 정수형이며 따라서 px와 py를 굳이 더하고자 한다면 250으로 연산은 하겠지만 250이라는 결과값이나 250번지의 값은 아무련 상관이 없는 것이기 떄문에 이 연산을 허용하지 않습니다. 좀 이해가 가지 않으면 이런 셈과 같습니다. 10살인 사람과 20살인 사람을 더해서는 30살인 사람이 뿅하고 튀어 나오진 않습니다.

    2. 포인터끼리는 뺼 수 있다.

      포인터끼리 더한 값은 아무런 의미가 없지만 뺀 값은 두 요소간의 상대적인 거리라는 의미로 사용 할 수 있습니다. 그래서 포인터 끼리의 뺄셈은 원칙적으로 허용하며 많이 사용도 합니다.

      1

      2

      3

      4

      5

      6

      7

      8

      9

      10

      11

      #include<cstdio>

       

      void main()

      {

          char ar[] = "pointer";

          char *pi1, *pi2;

       

          pi1 = &ar[0];

          pi2 = &ar[5];

          printf("%c와 %c의 거리는 %d\n"*pi1, *pi2, pi2 - pi1);

      }

      cs

      타입이 같은 임의의 두 포니터에 대한 뺄셈이 가능합니다. 하지만 일반적으로 두 포인터가 같은 배열 내의 다른 요소에 가리키고 있을 때만 실질적인 의미가 있습니다. 문자형의 ar 배열은 "Pointer"이라는 문자열을 저장하고 있으며 pildms 첫 번째 요소인 'P'자 위치를 가리키고 있으며 pi2는 여섯 번째 요소인 'e'의 위치를 가르키고 있습니다. 그러므로 둘의 차이로 5라는 답이 나옵니다. 만약 두 포인터 사이에 몇 개의 문자가 있는지 계산하려면 pi2-pi1-1=4라는 값이 나오고 'o' 'i' 'n' 't'  4개의 글자가 사이에 있음을 알 수 있습니다. 물론 이렇게 포인터 계산으로 나온 연산결과는 더 이상 포인터가 아니고 단순한 정수 값 입니다. 두 사람의 나이차가 나이 차이일뿐 나이를 뺀다고 2살짜라 아이가 생겨나진 않습니다. 그러므로 다음과 같은 대입 연산은 되지 않습니다.

      1

      pi1=pi2-pi3;

      cs

       ptr2-ptr3은 적법한 연산이지만 이 연산의 결과인 정수를 ptr1이라는 포인터 변수에 대입할 수는 없습니다. 그래서 앞의 소스에서도 printf에 pi2-pi1에 대응하는 서식은 %p가 아니라 %d였습니다.

    3. 포인터에 정수를 더하거나 뺄 수 있다.

      포인터끼리 더할 수는 없지만 포인터와 정수를 더할 수는 있습니다. 정수 덧셈의 다른 표현인 ++, --도 당연히 가능합니다. ptr에 정수 i를 더한 ptr+i는 ptr이 가리고 있는 번지에서부터 i번째 요소의 번지를 나타내는 의미 있는 값입니다.

      ar[0]   ptr

      ar[1]   ptr+1

      ………… 

      ar[i]   ptr+i

      -> 진한색은 주소입니다.

      ptr+1은 바로 다음 요소의 번지를 가리키며 ptr+2는 다음 다음 요소의 버지를 가르킵니다. 포인터와 정수의 뺄셈도 가능한데 ptr-1은 바로 이전 요소, ptr-2는 전전 요소를 가리킵니다. 물론 이 연산도 실질적인 의미가 있을려면 ptr이 배열내의 한 지점을 가리키고 가감한 결과 같은 배열내에 속해야 합니다. 앞에서 봤듯 포인터와 정수를 더할 때 실제 포인터 값의 이동 거리는 1당 sizeof(타입)만큼 이동합니다. ptr이 정수형 포인터 일떄는 ptr+1은 ptr배열의 4바이트 뒤를 가르킵니다. 포인터에 정수를 더하거나 뺀 연산의 결과는 역시 포인터입니다. ptr+i는 ptr번지에서 i 요소만큼 뒤쪽을 가르키는 번지 값이므로 이 연산 결과도 포인터가 되게 됩니다. 포인터끼리 뺼쎔한 결과가 정수인 것과는 구분해야합니다. 따라서 다음과 같은 연산은 적법합니다.

      1
      2
      ptr1-ptr+2;
      ptr1=ptr2--;

      cs

      ptr2에 2를 더한 결과는 ptr2의 다음다음 요소를 가르키는 포인터이며 따라서 결과를 ptr1에 대입이 가능합니다.

    4. 포인터끼리 대입할 수 있습니다.

      정수형 포인터 p1, p2가 있을 떄 p1=p2 대입식으로 p2가 기억하고 있는 번지를 p1에 대입할 수 있습니다. 다만 대입식의 좌변과 우변의 포인터 타입이 일치해야 한다는 것만 주의하면 됩니다. 만약 두 포인터의 타입이 틀릴 경우는 캐스트 연산자로 강제로 맞추어야 대입이 가능합니다.

      1
      2
      3
      4
      int *point_i ,variable=1234;
      unsigned *pointer_u;
      pointer_i = &variable;
      pointer_u=(unsigned *)pi;

      cs

      pointer_i는 부호있는 정수형 포인터이고 pointer_u는 부호없는 정수형 포인터인데 pu=pi로 바로 대입하면 타입이 맞지 않으므로 에러로 처리됩니다. 꼭 대입해려면 캐스트 연산자를 사용할 수 있되 대입 후의 결과에 대해서는 개발자가 책임을 져야합니다.

    5. 포인터와 실수와의 연산은 허용되지 않는다.

      번지라는 값은 정수의 범위에서만 의미가 있기 떄문에 실수와는 어떠한 연산도 할 수 없습니다. pc+0.5라는 연산을 허용하게되면 이 번지는 도대체 어디를 가르켜야 할지 애매할 것입니다. 바이트 중간의 비트를 가르킬 수는 없는데 왜냐하면 비트는 기억의 최소단위일 뿐이지 번지를 가지는 최소 단위는 바이트 이기 떄문입니다. 포인터와 실수를 연산하면 컴파일러는 "피연산자만 쓰라고" 에러 메세지를 출력합니다.

    6. 포인터에 곱셈이나 나눗셈을 할 수 없습니다.

      포인터에 곱셈, 나눗셈을 하는 것이 불가능한 일은 아니지만 연산의 필요성이 전혀 없다고 해야 맞습니다. 번지값을 곱해서 도대체 어디에서 쓸 것인가?를 생각해보면 전혀 필요가 없습니다. 음수 부호 연산자 (-), 나머지 연산자 (%), 비트 연산자, 쉬프트 연산자 등도 모두 포인터와는 함꼐 쓸 수 없는 연산자들입니다.

    7. 포인터끼리 비교는 가능하다.

      두 포인터가 같은 번지를 가리키고 있는지를 조사하기 위해 ==, != 상등 비교 연산자를 사용할 수 있으며 산술 연산과 동일한 의미를 가집니다. 물론 이때 양쪽의 데이터 타입은 일치해야합니다. 포인터 끼리 값을 비교하는 일은 그리 흔한 일은 아니지만 포인터값의 유효성을 점검하기 위해 NULL값과 비교하는 연산은 자주 이용됩니다. 포인터형을 리턴하는 함수들은 에러가 발생했다는 표시로 NULL을 리턴하므로 이 함수들의 실행 결과를 점검하기 위해 포인터와 NULL을 비교합니다.

      1
      2
      if(ptr  == NULL)
      if(ptr != NULL)
      cs

      이런 비교 연산문은 거의 대부분 "에러가 발생했으면 ~"이라는 조건식입니다. 상등 비교 연산자뿐만 아니라 <, >, <=, >=등의 크기를 비교하는 연산자도 사용할 수 있습니다. 이떄 양쪽은 데이터 타입이 일치해야 하는 것은 물론이고 이떄 양쪽은 데이터 타입이 일치해야 하는 것은 물론이고 의미있는 비교가 되려면 같은 매열내의 포인터여야 합니다. 다음 조건문은 "ptr1이 ptr2보다 더 뒤쪽 의 요소를 가리키고 있으면"이라는 뜻입니다.

      1
      if(ptr1 > ptr2)
      cs


간단하게 3개로 나타내면 다음과 같습니다.

  1. 포인터끼리 더할 수 없습니다.
  2. 포인터끼리 뺄 수 있으며 연산 결과는 정수입니다.
  3. 포인터와 정수의 가감 연산은 가능하며 연산 결과는 포인터입니다.
포인터 연산을 하는 소스를 한번 보겠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
#include<cstdio>
 
void main()
{
    int ar[] = { 12345};
    int *p1, *p2, *p3;
 
    p1 = &ar[0];
    p2 = &ar[4];
    p3 = p1 + (p2 - p1) / 2;
 
    printf("중간의 요소 = %d\n"*p3);
}
cs

덧셈이 허용되지 않으므로 p2-p1으로 두 지점의 거리를 구하고 시작버지(base)인 p1에 거리의 절반을 더하여 중간 지점의 번지를 구했으며 구 결과는 p3에 대입합니다. 그리고 *p3를 출력하면 원하는 중간지점의 값인 3이 출력되게 됩니다.
포인터끼리는 뺄셈이 가능하므로 p2-p3은 적법한 연산문이며 뺄셈 결과는 정수가 되므로 이 값을 2로 나눌 수 있습니다. (p2-p1)/2라는 수식은 두 포인터 간의 절반 거리를 나타내는 정수 상수를 만들어냅니다. 포인터에 정수를 더할 수 있으므로 p1에 절반 거리를 더할 수 있고 그 결과는 포인터이므로 p3가 이 포인터 값을 대입받을 수 있습니다 .

*ptr++

ptr이 포인터형의 변수일 때 *ptt은 빈번히 사용되는 문장이며 C언어의 특징을 잘 표현하는 전형적인 포인터 연산문입니다. 먼저 소스 보고 가겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
#include<cstdio>
 
void main()
{
    int ar[] = { 1020304050 };
    int *ptr;
 
    ptr = ar;
    for (int i = 0; i < 5; i++)
    {
        printf("%d\n"*ptr++);
    }
}
cs

크기 5의 ar 배열이 선언되어 있고 ptr 포인터 변수가 이 배열의 선두를 가르키는 포인터 입니다. 이 상태에서 다섯 번 루프를 돌며 *ptr++ 문장의 결과를 출력하면 ar 배열 요소가 순서대로 출력되는데 이 문장의 실행 순서를 알아 보겠습니다. 연산순위표를 보면 *연산과 ++연산은 연산순위가 같고 우측 우선의 결합순서를 가지므로 ++이 실행 된 후 *가 실행되게 됩니다. 그러나 ++이 후위형으로 기술되었기 떄문에 이 경우는 ++보다 *가 먼저 연산됩니다. 후위형 증감연산자는 연산 순위와는 상관없이 평가된 후에 증가하기 떄문에 *(ptr++)과 같이 ++이 먼져 연산되도록 강제 지정해도 결과는 동일합니다. 그러하여 *ptr이 먼져 연산되어 ptr이 가리키는 번지의 정수값이 먼져 읽혀지고 다음으로 ++연산이 실행되어 ptr이 다음 번지로 이동합니다. *ptr++연산문은 *ptr과 ptr++을 하나로 합쳐놓은 것이며 다음과 같이 표현해도 같은 말이 됩니다.

1
2
3
4
5
for(int i=0;i<5;i++)
{
    printf("%d\n",*ptr);
    ptr++;
}
cs

결국 한 동작을 하지만 한 문장이 두 문장이 됨으로 반드시 블록을 싸야하는 불편함이 생깁니다. *ptr++은 하나의 문장이기 때문에 블록을 쌀 필요 없고 또한 수식내에서 곧바로 사용할 수도 있어서 소스 길이를 짧게 만들어줍니다. *ptr++을 *++ptr로 바꾸면 결과는 완전히 달라집니다. 이렇게 되면 전위형의 ++연산자가 먼저 연산되기 떄문에 ptr이 다음 번지로 이동한 후 그 번지의 내용을 읽게 됩니다. 따라서 첫 번쨰 배열 요소는 출력대상에서 제외될 뿐만 아니라 존재하지 않는 ar[5]까지의 내용도 출력됩니다. *++ptr은 ++ptr, *ptr 두 문장으로 분리가 가능합니다. (*ptr)++은 완전히 다른 식입니다. 이렇게 되면 ++연산의 대상이 ptr이 아니라 *ptr이기 때문에 ptr이 가리키고 있는 메모리의 내용 자체가 증가하며 ptr은 변경되지 않으므로 계속 같은 번지만 가리키게됩니다. *ptr이 다음 번지 *ptr 자체를 하나의 변수처럼 생각하면 되는데  이 경우 *ptr은 ar[0]과 같고(*ptr)++은 ar[0]++과 같습니다. 출력된 결과는 10,11,12,13,14가 될 것입니다. 이에비해 ++(*ptr) 또는 ++*ptr은 ar[0]가 증가된 후 리턴되므로 11,12,13,14,15의 결과가 출력됩니다. 포인터 연산자와 증감 연산자를 함께 사용하는 식은 빈번하게 많이 쓰이며 이런 방식에 익숙해진다면 복잡한 연산문을 짧게 만들 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include<cstdio>
 
void main()
{
    int ar[] = { 1020304050};
    int *ptr = ar;
    int sum = 0;
 
    while (*ptr)
    {
        sum += *ptr++;
    }
    printf("sum=%d\n", sum);
}
cs

배열의 제일 끝에 0을두어서 이 값을 배열의 끝으로 정의하고 있으며 배열 처음부터 0을 만날 때까지 배열값을 읽어 sum에 누적시키면서 배열의 다음 위치로 이동합니다. C의 이런 축약적인 표현은 일단 익숙해지면 아주 편리합니다.


void형 포인터


포인터형 변수는 선언할 떄 반드시 대상체의 타입을 밝혀야 합니다. 가리키는 대상체의 타입을 알아야 *연산자로 대상을 읽을 수 있고 증감 연산자로 앞뒤로 이동이 가능합니다. 이런 일반적인 포인터에 비해 선언할 때 대상체의 타입을 명시하지 않는 특별한 포인터형이 있는데 이것이 바로 void형 포인터이다. void형 포인터를 선언할 때 void *타입을 지정합니다.

1
void *vp;
cs

이렇게 선언하면 vp포인터 변수의 대상체는 void형이 되며 이는 곧 대상체가 정해져있지 않다는 뜻입니다. void형은 함수와 포인터변수에게만 적용되므로 일반 변수에 쓸수 없습니다. void i;라는 선언문은 불법입니다. 다음은 void형 포인터들이되 모두 대상체가 정해져있지 않다는 사실에 기인합니다.


    1. 임의의 대상체를 가리킬 수 있다.

      대상체가 정해져 있지 않다는 말은 어떠한 대상체도 가리키지 못한다는 뜻이 아니라 임의의 대상체를 가리킬 수 있다는 얘기와도 같습니다. 선언할 때 대상체의 타입의 대상체만 가리킬 수 있지만 void형 포인터는 어떠한 대상체라도 가리킬 수 있습니다. 그래서 pi가 정수형 포인터 변수이고, pd가 실수형 포인터 변수이고 vp가 void형 변수 일 떄 다음 대입문들은 모두 적법합니다.

      1
      2
      vp=pi;
      vp=pd;
      cs

      정수형 포인터 pi가 가르키는 번지를 실수형 포인터 pd에 대입하고 싶다면 pd=pi; 대입식을 곧바로 쓸 수 없으며 반드시 pd=(double *)pi로 캐스팅 해야합니다. 대입문의 좌변과 우변의 타입이 같아야만 정상적인 대입이 가능합니다. 그러나 void형 포인터는 임의의 대상체를 모두 가리킬 수 있기 떄문에 대입받을 때 어떠한 캐스팅도 할 필요가 없습니다. 좌변이 void형 포인터일 떄는 우변에 임의의 포인터형이 모두 올 수 있습니다. vp는 정수형 변수도 가리킬 수 있고 실수형 변수도 가리킬 수 있는 것입니다.

      void형 포인터를 좀 더 쉽게 표현하면 임의의 대상체에 대한 포인터형입니다. 대상체가 정수든, 실수든 가리지 않고 메모리 위치를 기억할 수 있습니다. void형 포인터는 임의의 포인터를 대입받을 수 있지만 반대로 임의의 포인터에 void형 포인터를 대입할 때는 반드시 캐스팅을 해야합니다.

      1
      2
      pi=(int *)vp;
      pd=(double *)vp;
      cs

      만약 이 대입문에서 캐스트 연산자를 생략해 버리면 void *형을 int *형으로 변환할 수 없다는 에러 메시지가 출력됩니다. 만약 pi=vp; 대입식을 허용하면 *pi로 이 번지의 정수 값을 읽을 때 이 값이 온전한 정수임을 보장할 수 없습니다. 그리고 개발자는 vp가 가리키는 곳에 정수가 있다는 것을 확신할 때만 캐스트 연산자를 사용해야 합니다. C++에서보다 타입 체크가 덜 엄격한 C에서는 pi=vp대입을 허용합니다.

    2. *연산자를 쓸 수 없습니다.

      void 포인터는 임의의 대상체에 대해 번지값만을 저장하여 이 위치에 어떤 값이 들어 있는지는 알지 못합니다. 따라서 *연산자로 이 포인터가 가리키는 메모리의 값을 읽을 수 없습니다. 대상체의 타입이 정해저 있지 않으므로 포인터가 가리키는 위치에서 몇 바이트를 읽어야 할지, 또 읽어낸 비트를 어떤 식으로 해석해야 할지를 모르기 떄문입니다.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      #include<cstdio>
       
      void main()
      {
          int i = 1234;
          void *vp;
       
          vp = &i;
          printf("%d\n"*vp);//틀린 표현
          printf("%d\n"*(int *)vp);//맞는 표현
      }
      cs

      void형 포인터 vp는 정수형 변수 i가 저장된 번지를 대입받았습니다. 좌변이 void형 포인터이므로 vp=(void *)&i;와 같이 캐스트 연산자를 쓰지 않아도 곧바로 대입할 수 있습니다. 이 대입문에 의해 vp는 정수형 변수 i가 기억된 번지 값을 가지게 될 것입니다. 그러나 vp는 대상체가 정수형 변수라는 것을 모르기 때문에 *vp로 이 번지에 들어있는 값을 읽을 수는 없습니다. 만약 vp 번지에 저장된 값이 정수형이라는 것을 확실히 알고 있고 이 값을 꼭 읽고 싶다면 다음과 같이 캐스트 연산자를 사용해야 합니다.

      1
      printf("%d\n",*(int *)vp);
      cs

      vp를 잠시 정수형 포인터로 캐스팅하면 *연산자를 사용할 수 있습니다. 캐스팅된 vp는 정수형 포인터이므로 *연산자는 vp가 가리키는 번지에서 4바이트의 정수를 읽을 수 있습니다.
      (int *)vp -> vp를 잠시 정수형 포인터로 바꿉니다.
      *(int *)vp -> vp가 가르키는 번지의 정수값을 읽습니다.

      포인터 연산자와 캐스트 연산자의 우선 순위는 같으며 결합 순서는 우측 우선이기 떄문에 캐스트 연산자가 먼저 수행되어 vp를 정수형 포인터로 바꾸어 놓고 *연산자가 이 위치에서 정수값을 읽습니다. 따라서 *((int *)vp)처럼 굳이 괄호를 하나 더 쓸 필요는 없습니다. 물론 괄호를 싸놓으면 캐스팅이 먼저 된다는 것을 확실하게 알 수 있어서 안정되 보이지는 합니다.

    3. 증감 연산자를 쓸 수 없습니다.

      대상체의 타입이 정해져 있지 않으므로 증감 연산자도 곧바로 사용할 수 없습니다. 정수값과 바로 가감연산을 하는 것도 허용되지 않습니다. 대상체의 크기를 모르기 떄문에 얼마만큼 이동해야 할지를 모르는 것입니다.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      #include<cstdio>
       
      void main()
      {
          int ar[] = { 1234};
          void *vp;
       
          vp = ar;
          vp = vp + 1;
          printf("%d\n"*(int *)vp);
      }
      cs

      vp = vp + 1에서 "알수 없는 크기입니다."라는 오류가 출력됩니다. 왜냐하면 +1이 몇 바이트 뒤인지를 정확하게 알지 못하기 떄문입니다. 대상체의 크기를 모르기 때문에 다음 요소로 이동 할 수 없습니다. void *를 다음 요소로 이동하는 여러 가지 코드의 가능성여부를 점검해 보겠습니다.

          ①vp=vp+1 : 위 예제에서 보다시피 명백한 에러입니다. 대상체 크기를 모르기 때문에 증가할 양을 결정하지 못합니다. 축약형인
             vp+=1도 마찬가지 결과를 내보냅니다.
          ②vp++ : vp=vp+1과 같은 식이므로 역시 안됩니다. 에러로 처리되게 됩니다.
          ③vp=(int *)vp+1 : 가능합니다. vp를 잠시 int *로 캐스팅한 후 1을 더하면 4바이트 뒤쪽을 가리키는 int *타입의 포인터 상수가
             됩니다. void *는 임의의 포인터를 대입받을 수 있으므로 별도의 캐스팅없이 정수형 포인터를 대입받을 수 있습니다.
          ④(int *)vp++ : vp를 캐스팅한 후 증가하면 될 것 같지만 결합 순서에 의해 ++이 캐스트 연산자보다 먼저 연산되므로 vp++
          ⑤(int *)vp)++ : 캐스트 연산자를 괄호로 싸 먼저 연산되도록 했습니다. ++연산자의 피연산자는 좌변값이어야 하는데 캐스팅을        하면 좌변값이 아니므로 증가시킬 수는 없습니다.

      void형 포인터의 특징은 대상체가 정해져 있지 않으므로 임의의 번지를 저장할 수 있지만 *연산자로 값을 읽거나 증감연산자로 이동할 때는 반드시 캐스트 연산자가 필요합니다. 값을 읽거나 전후 위치로 이동하는 기능은 빼고 순수하게 메모리의 한 지점을 가리키는 기능만 가지는 포인터라고 할 수 있습니다.

void형 포인터의 활용

포인터로 액세스해야 할 대상체가 분명히 정해져 있을 떄는 해당 대상체형의 포인터 변수를 사용하면 됩니다. 예를 들어 정수형의 array배열을 액세스할 때는 정수형 포인터를 사용하고 문자열을 다루고 싶을 때는 문자형 포인터를 씁니다. 그러나 모든 상황에서 대상체를 미리 결정할 수 있는 것은 아니며 임의의 대상체에 대해 동작해야 할 경우가 있습니다. 대표적으로 메모리를 특정한 값으로 채우는 memset 함수를 보겠습니다.

1
void *memset(void*s, int c, size_t n);
cs

이 함수는 s번지에서 n바이트 만큼 c값으로 가득 채우는데 주로 배열 전체를 0으로 초기화할 때 사용됩니다. 실제 사용 예를 보겠습니다.

1
2
3
4
5
6
7
int ari[10];
char arc[20];
double ard[30];
 
memset(ari,0,sizeof(ari));
memset(arc,0,sizeof(arc));
memset(ard,0,sizeof(ard));
cs


이 함수의 첫 번쨰 인수 s가 void형 포인터로 되어 있기 떄문에 정수형 배열, 문자형 배열, 실수형 배열을 구분하지 않고 모두 인수로 받아들일 수 있습니다. 함수를 호출할 때 실인수 값이 형식 인수로 대입되는데 형식인수가 void *형이므로 호출문에 캐스트 연산자를 쓸 필요 없이 배열 이름만 적으면 배열의 시작 번지를 나타내는 포인터 상수가 형식 인수로 전달될 것입니다. memset 함수의 원형은 "시작 번지하고 길이만 던져, 몽땅 원하는 값으로 채워준다."라는 것을 설명하고 있습니다.

만약 void형 포인터가 없다면 각각의 타입에 대해서 memsetint, memsetchar, memsetdouble 같은 함수를 따로따로 만들어야하기 때문에 불편할 것입니다. memset 함수가 임의의 타입에 대해 메모리 채우기를 위해서는 임의의 대상체에 대한 포인터를 모두 전달받을 수 있어야 하며 이럴 때 사용하는 것이 바로 void*형입니다. 다음 소스는 *void 형을 이용하는 전형적인 형태입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include<cstdio>
void arDump(void *array, int length);
 
void main()
{
    int ari[] = { 1234};
    char arc[] = "pointer";
 
    arDump(ari, sizeof(ari));
    arDump(arc, sizeof(arc));
}
 
void arDump(void *array, int length)
{
    for (int i = 0; i < length; i++)
    {
        printf("%02X "*((unsigned char *)array + i));
    }
    printf("\n");
}
cs

arDump 함수로 배열의 시작 번지와 길이만을 전달하면 이 배열의 모든 내용이 16진수로 덤프된다 함수 내부에서 배열의 끝을 알 수는 없기 떄문에 길이도 인수로 전달해야 합니다. 실행결과는 다음과 같이 나오게 됩니다.


정수형 배열 ari든 문자형 배열 arc든 모두 array 형식 인수가 대입받습니다. array는 void *형이므로 전달받은 실인수의 타입에 대해서는 알지 못하며 오로지 그 시작 번지만을 알고 있습니다. 함수 내부에서 array번지의 값을 읽으려면 캐스팅을 해야 하는데 바이트 단위로 출력할 것이므로 unsigned char *형으로 캐스팅 했습니다. 일단 캐스팅을 하고 나면 +i 연산문도 사용할 수 있고 *연산자로 이 위치의 값을 읽는 것도 가능해 집니다. array는 대상체의 시작 번지만을 전달 받으며 대상체의 타입에 대해서는 모르지만 함수 내부에서 원하는 형식대로 캐스팅해서 사용 할 수 있습니다. 만약 arDump 함수를 수정하여 바이트 단위가 아닌 워드 단위(16비트)로 배열 내용을 출력하고 싶다면 다음과 같이 수정하면 됩니다.

1
2
3
4
for (int i = 0; i < length; i++)
{
    printf("%04X "*((unsigned char *)array + i));
}
cs

캐스트 연산자를 unsigned short *로 바꾸면 +i 연삼누에 의해 2바이트 단위로 이동할 것이며 *연산자는 이위에서 16비트을 읽을 것입니다. 바이트 단위가 워드 단위로 바뀌면 length는 절반으로 줄어들어야 합니다.


NULL 포인터


NULL포인터는 0으로 정의되어 있는 포인터 상수값입니다. 아주 특수한 시스템에서는 0이 아닐수도 있지만 일반적으로 0이라고 큰 무리가 없습니다. stdio.h헤더 파일을 살펴보면 다음과 같은 매크로 정의를 볼 수 있습니다.0이라는 상수보다는 좀 더 쉽게 구분되고 의미를 명확히 표현할 수 있는 NULL이라는 명칭의 매크로 상수를 쓰는것이 좋습니다.

1
#define NULL 0
cs

어떤 표인터 변수가 NULL값을 가지고 있다면 이포인터는 0번지를 가리키고 있는 것입니다. 0번지라면 메모리 공간의 제일 처음에 해당하는 첫 번째 바이트인데 이 위치도 분명히 실존하는 메모리 공간이므로 포인터가 0번지를 가리킬 수도 있습니다. 그러나 대부분의 플랫폼에서 0번지는 ROM이거나 시스템 예약 영역에 해당되므로 응용프로그램이 이 번지에 어떤 값을 저장하거나 읽을 수 없도록 보호되어 있습니다. 시스템 영역에 응용 프로그램이 고유 데이터를 저장할 수는 없기 떄문에 포인터 변수가 0번지를 가리키는 상황은 발생할 수 없습니다. 그래서 이런 상황은 일종의 에러로 간주되며 그렇게 하기도 약속이 되어있습니다. 포인터를 리턴하는 거의 대부분의 함수는 에러가 발생했을 때 NULL값을 리턴합니다. strchr함수는 문자열에서 특정 문자를 검색하여 발견된 위치를 리턴하는데 예를 들어 문자열 str에서 문자 r의 위치를 찾고 싶다면 다음과 같이 호출 할 수 있습니다.

1
pos=strchr(str,'r');
cs

str에 'r'이 있으면 그 번지를 찾아 pos에 대입하는데 만약 찾는 문자가 없으면 에러를 의미한 특이값 NULL을 리턴합니다. 이 함수가 NULL을 리턴했다는 것은 찾는 문자가 없다는 뜻이지 찾는 문자가 0번 째에 있다는 뜻은 절대 아닙니다. 포인터 리턴하는 함수를 호출하는 구분은 일반적으로 다음과 같이 작성합니다.

1
2
3
4
5
6
7
if(func()==NULL)
{
        //에러일떄 할 내용
}else
{
    //처리하려는 내용
}
cs

에러처리가 필요없으면 필요가 없으나 에러처리가 필요하면 NULL값 점검을 하여 처리하여야 합니다. 만약 리턴된 NULL을 0번지로 해석하여 이 영역을 읽거나 쓰게되면 그 프로그램은 바로 다운되어 버립니다. 다음 예시를 보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include<cstdio>
#include<string.h>
void main()
{
    char str[] = "pointer";
    char *p;
 
    p = strchr(str, 'r');
    if (p != NULL)
    {
        *= 's';
    }
    puts(str);
}
cs

"pointer"이라는 문자열에서 'r'을 찾아서 's'로 변경하되 'r'이 발견되지 않으면 아무 것도 하지 않도록 한 것입니다. 만약 NULL점검을 하지 않고 변경을 하게 하면 0번지를 건들이는 오동작을 할 가능성이 있습니다.


ptr=1234처럼 포인터에 상수 번지를 대입한다거나 포인터를 정수 상수와 비교하는 것은 허락되지 않습니다. 왜냐하면 응용프로그램 수준에서 절대 번지를 프로그래밍 해야 할 경우가 업스며 반드시 운영체제가 제공한 위치의 메모리만을 사용할 수 있기 떄문입니다. 만약 꼭 그렇게 하려면 ptr=(char *)1234; 식으로 캐스트 연산자를 통해 강제로 대입할 수는 있지만 일반적이지는 않습니다. 하드웨어를 직접 다루는 디바이스 드라이버 정도 되어야 사용할 일이 생깁니다.


포인터와 상수를 직접 연산할 수 없다는 것은 이해가 쉽지만 이 규칙의 예외가 있습니다. NULL입니다. NULL이라는 것은 실제로 0으로 정의된 상수이지만 이 상수는 아주 특별하게도 포인터 변수와 직접적인 연산은 허용이 됩니다. ptr=NULL; 이라는 대입문은 ptr을 무효화시키며 if(ptr==NULL)이라는 비교 연산문은 ptr의 유효성을 검사하는 조건문입니다.

'Programming > C' 카테고리의 다른 글

C언어 - 포인터(Pointer)(3/3)  (0) 2015.08.19
C언어 - 포인터(Pointer)(2/3)  (1) 2015.08.18
C언어 - 배열(Array)(2/2)  (0) 2015.08.15
C언어 - 배열(Array)(1/2)  (0) 2015.08.13
C언어 - 표준 함수  (0) 2015.08.11