혼공학습단 C 6주차📚

하영·2024년 2월 6일
0

혼공학습단

목록 보기
12/13
post-thumbnail

기본 미션 📣

포인터 핵심 내용 정리 | 포인터는 변수의 메모리 주소를 저장하는 변수로 자료형 *변수명;으로 변수처럼 선언하고 사용된다. 이때 주의할 점은 자료형을 적을 때, 포인터가 가리킬 변수의 자료형을 적어야 한다. 또, 포인터가 어떤 변수의 주소를 저장한 경우 '가리킨다'고 말하며 둘의 관계를 pa → a 로 간단하게 나타낸다. 포인터의 가장 중요한 점은 포인터로 어떤 변수를 가리키면 포인터가 가리키는 변수를 사용할 수 있다는 것인데, 이 뜻은 변수 a를 포인터 pa로 쓸 수 있다. 그렇게되면 *pa = 10;a = 10;을 같은 뜻으로 쓸 수 있다.

선택 미션 📣

9-2 도전 실전 예제 |

#include <stdio.h>

void swap(double* pa, double* pb);
void line_up(double* maxp, double* midp, double* minp);

int main(void)
{
	double max, mid, min;

	printf("실수값 3개 입력 : ");
	scanf("%lf%lf%lf", &max, &mid, &min);
	line_up(&max, &mid, &min);
	printf("정렬된 값 출력 : %.1lf, %.1lf, %.1lf\n", max, mid, min);

	return 0;
}

void swap(double* pa, double* pb)
{
	double temp;

	temp = *pa;
	*pa = *pb;
	*pb = temp;
}

void line_up(double* maxp, double* midp, double* minp)
{
	if (*maxp < *midp)
		swap(maxp, midp);
	if (*maxp < *minp)
		swap(maxp, minp);
	if(*midp < *minp)
		swap(midp, minp);
}

09-1 포인터의 기본 개념

📍 메모리의 주소

메모리라는 것은 우리가 데이터를 넣고 꺼내 쓰는 공간으로, 그 위치를 식별할 수 있어야 한다.

프로그램이 사용하는 메모리의 위치는 주소 값으로 식별할 수 있는데, 주소 값은 바이트 단위로 구분된다. 이 값은 0부터 시작하고 바이트 단위로 1씩 증가하므로 2바이트 이상의 크기를 갖는 변수는 여러 개의 주소 값에 걸쳐 할당된다.

지금까지는 이런 메모리 공간이나 값을 변수명으로 간단히 사용할 수 있었다.

📍 주소 연산자

이제는 변수를 이름이 아닌 주소로 사용하는 법도 알아보겠다. 여기서 주소는 변수가 할당된 메모리 공간의 시작 주소를 의미한다. 시작 주소를 알면 그 위치부터 변수의 크기만큼 메모리를 사용할 수 있다. 주소는 주소 연산자 &를 사용해서 구한다. 주소 연산자는 단항 연산자이며, 변수만을 피연산자로 사용해 시작 주소를 구한다. 주소 연산자를 사용한 예제를 보면

소스 코드 9-1.c

#include <stdio.h>

int main(void)
{
	int a;			// 5행
	double b;
	char c;

	printf("int형 변수의 주소 : %u\n", &a);			// 9행
	printf("double형 변수의 주소 : %u\n", &b);
	printf("char형 변수의 주소 : %u\n", &c);

	return 0;
}

5~7행처럼 변수 선언문이 실행되면 각 자료형의 크기만큼 메모리에 저장 공간이 할당된다.
만약 변수가 메모리 어디에 할당되었는지 알고 싶다면 9행~11행처럼 주소 연산자(&)를 사용하면 된다.

원래 주소는 보통 16진수로 표기한다. 그렇기 때문에 주소를 출력할 때는 전용 변환 문자인 %p를 사용하는 것이 좋은데, 여기서는 편의를 위해 주소 값을 10진수로 출력하고 음수가 없으므로 %u 변환 문자를 사용하였다.

📍 포인터와 간접 참조 연산자

