본문 바로가기
C언어/포인터

[C언어] 배열과 포인터 사이의 관계 - 2 (포인터 - 4)

by 프링글's 2022. 7. 10.

 

저번 포스트에서 배열과 포인터의 관계를 끝내보려했지만... 생각보다 내용이 많아서 끊고 2편으로 가기로 했다.

 

[C언어] 배열과 포인터 사이의 관계 - 1 (포인터 - 3)

Ⅰ. 배열과 포인터 배열의 각 원소들은 메모리 상에 연속적으로 위치해 있게 된다. 즉, int형 배열을 선언을 하면 각 원소들은 4바이트씩을 차지한체로 줄지어서 놓여있게 된다는 의미이다. 우리

jikim08.tistory.com

 

Ⅰ. 배열 이름의 주소값

이전에도 설명했듯이 배열의 이름은 sizeof연산자와 주소값 연산자(&)를 사용할 때를 제외하고는 배열의 첫번째 원소를 가리키는 포인터로 타입변환된다. 그런데 sizeof는 어떤 부분이 다른지 이미 알고 있지만 &연산자를 사용하면 무엇이 달라지는지 아직 모른다. 우선 다음 예제를 살펴보자.

#include <stdio.h>

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

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

(int (*parr)[3]에서 ()를 꼭 붙여주어야 한다. int *parr[3]은 포인터 배열로 뒤에서 설명)

대체 int (*parr)[3] = &arr이 무슨의미일까? 저번 포스트에서 포인터를 볼때 *p를 제외하고는 포인터가 가리킬 데이터의 형(type)으로 볼 수 있다는 이야기를 하였다. 여기도 마찬가지이다. 즉, parr이라는 포인터가 int형 원소를 3개 가지는 배열을 가리킨다는 의미이다. 즉, 이는 배열을 가리키는 포인터라고 할 수 있다. 따라서 arr[1]과 (*parr)[1]은 동일한 의미로 같은 값이 출력된다.

2
2

그럼 arr과 &arr은 무슨차이가 있는지 좀 더 자세히 살펴보자.

#include <stdio.h>

int main() {
  int arr[3] = {1, 2, 3};
  
  printf("%p\n", arr);
  printf("%p", &arr);
}

 

000000000062FE10
000000000062FE10

arr과 &arr을 출력했더니 같은 값이 나왔다... 그럼 진짜 무슨차이가 있는걸까? 그래서 아까 코드에 &arr대신 arr을 넣어보았다.

#include <stdio.h>

int main() {
  int arr[3] = {1, 2, 3};
  int (*parr)[3] = arr;   //&arr대신 arr을 넣어봄

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

아쉽게도 이를 실행시키면 오류가 발생한다.

분명 arr과 &arr은 같은 값이었는데... 무엇이 문제였을까? 계속해서 말했듯 arr은 배열의 첫번째 원소를 가리킨다. 그런데 우리가 선언한 parr이라는 포인터는 길이가 3인 int형 배열을 가리키고 있다고 형(type)을 정해준 것이다. int형 배열을 가리켜야 할 포인터에 int형 변수의 주소를 넘겨주어서 오류가 발생한 것이다.

그럼 이제 &arr이 arr과 어떻게 다른지 감이 온다. &arr은 배열 자체를 가리키는 주소인 것이다. 때문에 배열을 가리키는 포인터 parr에는 배열의 주소값인 &arr을 넣어주어야 한다.

정리하면, 배열의 이름은 배열의 첫번째 원소의 주소, &(배열의 이름)은 배열의 주소고 할 수 있다.

 

이에 대한 연장선으로 *parr과 parr의 차이도 그렇게 이해할 수 있다.

#include <stdio.h>

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

  printf("%p\n", *parr);
  printf("%p", parr);
}

 

000000000062FE10
000000000062FE10

이 두 값도 같은 값이 나오지만, *parr은 배열의 첫번째 원소의 주소값이고, parr은 배열의 주소값이다. 따라서 출력할 때에 (*parr)[1]에서 *을 빼고 parr[1]로 출력을 해보려 하면 오류가 생긴다.

printf("%d\n", parr[1])  //오류
printf("%d\n", (*parr)[1])  //정상

parr[1]이 오류가 생기는 이유는 포인터의 연산을 기억한다면 생각보다 간단하다.

우선 [ ]연산자에 의해 변환된 형태로 둘을 바꾸어 보자.

*(parr+1)
*(*parr+1)

위쪽은 parr이 가리키는 데이터가 12바이트이기 때문에 1을 더했을 때 parr에 담긴 주소값에 12가 더해진다. 이는 배열의 범위를 넘어가 버리기 때문에 오류가 발생하는 것이다. 하지만 *parr가 가리키는 데이터는 4바이트이므로 *parr에 담긴 주소값에 4가 더해져 정상적으로 배열의 두번째 원소의 값이 출력될 수 있다.

 


Ⅱ. 2차원 배열의 [ ]연산자

