OneDev

[C] 18. 포인터(pointer) 본문

Language/C

[C] 18. 포인터(pointer)

one_dev 2022. 7. 27. 21:34

▣ 목차


1. 포인터란?

2. 포인터 정의하기

3. 주소연산자(&)

4. * 연산자

5. 포인터에 타입이 있는 이유?

6. 상수포인터?

7. 포인터의 덧셈

8. 배열과 포인터

9. 배열을 가리키는 포인터

10. 포인터 배열


1. 포인터(pointer) 란?


포인터 : (메모리 상의) 특정 데이터의 주소값 을 보관하는 변수. (정확히는 시작 주소값)


포인터는 int 형 변수, char 형 변수와 같은 변수들과 전혀 다른 것이 아니다.

int 형 변수가 정수 데이터를 보관하고, float 형 변수가 실수 데이터를 보관했던 것처럼

포인터는 데이터가 저장된 주소값을 보관하는 변수인 것이다.

 

(참고)

포인터도 엄연한 변수이기 때문에 메모리 상에서 공간을 차지하고, 포인터 자신만의 주소를 갔고있다.

포인터 변수의 주소는 더블포인터(포인터의 포인터?)를 이용해 나타낼 수 있다.

2. 포인터 정의하기

포인터 p 가 A 라는 데이터가 들어있는 메모리 공간의 주소값을 저장하는 상황을 생각해보자.

이 때 포인터를 다음과 같이 정의할 수 있다

* 기호를 데이터형 뒤에 붙이냐 포인터 앞에 붙이냐에 따라 두 가지 방법이 있는데 어느 방법을 쓰던 상관 없다.

우리가 p 라는 포인터 변수를 통해 int 형 데이터 A 가 저장된 주소값을 가리키고 싶다면, int *p; (또는 int* p) 와 같이 정의하면 된다는 얘기다.

이렇게 정의하고 나면 p 는 int 형 데이터 A의 주소값을 저장하는 변수가 된 것이다.

 

3. 주소연산자(&)

포인터 변수를 정의하는 법을 알았으니 이제 정의한 포인터 변수에 어떻게 값을 넣을지에 대해 알아보자.

포인터 변수는 주소값을 저장하는 변수이므로, 주소값을 대입해주어야 한다.

그렇다면 주소값을 어떻게 대입할까? 메모리 상의 주소는 16진수로 표기된데다 숫자도 엄청나게 많아서 복잡한데 말이다.

 

결론부터 얘기하면 주소연산자 " & " 를 이용해 데이터의 주소값을 나타내는 것이 가능하다.

사용방법은 간단하다. 주소를 알고싶은 데이터 앞에 &를 붙여주면 된다.

&a;  // a의 주소값
int *p = &a // 포인터 변수 p 에 a 의 주소값을 넣었다!

( 참고 )

& 가 이항연산자(binary operator)로 사용될 때는 AND 연산자로서 기능하지만,

& 가 단항연산자 (unary operator) 로 사용될 때는 주소연산자로서 기능한다.

a & b; // AND 연산자
a &;  // 오류가 난다
&a;   // a의 주소값

 

4. * 연산자

& 연산자가 어떤 데이터의 주소가 어딘지 알려주는 역할을 했다면,

어떤 주소에 무슨 데이터가 들어있는지 알려주는 역할을 하는 연산자가 * 연산자 이다.

다시말해, * 연산자는 어떤 주소값에 대응되는 데이터를 가져오는 연산자인 것이다.

(참고)

& 의 경우와 비슷하게, 

* 연산자가 이항연산자로 사용될 때는 일반적인 곱셈을 의미하고,

* 연산자가 단항연산자로 사용될 때는 주소값에 대응되는 데이터를 가져오는 역할을 한다.

a * b;  // a 곱하기 b 를 의미한다 (이항 *연산자)
a* ;  // 오류 !
*a ;  // 단항 * 연산자 (이 경우 a 는 어떤 데이터의 주소값일 것이다)

 

*p 와 변수 a 는 정확히 동일하다

 

 

 

5. 포인터에 타입(type) 이 있는 이유?

앞서 말했지만 포인터에도 int *p 와 같이 타입을 명시해줘야 한다.

포인터는 변수라 했으니 그냥 pointer 라는 새로운 자료형을 만들어서 포인터를 선언할 때 사용하면 안되는 것일까?

다음의 예를 통해 포인터에 타입이 왜 필요한지에 대해 알아보자.

#include <stdio.h>

