- 포인터(Pointer)
- 상수 포인터 (Constant Pointer)
- 포인터의 연산
- Q&A
- 마치며
모든 데이터들은 메모리 상에 특정한 공간에 저장되어 있습니다.
제가 공부하고 원본이 되는 글인 [모두의 코드](자세한 참조는 글 맨 밑에 있습니다.)에서는,
이러한 특정한 공간을 '방'이라고 지칭했습니다!
한 방의 크기는 보통 1바이트입니다.
프로그램을 작동 시에는 컴퓨터는 여러 방들에 있는 데이터를 필요로 합니다.
따라서 어떤 방에서 데이터를 가져올 지 구분하기 위해 각 방에 고유한 주소(Address)를 붙였습니다.
예를 들어, 우리가 int형 변수 a
를 정의했다면 특정한 방에 변수 a
가 저장됩니다.
int a = 123; //int는 4바이트를 차지하므로 메모리 4칸을 차지
만약 a
의 방 주소가 0x1234
라고 한다면,
해당 위치부터 4바이트 공간에 있는 데이터를 123
으로 바꿉니다.
그런데 C에서는 아주 악명 높은 '포인터(Pointer)'라는 것을 만들었습니다.
사실 이름에서부터 '가리키는 것(가르쳐지는 대상이 아닙니다)'라는 의미입니다.
포인터 자체도 다른 변수들과 전혀 다르지 않은 하나의 '변수'입니다!
int형은 정수 데이터, float는 실수 데이터를 보관했던 것처럼, 포인터도 특정한 데이터를 보관하는 변수입니다.
바로, 특정한 데이터가 저장된 주소값을 보관하는 변수입니다.
'주소값'에 주목합시다!!!!!!!!!!!!!!!!!!!!!!!
- 포인터(Pointer)
: 메모리 상에 위치한 특정한 데이터의 시작 주소값을 보관하는 변수
변수를 정의할 때, int나 float처럼 여러 형(Type)들이 있듯이,
포인터에도 여러 형(Type)을 가집니다.
즉, int형 데이터 주소값을 저장하는 포인터와
float형 데이터 주소값을 저장하는 포인터는 서로 다르다는 것입니다.
우리가 대부분 사용하는 컴퓨터는 64비트, 즉 8바이트 주소값을 사용합니다.
그렇다면 주소값을 저장하는 변수인 포인터는 다 똑같은 크기를 가질텐데,
왜 Type을 구분할까요?
(나중에 살펴보겠습니다.)
(포인터에 주소값이 저장되는 데이터의 형) *(포인터 이름); (포인터에 주소값이 저장되는 데이터의 형)* (포인터 이름);
예를 들어, p
라는 포인터가 int
데이터를 가르키고자 한다면,
int *p;
int* p;
위처럼 작성하면 됩니다.
그렇다면 포인터 p
는 int형 데이터의 주소값을 저장하는 변수가 됩니다.
그런데 보통
int *p;
이런 식으로 많이 사용한다고 하네요.
그 이유는,
우리가 똑같은 타입의 변수를 선언할 때
int a, b, c;
이런식으로 하잖아요?
포인터도 마찬가지 입니다.
int *pa, *pb, *pc;
이런식으로요.
그런데 만약,
int* pa, pb, pc;
이런 식으로 선언하면,
pa
는 int형 데이터를 가리키는 포인터이지만,
pb
, pc
는 그냥 int형 변수입니다.
그러니, 이러한 실수를 방지하기 위해서는
int *p;
이렇게 습관을 들이는게 좋겠죠?
&
연산자포인터를 주소값을 저장하는 변수라고 정의했습니다.
그런데, 우리가 주소값을 어떻게 알 수 있을까요?
바로 &
를 이용합니다!!
&
연산자는 두 가지의 방식으로 사용할 수 있습니다.
- & 연산자 (Ampersand)
- AND 연산 : 이항연산자(Binary)
(a & b)
- 주소값 참조 : 단항연산자(Unary)
(&a)
&(주소값을 계산할 데이터)
2번째 방식인,
단항연산자(Unary)로 사용될 때
즉, 주소값을 참조할 때의 예시를 살펴봅시다.
#include <stdio.h>
int main() {
int a = 2;
printf("%p", &a);
return 0;
}
[Result]
000000F1DB6FF694
아마 위 프로그램을 실행할 때 마다 매번 결과가 달라질 것입니다.
위 프로그램은, &a
의 값을 16진수 형태(%p
)로 출력하고 있습니다.
결과를 보면, int형 변수 a
는 0x000000F1DB6FF694
를 시작으로 4바이트 공간을 차지하고 있습니다.
우리가 주목해야 하는 점은, 바로 값이 출력되었다는 것입니다!
다음 예제를 봅시다.
포인터 p
는 변수 a
가 저장된 주소값을 가집니다.
#include <stdio.h>
int main() {
int* p;
int a;
p = &a;
printf("포인터 p에 저장된 값 : %p\n", p);
printf("변수 a가 저장된 주소 : %p", &a);
return 0;
}
[Result]
포인터 p에 저장된 값 : 00000052BB18F8A4
변수 a가 저장된 주소 : 00000052BB18F8A4
참고로, 한 번 정의된 변수의 주소값은 바뀌지 않습니다.
따라서, 포인터 p
에 저장된 값, 변수 a
의 주소값이 동일하게 출력됩니다.
*
연산자포인터 : 특정한 데이터의 주소값 보관
이 때, 포인터는 *
를 붙임으로써 정의되며,
&
연산자로 특정 데이터의 메모리 상의 주소값을 알아올 수 있다는 것을 배웠습니다....
(벌써 지치네요..😅)
우리가 &
연산자로 데이터의 주소값을 알 수 있었습니다.
그러면 반대로, 특정 주소값의 데이터를 알 수 있을까요?
당연히 가능하겠죠!
바로, *
연산자입니다.
*
연산자도 &
연산자와 마찬가지로 2가지 방식으로 사용됩니다.
- * 연산자 (Asterisk)
- 곱셈 연산 : 이항연산자(Binary)
(a * b)
- 데이터 참조 : 단항연산자(Unary)
(*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;
}
[Result]
a 의 값 : 2
*p 의 값 : 2
포인터 p
에는 a
의 주소값을 넣었습니다.
그리고 a
에는 2
를 넣었습니다.
그리고 출력으로 a
의 값을 출력했습니다.
이후에는 *p
의 값을 출력하라고 했습니다.
즉, *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;
}
[Result]
a 의 값 : 3
*p 의 값 : 3
위의 예제도 마찬가지입니다.
포인터 p
는 변수 a
의 주소값을 가리킵니다.
그리고 포인터 p
가 가리키는 주소값에 3
이라는 값을 대입합니다.
그렇다면 당연히 변수 a
와 *p
는 동일한 값 3
을 저장하고 있는 것입니다.
(a
와 *p
는 동일하니까요😉)
우리가 사용하는 64비트 체제에서는,
주소값은 항상 8바이트입니다.
그런데, 굳이 Type이 필요할까요?
우리가 임의로 pointer
라는 Type을 만들었다고 가정해봅시다.
int a;
pointer *p;
p = &a;
*p = 4;
위의 코드에서,
메모리에 a
를 위해 4바이트 공간을 마련했으며,
마찬가지로 p
를 위해 8바이트 공간을 마련했습니다.
그리고 p
에 a
의 주소값을 전달했습니다.
그런데 *p = 4;
가 문제입니다...
포인터 p
에는 a
의 시작 주소만 들어가있습니다.
따라서, 컴퓨터는 *p
라고 했을 때 메모리 상에서 얼마만큼 읽어들어야 할 지 알 수가 없습니다.
int a;
int *p;
p = &a;
*p = 4;
라고 한다면,int *
만 보아도,
포인터 p
는 int형 데이터를 가리키는지 알 수 있습니다.
따라서, 변수 a
의 시작 주소로부터 4바이트만 읽으면 되는 것이죠.
앞서 배운 내용에서,
어떤 데이터를 상수로 만들기 위해 const
keyword를 사용했습니다.
예를 들어,
const int a = 3;
이런 식이죠.
const
키워드는,
"이 데이터의 내용은 절대로 바뀔 수 없다!!!!!!!"
라는 말입니다.
위의 예에서는,
"int형 변수 a
의 값은 절대로 바뀔 수 없다!!!!!"
가 되겠죠.
그래서, 변수 a
에 다른 값이나,
심지어 똑같은 값인 3
을 대입해도
컴파일 오류가 발생합니다.
컴퓨터는 어떤 값을 대입하든,
a
의 값이 바뀔 가능성만 있으면 오류를 출력합니다.
이는 프로그래머에게 엄청나게 중요한 기능입니다.
우리가 상수로 취급한 변수 Pi
가 있다고 생각해봅시다.
const double PI = 3.14;
우리가 코딩을 하다보니, 깜빡해서 Pi
값에 다른 값으로 초기화했을 때,
PI = 10;
컴파일러는 오류를 출력하고 알려줄 것입니다.
그런데 만약, 상수로 취급하지 않고 그냥 선언했다면?
double Pi = 3.14;
컴파일러는 아무런 오류 없이 프로그램을 실행할 것이고,
결과로는 이상한, 생각치도 못한 값이 나오겠죠.
하지만 이 오류를 깨닫기는 쉽지 않을 겁니다.
어우...끔찍하죠 😥
따라서,
절대로 변하지 않을 데이터는 무조건 상수로 처리해주는 것이 좋습니다.
그렇다면 포인터는 어떨까요?
#include <stdio.h>
int main() {
int a;
int b;
const int* pa = &a;
*pa = 3; // 올바르지 않은 문장
pa = &b; // 올바른 문장
return 0;
}
const int* pa = &a;
를 통해,
int형 데이터를 가리키는 포인터 pa
는 상수로 취급되어 값을 절대로 바꿀 수 없습니다.
그리고 포인터 pa
는 a
의 주소값을 가리키기 때문에,
즉, pa
가 가리키는 변수 a
의 값은 절대로 바뀔 수 없습니다.
다시 말해,
변수 a
를 직접 다룰 때는, 자유롭게 값은 변경할 수 있습니다.
하지만, 포인터 pa
를 이용해 변수 a
를 간접적으로 다룰 때는, 값을 바꿀 수 없습니다.
그렇다면 다음 예제는 어떨까요?
#include <stdio.h>
int main() {
int a;
int b;
int* const pa = &a;
*pa = 3; // 올바른 문장
pa = &b; // 올바르지 않은 문장
return 0;
}
위의 코드에서 차이점은, const
의 위치입니다!
앞선 예제에서는, const
가 맨 앞에 위치했지만,
이번에는 int*
와 pa
사이에 위치했습니다.
이것은 의미는 당연합니다.
pa
의 값이 절대로 바뀌면 안된다!!!
라는 것입니다.
포인터 pa
에는 변수 a
의 주소값이 들어갔습니다.
그렇다면 pa
에는 변수 a
의 주소값 이외의 다른 값은 올 수 없습니다!!!!!!!!!!
그렇다면 값, 주소 모두 변경할 수 없게끔 하는 포인터는 어떻게 할까요?
앞 선 예제를 합치면 됩니다.
#include <stdio.h>
int main() {
int a;
int b;
const int* const pa = &a;
*pa = 3; // 올바르지 않은 문장
pa = &b; // 올바르지 않은 문장
return 0;
}
#include <stdio.h>
int main() {
int a;
int* pa;
pa = &a;
printf("pa 의 값 : %p \n", pa);
printf("(pa + 1) 의 값 : %p \n", pa + 1);
return 0;
}
[Result]
pa 의 값 : 000000DC8359FAF4
(pa + 1) 의 값 : 000000DC8359FAF8
위의 예제를 실행시켜보면,
매번 저장되는 주소는 다르지만
pa
와 pa+1
의 값은 항상 4
만큼 차이납니다.
우리는 1
만 더했는데, 왜 차이는 4
만큼 날까요?
그렇다면 만약 변수가 int형이 아니라 다른 자료형이면요?
int형이면 4바이트,
char형이면 1바이트,
double형이면 8바이트만큼 차이가 납니다.
뺄셈도 마찬가지입니다.
그렇다면 포인터끼리의 덧셈은 어떨까요?
int* pa;
int* pc;
int* pc = pa + pb
이런 식으로요.
실행시켜보면 컴파일에러가 납니다.
사실 포인터끼리의 덧셈은 필요하지도 않습니다!!!
두 변수의 메모리 주소를 더한 값은 그냥 새로운 주소값일 뿐, 이전 변수와는 아무런 상관도 없습니다.
그런데 포인터끼리의 뺄셈은 가능하답니다....
(도대체 뭐야.... 😣)
(대학교 때의 멘붕이 되살아나고 있습니다...😨)
이 부분을 알고 나면,
위의 포인터 연산에 대해 그나마 이해하실 수 있다고 하네요....
지난 시간에 배운 '배열(Array)'는 변수가 여러개 모인 것입니다.
그런데 그 변수들은 '연속적으로' 위치해 있습니다.
그렇다면 포인터를 사용해서 배열의 각 원소에 쉽게 접근할 수 있습니다!!
#include <stdio.h>
int main() {
int arr[3] = {1, 2, 3};
int* p = &arr[0];
for (int i = 0; i < 3; i++) {
if ((p + i) == &arr[i]) { //포인터와 배열
printf("%d번째 원소 일치\n", i + 1);
}
}
return 0;
}
[Result]
1번째 원소 일치
2번째 원소 일치
3번째 원소 일치
위에서는, 포인터의 합 (p + i)
를 이용해 배열에 접근했습니다.
그 결과, 아무런 문제 없이 잘 실행됩니다.
아래 예시를 봅시다.
#include <stdio.h>
int main() {
int arr[3] = {1, 2, 3};
int* p = &arr[0];
printf("%p\n", p);
printf("%p", arr);
return 0;
}
[Result]
0000004C074FF7C8
0000004C074FF7C8
배열의 이름은 배열의 첫 번째 원소의 주소값을 나타내고 있습니다.
그렇다면, 배열의 이름은 '첫 번째 원소를 가리키는 포인터'인가요?
아닙니다!!!!!!!!!!!!!!!!!!!!
결론부터 말씀드리면,
"NO!!!!!!!!!!!!!!!!!!"
입니다.
#include <stdio.h>
int main() {
int arr[3] = {1, 2, 3};
int* p = arr;
printf("Size of Array : %d\n", sizeof(arr));
printf("Size of Pointer : %d", sizeof(p));
return 0;
}
[Result]
Size of Array : 12
Size of Pointer : 8
배열같은 경우는, int형이 3개의 크기로 이루어져 있기 때문에 12의 크기를,
포인터같은 경우는, 그냥 포인터의 크기인 8바이트를 반환합니다.
즉, <배열의 이름>과 <첫 번째 원소의 주소값>은 다른 것입니다.
그렇다면 왜 똑같은 값이 출력됐을까요?
C에서는 '배열의 이름'은 암묵적으로 첫 번째 원소를 가리키는 포인터로 타입 변환되기 때문입니다.
(sizeof연산자, &연산자와 사용될 때는 아닙니다.)
[ ]
연산자#include <stdio.h>
int main() {
int arr[3] = {1, 2, 3};
printf("arr[2] : %d\n", arr[2]);
printf("*(arr + 2) : %d", *(arr + 2));
return 0;
}
[Result]
arr[2] : 3
*(arr + 2) : 3
사실 컴퓨터는 C에서 [ ]
연산자가 쓰이면,
자동적으로 (arr + n)
으로 바꿔서 처리합니다.
따라서 arr[3]
은 3[arr]
로 사용할 수 있습니다.
똑같은 코드입니다.
하지만, 가독성도 떨어지고, 이해하기가 어렵기 때문에 잘 사용하지 않습니다.
-
C언어의 꽃이라고 불릴 수 있는 포인터에 대해 공부했습니다...
그래도 다행인 점은, 처음 접하는게 아닌
몇 번 배웠던 내용이라서 그나마 친숙하게 다가오네요.
사실 전에는 잘 이해가 안돼서 그냥 외웠던 기억이 있는데,
지금은 이해는 잘 되네요...😀
아마 const
에서 많이 헷갈렸던 것 같은데..
저같은 경우는 const의 범위가 헷갈리 때,
const 뒤에서 자료형을 빼고 보면 되더라구요..
const int* a
: *a는 바꿀 수 없다
int* const a
: a는 바꿀 수 없다
이런 식이죠.
그래도 활용해보면 또 모르겠어요....
그냥 하다보면 나아지겠죠..?
원래 공부는 삽질이잖아요 😂
[Reference] : 위 글은 다음 내용을 참고, 인용하여 만들어졌습니다.