2차원 배열은 2차원이라고 이름이 붙었다고 해서 메모리상에서도 배열이 2차원적으로 존재하고 있지는 않다. 컴퓨터 메모리의 구조는 1차원이기 때문에 데이터는 항상 선형으로 퍼져있다.

#include <stdio.h>
int main(){
    int arr[3][3];
    
    printf("%p\n", &arr[0][2]);
    printf("%p", &arr[1][0]);
}

 

000000000062FDF8
000000000062FDFC

이처럼 0번째 줄 마지막 요소와 1번째 줄 첫번째 요소의 메모리 차이가 4바이트인 것을 알 수 있다.(16진법에서 8과 C는 4차이이다.)

그렇다면 arr[0], arr[1]과 같은 건 무슨 역할을 할까? 이 부분은 크게 어렵지 않다. 배열의 이름과 같은 역할을 한다고 생각하면 간단하다. int arr[3]이라는 배열에서 arr이 배열의 첫번째 원소를 가리켰듯이 int arr[3][3]에서 arr[0]은 2차원 배열의 0번째 줄의 첫번째 원소를 가리키고 있다.

즉, 배열의 이름과 같이 sizeof연산자나 &연산자를 사용할 때를 제외하고는 arr[n]이 n번째 줄의 첫번째 요소를 가리키는 포인터로 타입변환된다.

 

그럼 2차원 배열의 이름인 arr은 어떻게 될까? arr[n]이 int*형이니까 arr은 int**형이 되는 걸까?

이에 대한 대답은 NO이다. 다음 예제를 통해 살펴보자.

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

  parr = arr;

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

  return 0;
}

이를 실행하면 오류가 발생하게 된다.

그 이유에 대해 설명하기 전, 우리는 arr[1][1]과 같은 식이 어떻게 연산되는지 부터 알고 올 필요가 있다.

 

이전에 [ ]연산자에 대해 설명할 때, arr[n]은 *(arr+n)으로 바뀌어서 계산이 된다고 말한 적이 있다. 그런데 int형은 4바이트 이므로 그 주소값을 보면 arr+4n이 된다.

그런데 arr[a][b]라는 2차원 배열이 있을 때 arr[n][m]은 어떤식으로 계산이 될까? 이는 n번째 줄 m번째 요소를 가져와야하므로 arr+4bn+4m이 된다. 1차원 배열과는 확연한 차이가 보인다. 1차원 배열에서는 배열의 원소의 크기만 알면 됐지만 2차원 배열에서는 추가로 한 줄의 길이인 b까지 알고 있어야 한다.

 

위 코드에서 오류가 생기는 이유가 바로 그것이다. 우리는 parr에게 배열의 한줄 길이를 알려준 바가 없고, parr[1][1]은 *(*(parr+1)+1)로 변환되는데, parr이 int*를 가리키므로 parr에 8바이트가 더해져 *(parr+1)은 배열의 세번째 요소인 3이 될것이고, 여기 1을 더하면 int형인 4바이트가 더해져 *(7), 즉 7이라는 주소값에 있는 데이터를 가져오라는 말이 된다.

이는 우리가 접근한 적이 없는 주소값이므로 읽을 수 없는 것이다.

 

따라서 다음과 같이 2차원 배열을 가리키는 포인터는 배열의 크기에 관한 정보를 주어야 오류가 생기지 않는다.

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

  parr = arr;

  printf("%d", parr[1][2]);

  return 0;
}

 

물론 (*parr)[5]와 같이 길이가 맞지 않으면 또 오류가 생기게 되므로 포인터가 가리킬 배열의 길이와 일치시켜주어야 한다.

 

 


Ⅲ. 포인터 배열

위에서 설명한 int (*parr)[3]은 배열을 가리키는 포인터였다. 그런데 여기서 괄호가 빠진 int *parr[3]은 포인터 배열이 된다.

무슨 차이일까?

우선 int (*parr)[3]은 전에도 봤듯이 parr이라는 포인터가 가리킬 데이터의 형(type)이 int [3]이라는 의미였다.

그런데 int *parr[3]은 int* parr[3]과 같은 의미라는 걸 알 수 있다. 즉, 배열을 int*형으로 선언한 것이다. 다시말해 배열의 원소인 parr[0], parr[1], parr[2]에 주소값이 저장된다는 것이다.

#include <stdio.h>
int main(){
    int a=1, b=2, c=3;
    int *parr[3];
    
    parr[0] = &a;
    parr[1] = &b;
    parr[2] = &c;
    
    printf("%d %d %d", *parr[0], *parr[1], *parr[2]);
}

 

1 2 3

위와 같이 각 배열의 원소가 포인터로 이루어진 배열을 포인터 배열이라고 한다.

 

정리하자면, 포인터가 배열을 가리키면 배열 '포인터', 포인터로 이루어진 배열은 포인터 '배열'이라고 할 수 있다. 

댓글