int main(){
    int a;
    pointer *p;	// pointer 라는 자료형이 있다면 참 편할텐데!
    p = &a;	// p 라는 포인터 변수에 a 의 주소값을 넣어주었다
    *p = 4;	 // (p = a의 주소) -- > 이 주소에 들어있는 값을 4로 하겠다!
    
  	return 0;
}

위의 코드를 차근차근 살펴보자.

int a;	// a 를 위해 메모리 상에 4바이트(int형 이니까!) 만큼의 공간을 마련해주었다

(참고) 주소값은 32비트 운영체제에서 4바이트, 64비트 운영체제 에서는 8바이트 만큼의 공간을 차지한다.

pointer *p; // p라는 pointer 변수를 위해 8바이트의 공간을 마련해줬다
p = &a; // 그리고 p에 (a의 주소값) 을 전달해줬다

여기까지는 이상없다.

문제가 발생하는 것은 다음부터이다.

*p = &a;

포인터 변수 p 에는 분명 a 의 주소가 들어가있다.

문제는 p에 들어가 있는 a의 주소값은 시작주소라는 점이다.

a가 메모리에서 차지하는 모든 주소들의 위치가 들어가있는 것이 아닌것이다.

따라서 *p 라고만 하면 컴퓨터는 메모리에서 얼마만큼의 공간을 읽어야 할 지 알 수 없다.

이에 포인터 변수가 가리키는 주소에 위치한 값의 데이터형을 적어주어 컴퓨터가 얼마만큼의 공간을 메모리에서 읽을지 알려주는 것이다.

int *p = &a; // "이 포인터는 int 형 변수를 가리키는구나 ! >> 시작 주소로부터 정확히 4바이트 읽어야지"

 

6. 상수 포인터?

일반적으로 어떤 데이터의 값이 변하지 않게끔(상수가 되게끔) 하기 위해 const 를 이용하였다.

포인터의 경우 const 를 어떻게 이용할 수 있는지 알아보자.

#include <stdio.h>

int main(){
    int a;
    int b;
    
    const int *pa = &a; //  pa 는 a 의 주소값을 가리키는 포인터 변수인데 const?
    
    *pa = 3;    // 잘못된 문장
    pa = &b;    // 올바른 문장(why?)
    
    return 0;
    
}

위의 코드를 실행해보면 다음과 같은 오류가 난다.

왜 이런 오류가 발생한걸까?

이에 대한 답을 얻기 위해 오류가 발생한 줄의 코드를 살펴보자.

*pa = 3;  // 오류가 발생한 문장

이 문장의 어디가 잘못되었는지 알기위해서는 const int 에 대해 살펴볼 필요가 있다.

 

우리는

const int *pa = &a;

의 코드를 통해 pa 라는 포인터 변수에 a 의 주소값을 대입해주었다.

그리고 const를 붙여줌으로써 이 값이 바뀌지 않는 값임을 선언하였다.

 

 여기서 const int가 뜻하는 바는 다음과 같다.

const int

"const int" 형의 변수를 선언" --> (X)

"int 형 변수를 선언. 다만 그 값은 바뀔 수 없는 상수임" --> (O)

 

즉, 포인터 변수 pa 의 값은 절대로 바뀔수 없는 것이고, 그렇기 때문에 *pa =3 이라는 코드에서 오류가 발생하는 것이다.

(바뀔 수 없는 값을 바꾸려고 시도하는 코드기 때문이라고 하면 이해가 편할듯? 하다. 적절한 비유인지는 의문이다)

 

위의 예시 코드에서 아주 살짝만 바뀐 또다른 예시 코드를 보자.

#include <stdio.h>

int main() {
  int a;
  int b;
  int* const pa = &a;   // 위의 예시에서는 const int *pa 였다 (순서 바뀜)

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

  return 0;
}

물론 이 예시코드도 컴파일해보면 오류가 난다.

하지만 오류가 발생하는 위치는 달리진다.

int* const pa = &a;

우리는 int* 를 가리키는 pa 라는 포인터를 정의하였다.

달라진 것은 const 키워드가 int* 앞에 있는 것이 아니라 int* 와 pa 사이에 놓이게 되었단 것이다.

const 의 의미를 고려하면, 이는 pa 의 값이 바뀔수 없는 것임을 의미한다.

즉, 포인터 변수 pa 는 처음에 가리킨 주소(a의 주소) 외에 다른 것을 가리킬 수 없단 의미이다.

다만 해당 주소에 들어있는 값에 대해서는 바꿀 수 없다는 말은 한적 없기 때문에 

