포인터를 사용하는 이유
포인터가 왜 필요할까? 포인터를 왜 쓸까? 포인터를 왜 사용할까? 에 대해서 생각해보기 전에...
포인터에 대해서 잘 모른다면 아래 '포인터란 무엇인가'부터 읽어보자. 이미 배경지식이 있다면 상관없다.
화씨를 섭씨로 바꾸는 프로그램을 작성한다고 가정해보자.
#include <stdio.h>
// 화씨 온도를 입력받아 섭씨단위로 바꿔준다.
void ToCelsius(double F)
{
F = (F - 32)/1.8;
}
// 물론 이렇게 반환한 값을 호출부에서 활용해도 된다.
// 하지만 본 글에서는 위의 구현에 대해서 다룬다.
double ToCelsius2(double F)
{
return (F-32)/1.8;
}
int main()
{
double temperature = 32; // 화씨 32도
ToCelsius(temperature); // 화씨 32도가 섭씨 0도로 바뀌었을 것이다.
printf("%lf", temperature); // 하지만 그대로 32.000... 가 출력된다!
return 0;
}
이 함수를 호출하면 매개변수로 넘겨준 변수가 전혀 바뀌지 않음을 알 수 있다. 왜 그럴까?
Pass by value
C/C++에서는 매개변수를 주소가 아닌 값으로 넘겨준다. 이것을 pass by value라고 한다. 따라서 32라는 값이 복사되어 함수로 넘겨진다고 보면 된다. 복사되어 넘겨졌기 때문에, 함수 내에선 아무리 변경해도 원본에 영향을 미치지 않는 것이다. 실제로 printf
로 주소를 확인하면 main
함수의 변수와 ToCelsius
함수 내에서 매개변수의 주소가 서로 다른 것을 알 수 있다.
(이 문단이 이해가 되지 않으면 아래 '포인터란 무엇인가'를 읽어보자.)
초급자라면 어려울 수도 있고 나중에 살펴봐도 되는 내용이지만, 프로세스의 스택 프레임에 대해서 알면 좀 더 직관적으로 이해될 수도 있다. 자세히 설명하기엔 내용이 너무 길어져 간단히 말하자면 함수가 호출될때 스택에 반환 주소, 매개변수, 지역변수등이 스택에 쌓이고 반환될때 이들이 제거된다. 다시 말해 main
의 지역 변수(원본)을 복사한 ToCelsius
함수의 매개변수(복사본)가 따로 스택에 있으며, 때문에 함수 내에서 이 매개변수로 계산을 하고 있으니 원본에는 영향을 미치지 않는다.
이에 대해서 자세히 알고 싶다면 '프로세스 스택 프레임' 이라고 검색하면 관련 자료가 많이 나올 것이다.
아무튼 함수 내에서 매개변수의 값을 변경하려면 어떻게 해야할까?
Pass by reference
포인터를 사용하면 된다. 왜 포인터를 사용하면 해결될까?
포인터와 주소 연산자를 사용하면 변수의 주소에 있는 값을 변경할 수 있다.
그러므로 매개변수로 포인터를 넘겨주면 포인터가 가리키는 변수, 즉 원본을 변경할 수 있는 것이다!
포인터는 단순히 변수의 주소이며 그 이상 그이하도 아니다.
포인터를 너무 어렵거나 복잡하게 생각하지 말자!
#include <stdio.h>
// 포인터(주소)를 매개변수로 받는다.
void ToCelsius(double *F)
{
// *연산자는 포인터가 가리키는 변수의 값을 나타낸다.
*F = (*F-32)/1.8;
}
int main()
{
double temperature = 32;
// 1번 방법
// 해당 변수의 주소를 넘겨준다.
ToCelsius(&temperature);
printf("%lf", temperature); // 정상적으로 변경됨!
// 2번 방법
// 포인터에 변수의 주소를 할당하고
double *p = &temperature;
// 포인터를 넘겨준다.
ToCelsius(p);
printf("%lf", temperature); // 정상적으로 변경됨!
return 0;
}
손 쉽게 해결되는 것을 볼 수 있다.
이렇게 어떤 주소(변수에 대한 참조)를 넘겨주는 것을 pass by reference라고한다.
사실 포인터가 가리키는 주소 자체도 값이기 때문에 엄밀히 말하자면 pass by value이다. 하지만 주소 자체가 어떤 값에 대한 참조(reference)이고 이 reference를 넘겨주므로 pass by reference와 동일하게 작동한다. C++에서는 매개변수로 &
를 받아 이를 주소 연산자를 사용하지 않고(즉 dereference하지 않고) 값을 변경하는데, 이쪽이 더 pass by reference 의미에 부합하긴 하다.
포인터 복잡한데 쓰지 말고 pass by 어쩌구 신경쓰지 말고 그냥 함수 내에서 값을 변경하지말고 반환값을 이용하면 되는거 아닌가? 싶은 생각이 들 수도 있다.
그리고 놀랍게도 우리는 이미 비슷한 일을 한번 겪어봤기 때문에 반환값만으론 부족하다는 사실을 알수 있다.
아래 항목을 읽고 차근차근 생각해보자!
- 왜
scanf
를 사용할 때 매개변수로 변수의 &를 넘겨주는가? scanf
가 실행된 후, 즉 입력을 받은 후에 그 변수에는 어떤 값이 들어있게 되는가?- 함수 실행 중 여러 매개변수의 값을 변경하고 싶다면?
- 의미 있는 값(예를 들면 함수가 성공적으로 실행되었을때 0, 이외에 1)을 반환하면서 매개변수의 값도 변경하고 싶다면?
이쯤 되면 Pass by value가 전부인 C에서 주소 관련 연산을 할 수 있는 포인터는 꼭 필요하다는 사실을 알 수 있을 것이다.
아직도 납득이 되지 않았다면, 이 외에도 다음과 같은 이유가 있다.
- 포인터 변수에 함수 주소를 저장하고 이를 호출해 상황에 따라 다르게 작동하는 코드를 만들 수 있다거나
- 크기가 큰 구조체나 클래스를 매개변수로 넘겨줄 때 값 복사로 넘겨주면 상당히 무거운 작업이 되므로 이에 대한 참조(주소)만 넘겨준다던가
- 동적 할당한 메모리를 포인터로 가리킬 수 있다거나
- ...
자료 조사를 하면서 다른 분들이 쓴 글도 봤는데, 개인적으론 위 본문의 접근 방법이 가장 이해가 잘되었고 필요성이 몸소 느껴졌었다. 처음에 포인터를 배우면서 비슷한 의문을 가질텐데, 나에겐 이런 설명이 포인터를 이해하는데 굉장히 도움이 됐던 것 처럼 C/C++를 배우는 분들에게도 꼭 도움이 되었으면 한다!
배경지식: 포인터란 무엇인가
포인터에 대해서(Pointer variables)
컴퓨터의 메인 메모리는 여러 바이트로 구성되어 있으며 각각의 바이트는 8비트의 정보를 저장할 수 있다.
예를 들면 정수 83을 0101 0011
과 같이 나타낼 수 있다. 이 바이트들은 고유한 주소를 갖고 있다. 만약 메모리에 n개의 바이트가 있다면, 이들의 주소는 0부터 n-1까지 존재한다. 아래 표를 보자.
주소 | 내용 |
0 | 0101 0011 |
1 | 0111 0101 |
2 | 0111 0111 |
3 | 0110 0001 |
... | |
n-1 | 0100 0011 |
실행 가능한 프로그램(Executable Program)은 코드(C프로그램의 명령어에 대응하는 기계어)와 데이터(프로그램의 변수들)로 구성되어 있다. 각 변수는 메모리의 1개 또는 그 이상의 바이트를 점유한다. 변수가 사용하는 주소의 첫 번째 바이트 주소는 그 변수의 주소이다. 예를 들면 변수 i가 2000과 2001번의 주소를 사용한다고 할 때, i의 주소는 2000이다.
주소를 숫자로 표현할 수는 있지만, 값의 범위가 정수형의 범위와 다를 수 있으므로 일반적인 정수 변수에 저장할 수 없다. 따라서 포인터 변수라는 것을 만들어 거기에 주소를 저장한다. 예를 들면 포인터 변수 p
에 변수 i
의 주소를 저장한다.
이때 "p
는 i
를 가리킨다."(points to)라고 한다. 포인터는 그저 주소를 저장하는 변수이다.
포인터 변수의 선언은 다른 변수 선언과 비슷하지만, 식별자 앞에 asterisk라고 불리는 *를 붙이는 것이 차이점이다.
// 다른 변수 선언과 함께 할 수 있다.
int i, j, a[10], b[20], *p, *q;
int *p; /* int만을 가리킨다. (points only to integers) */
double *q; /* double만을 가리킨다. (points only to doubles) */
char *r; /* char만을 가리킨다. (points only to characters) */
또한 포인터는 정수형이나 실수형 변수의 주소뿐만 아니라 함수나 변수에 속하지 않는 메모리 영역을 가리킬 수도 있으며, 다른 포인터를 가리킬 수도 있다. 다시 말해 포인터는 변수뿐만 아니라 오브젝트를 가리킨다.
주소 연산자(The Address and Indirection Operators)
포인터와 관련된 연산자는 주소 연산자로, &
(address)와 *
(indirection)이 있다. 예를 들면 다음과 같다.
x
가 변수일 때, &x
는 x
의 메모리 상 주소이다.
p
가 포인터일 때, *p
는 p
가 가리키는 오브젝트이다.
잘 이해가 안된다면, 다음 코드 예시로 살펴보자.
int i = 1234; // i의 주소가 2000이라 가정한다.
int *p; // integer 포인터 선언
p = &i; // 포인터에 i의 주소를 저장한다. 따라서 p는 2000이라는 값을 가진다.
int temp; // integer 변수 선언
temp = *p; // 변수에 p가 가리키는 오브젝트를 저장한다.
// p는 i를 가리키고 있었고 i의 값은 1234이다.
// 따라서 1234가 저장된다.
그래도 이해가 안된다면 그림을 봐보자.
p
는 i
의 주소 2000을 가리키고 있고 i
는 자신의 주소에 1234라는 값을 가지고 있다.
&i
는 i
의 주소이다. 따라서 p = &i
명령을 통해서 i
의 주소를 p
에 저장할 것이다.
*p
는 p
가 가리키는 오브젝트이다. i
를 가리키고 i
의 주소에 있는 실질적인 값은 1234이기 때문에, temp = *p
에서 1234라는 값이 저장된다.
포인터 할당(Pointer Assignment)
할당 연산자를 사용하여 포인터가 동일한 유형인 경우 포인터를 복사할 수 있다. 아래 코드를 보자.
// 변수 선언
int i, j, *p, *q;
/*
* 예시 1
*/
p = &i; // i의 주소가 복사되어 p에 저장된다.
q = p; // p의 값이 복사되어 q에 저장된다. 즉 i의 주소가 저장된다.
// p와 q 둘 다 i를 가리키기 때문에, i의 값을 변경할 때 다음과 같이 할 수 있다.
*p = 1; // i의 값이 1이 된다.
*q = 2; // i의 값이 2가 된다.
/*
* 예시 2
*/
p = &i; // p는 i를 가리킨다.
q = &j; // q는 j를 가리킨다.
i = 1; // i에 1을 할당한다.
*q = *p; // q가 가리키는 값에 p가 가리키는 값을 할당한다.
printf("%d", j); // 따라서 j는 1이 된다.
출처
C언어 배우시는 분들께 꼭 추천드리고 싶은 책
K. N King - C Programming : A Modern Approach