본문 바로가기
<CPP>/BASIC

13. 포인터(Pointer)

by CodeGrimie 2021. 1. 14.

다른 것들도 마찬가지지만 포인터는 특히 자주 수정될 예정이다.

구름 속에서 손을 젓는 기분으로 사용하고 있는데 이상하게 코드는 작동한다..

변수 주소를 저장하는 변수

CPP이 게임 개발에서 아직도 현역인 이유는 바로 이 포인터 때문일 것이다.

C부터 이어진 포인터의 개념 자체는 대단히 짧고 명료한데 단지 변수 주소를 저장할 뿐이다.

그 이상의 기능도 그 이하의 기능도 존재하지 않는다.

 

문제는 이 기능을 응용하는 경우의 수가 엄청 많고 거의 대부분의 다른 기능들과 융합할 수 있다.

 

조금은 복잡해 보이는 아래의 코드를 살펴보자.

 

▼ 포인터의 기본적인 예시

int iValue = 10;
wprintf(L"== iValue 값 ==\n");
wprintf(L" iValue[%p] = %d\n\n", &iValue, iValue);

int *pValue = &iValue;
wprintf(L"== pValue 값 ==\n");
wprintf(L" pValue[%p] = %p\n\n", &pValue, pValue);
wprintf(L"== pValue가 저장한 주소가 가진 값 ==\n");
wprintf(L"*pValue[%p] = %d\n\n", pValue, *pValue);

코드가 조금 복잡해보여도 걱정하지 말자.

실제 중요한 포인터가 사용된 코드는 단 한 줄이다.

나머지는 포인터가 어떤 일을 한 건지 알아보기 위한 출력문일 뿐이다.

 

위의 예시에서 가장 중요한건 포인터는 자기 자신의 주소 정보는 따로 가지는 별개의 변수라는 것이다.

포인터가 항상 4바이트/8바이트인 건 아니다.

포인터가 별개의 변수인 만큼 포인터를 저장하는 크기 역시 가지고 있다.

그리고 당연하겠지만 포인터의 포인터 역시 존재하고 포인터의 포인터의 포인터.. 도 가능하다.

대부분의 경우 포인터는 CPU와 운영체제에 따라 크기가 정해진다.

 

▼ 포인터 크기를 구하는 코드

#include <cstdio>

int main()
{
    int	iValue = 10;
    int *pValue = &iValue;

    // 포인터 크기
    printf(" pValue[%lld]\n\n", sizeof(pValue));
    return (0);
}

 

기본적으로 포인터는 32비트 CPU의 경우 (32/4)인 4바이트 크기를 가지고 64비트 CPU의 경우 (64/4)인 8바이트의 크기를 가지는 경우가 많다.

그러나 C와 CPP 표준에 의하면 CPU 비트 수와 포인터의 크기는 관계없어도 된다.(오잉?)

애초에 저수준까지 접근하기 위해 설계된 언어인 만큼 하드웨어의 최적화에 따라 포인터의 크기를 유동적으로 변환할 수 있게 되어있다.

 

실제로 임베디드 CPU 중 몇몇은 비트 수 보다 훨씬 작은 포인터 크기를 가진다고 한다.

 

추가로 운영체제에 따라서도 크기가 달라진다.

CPU는 64비트를 사용해도 운영체제가 32비트일 경우엔 포인터의 크기는 4바이트가 된다.

32비트 운영체제는 내부적으로 32비트가 최대로 생각하기 때문이다.

(변수형* 변수명) 이던 (변수형 *변수명)이던 작동상의 차이는 없다.

포인터의 *(Asterisk)를 변수형에 붙이느냐 변수명에 붙이는지는 프로그래머마다 다 다르다.

하지만 기능상의 차이는 없고 받아들이는 관점에 따라서 편함의 차이라고 한다.

 

▼ 변수명이던 변 수형이던 익숙한 게 최고다.

int main()
{
    int* num;
    char *c;
    return (0);
}