메모리의 주소는 필요할 때마다 계속 주소 연산을 수행하는 것보다 한 번 구한 주소를 저장해서 사용하면 편리한데, 포인터가 바로 변수의 메모리 주소를 저장하는 변수이다.

따라서 포인터도 변수처럼 선언하고 사용한다.

자료형 *변수명;

선언할 때는 일반 변수명을 만드는 규칙에 따라 포인터 이름을 짓고, 변수명 앞에 *를 붙인다. 그리고 자료형을 적는데, 주의할 점은 주소 위치에 있는 변수의 자료형을 적어야 한다. 예를 들어 int형 변수의 주소를 저장하면 int를 사용하고 double형 변수의 주소를 저장하면 double을 사용한다.

포인터 변수가 선언되면 일반 변수와 마찬가지로 메모리에 저장 공간이 할당되고 그 이후에는 변수명으로 사용할 수 있다.

소스 코드 9-2.c

#include <stdio.h>

int main(void)
{
	int a;
	int *pa;

	pa = &a;			// 8행
	*pa = 10;

	printf("포인터로 a 값 출력 : %d\n", *pa);
	printf("변수명으로 a 값 출력 : %d\n", a);

	return 0;
}

8행은 포인터에 a의 시작 주소를 저장하는 문장이다. 만약 변수 a가 메모리 100번지부터 103번지까지 할당되었다면 주소 값 100이 pa에 저장된다. 그러면 이제 포인터 pa는 변수 a가 메모리 어디에 할당되었는지 그 위치를 기억하는 것이다.

이렇게 포인터가 어떤 변수의 주소를 저장한 경우 '가리킨다'고 말하며 둘의 관계를 pa → a 처럼 화살표로 간단하게 표현한다. 따라서 x → y 로 표현한다면 'x는 y를 가리킨다'고 말할 수 있고 x는 포인터이며 변수 y의 주소를 저장하고 있다는 뜻이다.

포인터가 어떤 변수를 가리키면 포인터로 가리키는 변수를 사용할 수 있는데, 즉 포인터 pa로 변수 a를 사용할 수 있다. 포인터가 가리키는 변수를 사용할 때는 포인터에 특별한 연산자를 사용하는데, 이를 간접 참조 연산자( * ) 또는 포인터 연산자라고 한다.

따라서 이 코드도 결국 a에 10을 대입하는 것과 같고,

*pa = 10;

마찬가지로 11행과 12행의 출력 결과도 같다.

printf("포인터로 a 값 출력 : %d\n", *pa);		// 11행
printf("변수명으로 a 값 출력 : %d\n", a);

그런데 입력할 때 생각해 볼 문제가 있다. scanf 함수를 사용하려면 입력할 변수가 메모리 어디에 할당되었는지 저장 공간의 위치를 알아야 한다. 따라서 입력할 변수의 주소를 인수로 줘야 한다. 이것은 포인터 pa를 통해 변수 a에 입력할 때도 마찬가지인데, *pa는 a와 같으므로 &a는 &*pa와 같다.

scanf("%d", &a);						scanf("%d", &*pa);

그런데 pa가 a의 주소를 저장하고 있으므로 바로 pa를 사용해도 된다.

scanf("%d", pa);

📍 const를 사용한 포인터

const 예약어를 포인터에 사용하면 이는 가리키는 변수의 값을 바꿀 수 없다는 의미로, 변수에 사용하는 것과는 다른 의미를 가진다.

소스 코드 9-4.c

#include <stdio.h>

int main(void)
{
	int a = 10, b = 20;
	const int* pa = &a;

	printf("변수 a 값 : %d\n", *pa);		// 출력 결과 : 10
	pa = &b;	// 9행
	printf("변수 b 값 : %d\n", *pa);		// 출력 결과 : 20
	pa = &a;
	a = 20;		// 12행
	printf("변수 a 값 : %d\n", *pa);		// 출력 결과 : 20

	return 0;
}

만약 const가 일반 변수처럼 포인터 값을 고정시킨다면 9행에서 pa는 다른 변수의 주소를 저장할 수 없다. 그러나 출력 결과에서 pa는 const의 사용과는 무관하게 변수 b의 주소를 저장하고 그 값을 간접 참조해 출력하고 있다.

