*주의 - 혼자 학습한 내용이므로 틀릴 수 있으니 알려주시면 진심으로 감사드리겠습니다.
1. 포인터의 개념
포인터는 C언어의 가장 어려운 부분이면서 하드웨어에 직접 접근이 가능한 장점을 가지고 있는 부분이기도 하다.
여기서 하드웨어는 메모리 영역을 뜻하며, 메모리의 특정 부분의 주소 값을 알고 직접 찾아가서 값을 건드릴 수 있다.
포인터는 주소값을 갖고 있으므로 크기는 4B로 정해져 있다.
배열 타입을 가리키는 포인터 변수도 4B의 크기를 가진다.
포인터의 선언은 변수를 선언하는 것과 동일하지만, type과 idenfier 사이에 * 기호를 입력하여 선언한다.
예시) int * a; char * b[4]; struct tag * c;
주의해야 할 것은 우리가 프로그램을 실행할 때 접근할 수 없는 메모리 영역이 있다는 것이다.
일반적인 PC의 경우, 번호가 낮은 메모리에는 주로 시스템에 해당하는 값들이 저장되어 있으므로 함부로 접근할 수 없고, 만약에 바꿀 수 있다고 하더라도 컴퓨터에 큰 이상이 생길 것임을 알 수 있다.
그래서 포인터에 임의의 값을 넣어서 실행할 경우 흔하게 액세스 거부가 날 수 있다.
액세스 거부를 방지하기 위해 우리는 우리가 선언한 변수들의 주소를 가져와 사용하는 것이 가장 좋다.
예시) int * p = &a;
& 는 단항으로 쓰일 경우 변수의 주소의 값을 리턴한다.
즉, &a는 a가 저장되어 있는 메모리 상의 주소를 뜻하고, 이 값을 0x1000으로 가정할 경우 p = 0x1000 이 저장되게 된다.
헷갈릴 수 있는 것은, p의 주소가 0x1000이 아니라 p의 값이 0x1000라는 것이다.
p가 가리키고 있는 주소에 있는 값을 보기 위해선 *연산자(간접 연산자)를 붙이면 된다.
간접 연산자라고 불리는 이유는 자신의 메모리에 직접 값을 가지고 있지 않고, 남의 메모리에 간접적으로 접근하여 값을 다룰 수 있는 연산자이기 때문이다.
쏘 Easy 한 설명과 함께 보도록 하자.
그렇다면, 만약 포인터 값에 숫자를 더하면 어떻게 될까?
가령, int * p = &a; 의 경우에 p+1을 출력하면 어떤 값을 가질까?
#include <stdio.h>
int main(){
int a=10;
int * p = &a;
printf("%#.8x\n", p); //a의 주소
printf("%#.8x\n", p+1);//a의 주소 +4
printf("%#.8x\n", p-1);//a의 주소 -4
char c='A';
char * q= &c;
printf("%#.8x\n", q); //c 의 주소
printf("%#.8x\n", q+1); //c의 주소 +1
printf("%#.8x\n", q-1); //c의 주소 -1
}
위의 코드를 진행해보면, 컴퓨터마다 다른 결과가 나올 것이다.
a를 메모리 어디에 저장했는가는 중요하지 않다.
p+1의 값이 얼마나 차이 나는지 보자.
예를 들어서 p가 0x10000000이 나왔다고 치자. 그렇다면 p+1은 0x10000004가 나온다. 마찬가지로 p-1은 0x0FFFFFFC가 나오게 될 것이다.
하지만 char형 데이터를 가리키고 있는 q를 보자. q+1과 q-1 은 주소 값이 단 1밖에 차이가 나질 않는다.
즉 포인터로 어떤 값을 가리킬 경우, 그 타입을 명확하게 해야 정확하게 접근하여 사용할 수 있다. 만약 char형 데이터인데 int * q로 접근을 한다면 q+1은 c의 바로 다음 메모리가 아닌 4 증가한 위치의 메모리를 찾을 것이다.
포인터는 변수가 각자 유효 범위가 정해져 있기 때문에 유용하게 사용된다.
가령 함수를 호출했을 때를 생각해보면, 새로운 스택이 메인 함수의 스택 위에 쌓이게 되면서 메인함수의 변수들을 사용할 수 없게 된다. 이때 일반 변수로 전해주면 함수 스택에서 변수들이 새롭게 선언되기 때문에 메인함수의 변수들이 가려지게(invisible) 되고 이 때 아무리 값을 고쳐봐야 메인 함수는 영향을 받지 않는다.
그렇기 때문에 메인 함수에 저장된 변수들의 주소를 가져와 함수에서 간접적으로 접근하여 사용하는 call by address 방식을 사용할 수 있게 되는 것이다.
물론 모든 걸 전역 변수로 선언해도 간단하겠지만, 정적 메모리의 효율적인 사용과 지역변수들을 효과적으로 다루기 위해 포인터는 반드시 필요하며 숙련도를 높여둘 필요성이 있다.
분명히 하드웨어에 직접 접근한다는 것은 큰 가치가 있기 때문이다.
2. 포인터의 이해
포인터를 선언할 때는 가리키고 있는 대상이 무엇인지도 명확하게 선언해야 한다.
위의 예시는 integer type 인 a를 가리키고 있기 때문에 포인터 p의 형식이 int * p; 가 된 것이다.
그렇다면 만약 int b[4] 를 가리킬 경우는 어떻게 선언해야 할까?
마찬가지이다. b는 integer type이 4개 들어있는 배열을 뜻하므로 int * p = b; 가 된다.
그런데 생각해보면 무언가 이상하다.
b 는 배열의 이름이 아닌가? 만약 배열의 이름이라면 포인터는 변수 명의 값을 가지게 되므로 주소 값이 아니게 된다.
그렇다면 선언이 잘못된 것인가? 그렇지 않다. 실제로 p[0]의 값을 불러오면 b[0]의 값과 동일함을 볼 수 있다.
그렇다면 결론은 무엇일까. b가 사실은 배열의 이름이 아니고 배열의 시작 주소라는 것이다.
실제로 b 배열 안의 모든 원소들은 b가 현재 가지고 있는 주소 값으로 모두 접근이 가능하다.
가령 b[1] 의 경우 b의 두 번째 요소를 뜻하는데, *(b+1)의 값을 출력해도 같은 값을 가짐을 알 수 있다.
여기서 b+1 은 앞서 언급한 것처럼 integer의 해당하는 공간인 4B만큼 이동한 주소 값이 되고, 거기에 있는 원소는 b배열의 2번째 값이므로 동일한 값이 나온다는 것이다.
즉, 주소로 접근하는 것은 배열로 접근하는 것과 동일한 의미를 가지며 p[i] = *(p+i) 가 성립하게 된다.
그렇다면 앞서 배운 것을 다시 한번 돌아볼 필요가 있다.
char name[20]="FORSWDEV"; 라고 초기화했을 경우를 생각해보자.
name[1] = 'O' 가 나옴에서 눈치를 챌 수 있듯이 *(name + 1) = 'O' 가 될 것이고, 지금까지 사용한 char형 배열이 모두 포인터였음을 알 수 있다.
그렇다면 sizeof(name) 은 어떻게 될까? char형을 가리키는 포인터이므로 4B가 될까? (모든 포인터는 4B)
실제로 수행해보면 name의 크기는 20B로 나온다.
그렇다면 &name 은 어떻게 나올까?
또한 Visual Studio를 사용한다면 F10을 눌러 디버깅 모드를 실행해서 한 줄 한 줄 실행해보자.
조사식 혹은 자동으로 나오는 창을 보면 변수의 값과 주소를 볼 수 있다. (SHIFT + F5로 디버깅 종료)
실제로 name을 선언해서 조사식에 이름을 적으면 값과 형식을 볼 수 있는데, 여기서는 name의 형식이 char[20]으로 나오지만 실제로 포인터처럼 사용할 때에는 char *처럼 다뤄진다. 즉 printf("%c", *(name+1));을 해보면 s가 나온다.
하지만 &name과 &name[0] 은 다르다. 우선은 단항연산자들은 후치, 근치를 따르므로 &name[0] 은 &(name[0]) 과 같은 뜻이 되고 &name [0]의 형식은 char * 가 된다. 즉 가장 앞에 있는 character를 가리키는 포인터라는 뜻이다.
그에 반해 &name의 형식은 char[20] *이다. 이것은 name은 20개의 원소를 갖고 있는 배열이고 그것의 시작점을 가리키고 있다는 뜻이다.
또한 sizeof 연산자를 사용할 경우 , name은 20, &name[0] 은 20이다.
이는 아주 중요한 사실을 내포하는데, 바로 &과 sizeof를 사용할 때는 배열의 이름을 첫 번째 주소로 사용하지 않고 배열 전체를 가리키는 포인터로 사용한다는 것이다. 즉 평상시에 사용할 때는 name = &name[0] 과 동일한 뜻으로 사용되지만 sizeof 나 &을 만날 경우 배열 전체를 가리키는 타입으로 본다.
이는 배열을 call by address 형식으로 함수에서 처리하거나, 포인터 형식으로 배열에 접근할 때 항상 염두에 두고 있어야 할 점이다.
다음의 코드를 보자.
#include <stdio.h>
void func1(char name[20]){
printf("%d\n", sizeof(name));
}
void func2(char * name){
printf("%d\n", sizeof(name));
}
int main(){
char name[20] = "asdfASDF";
func1(name);
func1(&name);
func2(name);
func2(&name);
}
결과는 과연 어떻게 나올까? 4,20,4,20으로 나올까?
그렇지 않다.
실제로 돌려보면 4,4,4,4 가 나오는 것을 확인할 수 있을 것이다.
이것은 func1(char name[20]) 이 실제로는 name의 가장 첫 번째 주소만을 가지고 온다는 것이다.
즉 name[20]에 들어있는 20은 fake이며, compiler는 이 20을 인식하지 않고 name[20] = *name; 으로 바꿔서 인식한다.
즉 name[20]이 아니고 name[10]이나 name[]로 써도 동일한 결과가 나오는 것을 알 수 있고, compiler는 배열을 첫 번째 주소와 동일하게 인식한다는 것을 알 수 있다.
그렇기 때문에 sizeof(name[20])은 call by address에서 사용하기 힘들며, 보통 배열의 요소들의 개수를 같이 argument로 넣어준다.
그렇다면 2차원 배열은 어떻게 될까?
간단한 예시로 char name[3][10]; 이 있다고 생각해보자. name을 어디를 가리킬까?
예상했듯이 name배열의 시작점을 가리킨다. 그렇다면 &name의 type은 어디고 어디를 가리킬까?
name과 마찬가지로 시작점을 가리키지만, name이 단순히 char * 형식이라면 &name은 char[3][10]의 형식을 갖고 있을 것이다. 또한 sizeof(name) 도 마찬가지로 3*10B로 계산하게 된다.
그렇다면 name[0]는 무엇일까?
name[0] 는 name[0][0] ~ name[0][10] 까지 배열의 시작점일 것이다. 위와 마찬가지로 배열의 시작점이기 때문에 이름이 아닌 주소값을 가지고 있을거라고 예상할 수 있고, 실제로 name[0] = name과 같은 타입과 주소를 가지고 있음을 확인할 수 있다.
그렇다면 name[1]은 name+ ?로 표현할 수 있을까? 여기서 name[1]은 10개짜리 요소를 갖고 있는 배열과 같으므로 크기는 1*10=10B가 된다. name 도 마찬가지로 &이나 sizeof가 붙지 않았기 때문에 name [0]과 같은 타입과 주소를 갖고 있다고 했다. 그러므로 name + 1 은 10B 크기를 뛰어넘어 다음 주소를 가리키게 되며 그곳이 name[1]의 위치와 동일하다는 것을 알 수 있다.
정리해서, &name[0] = a 값과 같고, a[0] = *a 와 같다.
또 &name[1]= a+1과 값이 같고, name[1] = *(a+1)이 같은 값을 가지며 이때의 값은 name[1][0]과 같을 것이란 걸 예상할 수 있다.
살짝 복잡했지만, 포인터의 형식이 얼마나 중요했는지 알 수 있고, 포인터로 접근하려면 익숙해질 때까지 시간이 많이 들 것을 알 수 있다.
#include <stdio.h>
void func1(char name[][20]) {
for (int i = 0; i < 2; i++) {
for (int j = 0; j < 8; j++) {
name[i][j] = '1';
}
}
}
int main() {
char name[2][20] = { "asdfASDF", "ZXCVXZCV" };
func1(name);
printf("%s %s", name[0], name[1]);
}
물론 실제 사용에선 직관적으로 이용하기 쉽도록 2차원 배열로 많이 접근할 것이다.
위와 같이 call by address를 사용한다 해도 2차원 배열을 불러오는 것처럼 parameter를 받아도 문제없이 수행됨을 볼 수 있다.
구조체와 공용체에도 포인터를 쓸 수 있다.
물론 구조체와 공용체는 배열과 달리 이름으로 취급한다고 했었다. 만약 구조체의 이름이 a이고 그 주소가 &a라고 가정한다면, a의 멤버들에는 . 연산자를 통해 접근했었다. 예를 들어 a의 멤버가 idx가 있다고 치면, a.idx= 1; 이런 식으로 접근했었다. 만약 &a를 통해 접근하고 싶다면 어떻게 할까? 그때는 -> 연산자를 사용한다. 즉 (&a)->idx = 1; 이런 식으로 주소를 통해 접근할 수 있고, 그외의 차이는 없다.
그렇다면 만약 구조체가 배열이라면 어떨까?
type 이 structure로 바뀐 것뿐 똑같이 사용하면 된다. &이나 sizeof 가 붙지 않는 한, struct 하나를 가리키고 있는 pointer라고 생각하면 되고, 실제로 sizeof a = 16 이 나오는 것을 확인할 수 있다. sizeof a[0]은 4가 나온다.
또한 배열과 마찬가지로 이름을 포인터처럼 사용하여 접근할 수 있다.
a[1].idx= (a+1)->idx 가 성립하는 것으로 보아, a[1] = *(a+1) 이 성립함을 볼 수 있고, 여기서는 idx의 값을 출력하므로 *값 대신 ->idx가 쓰였다.
조금 신기한 것은 '자기참조 구조체'이다.
자신의 구조체 멤버로 자신을 넣을 수는 없다. 자기 자신을 정의하는 중에 다시 정의로 들어가 버리는 것이기 때문이다. (물론 정의가 완료된 다른 구조체를 멤버로 넣을 수는 있다.)
하지만 구조체 안에 자기 자신의 타입을 가리키는 포인터 선언은 가능하다.
다음과 같은 코드가 있을 때, 출력 값을 예상해보자.
#include <stdio.h>
struct tag1{
int a;
char b;
struct tag1 * p;
};
int main(){
struct tag1 A1={1,'A',0x0}, A2={2,'B',&A1};
printf("%d %c", A2.p -> a, A2.p -> b);
}
A2에서 pointer 값에 적힌 주소로 가서 거기에 있는 a를 꺼내라고 하니 1과 b를 꺼내라고 하니 'A'가 나올 것이다.
이런 식으로 구조체를 연결시켜서 값에 접근할 수 있는데 이를 linked list 나 vector로 활용할 수 있고, 나중에 알고리즘이나 자료구조에서 다시 언급한다.
3. 다중 포인터
포인터가 여럿 겹쳐있을 경우는 어떻게 처리할까?
가장 간단한 예시로 더블 포인터를 보자.
기본적인 선언은 int ** p; 로 선언한다.
이 뜻은 얼핏 보면 이해가 잘 가지 않는다.
그래서 나는 주로 말로 얘기하면서 감을 잡는 편인데, '포인터를 가리키는 포인터'라고 부른다.
즉 p 는 어떤 포인터가 있는 주소를 가리키고 있고, 그 주소에 가면 다시 어떤 값을 가리키는 포인터가 있다는 뜻이다.
가령 a= 10이라고 하고, a의 주소가 0x2000 이라고 한다.
int *q = &a; 라고 하며 &q는 0x1000 이라고 한다.
int **p = &q; 라고 하며, &p는 0x0000 이라고 한다.
그럼 이들의 관계는 다음과 같다.
즉 포인터를 가리키는 포인터를 더블 포인터라고 하고, 간접 연산자(*) 2개를 통해 값에 접근할 수 있다.
삼중, 4중, 90중 포인터도 마찬가지이며 각각 간접 연산자 3개 4개 90개를 통해 접근할 수 있다.
이 다중 포인터가 필요한 이유는 다음 예시와 함께 설명하겠다.
가령 char[] 을 교환하고 싶을 경우 그냥 포인터와 이중 포인터를 쓸 경우 두 가지를 보고 결과를 비교해보자.
#include <stdio.h>
void Swap1(const char* p, const char* q) {
char* tmp = p;
p = q;
q = tmp;
}
void Swap2(const char** p, const char** q) {
char* tmp = *p;
*p = *q;
*q = tmp;
}
int main(void)
{
char a[10]="asdf", b[10]="Zxcv";
Swap1(a, b);
printf("%s %s\n", a, b);
Swap2(a, b);
printf("%s %s\n", a, b);
return 0;
}
결과를 보면 그냥 포인터를 사용하여 값을 변경한 것은 함수가 끝나고 나면 main 함수에서는 원래대로 돌아오는 것을 볼 수 있다.
하지만 이중 포인터를 사용하여 값을 변경한 것은 함수가 끝나고도 main함수에 영향을 미치는 것이 보인다.
왜 그런 것일까?
char a[]은 알다시피 pointer 형식이다. 그렇다면 p나 q는 어떻게 만들어질까?
그림과 같이 Swap1이 호출되는 순간 p와 q라는 parameter에 a와 b의 주소값이 들어가게 되고, p와 q의 값이 바뀌고 나서 함수가 종료되고 나면 p와 q는 기억하지 않는다. 그렇기 때문에 main함수에서의 값이 변하지 않는 것이다.
반면 Swap2를 호출하면 다음과 같다.
main 함수에서 사용되는 주소값들에 직접 접근하기 때문에 스택에 상관없이 수정이 가능하게 된다.(그림은 좀 망했지만)
어쨌든 call by address에 사용된다는 사실을 기억하고 개념을 그림을 이해하는게 편해서 한 번 그려봤다.
실제로 a의 값이 바껴야하지만 그림으로 그리기 힘들어서 그냥 주소를 바꾸는 걸로 했다. a가 0x1004를 가리키고 zxcv를 가리켜야 되는데 이런 실수를.. 데헷
포인터는 이렇게 call by address 에서 유용하게 쓰이기 때문에 그림으로 간단하게 개념을 잡아봤다.
언급하지 않은 것들과 예시들이 있지만, 기본적인 이해를 하고 나서 실제로 써보는 게 더 도움이 될 것 같아 뒤에 후술 하기로 마음먹고(하려나?) 포인터는 여기서 마치도록 하겠다.
'C언어' 카테고리의 다른 글
C언어의 기초 - 선행처리기 preprocessor (0) | 2021.02.07 |
---|---|
C언어의 기초 - 비트 연산자 (0) | 2021.02.06 |
C언어의 기초 - 구조체와 공용체 (0) | 2021.02.03 |
C언어의 기초 - 변수와 배열 (0) | 2021.02.03 |
C언어의 기초 - 함수와 조건문, 반복문 (0) | 2021.02.01 |