포인터

CHOI·2021년 6월 24일
3

C 언어

목록 보기
16/28
post-thumbnail

1. 포인터를 이해하기 앞서

앞서 우리는 모든 데이터들을 메모리 상에 특정한 공간에 저장을 하였다. 앞으로 편의를 위해서 이러한 데이터들을 저장한 특정한 공간을 '방' 이라고 하겠다. 각 방에는 데이터가 들어가게 되는 것이다.

보통 한 방의 크기를 '1 바이트' 라고 한다. 우리가 만약 4바이트 int 형 변수를 선언하면 메모리상에 4개의 방을 차지하게 되는 것이다.

프로그램 작동시 컴퓨터는 여러 방들 안에 있는 데이터들을 필요로 하게 된다. 따라서 어떤 방에서 어떠한 데이터를 가져올지 구분하기 위해서 각 방에는 고유의 주소(address) 를 붙여 주었다. 마치 아파트에 각 집들을 호수로 구분하는 것 처럼.

예를 들어서 우리가 int 변수 a 를 정의했다면 특정한 방 안에 아래 그림 처럼 변수 a가 저장된다.

int a = 123;  // 메모리 4 칸을 차지하게 한다.


이때 0x152839는 그냥 필자가 아무렇게 정한 이 방의 주소(16진수)이다. 아무튼 0x152839에서 부터 4바이트 공간에 123 값이 저장되는 것이다.

여기까지는 이해하는데 큰 무리가 없을 것이라고 생각한다. 그런데 C언어를 개발한 사람은 아주 유용하면서 골때니는 것을 만들게 된다. 바로 포인터(Pointer)이다. 포인터라는 뜻은 '가리키다' 라는 뜻이다.(뱡향을 가리키다 할 때 그 가리키다)

사실 포인터는 우리가 앞서 보았던 intchar 변수들과 전혀 다른 것이 아니다. 포인터도 '변수' 이다. int 형 변수가 정수 데이터, float 변수가 실수 데이터를 보관했던 것 처럼 포인터도 특정한 데이터를 보관하는
'변수' 이다. 근데 어떤 데이터를 보관하고 있을까?

바로 특정한 데이터가 저장된 주소값을 보관하고 있는 변수이다.

포인터

다시 한 번 정리하자면 포인터란

메모리 상에 위치한 특정한 데이터의 (시작)주소값을 보관하는 변수

이다. 우리가 변수를 정의할 때 intfloat 처럼 여러가지 형(type)들이 있었다. 그런데 놀랍게도 포인터에도 이러한 형이 있다.

이 말은 즉, 메모리 상에 int 형 데이터의 주소값을 저장한 포인터와, float 형 데이터의 주소값을 저장한 포인터가 서로 다르다는 말이다.

여기서 이상한 느낌을 받을 수 있다. '주소값은 다 똑같이 0x12312 이런식으로 되어 있기 때문에 다 같은거 아니야?' 라고 생각할 있다. 맞는 말이다. 하지만 일단 계속해보자

C언어에서 포인터는 다음과 같이 정의 할 수 있다.

(포인터에 주소값이 저장되는 데이터의 형) *(포인터의 이름);

혹은

(포인터에 주소값이 저장되는 데이터의 형)* (포인터의 이름);
int* p;  // 라고 하거나
int *p;  // 로 하면 된다

즉 위 포인터 pint 형 데이터의 주소값을 저장하는 변수가 된다.

& 연산자

그런데 아직 2% 부족하다. 포인터가 주소값을 저장하는 변수라는 것은 알겠다. 그런데 포인터에 값을 집어 넣으려면 주소값을 알아야 하는데 우리는 주소값을 어떻게 알 수 있느냐 말이다. 걱정하지 않아도 된다. 바로 & 연산자를 사용하면 된다.

여기서 & 가 익숙한 사람도 있다. 왜냐하면 &AND 연산자이기 때문이다.

그런데 & 연산자를 사용하기 위해서는 두 개의 피연산자를 사용해야 한다. 즉,

a& b; // 문제없음

는 괜찮지만

a& // 오류발생

는 안된다. 언제나 두 개가 필요 하다는 것이다. 그러나 여기서 소개할 & 연산자는 오직 피연산자가 한 개인 연산자이다.(이러한 연산자를 단항(unary)연산자라 한다.) 따라서 위의 AND 연산자와 완전히 다르게 해석된다.

단항 & 연산자는 피연산자의 주소값을 불러온다. 예를 들어서 어떤 변수 a 의 주소값을 알고 싶다면

&a

이렇게 쓰면 된다.

그러면 실제 한 번 프로그램을 짜보자.

#include<stdio.h>
int main(){
    int a;
    a = 2;
    printf("%p \n", &a);
    
    
    return 0;
}
0x16fdff408

아마도 필자가 나온 값과 다른 값이 나올 수 있다. 사실 우연이 아닌 이상 다른 값이 나올 확률이 훨씬 높다. 더욱 신기한 점은 실행 할 때 마다 다른 값이 나온다.

결과값이 16자리가 아닌 이유

여기서 잠깐, 우리의 컴퓨터는 32bit 가 있고 64bit가 있는데 요즘은 대부분 64bit 컴퓨터를 사용한다.

  • 32bit 컴퓨터가 램을 4GB 까지만 사용할 수 있는 이유

64bit 컴퓨터를 사용한다는 것은 컴퓨터가 한번에 64bit의 메모리를 처리할 수 있다는 뜻이고 메모리의 주소값이 64bit 즉 8바이트로 이루어져 있다. 따라서 주소값이 0x000000000000 ~ 0xffffffffffffffff 까지의 값을 가진다.(0x 뒤로 한자리당 2의4승 즉, 2진수로 0000 하나이다 그러나 2진수로 표현하면 너무 길기 때문에 16진수로 표현하는 것이다. 이해가 안되면 '진수'강의를 다시 보자.)

위의 문장은 16진수(%p)로 출력하라고 명령 했다 그러나 눈치 있는 사람은 이미 알아차렸을 수 있지만 위의 결과 값은 8바이트(16진수로 16자리)가 아니다.

그렇다면 무엇이 문제일까? 사실 문제가 없다. 단지 앞에 0이 생략된 것이다. 주소값은 언제나 8바이트 크기, 즉 16진수로 16자리인데 앞에 0이 생략되어서 출력이 안된것 뿐이다. 따라서 위의 값은 사실 0x000000016fdff408 일 것이다.

아무튼 위의 결과를 보면 적어도 int 변수 a0x16fdff408 를 시작으로 4바이트 공간을 차지하고 있는 것을 알 수 있다. 이제 이 값을 포인터에 넣어주자

