프로그래밍 언어/C언어

C언어 11. 포인터

닉네임못짓는사람 2020. 7. 14. 19:08
반응형

이번 글에서는 포인터에 대해서 알아보도록 하겠습니다.

포인터는 C언어의 가장 특징적인 부분이자 C언어가 지금까지 이토록 사랑받는 이유 중 하나입니다.

그만큼 C언어 내에서 포인터가 가지는 비중은 매우 큽니다.

 

이러한 강력한 무기인만큼 포인터를 제대로 이해하고 사용하기란 그리 쉽지 않습니다.

제가 C언어를 처음 배울 때에도 주위의 많은 사람들이 어려움을 호소했고, 저 또한 마찬가지였습니다.

하지만 그만큼 이를 제대로 익혔을 때의 활용도와 성취감은 이루 말할 수 없습니다.

 

그럼 이제 포인터에 대해서 설명해보도록 할 텐데, 아주 적절한 예가 있어서 인용해보겠습니다.

데이터의 실제주소는 어디일까?


서울아파트 103호에 길동이가 산다고 가정해보도록 합시다.

(서울 아파트에는 길동이라는 사람은 단 한 명입니다.)

 

자 그럼 길동이가 사는 이 집을 가리킬 때 우리는 뭐라고 할 수 있을까요?

어디서든 그곳을 정확히 지칭할 수 있는 서울 아파트 103호라는 주소가 있을 것이고,

서울 아파트 내에서 사용할 수 있는 '길동이네'라는 주소가 있을 것입니다.

서울 아파트 103호가 이곳의 실제 주소이지만 서울아파트 내에서는

'길동이네'라는 주소가 더 많이 사용될 겁니다. (주민들 간 교류가 활발하다는 전제하)

 

이제 이를 빗대어 프로그램에서 한번 생각해보도록 합시다.

프로그램에서 기억공간을 가리킬 때에도 이와 같이 두 가지 방법을 사용할 수 있습니다.

첫 번째는 우리가 지금까지 계속해서 사용해왔던 변수명을 사용하는 것입니다.

변수를 선언하는 것은 메모리에 기억공간을 할당하고 이곳에 '길동이네'라는 이름을 붙이는 것입니다.

 

두 번째 방법은 이 공간의 실제 주소를 사용하는 것입니다. 즉 서울 아파트 103호를 사용한다는 것이죠.

이 실제 주소 값을 바로 C언어에서 포인터라고 부릅니다. 이 포인터에 대해 좀 더 자세히 알아봅시다.

 

컴퓨터의 메모리에는 바이트(byte)단위로 위치를 식별할 수 있는 물리적인 주소 값이 존재합니다.

주소값은 0번지부터 차례로 부여되며 컴퓨터의 메모리 용량에 따라 최댓값이 달라집니다.

우리가 C언어에서 변수를 선언하면 그 자료형의 크기만큼 메모리상에서 연속된 기억공간이 할당됩니다.

 

만약 우리가 int a라는 변수를 할당하면 메모리상에 4바이트만큼 공간이 할당되고,

시작 주소값이 100번지라면 100번부터 103까지의 공간을 이 변수가 차지하게 됩니다.

여기서 이 변수의 시작 주소값인 100번지를 포인터라고 말하는 것입니다.

이러한 포인터를 이용해 메모리의 실제 주소값에 접근해 값을 저장하거나 저장된 값을 사용할 수 있습니다.

그러면 이러한 변수의 실제 주소값을 어떻게 알아내는지 알아보도록 합시다.

주소 연산자(&)로 메모리 주소를 구해보자


변수의 포인터를 구하기 위해선 주소 연산자(&)를 사용하는데,

변수명 앞에 주소 연산자를 붙이면 메모리상에서 시작 주소값을 구해줍니다.

피연산자로는 반드시 변수명과 같이 메모리에서의 위치를 알 수 있는 대상이 와야 합니다.

(&123같은 형태는 주소값을 구할 수 없음)

 

실제 코드를 통해 변수의 주소값을 출력해보도록 합시다.

#include<stdio.h>

