본문 바로가기

윤성우의 C Programming

Chapter 13. 포인터와 배열

포인터와 배열의 관계

배열의 이름 자체는 포인터이다. 즉 메모리 블록의 시작 주소 값을 가지고 있고 출력도 할 수 있다. 

단, 일반 포인터 변수는 주소 값의 변경이 가능하지만 배열의 이름은 주소 값을 변경할 수 없다. 그래서 배열의 이름을 상수 형태의 포인터라고도 하고, 포인터 상수라고 부르기도 한다. 

int main(void)
{
itn arr[3] = {0,1,2};
printf("배열의 주소 값 : %p \n", arr);  // %p는 주소 값의 출력에 사용되는 서식문자이다. 
printf("배열의 첫번째 주소 값 : %p \n", &arr[0]);
printf("배열의 두번째 주소 값 : %p \n", &arr[1]);
printf("배열의 세번째 주소 값 : %p \n", &arr[2]);
//arr = &arr[i]  // arr은 주소 값의 첫 번째 요소를 가리키기 때문에 컴파일 에러가 발생한다. 
}
#출력 결과 
배열의 주소값 : 0012FF50
배열의 첫번째 주소값: 0012FF50
배열의 두번째 주소값: 0012FF54
배열의 세번째 주소값: 0012FF58

포인터 연산 

포인터를 대상으로 하는 증가 및 감소연산 

포인터 변수를 대상으로 증가 및 감소 연산이 가능하다. 그런데 1+1 =2 처럼 단순한 정수 간의 덧셈, 뺄셈이 아니다.

포인터 변수마다 자료형을 설정할 수 있는데 이 자료형이 몇 바이트인지에 따라 그 바이트 수 만큼 현재 위치에서 메모리 블록을 이동한다. 예를 들어, int형 포인터 변수 1을 더하면 4가 증가하고 double형이면 1을 더했을 때 8이 증가한다. 또한 모든 배열요소가 메모리 공간에 '나란히' 할당된다. 

#include <stdio.h>

int main() {
    int i = 10;
    double d = 20.0;

    int* pi = &i;
    double* pd = &d;

    printf("증가 전:\n");
    printf("pi = %p, pd = %p\n", (void*)pi, (void*)pd);

    pi++; // int 포인터를 1 증가
    pd++; // double 포인터를 1 증가

    printf("증가 후:\n");
    printf("pi = %p, pd = %p\n", (void*)pi, (void*)pd);

    return 0;
}
증가 전:
pi = 0000009D0CAFF944, pd = 0000009D0CAFF9430
증가 후:
pi = 0000009D0CAFF948, pd = 0000009D0CAFF938

 

arr[i] ==*(arr+i)

 

한 단계 더 나아가 배열을 접근하는 포인터의 예시를 살펴보자. 포인터의 증가, 감소는 현재 위치를 기준으로 메모리 블록을 int형(4바이트), double형(8바이트)과 같은 자료형의 크기에 맞추어 이동한다. 

#include <stdio.h> 

int main(void)
{
int arr[3]={1, 2, 3};
int * ptr = &arrl;

printf("%d %d %d \n", *ptr, *(ptr+1), *(ptr+2));
printf("%d", *ptr); ptr++;
printf("%d", *ptr); ptr++;
printf("%d", *ptr); ptr--;
printf("%d", *ptr); 
}
#출력 결과
1 2 3 
1 2 3 2

 

포인터를 처음 배우는 과정에서 가장 혼란스러운 부분 중 하나는 메모리 주소와 그 주소에 저장된 변수의 값이 서로 다르다는 사실을 명확히 구분하는 것이다. 결론적으로 앞서 보인 예제를 보면 다음의 식을 도출할 수 있다.  arr[i] ==*(arr+i)

arr+1 과 *(arr+1) 의 차이점 

#include <stdio.h> 

int main(void)
{
int arr[3]={1, 2, 3};
int * ptr = &arrl;

printf("%d %d %d \n", *ptr, *(ptr+1), *(ptr+2));
printf("%d", *ptr); ptr++;
printf("%d", *ptr); ptr++;
printf("%d", *ptr); ptr--;
printf("%d", *ptr); 
}

 