#include<stdio.h>
int main(){
    
    int *p;
    int a =2;
    
    p = &a;
    
    
    printf("포인터 p에 들어 있는 값 : %p\n", p);
    printf("int 변수 a 가 저장된 주소 : %p \n",&a);
    
    return 0;
}
포인터 p 에 들어 있는 값 : 0x7fff894c8b3c
int 변수 a 가 저장된 주소 : 0x7fff894c8b3c

둘이 같은 결과가 나온다. 어찌보면 당연한 결과이다. 포인터 pa 의 주소값을 대입했기 때문이다.

* 연산자

지금까지 배운것을 정리하자면 포인터는 특정한 데이터의 주소값을 저장하는 변수이다. 이 때 포인터는 주소값을 보관하는 데이터형의 * 를 붙임으로써 정의되고, & 연산자로 특정한 데이터의 메모리 상의 주소값을 알아올 수 있다.

& 연산자가 특정 데이터의 주소값을 얻어내는 연산자라면 특정 데이터의 주소값을 가지고 해당 주소값에 해당하는 데이터를 가져오는 연산자가 필요할 것이다. 이 역할을 바로 * 연산자가 한다.

하지만 앞서 * 연산자는 곱셈 연산자로 사용되었다. 다만 * 연산자는 피연산자 두 개에 작용할 때 곱셈 연산자로 사용된다.

a * b; // a 와 b 를 곱한다.
a *; // 오류!
*a; // 단항 * 연산자

* 연산자의 역할을 쉽게 풀이하자면

"나(포인터)를 나에게 저장된 주소값에 위치한 데이터로 생각해줘!"

라고 한다.

아래 예지를 보자

#include <stdio.h>
int main() {
  int *p;
  int a;

  p = &a;
  a = 2;

  printf("a 의 값 : %d \n", a);
  printf("*p 의 값 : %d \n", *p);

  return 0;
}
a 의 값 : 2
*p 의 값 : 2

위의 코드를 보면 포인터 p를 먼저 정의한 다음에 a에 2를 넣었다. 그런데 앞서 * 연산자를 나에 저장된 주소값에 해당하는 데이터로 생각하시오! 로 하게 하는 연산자라고 하였다. 따라서 *p 를 통해 변수 p 가 저장된 주소(변수 a 에 해당하는 주소)에 해당하는 데이터, 즉 변수 a 그 자체를 의미한다.

다시말해서 *p 와 변수 a 는 정확히 동일하다.

한 가지 예시를 하나 더 보자

#include <stdio.h>
int main() {
  int *p;
  int a;

  p = &a;
  *p = 3;

  printf("a 의 값 : %d \n", a);
  printf("*p 의 값 : %d \n", *p);

  return 0;
}

a 의 값 : 3
*p 의 값 : 3

위에서도 마찬가지로 p 에 변수 a의 주소값을 넣었다 그리고 *p 를 나에 저장된 주소값에 해당하는 데이터, 즉 변수 a의 값을 3으로 정의했다. 따라서 둘 다 3이 나오게 된다.


제 포인터 라는 말 자체의 의미에 대해서 생각해보자. int 형 변수 a 와 포인터 p 의 메모리상의 모습은 다음과 같다.