함수 밖의 변수에 직접 접근할 땐 포인터를 사용한다.

포인터를 사용하는 가장 큰 이유다.

CPP에는 함수에 인자를 전달 할 때 3가지의 방법이 있다.

 

▼ CPP에서 함수에서 인자를 전달하는 3가지 방법

void CallByValue(int lhs, int rhs);
void CallByAddress(int* lhs, int* rhs);
void CallByReference(int& lhs, int& rhs);

함수에 필요한 매개변수를 유심히 살펴보자.

변숫값(Value)을 던지는지, 주소 값(*, Pointer)을 던지는지, 참조(&, Reference)를 던지는지에 따른 차이일 뿐이다.

각자 어떤 차이가 있는지 알아보자.

 

▼ Call By Value는 단순 복사에 불과하다.

#include <cstdio>

void CallByValue(int num)
{
    num = 10;
    printf("NUM[%p] : %d\n", &num, num);
}

int main()
{
    int num = 5;

    printf("NUM[%p] : %d\n", &num, num);
    CallByValue(num);
    printf("NUM[%p] : %d\n", &num, num);
    return (0);
}

출력하면 깜짝 놀랄 결과가 나올 것이다.

분명 CallByValue 함수에서 num을 10으로 바꿨는데 여전히 5가 출력된다.

 

CallByValue 방식은 단순히 변숫값만 복사해서 전달한다.

쉽게 말하면 내가 A4에 숫자 5를 적어둔 걸 컴파일러에게 보여주면 컴파일러는 다른 A4 종이에 5를 옮겨적고 CallByValue 함수에게 전달해준다.

 

그렇기 때문에 함수의 매개변수 크기 만큼 내부적으로 메모리를 추가적으로 할당/해제한다.

 

▼ Call By Address는 그 값에 직접 접근한다.

#include <cstdio>

void CallByAddress(int* num)
{
    *num = 10;
    printf("NUM[%p] : %d\n", num, *num);
}

int main()
{
    int num = 5;

    printf("NUM[%p] : %d\n", &num, num);
    CallByAddress(&num);
    printf("NUM[%p] : %d\n", &num, num);
    return (0);
}

이번에는 정상적으로 num의 값이 바뀐 것을 확인할 수 있다.

함수의 인자로 변수의 주소값을 복사하여 던져줌으로써 함수에서 해당 변수를 변경할 수 있다.

우리가 포인터를 쓰는 가장 큰 이유가 바로 이 처럼 특정 변숫값을 변경하는 경우가 많기 때문이다.

 

그 밖에도 프로그램 최적화에 큰 도움이 되는 기능이 되기도 한다.

가령 int가 아니라 천 개의 int가 들어간 구조체였다면 어땠을까?

Call By Value였다면 천 개의 int를 가진 구조체의 크기(4000Byte)만큼 새로 할당하고 있을 것이다.

대신 구조체의 주소 즉, 포인터만을 던짐으로써 단 4~8Byte로 해결할 수 있다.

 

게임에서 이러한 메모리 낭비는 곧 프레임 드롭으로 이어지기 때문에 치명적이다.

int의 크기라 해봐도 4Byte에 불과하지만 그것들이 모여서 1MB, 1GB가 되는 걸 잊지 말자.

 

의외로 C에서는 Call By Reference라고 오해하는 경우가 많다.

C를 개발한 데니스 리치도 인정했지만 C에는 Call By Reference가 존재하지 않는다.

그저 Call By Value지만 던지는 값이 주소값이라서 Call by Address로 부르는 것뿐이다.

대다수의 경우 C의 자유로운 문법으로 작동상 Call By Reference 기능을 흉내 낼 수 있어서 더 혼란스럽다.

 

▼ Call By Reference는 참조한다.

#include <cstdio>

void CallByReference(int &num)
{
    num = 10;
    printf("NUM[%p] : %d\n", &num, num);
}