int main() {
	char ch = 'a';
	int in = 123;
	double db = 10.22;

	printf("ch의 포인터 : %u\n", &ch);
	printf("in의 포인터 : %u\n", &in);
	printf("db의 포인터 : %u\n", &db);
    	return 0;
}

위의 값들이 메모리상에서 변수들의 주소값, 즉 포인터입니다.

이 변수들은 위에서 설명한 것처럼 메모리상에서 해당 자료형의 크기만큼 공간을 차지하고 있을 것입니다.

또한 이 포인터는 프로그램을 실행할 때마다 달라지며, 출력할 땐 %u를 사용합니다.

 

그런데 이렇게 출력값만 보면 포인터가 일반 정수값처럼 보일 텐데,

실제 포인터는 특별한 정보를 가지고 있습니다.

그것은 바로 자신이 어떠한 자료형의 포인터인지에 대한 정보입니다.

 

예를 들어 char형 변수 ch가 메모리 100번지에 할당되어있고,

int형 변수 in이 메모리 100번지부터 103번지까지 할당되어있다고 가정해보도록 합시다.

(실제론 이렇게 겹쳐서 할당되진 않습니다.)

 

그리고 이 두 변수의 포인터를 구하면 모두 동일하게 100이 출력될 것입니다.

하지만 실제로 컴파일러는 이 두 포인터가 다르다고 인식합니다.

그 포인터 속에 자신의 자료형에 대한 정보가 포함되어 있기 때문입니다.

참조 연산자로 포인터에 접근하자


그렇다면 이젠 이 포인터를 사용해서 변수의 기억공간에 접근하기 위한 방법을 알아보도록 하겠습니다.

이때 참조 연산자(*)를 사용하는데, 바로 코드를 통해서 사용법을 알아보도록 합시다.

#include<stdio.h>

int main() {
	char ch = 'a';
	int in = 123;
	double db = 10.22;

	printf("ch의 값 : %c\n", ch);
	printf("in의 값 : %d\n", in);
	printf("db의 값 : %lf\n", db);

	*&ch = 'b';
	*&in = 321;
	*&db = 22.11;

	printf("ch의 값 : %c\n", ch);
	printf("in의 값 : %d\n", in);
	printf("db의 값 : %lf\n", db);

	return 0;
}

이 참조 연산자를 사용해 포인터의 기억공간에 접근해 값을 대입할 수 있습니다.

이와 같이 포인터를 통해 기억공간을 사용하는 것을 '참조'라고 합니다.

이를 통해서 다음과 같은 동작도 가능합니다.

#include<stdio.h>

int main() {
	int a = 123;
	int b = 321;

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

	a = *&b;				//*&a = *&b처럼 양쪽 다 참조를 사용해서 가능

	printf("a의 값 : %d", a);

	return 0;
}

위와 같이 참조를 통해서 b의 주소 공간에 저장되어 있던 값을 a에 대입할 수 있습니다.

지금까지 포인터를 구하는 법과 사용하는 법에 알아보았습니다.

그런데 프로그램 내에서 이 포인터를 지속적으로 사용하기 위해선 매회 변수의 포인터를 구하는 것이 아닌

포인터를 변수에 저장해서 사용하는 것이 더 효율적으로 사용할 수 있을 것입니다.

포인터 변수?


이때 사용하는 것이 포인터 변수인데, 코드를 통해서 확인해봅시다.

#include<stdio.h>

int main() {
	int a = 123;		//값을 저장할 변수
	int *ap;			//a의 값을 저장할 포인터변수
	ap = &a;			//ap에 a의 포인터를 저장

	printf("a의 값 : %d\n", a);
	printf("a의 주소값 : %u\n", &a);
	printf("ap의 값 : %u\n", ap);
	printf("ap를 통해 구한 a의 값 : %d\n", *ap);
	printf("ap의 포인터 : %u\n", &ap);
}

포인터 변수를 선언하는 방법은 (자료형 *변수명)입니다.

컴파일러 내에선 (자료형* 변수명)과 같이 정렬해주는데, 자료형과 변수명 사이에 *가 존재하기만 하면

되기 때문에(자료형    *     변수명)과 같이 사용해도 무방합니다.