( 참고로 주소값은 임의로 정한 값이다. 출처:https://modoocode.com/23)

포인터 p 에 어떠한 변수 a 의 주소값이 저장되어 있다면 포인터 p 는 변수 a 를 가리킨다 라고 말한다.

포인터 또한 엄연한 변수이기 때문에 특정한 메모리 공간을 차지하고 위 그림 처럼 특정한 주소값도 가지고 있다.

포인터는 왜 타입을 가지고 있을까

여기까지 잘 따라왔다면 다음과 같은 의문이 발생할 수 있다.

포인터는 주소값만 가지고 있는데 왜 굳이 타입이 필요할까? 어차피 주소값은 32비트 시스템에서는 4바이트고 64비트 시스템에서는 8바이트 인데 그냥 새로운 pointer 이라는 타입을 만들면 되지 않나?

매우 좋은 질문이다. pointer 라는 타입이 있다고 생각하고 아래의 지문을 보자

int a;
pointer *p;
p = &a;
*p = 4;

이제 이 코드를 컴퓨터가 어떤식으로 처리할 지 생각해보자

처음 3 줄까지는 무난하다. int 형 변수 a를 위해 4바이트 공간을 마련해주고 포인터 p 를 위해 8바이트 공간도 마련하였다. 그리고 포인터 p 에 변수 a 의 주소값을 8바이트 공간에 저장하였다. 문제는 다음이다.

*p = 4;

포인터 p 에는 명백하게 변수 a 의 주소값이 들어있다. 그런데 여기서 문제는 a 가 메모리에서 차지하는 모든 공간의 주소값이 들어간 것이 아니라 a시작 주소 값만 들어가 있는 것이다.

따라서, *p 라고 했을 때 어디서 시작하는 지는 알지만 메모리에서 얼만큼 읽어 들이지는 알 방법이 없다는 것이다.

한편

int a;
int *p;
p = &a;
*p = 4;

라고 한다면 포인터 pint * 라는 것을 보고 이 포인터는 int 데이터를 가리키는 것이구나 라는 것을 알고 시작 주소로부터 정확히 4바이트를 읽어 들어 값을 바꾸게 된다.


상수 포인터

앞서 상수라는 개념에 대해서 간단하게 찍먹을 했었다.(기억이 안나면 배열 강의로)

간단하게 다시 얘기하자면 어떠한 데이터를 상수로 선언하려면 앞에 count 를 붙이고 그 값은 절대 변하지 않는다. 였다.

count int a = 3;

다음과 같이 표현하고 이렇게 상수로 선언하고 난 뒤로 만약에 a = 4; 와 같이 변수처럼 다른 값을 넣어주면 컴파일 에러가 발생한다.

그렇다면 다음과 같이

a = 3;

을 하면 어떻게 될까? 이미 3이 들어 있기 때문에 값은 바뀌지 않지만 놀랍게도 컴파일 시 오류가 발생한다.

왜냐하면 위 문장은 a 가 바뀔 '가능성'이 있기 때문이다. 즉, 컴파일러는 a 에 무슨 값이 오든 상관쓰지 않는다. 그냥 무조건 바뀔 가능성이 있다면 오류를 출력한다.

여러분은 도대체 왜 상수를 사용하는지 의문을 가질 수 있다. 하지만 상수는 실제로 프로그래머들의 실수를 줄여주고 실수를 했더라고 실수를 잡아내는데 중요한 역할을 한다. 예를 들어서 다음 예제를 보자.

const double PI = 3.141592;

PI 는 변하지 않는 3.141592 라는 값을 가지게 되었다. 이렇게 한 번 선언을 하면 우리가 만약에 실수로 PI = 10 이라고 하여도 쉽게 오류를 찾고 고칠 수 있다. 만약에 상수로 선언하지 않았더라면 우리가 실수로 PI = 10 를 하면 컴파일러는 이를 오류로 처리하지 않는다. 따라서 우리는 어디서 문제가 발생한지 찾기 힘든 상태가 된다. 따라서 값이 편하지 않을 것 같은 값에는 무조건 const 키워드를 붙이는 습관을 들어야 한다.

const int * p = &a

그러면 이제 포인터에 상수를 사용할 수 있는지 한 번 보자

#include <stdio.h>
int main() {
  int a;
  int b;
  const int* pa = &a;

  *pa = 3;  // 올바르지 않은 문장
  pa = &b;  // 올바른 문장
  return 0;
}

이렇게 컴파일시에 오류가 발생한다.

일단 다음을 먼저 보자

const int* pa = &a;

여기서 의문을 가지는 사람이 있을 수 있다.

pa는 const int 로 선언되었으니까 상수인 포인터인데 어떻게 변할 수 있는 변수의 주소를 넣을 수 있지?? 그러면 안되는거 아니야???

하지만 앞서 말했듯이 const 라는 키워드는 값이 절대로 바뀌면 안된다 라는 것을 알려주는 키워드이다.

따라서 const int * 의 의미는 단순히 const int 형 변수를 가리키는게 아니라 int 형 변수인데 그 값을 절대로 바꾸지 말라 라는 의미이다. 즉, pa 는 어떠한 int 형 변수를 가리키는데 그 값은 절대로 바뀌면 안된다는 것이다.

a 자체는 변수이므로 값이 자유롭게 변경이 가능하지만 pa 를 통해서 a 를 간접적으로 가리킬 때는 컴퓨터가 '아 내가 const 인 변수를 가리키고 있구나' 라고 생각하게 된다.

따라서

*pa = 3;

은 컴파일 오류가 발생한다. 물론 a = 3; 같은 문장은 오류가 발생하지 않는다. 앞서 말했듯이 a 자체는 const (즉, 상수)가 아니기 때문이다.

조금 다른 예제로 다시 말하자면(포인터 변수 명을 p라고 하였다.)

int a = 3;
int b = 2;
const int *p = &a;

라고 하면 변수 a 자체는 '상수' 가 아니지만 포인터 p 에 해당하는 주소값에 위치한 값을 바꾸지는 않겠다는 의미이다. 따라서 *p = 4 를 할 시에는 오류가 발생하지만 변수 a 자체는 '상수'가 아니므로 a = 4 를 할 때는 오류가 발생하지 않는다.

그렇다면 다음 문장은 어떨까?

p = &b;

문제없이 컴파일이 잘 된다. 그 이유는 포인터 p 에 해당하는 주소값에 위치한 값이 바뀌는 것이 아니라 주소값 자체가 바뀌기 때문에 문제가 없다.

int * const p = &a

#include<stdio.h>
int main(){
    int a = 3;
    int b = 1;
    int * const p = &a;
    
    *p = 99; // 문제없음
    p = &b;  // 컴파일 에러 발생
    
    return 0;
}

위 예제를 보자 아까와는 조금 다른 부분이 있다.

int * const p = &a;

int * 을 가리키는 p 라는 포인터를 정의헀다. 그런데 const*p 사이에 있다. 이게 무슨 의미인지는 const 의 키워드 의미를 그대로 생각해보면 간단하다. **p 의 값이 바뀌면 안된다는 것이다.**

포인터의 의미는 앞서 계속 말했듯이 '포인터는 가리키는 데이터의 주소값을 저장하는 변수'이다. 즉 위의 경우 a 의 주소값이 p 에 저장된 것인데 pconst 라는 의미는 p 의 값이 절대 바뀌면 안된다는 뜻이다. 즉, 결과적으로 '주소값'이 바뀌면 안된다는 것이다. 따라서

p = &b; // 컴파일 에러 발생

와 같이 '주소값'을 바꾸려면 컴파일 오류가 발생하는 것이다. 그러나

*p = 99; // 문제없음

는 오류가 발생하지 않는다. 그 이유는 '주소값'이 바뀌는것이 아니라 '주소값에 해당하는 데이터'가 바뀌는 것이기 때문이다.

위에 있던 것들을 다 합쳐서 보면

#include <stdio.h>
int main() {
  int a;
  int b;
  const int* const p = &a;

  *p = 3;  // 올바르지 않은 문장
  p = &b;  // 올바르지 않은 문장

  return 0;
}

가 된다.

포인터의 덧셈

이번에는 포인터의 덧셈에 대해서 확인해보자

#include<stdio.h>
int main(){
    int a;
    int *p = &a;
    
    printf("p의 값 : %p \n",p);
    printf("(p+1)의 값 : %p \n",p+1);
    
    return 0;
}
p의 값 : 0x16fdff408 
(p+1)의 값 : 0x16fdff40c

위의 출력 결과는 각자 다를 것이다 하지만 두 주소값의 차이는 4바이트 차이가 날 것이다.(16진수인 것을 까먹지 말자. )

여기서 왜 4바이트 차이가 나는 것이니 이상한 사람들이 있을 것이다.

printf("(p+1)의 값 : %p \n",p+1);

에서 볼 수 있듯이 우리는 p + 1 의 값을 출력하라고 했습니다. 앞서 말했듯이 p 에는 자신이 가리키는 값의 주소값이 들어 있습니다 p0x16fdff408 라고 하니까 p+1 의 값은 0x16fdff408 + 1 인데 0x16fdff409 가 나오는 것이 아니라 0x16fdff40c 가 나왔다.

왜인지 생각해보면 앞서 우리는 포인터의 형이 int * 라고 했는데 설마 int 형이여서 4바이트???

위의 추론이 맞는지 확인해보기 위해 다음 코드를 보자

#include <stdio.h>
int main() {
  int a;
  char b;
  double c;
  int* pa = &a;
  char* pb = &b;
  double* pc = &c;

  printf("pa 의 값 : %p \n", pa);
  printf("(pa + 1) 의 값 : %p \n", pa + 1);
  printf("pb 의 값 : %p \n", pb);
  printf("(pb + 1) 의 값 : %p \n", pb + 1);
  printf("pc 의 값 : %p \n", pc);
  printf("(pc + 1) 의 값 : %p \n", pc + 1);

  return 0;
}
pa 의 값 : 0x7ffcf64a2e04
(pa + 1) 의 값 : 0x7ffcf64a2e08
pb 의 값 : 0x7ffcf64a2e03
(pb + 1) 의 값 : 0x7ffcf64a2e04
pc 의 값 : 0x7ffcf64a2e08
(pc + 1) 의 값 : 0x7ffcf64a2e10

위 결과를 보면 알 수 있듯이 추론이 맞았다는 것을 알 수 있다. char 는 1바이트, double 은 8바이트인데 정확이 값들을 보면 pb 는 1만큼, pc 는 8만큼 차이가 난 것을 볼 수 있다.

그런데 머리 한 켠에서 의구심이 든다. "왜 하라는대로 안하고 포인터가 가리키는 형 만큼 더할까?" 이에 대한 해답은 뒤에서 나온다.

포인터의 뺄셈

일단 다른것들을 좀 더 해보자. 그렇다면 뺄셈은 가능할까? 직관적으로 뺄셈도 가능할 것이라고 생각이 든다. 왜냐하면 뺄셈 또한 덧셈과 다를게 없기 때문이다.(1 - 1 = 1 + ( -1 ))

아무튼 한 번 해보면 다음과 같다.

#include <stdio.h>
int main() {
  int a;
  int* pa = &a;

  printf("pa 의 값 : %p \n", pa);
  printf("(pa - 1) 의 값 : %p \n", pa - 1);

  return 0;
}
pa 의 값 : 0x7ffe4f4fa47c
(pa - 1) 의 값 : 0x7ffe4f4fa478

우리가 예상한대로 4만큼 빠졌다.

포인터 + 포인터

그렇다면 포인터들 끼리 더하는 것도 가능할까?

#include <stdio.h>
int main() {
  int a;
  int *pa = &a;
  int b;
  int *pb = &b;
  int *pc = pa + pb;

  return 0;
}
error C2110: '+' : 두 포인터를 더할 수 없습니다.

위의 경우에는 오류가 발생하는 것을 볼 수 있다.

왜 두 포인터끼리의 덧셈은 안될까 생각해보면 사실 포인터끼리의 덧셈이 의미가 없을 뿐더러 필요가 없다는 것을 알 수 있다. 두 변수의 메모리 주소를 더해서 나온 주소값은 이전에 두 포인터들이 가리키던 값과는 전혀 관련이 없는 메모리속에 임의의 지점일 것이기 때문이다. 아무튼 포인터끼리의 덧셈은 아무런 의미가 없기 때문에 C 언어 에서는 수행할 수 없다.

그렇다면 왜 포인터에 정수를 더하는 것은 왜 되는 것일까?? 이에 대한 이유는 아래에서 설명하겠다.

그런데 한 가지 놀라운 점은 포인터 끼리의 뺄셈은 가능하다는 것이다. 이에 대한 설명 또한 나중에 하겠다.

일단 다음의 예제를 보자

#include <stdio.h>
int main() {
  int a;
  int* pa = &a;
  int* pb;

  *pa = 3;
  pb = pa;

  printf("pa 가 가리키고 있는 것 : %d \n", *pa);
  printf("pb 가 가리키고 있는 것 : %d \n", *pb);

  return 0;
}
pa 가 가리키고 있는 것 : 3
pb 가 가리키고 있는 것 : 3

어떻게 보면 당연한 결과가 나온다.

pb = pa;

이 부분에서 pa 에 저장되어 있는 값(즉 pa 가 가리키고 있는 변수 a의 주소값)을 pb 에 대입하였다. 따라서 pb 에 저장된 주소값은 pb 에 저장된 주소값과 같다. 결과적으로 둘다 a 를 가리키게 되는 것이다. 여기서 주의해야할 점은 papb 의 형(type)이 같아야 한다는 것이다. 다시 말해서 paint * 이면 pbint * 이여야 한다는 것이다. 만일 형이 다르다면 형변환을 해줘야 하는데 이에 대한 이야기는 나중에 하자.

배열과 포인터

우리는 이전에 배열에 대해서 공부한 적이 있다. 그때 배열은 변수가 여러개 모인 것으로 생각할 수 있다고 했다. 그런데 또 다른 특징이 하나 더 있다. 바로 배열들의 원소들은 메모리상에서 연속되게 놓인다는 것이다.

int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

라고 정의 하면 메모리상에는 다음 그림과 같이 저장이 된다.

즉 위와 같이 메모리상에 연속된 형태로 나타난다. 한 개의 원소는 int 형 원소이기 때문에 4바이트를 차지하게 된다. 물론 실제로 확인해 볼 수 있다.

#include <stdio.h>
int main(){
    int arr[10] = {1,2,3,4,5,6,7,8,9,10};
    
    for (int i = 0;i<10;i++){
        printf("arr[%d] 의 주소 : %p \n",i, &arr[i]);
    }
    
    return 0;
}
arr[0] 의 주소 : 0x16fdff3e0 
arr[1] 의 주소 : 0x16fdff3e4 
arr[2] 의 주소 : 0x16fdff3e8 
arr[3] 의 주소 : 0x16fdff3ec 
arr[4] 의 주소 : 0x16fdff3f0 
arr[5] 의 주소 : 0x16fdff3f4 
arr[6] 의 주소 : 0x16fdff3f8 
arr[7] 의 주소 : 0x16fdff3fc 
arr[8] 의 주소 : 0x16fdff400 
arr[9] 의 주소 : 0x16fdff404

실제로 해보면 필자와 값이 조금 다를 수 있지만 어쨋든 4바이트씩 증가하는 것을 볼 수 있다.

아마 여기까지 왔다면 이런 생각이 들 수 있다.

아! 포인터로도 배열의 원소에 쉽게 접근할 수 있겠다!

(이 생각이 들지 않는다면 다시 이 글을 다시 처음부터 읽어야 한다)

배열의 시작부분을 포인터로 정의한 다음에 1 씩 더하면 다음 원소를 가리키겠다는 것을 느낄 수 있을 것이다.

위와 같은 것이 가능한 이유는 포인터는 자신이 가리키는 데이터의 '형'의 크기를 곱한 만큼 덧셈을 수행하기 때문이다. 예를 들어서 p 라는 포인터가 int a; 를 가리킨다면 p + 1 을 할 때 p 의 주소값에 사실은 1 * 4 가 더해지고 p + 3 을 하면 p 의 주소값에 3 * 4 인 12가 더해진다는 것이다.

이 아이디어를 적용시켜서 배열의 원소를 가리키는 포인터를 만들어보자

#include <stdio.h>
int main(){
    int arr[10] = {1,2,3,4,5,6,7,8,9,10};
    int *p = &arr[0];
    int i;
    
    for (i = 0 ; i<10; i++){
        printf("arr[%d]의 주소값 : %p ", i , &arr[i]);
        printf("(p + %d)의 주소값 : %p" ,i, (p + i));
        
        if (&arr[i] == ( p + i)){
            printf("---> 일치 \n");
        } else {
            printf("---> 불일치 \n");
        }
    }
    
    return 0;
}
arr[0]의 주소값 : 0x16fdff3e0 (p + 0)의 주소값 : 0x16fdff3e0---> 일치 
arr[1]의 주소값 : 0x16fdff3e4 (p + 1)의 주소값 : 0x16fdff3e4---> 일치 
arr[2]의 주소값 : 0x16fdff3e8 (p + 2)의 주소값 : 0x16fdff3e8---> 일치 
arr[3]의 주소값 : 0x16fdff3ec (p + 3)의 주소값 : 0x16fdff3ec---> 일치 
arr[4]의 주소값 : 0x16fdff3f0 (p + 4)의 주소값 : 0x16fdff3f0---> 일치 
arr[5]의 주소값 : 0x16fdff3f4 (p + 5)의 주소값 : 0x16fdff3f4---> 일치 
arr[6]의 주소값 : 0x16fdff3f8 (p + 6)의 주소값 : 0x16fdff3f8---> 일치 
arr[7]의 주소값 : 0x16fdff3fc (p + 7)의 주소값 : 0x16fdff3fc---> 일치 
arr[8]의 주소값 : 0x16fdff400 (p + 8)의 주소값 : 0x16fdff400---> 일치 
arr[9]의 주소값 : 0x16fdff404 (p + 9)의 주소값 : 0x16fdff404---> 일치

이렇게 포인터에 정수를 더하는 것만으로도 배열의 각 원소를 가리킬 수 있다. 그렇다면 * 를 이용하여 원소들과 똑같은 역할을 할 수 있게 되겠다. 아래와 같이 말이다.

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

  parr = &arr[0];

  printf("arr[3] = %d , *(parr + 3) = %d \n", arr[3], *(parr + 3));
  return 0;
}
arr[3] = 4 , *(parr + 3) = 4