이러한 이유는 포인터에 사용된 const는 'pa가 가리키는 변수 a는 pa를 간접 참조해 바꿀 수 없다'는 뜻이기 때문이다. 그렇기 때문에 12행을 *pa = 20;과 같이 사용하면 에러가 발생한다.

따라서 변수 a는 어디까지나 포인터를 통해서만 바꿀 수 없으며 변수 a 자체를 사용하면 얼마든지 바꿀 수 있다. 그래서 포인터에 const는 주로 문자열 상수를 인수로 받을 때 사용된다.

09-2 포인터 이해하기

📍 주소와 포인터의 차이

주소는 변수에 할당된 메모리 저장 공간의 시작 주소 값 자체고, 포인터는 그 값을 저장하는 또 다른 메모리 공간이다. 따라서 특정 변수의 주소 값은 바뀌지 않지만, 포인터는 다른 주소를 대입해 그 값을 바꿀 수 있다. 한마디로 주소는 '상수'이고, 포인터는 '변수'라는 뜻이다.

따라서 두 포인터가 같은 주소를 저장하는 일, 즉 하나의 변수를 동시에 가리키는 일도 가능하다.

int a ;
int *pa, *pb;
pa = pb = &a;

이 경우 a 값을 바꾸거나 연산하는 데 pa와 pb를 모두 사용할 수 있다.

상수와 변수는 용도가 분명히 다르다. 주소도 포인터처럼 간접 참조 연산자로 쓸 수 있지만, 상수이므로 대입 연산자 왼쪽에 올 수 없는 것처럼 말이다.
그렇기 때문에 주소와 포인터는 서로 꼭 구분해서 이해하는 것이 좋다.

📍 주소와 포인터의 크기

포인터도 저장 공간이므로 그 크기가 있는데, 포인터의 크기는 저장할 주소의 크기에 따라 결정된다. 포인터의 크기는 컴파일러에 따라 다를 수 있으나 주소와 포인터는 가리키는 자료형과 상관없이 그 크기가 같다.

소스 코드 9-5.c

#include <stdio.h>

int main(void)
{
	char ch;
	int in;
	double db;

	char* pc = &ch;
	int* pi = &in;
	double* pd = &db;

	printf("char형 변수의 주소 크기 : %d\n", sizeof(&ch));
	printf("int형 변수의 주소 크기 : %d\n", sizeof(&in));
	printf("double형 변수의 주소 크기 : %d\n", sizeof(&db));

	printf("char * 포인터의 크기 : %d\n", sizeof(pc));
	printf("int * 포인터의 크기 : %d\n", sizeof(pi));
	printf("double * 포인터의 크기 : %d\n", sizeof(pd));

	printf("char * 포인터가 가리키는 변수의 크기 : %d\n", sizeof(*pc));
	printf("int * 포인터가 가리키는 변수의 크기 : %d\n", sizeof(*pi));
	printf("double * 포인터가 가리키는 변수의 크기 : %d\n", sizeof(*pd));

	return 0;
}

실행 결과

char형 변수의 주소 크기 : 8
int형 변수의 주소 크기 : 8
double형 변수의 주소 크기 : 8

char * 포인터의 크기 : 8
int * 포인터의 크기 : 8
double * 포인터의 크기 : 8

char * 포인터가 가리키는 변수의 크기 : 1
int * 포인터가 가리키는 변수의 크기 : 4
double * 포인터가 가리키는 변수의 크기 : 8

이 예제를 보면 각각 변수 자체의 크기는 다르지만, 그 시작 주소 값의 크기는 모두 같은 것을 볼 수 있다. 또한 포인터도 가리키는 자료형과 상관없이 모두 크기가 같다.

📍 포인터의 대입 규칙

포인터는 다음 규칙에 따라 제한적으로 사용해야 한다.

규칙 1 ) 포인터는 가리키는 변수의 형태가 같을 때만 대입해야 한다.