*pa 와 같이 해당 주소에 들어있는 값을 수정하는 것은 가능하다.

 

7. 포인터의 덧셈

(1) 포인터에 정수를 더하는 경우

이번에는 포인터의 덧셈에 대해 알아보자.

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

  printf("pa 의 값 : %p \n", pa);		// pa 의 값 출력 -> a의 주소가 나올 것
  printf("(pa + 1) 의 값 : %p \n", pa + 1); 		// pa + 1 은 무슨값이 나올까?

  return 0;
}

 

위의 코드를 컴파일 해보면 다음과 같은 결과를 얻을 수 있다.(구체적인 값은 컴퓨터마다 다를 수 있으니 참고)

16진수로 표현된 주소

눈여겨 봐야할 것은 pa 의 값과 pa +1 의 값이 정확히 4 차이난다는 것이다.

왜 1을 더했는데 4가 증가한 걸까?

그 이유는 포인터에 정수룰 더하면 그 포인터가 가리키는 데이터 형의 크기만큼 곱해서 더하기 때문이다.

위의 예시에서 pa 가 int형 변수를 가리키기 때문에 int형 데이터의 크기인 4 만큼 더한 것이다.

데이터형이 char 이었다면 1만큼 더했을 것이고, double 이었다면 8만큼 더했을 것이다.

만약 pa 가 int 형 변수를 가리켰을 때 pa +2를 했다면 2 x 4 = 8 만큼 증가했을 것이다.

왜 이렇게 덧셈이 수행되는지는 뒤에 나올 배열과 포인터에 대해 알아보면 알게될 것이다.

 

뺄셈의 경우 덧셈과 본질적으로 다르지 않기 때문에 넘어가도록 하겠다.

(직접 해보면 덧셈과 유사한 결과가 나올 것이다).

 

(2) 포인터끼리의 덧셈

결론부터 말하면, 포인터끼리의 덧셈은 허용되지 않는다.

사실 포인터 끼리의  덧셈, 뺄셈은 아무런 의미도 없고, 할 필요도 없다.

포인터는 주소를 가리키는 변수인데, 주소끼리 더하고 빼봤자 엉뚱한 주소밖에 나오지 않기 때문이다.

놀라운 사실은 포인터끼리의 뺄셈은 허용된다는 것이다.(이유는 모름)

8. 배열과 포인터

배열의 원소들은 메모리 상에 연속되게 놓인다.

이를 예시 코드를 통홰 확인해보자.

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

  for (i = 0; i < 10; i++) {
    printf("arr[%d] 의 주소값 : %p \n", i, &arr[i]);
  }
  return 0;
}

컴파일 하면 

 

주소값이 4씩(int 형) 증가하는 것을 확인할 수 있다.

 

이 사실을 이용하면 포인터를 이용해 배열의 원소에 쉽게 접근하는것이 가능해진다.

 

포인터에 정수값을 더하면 그 포인터가 가리키는 데이터형의 크기만큼 곱해서 더한다는것을 바로 위에서 알아보았다.

배열의 시작 주소를 가리키는 포인터를 정의한 뒤 

그 포인터에 1을 더하면 그 다음 원소의 주소를 가리키게 되고,

2를 더하면 다다음 원소의 주소를 가리키게 되고

...

n 을 더하면 n+1번째 원소의 주소를 가리키게 되는 식이다.

즉, 포인터에 정수를 더하는 것 만으로도 배열의 각 원소를 가리킬 수 있다.

 

또한, * 을 이용하여  원소들과 똑같은 역할을 할 수 있게된다.

(예시)

#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;
}

이를 실행하면 다음과 같은 결과를 얻을 수 있다.

이를 알기 쉽게 그림으로 나타내보았다.

 

 

 

 

※ 참고 : 사실 배열의 이름은 배열의 시작 주소를 가리킨다! 

다만 그렇다고 해서 배열의 이름이 포인터인 것은 아니다. (배열은 배열이고 포인터는 포인터다).

정확히는, C 언어 상에서 배열의 이름이 sizeof 연산자나 주소연산자 & 와 사용될 때를 제외하고,

배열의 이름을 사용시 암묵적으로 배열의 첫 원소를 가리키는 포인터로 타입 변환되는 것이다.

(단, 이러한 암묵적 변환은 주소값 연산자 &가 붙어있는 경우엔 이루어지지 않는다 / 예시 - &arr ) 

 

 

9. 배열을 가리키는 포인터