parr + 3 을 하면 arr[3] 의 주소값이 되고 거기에 * 를 붙여주면 그 주소값에 해당하는 데이터를 의미하므로 *(parr + 3)arr[3] 과 동일하게 된다.

배열의 이름의 비밀

배열을 사용하다보면 다음과 같은 잘못을 한 적이 있었을 것이다.(아니면 유감이다)

#include <stdio.h>
int main(){
    int arr[10] = {1,2,3,4,5,6,7,8,9,10};
    
    printf("%d \n", arr);
    
    return 0;
}

그러곤 1도,2도,3도,10도 아닌 값이 나온다. 놀랍게도 그 때 출력되는 값은 다음과 같다.

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

  printf("arr 의 정체 : %p \n", arr);
  printf("arr[0] 의 주소값 : %p \n", &arr[0]);

  return 0;
}
arr 의 정체 : 0x16fdff3e0
arr[0] 의 주소값 : 0x16fdff3e0

놀랍게도 arrarr[0] 의 주소값은 같다.

따라서 배열의 이름은 배열의 첫 번째 원소의 주소값을 나타낸다는 것을 알 수 있다. 그렇다면 배열의 이름은 배열의 첫 번째 원소를 가리키는 '포인터'라고 할 수 있을까? 결론만 말하자면 아니다!

