안녕하세요 파일입니다. 앞에서 다차원 배열을 다룬 뒤로 거점 한 달쯤에 뵙네요!
앞의 연도가 바뀐 건 기분 탓입니다 ㅎㅎ.. 가 아니고
저 글을 작성하고 많이 바빠졌는데 까먹고 강의를 강제 동면시켜버렸습니다 죄송합니다 ㅋㅋ..
그래도 제 강의 봐주시는 분들이 꽤 있었는데 이미 다른 강의로 도망가버린 거 같아요..
그래도 꽤나 많은 부분을 공부했으니 스스로 잘하시고 계시겠죠 ㅎㅎ?
어쨌든 C언어 개념에 고지가 얼마 남지 않았습니다!
오늘 배울 것은 포인터입니다!
C언어가 Low-Level에 가깝다고 표현하는 이유가 바로 C언어에 존재하는 포인터 때문입니다.
포인터는 일종의 흑마법에 가까운데 일단 이해하긴 어렵고 위험하나 완벽히 이해한다면 거의 모든 것을 할 수 있습니다.
사실 이런 설명을 들으면 무슨 개소린가 싶겠지만 이 글을 쓰는 저도 포인터를 완벽히 활용하진 못합니다.
대학교 컴공 수업을 들으며 C언어를 가르쳐준 교수님이
"포인터 제대로 이해 못하면 C 쓰지말고 Python 써라" 라고 하신적이 있는데 기억에 남네요..
그만큼 포인터가 C언어에서 중요하고 많은 역할을 할 수 있습니다.
이 부분부터 난이도가 많이 올라가고 어려워지기에 포인터 부분을 심화 학습하시려면 많은 반복이 필요하므로
다양한 자료로 공부를 하시는것을 추천드립니다.
한번에 이해가 되지 않아도 좌절하지 마시고 끝까지 정진해보세요!
주소에 관하여
무언가 길을 찾는 상황에서 전화로 누군가에게 길 가는 법을 듣는 것이 아닌 정확한 주소를 알게 되면 어떤 장점이 있을까요? 정확한 한 지점을 알게 되니 그곳으로 바로 찾아갈 수 있다는 장점이 생기게 됩니다.
C언어에서도 이런 역할을 하는 변수가 있는데 바로 포인터입니다. 포인터는 어떤 값의 주소를 제공해주면 그 주소를 가리키게 되고 해당 주소에 접근하게 해 줍니다.
지금까지는 그냥 변수선언은 int a = 30; 와 같이 한다고 배웠습니다.
하지만 컴퓨터 구조적으로 보면 프로그램 실행시 컴퓨터의 메모리에 적재되게 되고 그 메모리 내부에서 변수의 수정, 값 할당등이 이루어지게 됩니다.
예를 들어 int a = 30; 이라고 코드가 실행되면 컴퓨터가 알아서 메모리에 해당 변수공간을 할당되고 30이라는 값을 저장합니다.
메모리는 기본적으로 바이트(=8비트) 단위로 엑세스를 합니다. 한번 읽을때마다 1바이트씩 읽습니다.
그리고 메모리엔 각각 주소값이 있는데 만약 a = 30이 저장된 곳의 주소값을 알고있다면 그곳에 찾아가서 바로 30이라는 데이터 값에 엑세스할 수 있습니다.
포인터(Pointer)
포인터는 주소를 저장하는 변수입니다.
기존의 변수는 값(value)을 저장하는데 비해 포인터는 메모리 공간의 주소(address)를 저장합니다.
여기서 메모리는 컴퓨터의 RAM을 의미합니다.
앞에서 배웠듯 변수 이름 앞에 & 연산자를 붙이면 해당 변수의 시작 주소를 반환합니다.
이것을 포인터 변수에 넘겨주면 됩니다.
말로 하는 것보단 한 번의 예제가 나으니 아래 예제를 보겠습니다.
#include <stdio.h>
int main(){
int a = 10;
printf("%x", &a);
return 0;
}
62fe1c
--------------------------------
Process exited after 0.01011 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .
출력 값을 보면 알겠지만 변수 a는 메모리 주소 62fe1c에 저장되어 있습니다.
(메모리 주소기 때문에 이 값은 컴퓨터 실행 환경마다 매번 다르게 나타납니다.)
저 주소에 가보면 값 10이 저장되어 있는 겁니다.
이것을 포인터 변수에 넘기게 되면 다음과 같습니다.
#include <stdio.h>
int main(){
int a = 10;
int * ptr = &a;
return 0;
}
자료형과 변수 이름 앞에 *을 붙여서 ptr이라는 포인터 변수를 생성해줬습니다.
&a는 a의 주소를 가리키는 말인데 이것을 포인터 변수에 넘겨줬으니 현재 포인터 변수의 값은 a의 주소를 가지고 있습니다.
Pointer는 Point(가리키다) + er 이 합성된 단어로 무언가를 가리킨다는 단어입니다.
즉 지금 위의 ptr이라는 포인터는 변수 a의 주소를 가지고 있으므로
a의 주소를 가리키고 있는 셈이 됩니다.
포인터 변수가 변수 a의 주소를 마치 손가락 가리키듯이 가리키고 있는 형상을 생각해보세요.
그림으로 나타내면 아래와 같습니다.
여기서 주의할 점은 포인터 변수 ptr은 값으로 A의 주소를 가지고 포인터 변수의 주소는 &ptr이 된다는 점입니다.
포인터 변수의 선언
포인터 변수의 선언은 다음과 같이 합니다.
다른 변수와 마찬가지로 자료형을 지정해주는데 이건 가리킨 주소 값에 해당하는 변수의 자료형을 지정해주면 됩니다.
예를 들어 위의 예제에서 int형 변수인 a의 주소인 &a를 포인터 변수에 가리키게 지시했는데
a라는 변수가 int형이므로 포인터로 가리킬 때도 int형으로 해야 합니다.
이러한 이유는 각 자료형 별로 차지하는 공간(바이트)이 다르기 때문에 포인터로 주소를 넘겨받고 탐색할 때도 동일하게 맞춰줘야 합니다. 이것에 대해선 추후에 설명드리겠습니다.
자료형을 정했으면 뒤에 *을 붙여줍니다, 포인터 변수명은 원하는 걸로 지어주시면 됩니다.
값은 주소값을 별도로 저장하지 않을 경우 꼭 NULL로 초기화해줍니다.
대문자로 써야 하고 NULL의 의미는 관용적으로 포인터에 빈 값을 저장하겠다는 뜻입니다.
포인터 선언 시 * 기호 위치에 대해
int * ptr = &a;
int* ptr2 = &a;
int * ptr3 = &a;
* 기호 위치에 대해 어떤 곳에 붙이는 것이 의미가 있나 갑론을박이 조금 있으나
결과적으로 포인터 선언시 위 표현들은 전부 동일한 표현입니다.
컴파일러가 어떻게 하든 공백은 무시하고 *을 찾아서 읽고 포인터로 인식하기 때문에
* 은 어디에 붙이든 상관없습니다.
#include <stdio.h>
int main(){
int * ptr = NULL;
char * ptr2 = NULL;
printf("%d %d", ptr, ptr2);
return 0;
}
0 0
--------------------------------
Process exited after 0.01045 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .
int, char형 포인터를 각각 선언하고 NULL로 초기화를 한 후 값을 보기 위해 %d로 출력해봤습니다.
0이 출력됩니다.
아까 NULL이 0이 아니라고 했는데 왜 0이 출력되냐 하면 저건 정수가 아니라 ASCII 코드값입니다.
NULL이 아스키코드 번호 0번입니다.
저희가 아는 숫자 0은 ASCII코드로 48번입니다.
포인터 변수를 초기화할 때 변수처럼 0 같은 값을 초기화하지 않는 이유는 포인터가 주소 값을 가리키고 있기 때문입니다.
포인터 변수에 주소 값을 넘기면 포인터가 그 주소 값을 가리키게 되고 해당 주소로 가서 값을 바꿀 수 있는데
포인터 변수에 주소 값을 넘길 때 0을 넘기면 0번 주소의 메모리의 공간이 지금 어떤 상태인지 모르기 때문에 (비어있는지 차있는지) 절대 0으로 초기화하시면 안 됩니다.
(+ 2022-08-28)
오랜만에 제 글을 다시 읽으면서 조금 잘못된 정보가 있어서 수정합니다.
실제로 int * ptr = NULL; 과 int * ptr = 0은 같은 의미입니다.
NULL은 NULL pointer 로써 그 정의상 (void*)0 과 같은 의미입니다.
포인터로 0을 타입 변환했다는 것은 포인터가 주소를 저장하는 변수이므로 NULL은 주소 0과 같은 의미 입니다.
물론 int a = NULL 과 int a = 0 의 의미는 다릅니다. 전자의 경우 주소 0을 a에 저장, 후자의 경우 정수 0을 a 에 저장한다는 의미입니다.
==> 관련 내용 참고 https://noirstar.tistory.com/16
일반적으로 포인터에 값을 저장할땐 아무것도 없다는 의미에서 NULL 이라는 상수를 이용하게 되며, 0이나 NULL이 아닌 특정한 값을 적어주지 않는 이유는 그 메모리 주소에 무슨 값이 있는지 알 수 없기 때문입니다.
만약에 특정 메모리 주소에 무슨 값이 있는지 모르는데 그걸 포인터로 저장하고 마음대로 값을 접근해서 변경해버리면 최악의 상황에는 프로그램 메모리가 꼬여서 프로그램이 날아갑니다.
그렇기에 C언어보다 상대적으로 High-Level 언어인 Java, Python 같은 경우엔 포인터를 지원하지 않고, 포인터를 쓰지 않는 쪽으로 발전하고 있습니다.
#include <stdio.h>
int main(){
int a = 10;
int * ptr = &a;
printf("%d %d %d", *ptr, a, *&a);
return 0;
}
10 10 10
--------------------------------
Process exited after 0.009635 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .
아까 위쪽의 예제에서 포인터 변수에 참조 연산자(*)를 활용하여 값을 출력해 보겠습니다.
ptr은 변수 a의 주소를 가리키고 있습니다.
그렇기에 *을 붙여주면 a의 주소에 대한 값, 10 이 출력됩니다.
*ptr, a, *&a 은 전부 동일 표현이란 것, 이해되시죠?
앞에서 설명드렸습니다.
참고로 포인터 변수를 이용해 값을 참조하는 방법을 간접 참조라고 하고 변수의 값을 직접 사용하는 것은 직접 접근이라고 합니다.
즉 포인터 변수를 이용해 a의 값을 참조한 *ptr은 간접 접근, a의 값을 직접 출력하는 것은 직접 접근입니다.
포인터 변수를 통한 변수의 값 변경
#include <stdio.h>
int main(){
int a = 10;
int * ptr = &a;
return 0;
}
방금 위에서 확인한 예제를 다시 가져왔습니다.
포인터 변수를 이용해서 한번 a의 값을 바꿔 보겠습니다.
#include <stdio.h>
int main(){
int a = 10;
int * ptr = &a;
printf("변경전 a의 값 : %d\n", a);
*ptr = 100;
printf("변경후 a의 값 : %d", a);
return 0;
}
변경전 a의 값 : 10
변경후 a의 값 : 100
--------------------------------
Process exited after 0.01067 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .
해당 예제를 실행해보면 신기하게도 a = 100이라고 써주지 않았는데도 a의 값이 100으로 변경됐습니다.
변경이 된 이유는 이 부분 때문입니다.
*ptr = 100;
ptr은 a의 주소를 가리키고 있습니다.
즉 이 코드를 이해할 때 다음과 같이 이해합니다.
ptr로 가서(a의 주소로 가서) *ptr (그 주소에 해당하는 값을)의 값을 100으로 바꾼다.
라고 이해합니다. (아니면 "a의 주소에 해당하는 값 10을 100으로 바꾼다"라고 생각하셔도 됩니다.)
말이 어렵나요? 간단히 생각하면 포인터 변수가 가리키는 주소 값으로 찾아가서 그곳의 value 값을 바꾼다입니다.
이것이 바로 포인터의 장점입니다.
포인터는 변수의 주소를 알고 있기에 그 주소로 언제든 접근해 * 연산자를 이용해 값을 바꿀 수 있습니다.
포인터 연산
ptr 변수에 62FE14 란 주소 값(16진수)이 저장되어 있을 때 ptr에 1을 더해주면 어떻게 될까요?
62FE15가 될까요? 아래 예제를 봅시다.
#include <stdio.h>
int main(){
int a = 10;
int * ptr = &a;
printf("%p\n", ptr);
ptr = ptr + 1;
printf("%p", ptr);
}
000000000062FE14
000000000062FE18
--------------------------------
Process exited after 0.01016 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .
특이하게도 1이 더해진 것이 아니라 4가 더해졌습니다.
포인터에 덧셈, 뺄셈 연산을 진행하면 그 포인터가 어떤 형태의 포인터냐에 따라 값이 다르게 바뀝니다.
예를 들어 int형 변수를 가리키는 int형 포인터의 경우에는 int형의 크기가 4바이트 이므로 1을 더하면 주소 값에 4가 더해지고 1을 빼면 4가 빼 집니다.
double 형 포인터는 double 형의 크기가 8바이트 이므로 주소 값에 8씩 더하거나 빠집니다.
이런 특성을 이용해서 포인터를 이용해서 배열을 탐색할 수 있습니다.
C언어에서 배열은 연속적인 메모리 공간을 가진다는 것 기억나시나요?
포인터로 배열의 시작 주소를 넘겨받고 1씩 더해서 포인터 연산을 진행하면 요소를 1개씩 탐색할 수 있는 것입니다.
또 앞에서 배웠듯 배열의 이름은 배열의 시작 주소라고 했습니다.
배열의 이름이 배열의 시작 주소를 가리키고 있다는 겁니다.
즉 배열의 이름이 포인터라는 것을 알 수 있습니다.
실제로, 배열은 포인터를 이용하여 구현되어 있습니다.
포인터를 이용한 배열 탐색
#include <stdio.h>
int main(){
int arr[] = {1,2,3,4,5,6,7,8,9,10};
for (int i = 0; i < 10; i++){
printf("%x\n", &arr[i]);
}
}
62fdf0
62fdf4
62fdf8
62fdfc
62fe00
62fe04
62fe08
62fe0c
62fe10
62fe14
--------------------------------
Process exited after 0.01053 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .
위 예제의 배열을 한번 포인터로 탐색해보겠습니다.
int 형 배열이므로 공간이 4바이트씩 연속적으로 할당 되어 있는 것을 볼 수 있습니다.
왜 연속적으로 할당되어있는지 기억이 잘 안나시면 아래 강의를 한번더 복습하시길 바랍니다.
https://pgh268400.tistory.com/127
그림으로 나타내면 아래와 같습니다.
배열의 이름이 포인터라고 했으므로 arr은 배열 요소의 첫번째 주소값 즉 &arr[0] 을 가리키고 있는 셈이 됩니다.
그리고 배열을 사용할때는 3번째 인덱스에 해당하는 값은 arr[3]과 같은식으로 사용합니다.
그러면 ptr이라는 포인터 변수로 arr의 시작주소를 넘겨받아 arr의 시작주소를 가리키게 된다면?
ptr이 arr이라는 배열과 같은 용례로 사용할 수 있게 됩니다.
즉,
arr(배열의 이름)이 arr이라는 배열의 시작주소를 가리키고 있고 a[0], a[1] 이라는 형태로 접근 하듯
ptr이 arr이라는 배열의 시작주소를 똑같이 가리키고 있다면 ptr[0], ptr[1] 이라는 형태로 똑같이 접근할 수 있게 된다는 것입니다.
이런것이 가능한 이유는 다시 말하지만 배열이 포인터로 구현되어 있기 때문입니다.
그림으로 나타내면 이런식으로 되게 됩니다.
#include <stdio.h>
int main() {
//int형 배열 arr을 선언하고 초기화 한다.
int arr[] = { 1,2,3,4,5,6,7,8,9,10 };
//이 배열의 첫번째 요소를 가리키는 포인터 변수 ptr을 선언한다.
int* ptr = arr;
//이 표현과 같음 : int* ptr = &arr[0];
//배열의 이름이 첫 요소를 가리키고 있으므로
//포인터로 첫 요소의 주소값을 가리키면 배열과 동일하게 사용 가능하다.
for(int i = 0; i < 10; i++){
printf("%d\n", ptr[i]);
}
printf("----------------------\n");
for(int i = 0; i < 10; i++){
printf("%d\n", arr[i]);
}
return 0;
}
1
2
3
4
5
6
7
8
9
10
----------------------
1
2
3
4
5
6
7
8
9
10
--------------------------------
Process exited after 0.0115 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .
위 코드의 실행 결과를 보면 ptr[i]와 arr[i]의 값이 동일한 것을 알 수 있습니다.
추가해서, 아까 배운 포인터 연산을 통해 배열의 요소를 탐색해 보겠습니다.
#include <stdio.h>
int main() {
//int형 배열 arr을 선언하고 초기화 한다.
int arr[] = { 1,2,3,4,5,6,7,8,9,10 };
//이 배열의 첫번째 요소를 가리키는 포인터 변수 ptr을 선언한다.
int* ptr = arr;
//이 표현과 같음 : int* ptr = &arr[0];
//배열의 이름이 첫 요소를 가리키고 있으므로
//포인터로 첫 요소의 주소값을 가리키면 배열과 동일하게 사용 가능하다.
for(int i = 0; i < 10; i++){
printf("%d\n", *ptr);
ptr++;
}
return 0;
}
int형 배열이 4바이트씩 연속된 공간을 가지고 있으므로
포인터로 우선 arr의 시작주소를 가리키게 하고 그 값을 값 참조 연산자(*)로 출력하면 arr[0] 과 같은 값이 출력되게 됩니다.
그리고 포인터 연산을 진행하여 1씩 더해주면 4바이트씩 주소값이 건너뛰어지고 참조 연산자를 이용하면 그것에 해당하는 값을 출력하게 되므로 a[1], a[2], a[3]... 포인터를 통해 배열을 탐색할 수 있게 됩니다.
위 그림을 보면 더 잘 이해가 되실 것 입니다.
(편의를 위해 ptr + 3 부턴 생략하였습니다.)
포인터와 sizeof() 함수에 대해
예전 시간에 sizeof() 함수에 대해 배운적이 있었습니다.
sizeof() 함수를 사용하게 되면 해당 자료형의 크기를 반환합니다.
그 자료형의 크기라는 것은 메모리에 할당되고 있으므로 메모리에 할당된 크기와 동일한 표현입니다.
sizeof(arr) / sizeof(int)
이런식으로 사용하면 배열 요소의 갯수를 구할 수 있었던 것. 기억 나시나요?
배열은 기본적으로 메모리 공간에 연속적으로 할당되므로
int형 배열의 경우 4바이트 x 요소 갯수 = 메모리 공간 크기로 할당되며
메모리 공간크기 sizeof(arr)을 int형 공간 크기인 4바이트로 나눠주면 요소 갯수를 구할 수 있었습니다.
위에서 배열의 이름이 곧 포인터이고 첫번째 요소를 가리키고 있으며, 포인터로 배열의 이름을 가리키게 한다면 배열과 같은 용래로 사용할 수 있다고 했는데 그러면 포인터로 가리키고 있는 배열 요소의 갯수도 구할 수 있을까요?
아래의 예제를 봐주세요.
#include <stdio.h>
int main()
{
int arr[] = { 1,2,3,4,5,6,7,8,9,10 };
int* ptr = arr;
printf("%d\n", sizeof(arr));
printf("arr의 배열 요소 개수 : %d\n", sizeof(arr) / 4);
printf("%d\n", sizeof(ptr));
return 0;
}
40
arr의 배열 요소 개수 : 10
8
--------------------------------
Process exited after 0.02417 seconds with return value 0
계속하려면 아무 키나 누르십시오 . . .
실행 결과를 보면 아시겠지만 sizeof(arr)의 경우 4x10 = 40바이트를 반환해서 배열 요소의 갯수를 계산할 수 있는 반면에 포인터는 포인터 그 자체만으로는 주소를 가리키고 있는, 주소를 가지고 있는 변수이므로 8바이트 만큼 메모리 공간에 할당되어 있음을 알 수 있습니다.
포인터 변수는 기본적으로 32비트 환경에서는 4바이트 만큼 메모리 공간에 할당되고, 64비트에서는 8바이트 공간만큼 할당됩니다. (현재 필자의 컴퓨터는 64비트 환경입니다.)
그렇기 때문에 포인터로 배열 첫번째를 가리켜서 배열처럼 사용은 가능하나 sizeof등의 계산으로 요소 갯수를 알 수 없다는 점은 유의해주시길 바랍니다.
이건 꽤 중요한 사실인데 함수에서 배열을 인자값으로 받을때 배열 요소의 갯수를 모르면 매우 난처해집니다. 이는 추후 설명드릴 예정이니 걱정마세요.
다음장에서 계속 됩니다..
'프로그래밍 강좌 > C' 카테고리의 다른 글
[C언어 강좌] #13-3 포인터(Pointer) (0) | 2021.11.23 |
---|---|
[C언어 강좌] #13-2 포인터(Pointer) (0) | 2021.11.07 |
[C언어 강좌] #12-2 [Array] 다차원 배열 (8) | 2020.05.17 |
[C언어 강좌] #12-1 [Array] 1차원 배열 (6) | 2020.03.30 |
[C언어 강좌] #11 정적변수, 지역변수, 전역변수, 외부변수, 레지스터 변수 (6) | 2020.01.28 |