결론적으로, arr+1 은 메모리의 주소 번지를 참조하고 *(arr+1) 은 포인터가 가리키는 실제 값을 참조한다. 

위 예시에서, arr[0]의 메모리 주소가 0x0012FF50이라고 가정해 보자. 이 주소는 , arr 또는 ptr을 사용하면 참조할 수 있다.

 

이 주소에 저장된 값은 메모리 주소 0x0012FF50에 담겨 있으며, arr[0] 또는 *ptr을 통해 접근할 수 있다.

이를 통해 우리는 두 가지 중요한 사실을 이해할 수 있다.

  1. ptr 은 메모리의 번지 수를 나타내는 변수이다. 이 경우, 배열의 첫 번째 요소의 주소를 가리킨다.
  2. *ptr을 사용하면 ptr이 가리키는 위치에 저장된 실제 값을 얻을 수 있다.   

 

상수 형태의 문자열을 가리키는 포인터

마지막에 널 문자가 삽입되는 문자열의 선언방식에는 두 가지가 있다.

하나는 배열이름, 다른 하나는 Char형 포인터 변수이다. 

char str[] = "string";
char * str = "Ptr string";

 

이 둘의 차이점은 아래 그림과 같이 배열은 글자 하나씩 메모리 블록에 할당하지만 포인터는 글자를 블록 하나에 다 담아 블록 하나의 주소 값만 가리킨다는 것에서 아래와 같은 차이점과 특징이 나타난다. 

 

1. 포인터 변수(char *str = "string")는 변수 형태의 포인터이자, 상수 형태의 문자열이다.

즉, 화살표 위치만 바꾸면 되기 때문에 가리키는 대상을 변경할 수 있다.

그러나 문자의 내용은 변경할 수 없다. 왜냐하면 한 메모리 블록 안에 모든 문자가 다 들어가 있기 때문이다. 

2. 배열이름(char str[ ]="string")은 상수 형태의 포인터이자, 변수 형태의 문자열이다.

즉, 배열 이름은 언제나 첫 번째 메모리 블록의 값을 가지고 있어야 하기 때문에 가리키는 대상을 변경할 수 없다.

하지만 박스 안에 원하는 물건을 넣을 수 있는것처럼 문자 하나하나가 한 블록의 방을 가지고 있기 때문에 문자의 내용은 자유롭게 변경할 수 있다. 

 

그렇다면 상수 형태의 문자열인 포인터 변수의 문장 처리 과정을 살펴보자

char * str ="string";

위의 문장이 실행되면 먼저 따옴표로 묶인 문자열이 OS가 할당한 어떤 메모리 한 블록에 저장된다. 

그 다음 포인터 변수 str 에는 그 메모리의 주소 값이 저장된다. 

char * str =0x123550;

 

참고로 printf도 같은 방식으로 문자열을 출력해야 할 때 문자열을 통째로 전달받지 않고 , 문자열의 "주소 값"을 전달받는다.

포인터 변수로 이루어진 배열 : 포인터 배열 

포인터 변수로 이루어져 주소 값이 저장이 가능한 배열을 포인터 배열이라고 한다. 포인터 배열의 선언방식은 다음과 같다.

int * arr[10]; // 길이가 10인 int형 포인터 배열 arr

double * arr[5]; // 길이가 5인 double형 포인터 배열 arr

 

포인터 배열의 예제를 살펴보자 

#include <stdio.h>