주의 사항

이 부분은 (저를 포함한) 많은 사람들이 헷갈렷던 부분들 중 하나 입니다. 포인터를 갓 배운 상태에서 읽어보면 이해가 잘 가지 않을 수 도 있으니, 나중에 포인터와 조금 친숙해진다면 꼭 다시 읽어보는 것을 추천합니다.

배열은 배열이고 포인터는 포인터이다.

예를 들어서 sizeof 를 활용한 예제를 보자 sizeof 는 크기를 알려주는 연산자이다.

#include <stdio.h>
int main(){
    int arr[10] = {1,2,3,4,5,6,7,8,9,10};
    int *p;
    p = arr;
    
    printf("Sizeof(arr) : %d \n", sizeof(arr));
    printf("Sizeof(parr) : %d \n", sizeof(p));
    
    return 0;
}
Sizeof(arr) : 40 
Sizeof(parr) : 8

재미있는 결과가 나온다.

printf("Sizeof(arr) : %d \n", sizeof(arr));

sizeofarr 자체에 그대로 사용했을 때는 배열의 실제 크기가 나온다. 우리의 arrint 형 원소 10개가 있기 때문에 크기가 40이 된다. 반면에 psizeof 연산자를 사용하면

printf("Sizeof(parr) : %d \n", sizeof(p));

배열 자체의 크기가 아니라 그냥 포인터의 크기를 출력한다. (64비트 컴퓨터를 사용하고 있으므로 8바이트가 나온다.)

따라서 배열의 이름과 배열의 첫 번째 원소의 주소값은 엄연히 다른 것 이다. 그런데 왜 도대체 왜 두 값을 출력하면 동일한 값이 나올까?

그 이유는 C 언어 상에서 배열의 이름은 sizeof 연산자나 주소값 연산자 (&) 와 사용할 때 (예를 들어서 &arr ) 를 제외하면 암묵적으로 배열의 이름을 사용하면 배열의 첫 번째 원소를 가리키는 포인터로 타입이 변환되기 때문이다.

그렇다면 왜 아래의 코드에서 배열의 시작 원소의 주소값이 나왔는지 이해 할 수 있다.

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

  printf("arr 의 정체 : %p \n", arr);
  printf("arr[0] 의 주소값 : %p \n", &arr[0]);

  return 0;
}

arrsizeof 연산자와 사용되지도 않았고 주소값 연산자와도 사용되지 않았기 때문에 arr 는 첫 번째 원소의 주소값을 가리키는 포인터로 변환되었기 때문에 &arr[0] 과 일치하게 된다.

[] 연산자의 역할

많은 사람들이 [] 또한 연산자였다는 사실에 조금 놀랐을 것이다. 하지만 앞서 연산을 배웠던 부분에서 이미 [] 가 연산자로 나왔었다.

그런데 앞서 포인터 연산이 어떻게 되는지 보았기 때문에 [] 연산자의 역할을 대충 짐작 했을 것이다.

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

  printf("arr[3] : %d \n", arr[3]);
  printf("*(arr+3) : %d \n", *(arr + 3));
  return 0;
}
arr[3] : 4
*(arr+3) : 4