포인터끼리 대입 연산을 수행하면 여러 개의 포인터로 같은 데이터를 다루는 것이 가능하다.
그러나 규칙을 지키지 않는 대입 연산은 그 결과를 예상할 수 없게 된다.

따라서 int형을 가리키는 포인터 변수 p를 double형을 가리키는 포인터 변수 pd에 대입을 시도하면 가리키는 자료형이 일치하지 않으므로 경고 메세지가 나타난다.

규칙 2 ) 형 변환을 사용한 포인터의 대입은 언제나 가능하다.

포인터가 가리키는 자료형이 다른 경우라도 형 변환 연산자를 사용하면
경고 메세지 없이 대입할 수 있다.

double a = 3.4;
double *pd = &a;
int *pi;
pi = (int *)pd;

여기서 pi에 간접 참조 연산을 수행하면 변수 a의 일부를 int형 변수처럼 사용할 수 있다.
만약 *pi = 10;과 같이 a의 일부분에 정수를 저장하면 정수와 실수의 데이터 크기와 저장 방식이 다르므로 a에 저장한 실수 값은 사용할 수 없다.

📍 포인터를 사용하는 이유

포인터를 사용하려면 추가적인 변수 선언도 필요하고 각종 연산도 수행해야 하므로 굳이 변수를 사용할 때 포인터를 사용하지 않아도 된다. 그러나 임베디드 프로그래밍을 할 때 메모리에 직접 접근하는 경우나 동적 할당한 메모리를 사용하는 경우에는 포인터가 반드시 필요하다.

소스 코드 9-7.c

#include <stdio.h>

void swap(int* pa, int* pd);

int main(void)
{
	int a = 10, b = 20;

	swap(&a, &b);
	printf("a:%d, b:%d\n", a, b);

	return 0;
}

void swap(int* pa, int* pb)
{
	int temp;

	temp = *pa;
	*pa = *pb;
	*pb = temp;
}

이 예제를 보면 포인터의 필요성을 알 수 있는데, 함수가 호출되면 포인터는 pa, pb는 main 함수의 변수 a와 b의 주소를 저장하므로 각각 a와 b를 가리키는 상태가 된다. 그럼 이제 swap 함수에서 포인터 pa, pb에 간접 참조 연산을 수행하면 main 함수의 변수 a와 b를 자유롭게 사용할 수 있다.

교환 작업은 swap 함수 안에서 포인터를 통해 진행되지만, 실제로 바뀌는 값은 main 함수의 변수 a와 b가 된다. 결국 swap 함수는 포인터를 통해 main 함수의 변수 a, b를 공유하므로 두 변수를 직접 바꾸는 일이 가능해진다.

그럼 여기서 포인터 없이는 두 변수의 값을 바꿀 수 없는지 생각하게 된다.

먼저 swap 함수에서 main 함수의 a, b를 이름으로 직접 사용하는 방법을 사용해 보겠다.

void swap(void)
{
	int temp;

	temp = a;
	a = b;
	b = temp;
}

이 함수는 함수 안에 선언된 변수명에 사용 범위가 함수 내부로 제한되므로 main 함수에 있는 변수 a, b를 swap 함수에 사용하면 에러가 발생한다. 결국 이 방법은 컴파일 단계에서 문제가 발생하게 된다.

다음 방법은 main 함수에서 a, b의 값을 swap함수에 인수로 주는 방법이다.

void swap(int x, int y)
{
	int temp;

	temp = x;
	x = y;
	y = temp;
}

이 방법도 swap 함수 안에서는 a, b의 복사본을 바꾸므로 main 함수의 두 변수 a, b의 값은 변함이 없다. swap 함수에서 매개변수의 이름을 a, b로 사용해도 결과는 같다. 이름이 같아도 함수가 다르면 메모리에 별도의 저장 공간을 확보하기 때문이다.

마지막으로 swap 함수에서 바꾼 값을 main 함수로 반환하는 방법을 생각할 수 있다.
하지만 이 방법도 함수가 반환할 수 있는 값은 하나밖에 없다는 것에서 불가능이다.

따라서 두 변수의 값을 바꾸는 방법은 포인터를 사용하는 법밖에 없다.

0개의 댓글