int main() {
    // 세 개의 정수 배열 선언
    int arr1[] = {1, 2, 3};
    int arr2[] = {4, 5, 6};
    int arr3[] = {7, 8, 9};

    // 정수 배열의 포인터 배열 선언 및 초기화
    int *arrPointers[3] = {arr1, arr2, arr3}; // 배열을 가리킬때는 배열의 이름(배열 1번째 값)으로 선언. & 사용 X

    // 포인터 배열을 이용하여 배열의 요소 출력
    for (int i = 0; i < 3; i++) { // 각 배열에 대하여
        for (int j = 0; j < 3; j++) { // 각 배열의 요소에 대하여
            printf("%d ", arrPointers[i][j]); // arrPointers[i]는 i번째 배열을 가리킴
        }
        printf("\n");
    }

    return 0;
}
  1. 배열 선언: 우선, int arr1[] = {1, 2, 3};, int arr2[] = {4, 5, 6};, int arr3[] = {7, 8, 9};를 통해 세 개의 정수 배열을 선언한다. 각 배열은 3개의 정수 요소를 갖는다.
  2. 포인터 배열 선언: int *arrPointers[3] = {arr1, arr2, arr3};에서 arrPointers는 정수 포인터의 배열을 선언한다. 이 배열은 세 개의 정수 배열 arr1, arr2, arr3 각각의 시작 주소를 저장한다. & 주소 값을 사용하면 각 배열 3개의 정수를 모두 가리키기 때문에 컴파일 오류가 발생한다. 
  3. 배열 요소 접근: 이중 for 반복문을 사용하여 포인터 배열을 통해 각 배열의 요소에 접근하고 출력한다. arrPointers[i][j]는 i번째 배열의 j번째 요소를 의미한다.  예를 들어 arrPointers[0][0]은 arr1 의 첫 번째 요소, 즉 1을 출력하게 된다.

이와 같이 포인터 배열을 사용하면 1차원 배열을 이용하여 2차원 배열과 유사한 기능을 수행할 수 있다. 

 

문자열을 저장하는 포인터 배열 

문자열의 주소 값을 저장하는 배열을 포인터 배열이라고 한다. 

char *strArr[3]; // 길이가 3인 char형 포인터 배열

아래 예제와 같은 큰 따옴표로 묶여서 표현되는 문자열은 그 형태에 상고나없이 메모리 공간에 저장된 후 그 주소 값이 반환된다. 

#include <stdio.h>

int main(void)
{
char *strArr[3]={"ABC", "DEF", "GHI"};

printf("%s"\n, strArr[0]);
printf("%s"\n, strArr[1]);
printf("%s"\n, strArr[2]);
return 0; 
}

// 출력 결과 : 
ABC
DEF
GHI

 

즉, 문장이 실행되면 초기화 리스트에 선언된 문자열들이 메모리 공간에 저장되고, 그 위치에 저장된 문자열의 주소 값이 반환된다. 따라서 문자열이 저장된 이후에는 각 요소 첫 번째 문자의 주소 값이 저장된다. 

char *strArr[3]={0xff1004, 0xff1005, 0xff1006};

 

이제 메모리 관점에서 살펴보자 

    1. strArr 배열의 메모리 사용량:
      • strArr는 char * 타입의 포인터 3개를 저장한다. 64비트 시스템에서 포인터의 크기는 보통 8바이트이다. 따라서, strArr 배열 전체는 8바이트 * 3 = 24바이트를 사용한다.
    2. 각 문자열 리터럴의 메모리 사용량:
      • 문자열 리터럴 "ABC", "DEF", "GHI" 각각은 실제 문자 데이터와 문자열의 끝을 나타내는 널 문자('\0')를 포함하여 메모리에 저장된다.
      • 각 문자열은 4개의 문자('A', 'B', 'C', '\0' 등)를 포함한다. char 타입은 대부분의 시스템에서 1바이트를 사용한다. 따라서, 각 문자열 리터럴은 4바이트를 사용한다.

여기서 주의할 점은  strArr 배열의 메모리 사용량과 문자열 리터럴의 메모리 사용량을 구분해서 이해해야 한다는 것이다. strArr 배열의 메모리 사용량은 포인터들이 차지하는 공간과 관련이 있고, 각 문자열 리터럴의 메모리 사용량은 별도로 계산되며, 이들은 서로 다른 메모리 영역에 저장된다. 

    • strArr 배열은 포인터들을 저장하는 데 24바이트(8바이트* 3)를 사용한다.
    • 각 문자열 리터럴은 각각 별도의 4바이트를 사용한다.