사실 컴퓨터는 C에서 [] 라는 연산자를 사용하면 자동적으로 위 형태로 바꾸어서 처리하게 된다. 즉, 우리가 arr[3] 을 하면 컴퓨터는 자동적으로 *(arr + 3) 으로 바꾸어서 처리한다는 말이다.

그리고 arr+ 연산자와 같이 사용하기 때문에 앞 서 말했듯이 첫 번째 원소를 가리키는 포인터로 변환되어 arr + 3 이 포인터 덧셈을 수행하게 된다. 그리고 이는 배열의 4 번째 원소를 가리키게 된다.

따라서 다음과 같이 신기한 연산도 가능하다

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

  printf("3[arr] : %d \n", 3 [arr]);
  printf("*(3+arr) : %d \n", *(arr + 3));
  return 0;
}
3[arr] : 4
*(3+arr) : 4

3 [arr] 가 조금 이상한 형태이다. 사실 이렇게하면 가독성도 떨어져서 대부분의 프로그래머들은 arr[3] 의 형태로 사용할 것이다. 하지만 앞에서도 [] 는 연산자로 3[arr]*(3 + arr) 로 바꿔주기 때문에 arr[3] 과 동일한 결과를 출력할 수 있다.

1 차원 배열 가리키기

이전에 말했듯이 int arr[10]; 이라는 배열을 만들면 sizeof 연산자나 주소 연산자를 사용하는 경우를 제외하면 arrarr[0] 을 가리키는 포인터로 타입 변환 된다.

그렇다면 다른 int* 포인터가 이 배열을 가리킬 수 있을까??