위에서 얘기한 것을 요약하자면 다음과 같다.

  • sizeof 와 주소값 연산자(&)을 사용할 때를 제외하면, 배열의 이름은 첫 번째 원소를 가리킨다.
  • arr[i] 와 같은 문장은 사실 컴파일러에 의해 *(arr + i) 로 변환된다.

(1) 1차원 배열을 가리키는 포인터

 

아래와 같은 배열을 만들었다고 생각해보자.

int arr[10];

앞서 언급한 특수한 경우를 제외하고 arr 은 arr[0] 을 가리키는 포인터로 타입이 변환된다.

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

 

이에 대한 답을 얻기위해 아래의 예시 코드를 실행해보자.

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

  parr = arr;  // parr = &arr[0]; 도 동일하다

  printf("arr[1] : %d \n", arr[1]);     // 배열의 두 번쨰 원소값 출력
  printf("parr[1] : %d \n", parr[1]);    // parr 은 포인터인데 parr[1] 은 뭘까??
  return 0;
}

를 실행하면

같은 값이 출력된 것을 볼 수 있다.

 

어떻게 이런 결과값이 나왔는지 살펴보면서 질문에 대한 답을 찾아보도록 하자.

parr = arr;

우리는 parr 에 arr 을 대입하였다.

앞서 말했듯이 arr 은 배열의 첫 원소를 가리키는 포인터로 변환되고, 그 원소의 타입이 int 이므로

포인터의 타입은 int* 가 될것이다.

따라서 이 문장은 아래와 정확히 동일한 문장이 된다.

parr = &arr[0]

따라서  parr 을 통해 arr 을 이용했을 때와 동일한 방식으로 배열의 원소에 접근할 수 있게 된것이다.

이것을 그림으로 나타내보면 다음과 같다

참고) 한 방의 크기는 편의를 위해 4바이트로 하였음

(2) 2차원 배열을 가리키는 포인터

보통 2차원 배열이라 하면 말 그대로 2차원 형태로 이루어진 구조를 떠올린다(적어도 나는 그렇다).

하지만 컴퓨터 메모리는 1차원 구조이기 때문에, 2차원배열이든 3차원 배열이든 사실 메모리 상에 선형적으로 존재한다.

출처 : 모두의 코드 블로그

그렇다면 위의 2차원 배열에서 arr[0] 은 무엇을 의미할까?

아래의 코드를 통해 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] 은 arr[0][0] 의 주소값과 같고,

arr[1] 은 arr[1][0] 의 주소값과 같은 것을 볼 수 있다.

 

이를 통해 우리는

기존과 마찬가지로 (sizeof 나 주소연산자와 같이 사용되는 경우를 제외하고) arr[0] 은 arr[0][0] 을 가리키는 포인터로,

arr[1] 은 arr[1][0] 을 가리키는 포인터로 암묵적으로 변환된다는 것을 알 수 있다.

이를 응용하여 2차원 배열의 행의 개수, 또는 열의 개수를 계산할 수 있다

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]));
}

 

 

 

이제 2차원 배열을 가리키는 포인터에 대해 알아보자.

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

그 형식은 다음과 같다

예시를 해석해보면, int 형 2차원 배열을 가리키는데 그 배열의 한 행의 길이가 3 이라는 뜻이다.

 

 

10. 포인터 배열

포인터 배열이란 말 그대로 포인터들의 배열 이다.

바로 위에서 설명한 것은 배열을 나타내는 포인터들인 것과 반대로 포인터들을 모아놓은 배열인 것이다.

(배열은 배열이고 포인터는 포인터다 라는 말을 기억하자)

 

먼저 예시코드를 실행해보자.

#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;
}

실행해보면

구체적인 주소값은 컴퓨터마다 다를 수 있으니 참고

 

먼저 arr 배열의 정의 부분부터 보도록 하자.

int *arr[3];

위 정의는 아래의 정의와 동일하다.

int* arr[3];

우리가 배열의 형을 int, char 등등으로 할 수 있듯이,  배열의 형을 int* 로도 할 수 있다

다시말해, 배열 각각의 원소가 int 를 가리키는 포인터로 선언된 것이다.

따라서 int 배열에서 각각의 원소를 int 형 변수로 취급했던 것처럼, int* 배열에서 각각의 원소를 포인터로 취급할 수 있다.

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

각각의 원소는 int 형 변수 a, b, c 를 가리키게 된다.

이를 그림으로 표현해보면 다음과 같다.

arr[0]에는 a의 주소값, arr[1] 에는 b 의 주소값, arr[2]에는 c의 주소값

 

Comments