또한 포인터 변수 또한 변수이기 때문에 이 또한 포인터를 가집니다.

포인터는 왜 써야 될까?


이러한 포인터는 왜 사용할까요? 그냥 변수명만으로도 충분히 값을 변경하고 사용할 수 있는데 말이죠.

간단한 예를 하나 들어볼 텐데, 이를 위해선 함수와 변수에 대해 추가적인 설명이 필요합니다.

 

먼저 C언어 내에서 함수는 각각 독립적인 기억공간을 가지고 있습니다.

처음에 말한 서울 아파트 이야기에서 '갈동이네'는 서울 아파트 내에서만 통한다고 했었습니다.

서울 아파트를 함수, '길동이네'를 변수명이라고 이야기할 수 있을 것입니다.

 

따라서 함수 내에서 선언한 변수명은 해당 함수에서만 사용할 수 있으며, 다른 함수와는 독립적입니다.

때문에 main함수에서 선언한 변수명과 동일하게 사용자 정의 함수에서도 변수명을 지정할 수 있습니다.

이러한 변수들을 프로그래밍에선 지역변수라고 이야기하며,

이 변수들은 함수가 시작될 때 메모리 공간에 생성되고 종료됨과 동시에 메모리 공간에서 사라집니다.

 

그런데 프로그램 내에서 메모리 공간은 모든 함수가 공유하고 있기 때문에,

포인터를 사용하면 함수2에서 함수1에있는 변수의 값을 얼마든지 변경할 수 있습니다.

코드를 통해 이를 확인해보도록 합시다.

#include<stdio.h>

void pointerCheck(int num);

int main() {
	int num = 123;
	printf("main함수 num의 값 : %d\n", num);
	printf("main함수 num의 포인터 : %u\n", &num);
	pointerCheck(num);
    	return 0;
}

void pointerCheck(int num) {
	printf("pinterCheck함수 num의 값 : %d\n", num);
	printf("pinterCheck함수 num의 포인터 : %u\n", &num);
}

위의 코드는 main함수에서 num변수를 선언해 이 값을 pointerCheck함수로 보내고,

각 함수에서 num의 값과 포인터를 확인하는 프로그램입니다. 편의상 pointerCheck를 pC라고 하겠습니다.

결과를 확인해보면 두 변수의 값은 동일하지만 실제 메모리 상의 포인터 값은 다른 걸 확인할 수 있습니다.

또한 pC함수에서 num변수의 값을 아무리 변경해도 main함수의 값은 전혀 바뀌지 않을 것입니다.

이것이 함수 내에서 변수가 서로 독립적이라는 것을 증명하는 것입니다.

 

그렇다면 이제 포인터를 사용하여 pC함수에서 main함수의 num변수값을 변경해보도록 합시다.

#include<stdio.h>

void pointerCheck(int *num);

int main() {
	int num = 123;
	printf("main함수 num의 값 : %d\n", num);
	printf("main함수 num의 포인터 : %u\n", &num);
	pointerCheck(&num);
	printf("main함수 num의 값 : %d\n", num);
	printf("main함수 num의 포인터 : %u\n", &num);
	return 0;
}

void pointerCheck(int *num) {
	printf("pinterCheck함수 num의 값 : %d\n", *num);
	printf("pinterCheck함수 num의 포인터 : %u\n", num);
	*num = 321;
}

이번에는 pC함수에 num변수의 포인터를 인자로 넘겨주면서 해당 포인터를 참조해 값에 접근합니다.

전달인자를 포인터로 넘겨주기 때문에 pC함수의 매개변수는 포인터 변수로 선언해주셔야 합니다.

 

결과를 확인해보시면 main함수에서의 num의 주소값과 pC함수에서의 num의 주소값이 일치하며

pC함수에서 포인터를 참조하여 값을 변경하니 main함수 내에서 num변수의 값이 바뀐 걸 알 수 있습니다.

 

사실 이러한 결과는 포인터를 사용하지 않고도 해결할 수 있습니다.

그냥 pC변수로부터 반환 값을 가져와 main함수의 num에 대입시켜주면 끝인 일이죠.