#include <stdio.h>
int main(){
    int arr[10] = {1,2,3,4,5,6,7,8,9,10};
    int *p;
    p = arr;
		// p = &arr[0]; 과 동일하다    

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

실행 결과

2 
2

여기서 중심적으로 볼 부분은 다음과 같다.

 p = arr;

바로 arrp 에 대입되는 부분이다. 앞서 말했듯이 여기서 arr 는 배열의 첫 번째 원소를 가리키는 포인터로 변환이 되고, 그 원소의 타입은 int 형 이므로, 포인터 타입은 int * 가 될 것이다. 따라서 위 문장은 아래의 문장과 정확하게 동일하다.

p = &arr[0];

따라서 p 를 이용하여 arr 를 이용했을 때와 동일하게 배열의 원소에 마음껏 접근할 수 있게 되는 것이다. 위 모습을 그림으로 그리면 다음과 같다.

출처:https://modoocode.com/25

참고적으로 방의 크기는 그림의 단순화를 위해 4바이트로 정했다.

#include <stdio.h>
int main(){
    int arr[10] ={100, 98, 97, 95, 89, 76, 92, 96, 100, 99};
    int *p = arr;
    int sum =0;
    
    while (p - arr <=9){
        sum += (*p);
        p++;
    }
    
    printf("내 시험 점수 평균 : %d \n",sum/10);
    
    
    
    return 0;
}

실행 결과

내 시험 점수 평균 : 94

일단 다음을 보자

int *p = arr;

우선 int 형 1차원 배열을 가리킬 수 있는 int* 포인터를 정의했다. 그리고 이 p 는 배열 arr 을 가리킨다.

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

여기서는 이해하는데 큰 무리가 없을 것이다 만약에 while 을 까먹었으면 반복문을 다시 보는걸 추천한다.

sum += (*p);sum = sum + *p 와 같다는 것을 알 수 있을 것이다.

p++;

그리고 p 를 1 증가시켜주었다. 이전에 공부 했듯이 포인터 연산에서 1을 더하면 주소값에 + 1이 되는 것이 아니라 1 * (포인터가 가리키는 타입의 크기) 가 더해진다는 뜻이다.

즉, int 형 포인터 이므로 4바이트가 더해져 배열의 그 다음 원소를 가리카게 된다.

그렇다면 이제 위 코드를 이해하는데 문제가 없을 것이다. 그런데 여기서 왜 굳이 p = arr 를 해서 따로 선언해 주었을지 의문이 들 수 있다. 그냥 arr 만 사용해서는 풀 수 없을까? arrarr[0] 을 가리킨다는 것을 알고 있으니까 arr 를 증가시켜서 *(arr) 를 통해서 접근해서 풀면 어떨까 라는 생각이 들 수 있다.

#include <stdio.h>
int main() {
  int arr[10] = {100, 98, 97, 95, 89, 76, 92, 96, 100, 99};

  arr++;  // 오류
  return 0;
}

위 코드를 컴파일 해보면 오류가 발생한다.

배열의 이름이 배열의 첫 번째 원소를 가리키는 포인터로 타입이 변경이 된다고 했을 때, 이는 단순히 배열의 첫 번째 원소를 가리키는 주소값 자체가 될 뿐이다. 따라서 arr++ 문장을 C 컴파일러 입장에서 보면 다음과 같다.

(0x7fff1234) ++;

포인터의 포인터

int **p;

이건 **int 를 가리키는 포인터의 포인터** 라고 할 수 있다. 쉽게 머리에 와 닿지 않을 수 있다.

#include <stdio.h>
int main(){
    int a;
    int *p;
    int **pp;
    
    p = &a;
    pp = &p;
    
    a = 3;
    
    printf("a : %d // *p : %d // **pp : %d \n", a, *p, **pp);
    printf("&a : %p // p : %p // *pp : %p \n", &a, p, *pp);
    printf("&p : %p // pp : %p \n", &p, pp);

    
    
    
    return 0;
}

실행 결과

a : 3 // *p : 3 // **pp : 3 
&a : 0x16fdff3f8 // p : 0x16fdff3f8 // *pp : 0x16fdff3f8 
&p : 0x16fdff3f0 // pp : 0x16fdff3f0

결과는 약간 다를 수 있지만 같은 행에는 같은 결과가 나온다는 것에 주목하자.

사실 생각해보면 간단하다.

pp 에는 p 의 주소값이 들어가 있고, p 에는 a 의 주소값이 들어가 있다.

(위 그림은 https://modoocode.com/25 에서 가져온 것으로 필자는 ppa 는 pp, pa 는 p 로 바꾼 것이다.)

pp 에는 p 의 주소값이 들어가 있고, p 에는 a 의 주소값이 들어가 있다.

배열 이름의 주소값?

이전에 배열 이름은 sizeof 연산자나 주소 연산자를 사용할 때 빼고는 전부 포인터 타입으로 변환이 된다고 했다. 그렇다면 주소 연산자를 사용하면 어떻게 되길레 그러는지 알아보자.

#include <stdio.h>

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

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

실행 결과

arr[1] : 2
parr[1] : 2

이다. 먼저 아래의 문장을 살펴보자.

int (*parr)[3] = &arr;

&arr 는 무슨 의미일까? 주소 연산자와 같이 사용 되었기 때문에 포인터의 주소값은 아닐 것이다.

arr크기가 3인 배열이기 때문에 &arr 를 보관할 포인터는 크기가 3인 배열을 가리키는 포인터가 되어야 할 것이다. 그리고 C 언어 문법상에서 이를 정의하면 위와 같다.

참고로 parr 를 정의할 때 *parr 를 꼭 () 로 감싸야한다. 만일 괄호를 빼버리면

int *parr[3]

와 같이 되어서 C 컴파일러는 int * 원소가 3개를 가지는 배열을 정의하는 것으로 생각한다.

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

parr 은 크기가 3인 배열을 가리키는 포인터이기 때문에 배열을 직접 나타내기 위해선 * 연산자를 사용하여 arr 를 참조해야 한다. 따라서 (*parr)[1]arr[1] 과 같은 문장이 된다.

한 가지 재미있는 점은 parrarr 은 같은 값을 가진다는 것이다.

#include <stdio.h>

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

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

실행 결과

arr : 0x7ffda08cd25c
parr : 0x7ffda08cd25c

arrparr 모두 배열의 첫 번째 원소의 주소값을 출력한다. 물론 두 개의 타입은 다르지만 말이다.

이는 사실 당연한 것인데 arr 자체가 어떠한 메모리 공간에 존재하는 것이 아니기 때문이다.

이와 같이 C 언어가 이상하게 작동하는 이유는 역사에 숨겨져 있다.

C 언어는 B 언어에서 파생된 언어인데, B 언어에서는 실제 배열이 있고, 배열을 가리키는 포인터가 따로 있었습니다. B 언어에서 arr 과 arr[0]arr[1] 은 각기 다른 메모리를 차지하는 녀석들이고, arr 이 실제로 arr[0] 를 가리키는 포인터 였습니다. 따라서 arr 의 값을 출력하면 실제로 arr[0] 의 주소값이 나왔고, &arr 은 arr 의 주소값이 나왔겠지요. 따라서 B 언어에서 arr 과 &arr 은 다른 값을 출력했을 것입니다.

하지만 C 언어를 만든 데니스 리치 아저씨는 B 언의 문법을 계승하되, 이와 같이 비효율적으로 배열을 정의할 때 배열의 시작점을 가리키는 포인터로 공간을 낭비하고 싶지 않았습니다. 따라서 위와 같이 조금 이상하지만 그래도 메모리 공간을 효율적으로 쓰게 되는 배열 - 포인터 관계가 탄생하게된 것입니다.

(출처 : https://modoocode.com/25)

2 차원 배열의 [] 연산자

int a[2][3];

이전에 말했지만 2차원 배열은 사실 1차원 배열이 여러 개 있다고 생각하면 된다.

위의 경우 int a[3] 배열이 2개가 메모리에서 연속적으로 존재한다.

2차원 이라는 단어 때문에 메모리에서도 2차원으로 존재 한다고 생각할 수 있지만 컴퓨터 메모리 구조는 1차원이기 때문에 항상 선형으로 퍼져있다.


실제로 프로그래밍을 하여서 주소값을 출력하면 위 사진처럼 연속적으로 되어 있다는 것을 알 수 있다.

그렇다면 위 2차원 배열에서 arr[0] 같은 애들은 무엇을 의미하는 것일까?

#include <stdio.h>
int main() {
  int arr[2][3];

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

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

  return 0;
}

실행 결과

arr[0] : 0x7ffda354e530
&arr[0][0] : 0x7ffda354e530
arr[1] : 0x7ffda354e53c
&arr[1][0] : 0x7ffda354e53c

arr[0] 의 값이 arr[0][0] 의 주소값과 같고, arr[1] 의 값이 arr[1][0] 의 주소값과 같다는 것을 알 수 있다. 사실 기존에 1차원 배열과 마찬가지로 sizeof 연산자나 주소 연산자를 사용하지 않으면 arr[0]arr[0][0]가리키는 포인터로 암묵적으로 타입이 변하arr[1]arr[1][0]가리키는 포인터로 암묵적으로 타입이 변한다.

주의 사항

1 차원 배열 int arr[] 에서 arr 과 &arr[0] 는 그 자체로는 완전히 다른 것이였던 것처럼 2 차원 배열 int arr[][] 에서 arr[0] 과 &arr[0][0] 와 다릅니다. 다만 암묵적으로 타입 변환 시에 같은 것으로 변할 뿐입니다.

2 차원 배열 행과 열 개수 구하기

따라서 sizeof 를 사용하면 2차원 배열의 행과 열의 개수를 구할 수 있다.

#include <stdio.h>
int main() {
  int arr[2][3] = {{1, 2, 3}, {4, 5, 6}};
  printf("전체 크기 : %d \n", sizeof(arr));
  printf("총 열의 개수 : %d \n", sizeof(arr[0]) / sizeof(arr[0][0]));
  printf("총 행의 개수 : %d \n", sizeof(arr) / sizeof(arr[0]));
}

실행 결과

전체 크기 : 24
총 열의 개수 : 3
총 행의 개수 : 2

sizeof(arr[0]) 의 경우 0번째 행의 크기가 나올 것이다. int 형 이므로 크기가 4 이니까 3 * 4 = 12 즉, 12가 나올 것이다. 이와 같은 개념으로 생각하면 위의 코드를 해석하는데 문제가 없을 것 이다.

여기까지 잘 왔다면 질문을 해보겠다.

만일 2차원 배열의 이름을 포인터로 전달하기 위해서 해당 포인터의 타입은 무엇이 될까? arr[0]int * 가 보관 할 수 있으니까 arrint ** 이 보관할 수 있을까? 답은 아니다!

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

우선 위에서 2차원 배열의 이름이 왜 int ** 가 될 수 없는지 살펴보자 만약에 int ** 형이 될 수 있다면 앞서 1차원 배열에서도 했던것 처럼 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;
}

컴파일 오류

test.c: In function ‘main’:
test.c:6:8: warning: assignment to ‘int **’ from incompatible pointer type ‘int (*)[3]’ [-Wincompatible-pointer-types]
    6 |   parr = arr;
      |

그런데 컴파일시 이런 경고가 나타난다. 무시하고 실행하면

실행 결과

arr[1][1] : 5
[1]    8834 segmentation fault (core dumped)  ./test

이전에 배열을 배웠을 때도 나왔던 오류가 나타난다. 그 때 int arr[3]; 을 했는데 arr[10] = 2; 와 같이 허가되지 않는 공간에 접근하려 하기만 해도 위와 같은 오류가 나왔었다.

위 예제도 마찬가지이다. parr[1][1] 에서 이상한 메모리 공간의 값에 접근하였기 때문에 발생한 일이다. 왜 이런일이 일어났을까?

먼저, int arr[10] 이라는 배열에서 x 번째 원소의 주소값을 알아내는 방법을 생각해보자. 만약일 배열의 시작 주소를 그냥 arr 이라 한다면 arr[x] 의 주소값은

arr+4xarr+4x

와 같이 나타낼 수 있다. 이번에는 int arr[a][b] 라는 2차원 배열을 생각해보자. 여기서 arr[x][y] 라는 원소를 참조할 때 이 원소의 주소값을 어떻게 알 수 있을까? int arr[a][b]arr[b] 짜리 배열이 a 개 있다고 생각하면 된다. 따라서 arr[x][0] 의 주소값은 x 번째 int arr[b] 짜리 배열일 것이다. 그러면 주소값은 arr 가 시작주소라고 할 때

arr+4xbarr + 4xb

가 된다. 그러면 arr[x][y] 의 주소값은

arr+4xb+4yarr + 4xb + 4y

가 된다는 것을 알 수 있다. 여기서 중요한 것은 arr[x][y] 의 주소값을 알기 위해서 x , y 뿐만 아니라 b 의 값도 알아야 한다는 것이다.

따라서 2차원 배열을 가리키는 포인터를 통해서 원소에 정확하게 접근하기 위해서는 다음이 필요하다.

  1. 가리키는 원소의 크기(여기선 4)
  2. b의 값

위 두가지 정보가 있다면 컴파일러는 원소에 올바르게 접근할 수 있다. 그렇다면 실제로 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;
}

실행 결과

parr[1][2] : 6 , arr[1][2] : 6

2차원을 가리키는 포인터는 배열의 크기에 관한 정보를 담고 있어야 한다고 했다.

int (*parr)[3];

이렇게 포인터를 정의했다. 이제 앞서 말했던 두 가지 정보를 알 수 있는지 확인해보자.

먼저 배열의 형을 통해서 원소의 크기를 알 수 있다.(조건1 만족)

그리고 2차원 배열의 열의 개수 를 통해서 b 를 구할 수 있다.(즉, 배열의 한 행의 크기) 따라서 (조건2 만족)

아무튼 이와 같이 정의된 포인터 parr 을 해석 해보면 int 형 2차원 배열을 가리키는데 , 그 배열의 한 행의 크기는 3인 것을 알 수 있다.

그런데 위와 같은 형태를 어디서 많이 본 것 같다는 것을 알 수 있다. 저 parr크기가 3인 배열을 가리키는 포인터를 의미한다. 그런데 이게 말이 되는 이유는 1차원 배열에서 배열의 이름은 첫 번째 원소를 가리키는 포인터로 변환된 것 처럼 2차원 배열에서 배열의 이름은 첫 번째 행을 가리키는 포인터로 타입이 변환이 된다. 그리고 첫 번째 행의 크기는 3인 1차원 배열이다.

#include <stdio.h>
int main() {
  int arr[2][3];
  int brr[10][3];
  int crr[2][5];

  int(*parr)[3];

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

  return 0;
}

앞선 내용이 이해가 되었다면 위 코드도 왜 arrbrr 은 문제가 없는데 crr 은 문제가 있는지 알 수 있을 것이다.

그럼 다음 예제를 보자

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

  parr = arr;

  printf("parr[1][1] : %d \n", parr[1][1]);  // 버그!

  return 0;
}

