멈추지 않고 끈질기게

[C++] 포인터(Pointer) 본문

C++

[C++] 포인터(Pointer)

sam0308 2023. 3. 8. 17:53

※ 해당 포스팅은 개인의 공부 정리용 글입니다. 틀린 내용이 있다면 추후 수정될 수 있습니다.

※ 해당 포스팅은 C++ 14 버전을 기준으로 작성되었습니다.

 

 

 

이번 포스팅에서는 C++에서 주소를 직접 사용하는 포인터에 대해 알아보겠습니다.

1. 포인터의 정의

 포인터는 다른 변수의 주소를 저장하는 변수입니다. 영어에서 Point는 '가리키다'라는 뜻이 있으므로, 포인터는 한마디로 다른 변수를 (주소를 통해)가리키는 변수라고 할 수 있겠습니다.

int main()
{
    // 포인터의 선언 및 초기화
    int num = 0;
    int* numPtr = #
    cout << "num의 주소 : " << &num << endl;
    cout << "numPtr의 값 : " << numPtr << endl << endl;

    // 포인터를 통한 값 수정
    float fNum = 3.14f;
    float* fPtr = &fNum;
    cout << "fNum의 값 : " << *fPtr << endl;
    *fPtr += 5;
    cout << "fNum + 5 = " << fNum << endl << endl;

    // 포인터 연산(+, -) 
    __int64 lNum = 0;
    __int64* lPtr = &lNum;
    cout << "lPtr의 값 : " << lPtr << endl;
    cout << "lPtr + 1의 값 : " << ++lPtr << endl; // 8 증가
    cout << "lPtr + 4의 값 : " << lPtr + 3 << endl; // 24 증가
}

그림 1. 포인터의 선언 및 사용

 포인터는 [타입]* [포인터 이름]; 과 같은 형식으로 선언하며, 다른 변수의 주소를 이용해 초기화합니다. 다른 변수의 주소는 변수앞에 &를 붙이면 됩니다. numPtr의 값을 출력해보면 num의 주소값과 동일함을 확인할 수 있습니다.

 

 포인터 변수를 통해서 포인터가 가리키는 대상을 직접 수정할 수도 있습니다. 포인터 변수 앞에 *을 붙일 경우 포인터가 가리키는 대상과 동일한 의미입니다. 상기 코드에서 *fPtr이 fNum과 동일한 값을 출력하며, *fPtr + 5로 fNum 값을 직접 변경할 수 있음을 알 수 있습니다.

 

 참고로 포인터의 값(주소) 자체에 덧셈, 뺄셈 연산도 가능하지만, 일반적인 숫자 연산과는 조금 다릅니다. __int64 타입의 포인터에 ++ 연산을 하면 8 증가, +3 연산을 하면 24만큼 증가했음을 알 수 있습니다. 즉, 포인터에 덧셈, 뺄셈 연산 시 포인터 타입의 크기만큼 증감합니다. 

 

 

 포인터의 경우 함수에서 매개변수의 수정을 금지하는 const 키워드의 사용 방법이 조금 특수합니다.

 

그림 2. 포인터와 const

 포인터에 const 사용 시 *을 기점으로 앞, 뒤에 적었을 때 그 의미가 달라집니다. const [타입]* [변수이름] 의 경우 포인터가 가리키는 대상의 값을 수정 금지한다는 뜻입니다. PtrFunc1() 함수를 보면 *ptr = 100; 에서 오류를 내고있음을 알 수 있습니다. [타입]* const [변수이름] 의 경우 포인터가 가리키는 대상, 즉 주소값을 수정 금지한다는 뜻입니다. PtrFunc2() 함수를 보면 ptr++; 에서 오류를 내고 있습니다. 둘 다 금지하고 싶을 경우에는 조금 길어지지만 양 쪽에 const 키워드를 작성하면 됩니다. PtrFunc3() 함수를 보면 둘 다 오류를 내고 있음을 알 수 있습니다. 

 

2. 포인터의 장점

 변수의 값을 변경하려면 그냥 해당 변수를 건드리면 되는데 왜 포인터가 필요할까요? 우선 포인터는 지역 변수를 다른 함수에서 직접 변경할 수 있게 해줄 수 있습니다.

void SetHpFull(int hp)
{
    hp = 100;
}

int main()
{
    int hp = 10;
    SetHpFull(hp);
    cout << "HP: " << hp << endl;
}

