포인터는 C에서 여타 가장 헷갈리고 어려운 개념이다. 허나 정작 개념 자체만 놓고 보았을 때는 매우 단순하다. 단지 store할 수 있는 것이 일반 정수, 실수, 문자를 넘어서 주소가 되었을 뿐이다. 그래서 제대로 포인터에 대해 정리해 보고자 이 글을 쓴다.
포인터(pointer)는 다른 변수, 혹은 그 변수의 메모리 공간 주소를 가리키는 변수를 말한다. 말 자체(pointer)에서도 알 수 있듯이 무언가를 가리키고 있다(point)는 의미를 담고 있다. 그 가리킴을 당하는 대상이 변수가 되는 것이고 그 변수에는 값이 저장되어 있으므로 그 값을 간접적으로 접근할 수 있는 것이다.
포인터도 엄연히 변수이다(상수 포인터도 존재하다만), 대신 담기는 대상이 일반 값이 아니라 특정 변수의 주소가 담기게 되는 것이다. 포인터의 사용 예시를 통해 메모리를 살펴보자.
int a = 10;
int* ptr = &a;
*ptr = 12;
변수 a의 주소를 0x01, ptr의 주소를 0x55라고 가정하자. 메모리의 관점에서는 왼쪽 그림과 같이 a, ptr가 4바이트의 공간씩 차지할 것이다. 왜냐하면 둘 다 int형으로 선언되었기 때문이다.
a에는 10이 저장되어 있지만 ptr에는 a의 주소인 0x01이 담겨 있다. 하지만 주소를 담기만 해서 좋은 점이 무엇인가??
포인터는 *(참조 연산자)로 두 가지 역할을 수행할 수 있다.
int a = 10;
int* ptr = &a; // 선언부
*ptr = 12; // 역참조부
1. 선언(초기화) : declaration
ptr 변수는 포인터 변수이고 아직 누구를 가리키고 있지는 않지만 int 타입을 가리킬 것이다. 선언 후에 밑의 '역참조' 기능을 사용하기 위해서는 반드시 초기화를 해주어야 한다. 의도치 않은 메모리 접근이 일어날 수 있기 때문에 선원과 동시에 초기화하는 것이 좋다. 그래서 선언 후 초기화를 원하면 아래와 같이 해줄 수 있다.
int* ptr = NULL;
int* ptr = 0;
2. 역참조 : dereference
선언과 초기화를 통해 변수에(위의 경우 a에) 접근할 수 있는 통로를 개척한 것이다. 이제 실제로 변수의 값을 변화시키고 싶다면 역참조를 사용한다. 즉 *ptr = 12의 효과는 a에 들어 있는 값을 12로 바꾸는 것과 같다.
포인터 변수에 저장되어 있는 내용물을 printf로 확인하고 싶으면 포인터의 자료형인 %d(int)를 사용해야 할까? int가 아니라 int*임을 명심하자. 즉 int와 포인터 변수에 들어 있는 값은 하등 관련이 없다. 어차피 주소만 들어 있을 뿐이기 때문에 %d가 아닌 %p를 형식 지정자로 printf에 적어주어야 한다.
이번에는 다음 값을 예상해 보자.
printf("%d", sizeof(ptr));
이번에는 4일까? 아니다. 왜냐하면 64비트 아키텍처에서는 주소를 64비트(8 바이트)로 표현하기 때문에 포인터 변수의 크기가 8 바이트인 것이다. 방금 전에도 알아보았듯이 ptr에는 정수가 아닌 주소가 담겨 있다.
그렇다면 아래의 결과는 어떨까?
printf("%d", sizeof(*ptr));
이제는 역참조 연산자를 통해 *ptr는 a로 바뀌기 때문에 4가 출력된다.
배열의 이름은 포인터이다. 배열이 애초에 어떻게 생겼는지 생각해 보자, 연소고딘 메모리 속에서 배열의 타입만큼의 공간이 순서대로 할당되어 있는 것이다. 대신 다른 포인터와는 달리 그 값을 바꿀 수 없는 '상수 형태의 포인터'이다.
예를 들어 아래의 코드는 오류를 발생시킨다.
int arr[4] = { 0 };
arr = NULL;
왜냐하면 배열의 이름은 다른 값을 새로 집어넣을 수 있는 형태가 아니기 때문이다. 우리가 일반적으로 생각하는 상수와 특성이 비슷하다고 생각해도 된다.
이때 "식이 수정할 수 있는 Ivalue여야 합니다"라고 에러 메시지가 뜬다. Ivalue는 할당이 가능한 표현식으로 변수나 배열 요소가 해당된다.
배열 포인터가 일반 변수 포인터와는 다른 점이 있다. arr와 &arr는 같을까? 다를까?
arr에 &를 붙여서 뭔가 다를 거 같지만 사실 둘의 값은 같다. 우리의 직관을 살짝 벗어나는 결과지만 그것은 배열이 "자 자신을 가리키는 포인터"이기 때문이다. 우리가 여태 봐왔던 변수 포인터는 말 그대로 "다른 변수"를 가리키는 포인터 역할이기 때문에 웬만해서는 포인터가 가리키는 주와 그 포인터의 주소는 다르다(물론 강제로 만들 수는 있다).
그렇다면 arr만 가지고도 derefering(*) 할 수 있을까? 배열의 이름은 배열의 첫 번째 요소를 가리키는 포인터이다. 즉 아래의 출력을 예상할 수 있다.
int arr[4] = { 1,2,3,4 };
printf("%d", *arr); // 결과 : 1
포인터는 증감 연산이 가능하다 증감 연산은 해당 포인터가 선언된 자료형 크기만큼 +1 마다 메모리 주소를 점프할 수 있게 해 준다.
예를 들어 int* ptr에 대해 ptr의 주소가 0000이라면 ptr+1은 4바이트(int) 만큼 다음인 0004를 가리키게 된다. 그렇다면 아래와 같은 상황도 사실상 가능하다.
int a = 10; // 주소 : 0000
int b = 20; // 주소 : 0008
int* ptr = &a;
*(ptr + 2) = 100; // b에는 20 대신 100이 저장된다.
ptr + 2 = &b는 왜 불가능할까? 왜냐하면 ptr + 2는 이미 정해진 주소이므로 그 주소에는 어느 것도 값을 대입할 수 없기 때문이다. ptr + 2는 메모리 상에서의 '위치'일뿐이지 주소를 대입할 수 있는 변수가 아니다!!
이런 측면에서 증감 연산자는 일반 포인터 변수에서 즐겨 사용되지 않는다. 증감된 메모리에 무엇이 있을 줄 알고 그 메모리에 접근하려 하는가? 상당히 위험한 행동이다. 그래서 주변 메모리에 무엇이 있을지 예측이 일정한 배열에서 사용이 용이하다.
int arr[4] = { 1,2,3,4 };
for (size_t i = 0; i < 4; i++)
{
printf("%d ", *(arr + i));
}
배열이 상수 포인터인 것을 실감 나게 해주는 예제를 살펴보자.
int arr[] = { 1,2,3 };
int* ptr = arr;
*(++arr) = 10; // 에러
*(++ptr) = 10; // arr[1]을 10으로 변경
++arr는 arr=arr+1의 의미를 담고 있다. 하지만 arr는 새로운 포인터나 주소를 할당할 수 있는 포인터 변수가 아니므로 에러가 발생한다. 반면에 ptr는 arr의 첫 번째 요소의 주소를 가리키는 포인터 '변수'이므로 수정이 가능하다. 따라서 기존 ptr(arr[0])에서 4 바이트 넘어간(ptr을 1 증가시킨 후에 *derefering) arr[1]의 값에 접근한다.
다른 예시를 들어보자. 배열의 두 번째 요소를 ++를 이용해서 1만큼 증가시키고 싶다고 할 때 우리의 의도를 아래의 코드가 충분히 이행해 줄까?
*(arr + 1)++;
얼핏 보면 그럴싸해 보인다. 두 번째 요소(arr+1)에 접근해서 역참조(*)한 다음에 후위 증가 연산자를 통해 1만큼 증가시킨다. 하지만 위의 수식은 에러를 일으킨다. 왜냐하면 후위 증가 연산자인 ++의 우선순위가 *보다 높아서 사실상 순서가 다음과 같아지는 것이다.
*((arr + 1)++);
앞서 살펴보았듯이 arr는 상수 포인터이므로 그 자체만으로는 증감 연산자를 붙일 수 없기 때문에 에러가 발생한다.
따라서 우리가 의도(역참조 후 증가)대로 코드를 수정하면
++*(arr + 1);
1. 전위 증가 연산자와 *의 우선순위가 같고 둘 다 associativity가 오른쪽에서 왼쪽이기 때문에 dereferencing을 먼저 하고 증가시킨다.
(*(arr+1))++
2. 괄호를 이용해 우리가 이전에 잘못 작성했던 식에서 강제로 우선순위를 바꿔준 것이다.
포인터의 다른 용도로는 함수의 인자가 있다. 예를 들어 두 변수의 값을 서로 바꾸어주는 함수를 만들고 싶다고 하자.
#include <stdio.h>
void swap(int a, int b) {
int temp = a;
a = b;
b = temp;
}
int main() {
int a = 1, b = 2;
swap(a, b);
return 0;
}
실행해 보면 알겠지만 a와 b에 각각 인수로 전달된 변수의 값들은 뒤바뀌지 않을 것이다. 왜냐하면 함수에서의 인자는 호출과 함께 메모리가 새로 생성되고 호출이 끝나면 같이 소멸하는 변수일뿐이기 때문이다. 이를 가리켜서 call by value라고 한다.
Call by value(값에 의한 호출) : 함수가 호출되었을 때 인자들의 값만 복사해서 함수에 전달한다. 직관적이고 이해하기 쉽지만 복사된 대상에 따라 메모리 사용량이 증가할 수 있다. 또한 인자 값의 변화는 오직 함수 내에서만 유효하다.
그렇다면 우리가 원하는 swap함수는 만들 수 없는 것일까? 그래서 존재하는 것이 포인터이다.
#include <stdio.h>
void swap(int* a, int* b) {
int temp = *a;
*a = *b;
*b = temp;
}
int main() {
int a = 1, b = 2;
swap(&a, &b);
return 0;
}
이렇게 되면 인자 a(int* a)에는 메인 함수의 a변수 주소를 값으로 갖고 있는 포인터가 되는 것이다. 즉 이제 swap 함수 내에서 일어나는 인자 a에 대한 변화는 고스란히 메인 함수의 a한테 영향을 미치게 된다. 이것을 call by reference라고 한다.
Call by reference(참조에 의한 호출) : 인자로 주소를 전달하여 직접 참조하게 한다. 직접 참조인 만큼 새로운 메모리를 생성하지 않아 빠르고 함수 호출이 끝나도 주소를 통해 변화가 일어났기 때문에 인자로 선택된 변수에도 함수 내에서의 변화가 전이된다.
구조체에서는 포인터를 어떻게 사용할까?
먼저 구조체가 다음과 같이 정의 되어 있다고 전제하면 다음과 같이 선언할 수 있다.
typedef struct {
int age;
char* name;
} Human;
Human* p1 = (Human*)malloc(sizeof(Human));
이전에 포인터에 malloc을 사용하지 않은 이유는 가리키는 대상이 존재했기 때문이다. 이전에는 포인터를 사용해서 어떤 변수의 주소를 담고 간접 참조하는 것이 목적이었다면 구조체에서는 구조체 자체를 포인터로 정의하고 값을 가질 수 있게 메모리를 할당해 주는 것이 목적이다. 물론 아래와 같이 하면 malloc이 필요 없어진다.
Human p1;
Human* ptr_p1 = &p1;
선언이 일어나는 순간 구조체 포인터 p1의 메모리 할당은 다음과 같아진다.
구조체 포인터는 구조체의 첫 번째 요소를 가리키게 되고 만일 역참조를 원하면 다음과 같이 접근할 수 있다.
(*p1).age = 1; // 역참조 후 접근.
p1->age = 1; // 직관적인 이해와 편의를 위해 화살표 연산자 이용.
'언어 > C' 카테고리의 다른 글
[C언어] What is scanf()? (0) | 2023.09.18 |
---|---|
[C언어] What is printf()? (0) | 2023.09.11 |