무슨 일이 일어났을까? 일단 parr 에는 arr 의 주소가 들어가 있다. 그런데 parr[1][1] 이 어떻게 해석되는지 보자.

parr[1][1]*(*(parr + 1)+1) 과 동일한 문장이다. parr + 1 은 뭐가 될까? 일단 parrint* 를 가리키는 포인터이고, int* 의 크기는 8바이트(주소값을 가지고 있으니까)이기 때문에 parr + 1 하면 실제 주소값이 8 증가하게 된다.

따라서 parr + 1 은 배열의 3 번째 원소의 주소값을 가지게 된다.(왜냐하면 int 가 4바이트 이기 떄문이다.) 따라서 *(parr + 1) 의 값은 3이 된다. 그렇다면 *(parr + 1)+1 은 어떻게 될까? *(parr + 1)int 이므로 4 를 더하게 되어서 *(parr + 1)+1 는 7이 된다. 따라서 *(*(parr + 1)+1) 은 마치 주소값 7에 있는 값을 읽어라 라는 말이 된다. 그리고 해당 위치는 프로그램이 읽을 수 없기 때문에 오류가 발생하게 되는 것이다.

포인터 배열

이제 마지막으로 포인터 배열에 대해서 알아보자

포인터 배열은 말 그대로 포인터들의 배열이다. 앞서 배열 포인터를 공부했어서 둘이 헷갈릴 수 있겠다. 배열 포인터는 배열을 가리키는 포인터이고 포인터 배열은 포인터들의 배열이다. 그냥 언제나 뒷부분이 진짜라고 생각하면 편하다. 포인터 배열는 배열이고, 배열 포인터는 포인터이다.

#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 : %p, arr[0] : %p \n", &a, arr[0]);
  return 0;
}

실행 결과

a : 1, *arr[0] : 1
b : 2, *arr[1] : 2
b : 3, *arr[2] : 3
&a : 0x7ffe8a2fa4e4, arr[0] : 0x7ffe8a2fa4e4

먼저 다음 문장을 보자

int *arr[3];

우리가 배열의 형을 int , float , char 로 정의 했듯이 int* 로도 정의 할 수 있다. 즉, 배열의 각각의 원소를 int 를 가리키는 포인터형으로 선언한 것이다. 따라서 배열의 각각의 원소를 포인터 취급할 수 있다. 마치 아래처럼

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

그림만 보아도 포인터 배열을 이해하는데 문제가 없을 것이다. 포인터 배열에 대해서는 여기까지만 짧게 보고 넘어가겠다. 하지만 C 언어에서 상당히 중요하게 다루어지는 개념이다. 그 부분에 관한 얘기는 아직 하기에는 시기상조이기 때문에 나중에 차차 더 알아보자.

profile
벨로그보단 티스토리를 사용합니다! https://flight-developer-stroy.tistory.com/

0개의 댓글