하지만 바꿔야 할 변수가 2개 이상이라면 이렇게는 불가능하고 포인터를 사용해야만 가능할 것입니다.

반환값은 하나지만 매개변수는 얼마든지 가능하니까 말이죠.

 

예를 들어 main함수의 두 변수의 값을 서로 바꾸는 프로그램을 만들어봅시다.

#include<stdio.h>

void exchange(int* num1, int* num2);

int main() {
	int num1 = 123;
	int num2 = 321;
	printf("num1의 값 : %d\n", num1);
	printf("num2의 값 : %d\n", num2);
	exchange(&num1, &num2);
	printf("num1의 값 : %d\n", num1);
	printf("num2의 값 : %d\n", num2);
	return 0;
}

void exchange(int* num1, int* num2) {
	printf("exchange함수 실행\n");
	int tmp;
	tmp = *num1;
	*num1 = *num2;
	*num2 = tmp;
}

이와 같이 포인터를 사용하면 변수가 선언된 함수가 아닌 다른 함수에서도 두 변수에 접근할 수 있습니다.

또한 포인터를 함수의 인자로 넘겨줄 때는 인자와 매개변수의 포인터 자료형이 서로 일치해야 합니다.

scanf와 포인터


이쯤에서 잠시 과거로 돌아가 scanf에 대해서 이야기해보도록 합시다.

scanf를 사용할 땐 언제나 입력값을 저장할 변수 앞에 &를 붙였는데, 그 이유가 궁금하셨을 겁니다.

이것은 주소 연산자로서 입력값을 저장할 변수의 주소값을 계산하여 scanf함수에 전해주었던 것입니다.

이렇게 하는 이유는 scanf함수 내에서 main함수에 있는 변수에 접근해야 하기 때문입니다.

포인터 변수의 크기는?


그럼 이러한 포인터 변수는 크기가 몇 바이트일까요?

사실 C언어에서 사용하는 모든 상수는 일정한 크기를 가지고 있습니다.

포인터도 사실상 값이 정해진 상수이기 때문에 동일한 크기를 가지고 있을 것입니다.

sizeof를 사용한 코드를 통해 확인해보도록 합시다.

#include<stdio.h>

int main() {
	char ch = 'a';
	short sh = 123;
	int in = 123;
	long lo = 123;
	long long llo = 123;
	float fl = 123.12;
	double db = 123.12;
	long double ldb = 123.12;

	printf("char형 포인터의 크기 : %d\n", sizeof(&ch));
	printf("short형 포인터의 크기 : %d\n", sizeof(&sh));
	printf("int형 포인터의 크기 : %d\n", sizeof(&in));
	printf("long형 포인터의 크기 : %d\n", sizeof(&lo));
	printf("long long형 포인터의 크기 : %d\n", sizeof(&llo));
	printf("float형 포인터의 크기 : %d\n", sizeof(&fl));
	printf("double형 포인터의 크기 : %d\n", sizeof(&db));
	printf("long double형 포인터의 크기 : %d\n", sizeof(&ldb));
    	return 0;
}

결과를 확인해보시면 모든 포인터들은 동일하게 4바이트 크기를 가지고 있다는 것을 알 수 있습니다.

즉 visual studio2019 컴파일러가 주소 값 자체를 4바이트로 처리하고 있다는 것입니다.

이것은 포인터 변수의 크기를 확인해보아도 동일할 것이니 한번 확인해보시길 바랍니다.

하지만 이 값은 컴파일러에 따라 다를 수도 있습니다.

 

이 정도로 포인터에 대한 설명은 끝내도록 하겠습니다.

충분히 이해하실 때까지 코딩을 통해 확인해 보시는 것을 권장합니다.

다음 글에선 배열과 포인터의 관계에 대해서 알아보도록 하겠습니다.

감사합니다.

반응형

'프로그래밍 언어 > C언어' 카테고리의 다른 글

C언어 13. 문자열  (0) 2020.07.16
C언어 12. 배열과 포인터  (0) 2020.07.15
C언어 10. 배열  (0) 2020.07.13
C언어 9. 사용자 정의 함수  (0) 2020.07.12
C언어 8. 계산기 프로그램  (0) 2020.07.12