그림 1. 매개변수 수정

 상기 C++ 코드는 int형 변수 hp를 10으로 설정한 뒤 SetHpFull() 함수의 매개변수로 전달한 뒤 hp 값을 출력하는 내용입니다. 실행해보면 초기에 설정한 10에서 변하지 않은 것을 볼 수 있습니다. 메인 함수의 지역변수와 함수의 매개변수는(이름을 동일하게 설정하여 헷갈리지만) 엄연히 다른 스택 프레임에 존재하는 별개의 변수입니다. 

 

그림 2. 지역변수와 매개변수

 구분하기 쉽게 SetHpFull() 함수의 매개변수 이름은 health로 바꾼 뒤 실행하여 조사식에서 hp와 health의 값을 확인해보면, 각 변수의 주소와 값이 다르다는 것을 알 수 있습니다. 다음과 같이 매개변수로 포인터를 받도록 수정하면 이러한 문제를 해결할 수 있습니다.

void SetHpFull(int* health)
{
    *health = 100;
}

int main()
{
    int hp = 10;
    SetHpFull(&hp);
    cout << "HP: " << hp << endl;
}

그림 3. 포인터 매개변수

 

 지금처럼 단순히 int형 변수 하나를 변경시키는 경우라면 함수에 반환값을 만들어서 해결할 수도 있습니다. 하지만 이러한 방식은 매개변수의 사이즈가 커질수록 포인터를 사용하는 방법에 비해 낭비가 심해집니다.

struct Enemy
{
    int hp;
    int atk;
    int def;
    float speed;
};

Enemy InitializeEnemy(Enemy enemy)
{
    enemy.hp = 100;
    enemy.atk = 30;
    enemy.def = 10;
    enemy.speed = 1.3f;

    return enemy;
}

void InitializeEnemyByPtr(Enemy* enemy)
{
    enemy->hp = 100;
    enemy->atk = 30;
    enemy->def = 10;
    enemy->speed = 1.3f;
}

int main()
{
    Enemy enemy;
    cout << "Enemy의 사이즈: " << sizeof(enemy) << endl;

    Enemy* enemyPtr;
    cout << "Enemy 포인터의 사이즈: " << sizeof(enemyPtr) << endl;
}

그림 4. 객체와 포인터의 사이즈

 

 Enemy 구조체를 정의한 뒤 구조체 객체를 매개변수로 받아 수정한 후 반환하는 InitializeEnemy() 함수와 포인터를 매개변수로 받아 수정하는 InitializeEnemyByPtr() 함수를 선언하였습니다. 메인 함수의 실행 결과를 먼저 살펴보면 Enemy 객체의 사이즈는 16바이트인데 비해 포인터의 사이즈는 4바이트임을 알 수 있습니다. 포인터는 단지 주소를 저장할 뿐이므로, 객체의 원래 크기와 상관 없이 운영체제에 따라 고정된 크기를 가지기 때문입니다(상기 코드는 x86(32-bit) 환경에서 실행하여 4바이트이며, x64(64-bit) 환경이라면 8바이트입니다).

 

 즉,  InitializeEnemy() 함수 실행 시 16바이트짜리 구조체 전체를 매개변수로 복사하여 전달한 뒤 수정된 결과를 다시 복사하여 반환하는 과정을 거쳐야 하지만, InitializeEnemyByPtr() 함수는 그저 4바이트(8바이트)짜리 포인터 변수 하나만 전달하여 값을 직접 수정하고, 무언가 반환할 필요도 없습니다. 매개변수의 크기가 크면 클수록 이러한 차이는 더 커지게 됩니다. 따라서 포인터를 잘 활용하면 메모리 효율이 증가하게 됩니다.

 

 

3. 주의사항

 다만 포인터는 메모리의 특정 주소에 직접 접근할 수 있게 해주는 만큼 치명적인 이슈를 발생시킬 수도 있습니다.

int main()
{
    char c = 1;
    int* cPtr = (int*)&c; // 강제 형변환

    *cPtr = 0xbbbbbbbb; // 4바이트 입력
}

그림 5. 포인터의 강제 형변환

 상기 코드에서는 char 타입 변수의 주소를 통해 int 타입 포인터 cPtr를 초기화하였습니다. 원래 다른 타입의 주소를 통해 포인터를 초기화하는 것은 불가능하지만, (int*)를 붙여 강제로 형변환 시켜 초기화하였습니다. cPtr에 4바이트 값을 넣어보면 4바이트의 주소들의 값이 변하는 것을 확인할 수 있습니다. char 타입의 변수를 이용하여 포인터를 만들었지만 int형 포인터가 되었기 때문에 4바이트를 변경하는 것입니다. 물론 이와 같이 포인터를 강제 형변환하는 상황은 별로 없겠지만, 포인터를 잘못 사용하면 의도하지 않은 영역의 주소를 침범하는 것도 가능하다는 점은 주의해야 합니다.