int main()
{
    int num = 5;

    printf("NUM[%p] : %d\n", &num, num);
    CallByReference(num);
    printf("NUM[%p] : %d\n", &num, num);

    return (0);
}

C에는 Call By Reference가 존재하지 않는다.

Call By Reference는 CPP의 참조자('&')의 등장으로 생겨난 기능이다.

겉모습도 큰 차이가 없지만 컴파일 결과 자체도 Call By Address와 다르지 않다.

내부적으로 '&'가 const 포인터로 변환되기 때문에 주소 값을 복사한다는 것에서는 동일하다.

 

▼ 단순한 값만 바꾸는 Swap 함수라면 둘의 차이점은 없다.

void SwapWithAddress(int *lhs, int *rhs)
{
    int _temp;

    _temp = *lhs;
    *lhs = *rhs;
    *rhs = _temp;
}

void SwapWithReference(int &lhs, int &rhs)
{
    int _temp;

    _temp = lhs;
    lhs = rhs;
    rhs = _temp;
}

그런데 만약 lhs와 rhs가 값뿐 아니라 메모리 주소까지 교환되어야 한다면 어떨까?

참조자는 내부적으로 (const)가 붙기 때문에 기본적으로 값을 변경할 수 없다.

그럴 때에는 Call By Address를 사용하여 서로 교환할 수 있다.

 

▼ CPP의 포인터는 훨씬 자유롭다.

void Swap(int* lhs, int* rhs)
{
    int* _temp;

    _temp = lhs;
    lhs = rhs;
    rhs = _temp;
}

포인터는 배열의 선조다.

배열과 포인터는 많이 유사한데 애초에 배열이 포인터에서 파생되어서 나온 편의 기능이다.

모든 배열은 끝이 존재하는데 문자열의 경우엔 ' \0 '이 끝을 나타내고 그 밖의 배열은 내장된 길이에 다다르면 끝이 난다.

 

일전에도 말했듯이 배열은 무조건 차례대로 주소 값을 가진다.

이건 문자열이던 배열이던 절대 예외가 없다.

애초에 배열도 연속되는 특징을 살려서 첫 번째 주소만 저장하는 것이다.

 

▼ 배열과 문자열은 반드시 차례대로 주소를 가진다.

#include <cstdio>

int GetStringLength(const char* str)
{
	int length;

	length = 0;
	while (*(str++))
		length++;
	return (length);
}

int main()
{
	const char* str = "HelloWorld";
	printf("STR LENGTH : %d", GetStringLength(str));
	return (0);
}

포인터와 배열의 관계를 또 명확하게 알 수 있는 또 다른 예시가 있다.

 

▼ 문자열과 포인터의 관계

char		c;          // 한 글자
char*		str;        // 한 문장
char**		block;      // 한 단락
char***		page;       // 한 쪽
char****	chapter;    // 한 장
char*****	book;       // 한 권

포인터가 늘어날 수록 저장하는 범위가 늘어나고 있다.

글자 하나하나가 모인 문장, 문장이 모인 단락, 단락이 모인 한 쪽..

결국 한 글자 저장된 주소에 여러 글자가 연속되어 있다면 컴파일러는 문장이라 생각한다.

 

포인터의 가장 기능은 메모리 주소값을 저장하는 것 뿐이지만 그걸 응용해서 만들어진 기능들은 너무나 많다.

그리고 대부분의 알고리즘은 이 포인터와 자주 엮인다.

 

포인터에 익숙해지는 것이 다르게 말하면 게임 최적화를 생각할 수 있는 계기가 되는건 아닐까.

'<CPP> > BASIC' 카테고리의 다른 글

15. 동적 할당과 배열의 차이  (0) 2021.01.18
14. 할당, 해제  (0) 2021.01.15
12. 열거형(Enum)  (0) 2021.01.14
11. 고정 크기 배열(Array)  (0) 2021.01.13
10. 상수  (0) 2021.01.13

댓글